Compare commits

..

61 Commits

Author SHA1 Message Date
Cody Henthorne
811bef8c35 Bump version to 5.13.1 2021-05-26 20:07:20 -04:00
Cody Henthorne
057107ea7a Updated language translations. 2021-05-26 20:02:54 -04:00
Alex Hart
273e5f9168 Remove gradient support from api 19. 2021-05-26 19:56:20 -04:00
Alex Hart
35930fb23a Fix several ChatColors issues. 2021-05-26 20:06:57 -03:00
Alex Hart
c794b5c2e7 Only display edit pencil if custom color is selected. 2021-05-26 19:56:04 -03:00
Greyson Parrelli
e74d502ae6 Remove legacy session version.
Hasn't been used since the TextSecure days!
2021-05-26 17:46:58 -04:00
Greyson Parrelli
e5ce6e3e2e Fix internal preference. 2021-05-26 12:45:54 -04:00
Greyson Parrelli
65020dde1a Fix some missed cases for blocking unregistered sends. 2021-05-26 12:02:22 -04:00
Alex Hart
98f432d23c Fix advanced prefs dialog title. 2021-05-26 11:50:17 -03:00
Cody Henthorne
2651b789dd Fix some group description UX oddities. 2021-05-26 10:42:36 -04:00
Cody Henthorne
dbabac34b0 Fix video not showing until phone moved. 2021-05-26 10:25:58 -04:00
Alex Hart
6866b7a277 Fix chat color selection context menu positioning. 2021-05-26 11:13:25 -03:00
Alex Hart
03c19f54c2 Set background of typing indicator to match conversation. 2021-05-26 10:56:09 -03:00
Alex Hart
ba510ca77d Update chat pluralization. 2021-05-26 10:47:40 -03:00
Alex Hart
bb7409fd91 Add proper background color for quote preview. 2021-05-26 10:14:22 -03:00
Alex Hart
23e5da4d95 Fix issue where message sender was impacting bubble color in groups. 2021-05-26 09:41:20 -03:00
Greyson Parrelli
fb1b46b67e Bump version to 5.13.0 2021-05-26 00:45:32 -04:00
Greyson Parrelli
7a21e6b5f8 Updated language translations. 2021-05-26 00:45:06 -04:00
Greyson Parrelli
6342a45b4e Separate avatar colors from chat colors. 2021-05-26 00:39:59 -04:00
Alex Hart
bcc5d485ab Update chat colors. 2021-05-26 00:39:59 -04:00
Rainer Matischek
36fe150678 Increase maximum zoom level for large images. 2021-05-26 00:39:59 -04:00
Greyson Parrelli
54f92ae466 Do not send if unregistered. 2021-05-26 00:39:59 -04:00
Cody Henthorne
b9b2924939 Add screen share receive support and improve video calling rotation. 2021-05-26 00:39:59 -04:00
Greyson Parrelli
513e5b45c5 Show notifications for group creates. 2021-05-26 00:39:59 -04:00
Greyson Parrelli
1fad5e2c1e Add some extra preconditions to reaction processing. 2021-05-26 00:39:59 -04:00
Greyson Parrelli
5a28cf616d Do not allow bad QR data to crash. 2021-05-26 00:39:59 -04:00
Cody Henthorne
c08199659b Support pasting of images into input text. 2021-05-26 00:39:59 -04:00
Greyson Parrelli
ca508514a7 Updated flipper to 0.91.0 2021-05-26 00:39:59 -04:00
Greyson Parrelli
da2038dd46 Revert "Temporarily block payments in all regions."
This reverts commit 152cc27394.
2021-05-26 00:39:59 -04:00
Greyson Parrelli
f02e2d23d0 Bump version to 5.12.3 2021-05-26 00:31:33 -04:00
Greyson Parrelli
ef1c25c3d3 Updated language translations. 2021-05-26 00:31:02 -04:00
Alex Hart
152cc27394 Temporarily block payments in all regions. 2021-05-26 00:28:05 -04:00
Greyson Parrelli
c582aca465 Bump version to 5.12.2 2021-05-20 11:15:00 -04:00
Greyson Parrelli
80e85fb49a Updated language translations. 2021-05-20 11:14:01 -04:00
Greyson Parrelli
d660e22e61 Pull translations in parallel. 2021-05-20 11:10:16 -04:00
Cody Henthorne
51856c4f06 Add support back for Android Auto. 2021-05-20 10:42:06 -04:00
Cody Henthorne
fd37da42f9 Revert "Remove Android Auto support (for now)."
This reverts commit 6c2adfeec2.
2021-05-20 09:46:38 -04:00
Cody Henthorne
11df2bc51f Replace spongy with libsignal x509 generation for device transfer. 2021-05-19 17:29:48 -04:00
Cody Henthorne
6770d21cf7 Fix crash when processing invalid mentions. 2021-05-19 13:15:28 -04:00
Cody Henthorne
f490d1f6d2 Add long click copy for urls in group descriptions. 2021-05-19 12:29:34 -04:00
Cody Henthorne
f890ae8ddc Enforce two line limit on group description.
Sorry.
2021-05-19 11:57:53 -04:00
Cody Henthorne
5d5d61d8ed Pluralize units for custom timer dialog. 2021-05-19 09:40:20 -04:00
Cody Henthorne
75589f1b2d Use new expire timer dialog from overflow menu. 2021-05-18 20:23:59 -04:00
Greyson Parrelli
6225c676e2 Bump version to 5.12.1 2021-05-18 19:31:12 -04:00
Greyson Parrelli
9b18668f49 Updated language translations. 2021-05-18 19:30:53 -04:00
Greyson Parrelli
2f80e7f1ff Put the default message timer behind a feature flag. 2021-05-18 19:26:25 -04:00
Greyson Parrelli
790413680d Bump version to 5.12.0 2021-05-18 18:28:24 -04:00
Cody Henthorne
47e9a4ec29 Fix hard to see media send HUD. 2021-05-18 18:21:49 -04:00
Cody Henthorne
defd5e8047 Add universal disappearing messages. 2021-05-18 18:21:48 -04:00
Greyson Parrelli
8c6a88374b No longer use SignalServiceAddress legacy identifier.
We had to do this in the past because we previously didn't allow
UUID-only contacts back in the day. This hasn't been the case for some
time. We should be preferring the UUID in all cases.
2021-05-18 18:21:48 -04:00
Greyson Parrelli
7343613bea Revert "Temporarily block payments in all regions."
This reverts commit ec486d66f7.
2021-05-18 18:21:48 -04:00
Greyson Parrelli
155dda1fa4 Bump version to 5.11.5 2021-05-18 18:02:37 -04:00
Greyson Parrelli
3c74306c8d Updated language translations. 2021-05-18 18:02:18 -04:00
Alex Hart
13ecd9eee6 Temporarily block payments in all regions. 2021-05-18 17:56:38 -04:00
Alex Hart
c48f3b4582 Bump version to 5.11.4 2021-05-17 17:03:21 -03:00
Alex Hart
30c007194d Updated language translations. 2021-05-17 17:03:21 -03:00
Cody Henthorne
ef5b68eb35 Add report spam in message request state. 2021-05-17 17:03:21 -03:00
Cody Henthorne
c47dcd5720 Add code formatting styles. 2021-05-17 17:03:21 -03:00
Greyson Parrelli
ed3c5ab479 Do more to ensure that we have the latest self in StorageSyncJob. 2021-05-17 17:03:21 -03:00
Cody Henthorne
a697b6c3d4 Fix long text layout bug in media quality selector. 2021-05-17 17:03:21 -03:00
Alex Hart
3965df78c9 Fix several settings issues. 2021-05-17 17:03:21 -03:00
417 changed files with 12746 additions and 3797 deletions

190
.idea/codeStyles/Project.xml generated Normal file
View File

@@ -0,0 +1,190 @@
<component name="ProjectCodeStyleConfiguration">
<code_scheme name="Project" version="173">
<option name="RIGHT_MARGIN" value="240" />
<option name="FORMATTER_TAGS_ENABLED" value="true" />
<option name="SOFT_MARGINS" value="160" />
<JavaCodeStyleSettings>
<option name="GENERATE_FINAL_LOCALS" value="true" />
<option name="DO_NOT_WRAP_AFTER_SINGLE_ANNOTATION" value="true" />
<option name="ALIGN_MULTILINE_ANNOTATION_PARAMETERS" value="true" />
<option name="ALIGN_MULTILINE_TEXT_BLOCKS" value="true" />
</JavaCodeStyleSettings>
<JetCodeStyleSettings>
<option name="NAME_COUNT_TO_USE_STAR_IMPORT" value="2147483647" />
<option name="NAME_COUNT_TO_USE_STAR_IMPORT_FOR_MEMBERS" value="2147483647" />
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
</JetCodeStyleSettings>
<codeStyleSettings language="JAVA">
<option name="BRACE_STYLE" value="5" />
<option name="CLASS_BRACE_STYLE" value="5" />
<option name="METHOD_BRACE_STYLE" value="5" />
<option name="ALIGN_MULTILINE_CHAINED_METHODS" value="true" />
<option name="ALIGN_MULTILINE_PARAMETERS_IN_CALLS" value="true" />
<option name="ALIGN_MULTILINE_BINARY_OPERATION" value="true" />
<option name="ALIGN_MULTILINE_ASSIGNMENT" value="true" />
<option name="ALIGN_MULTILINE_TERNARY_OPERATION" value="true" />
<option name="ALIGN_MULTILINE_THROWS_LIST" value="true" />
<option name="ALIGN_MULTILINE_EXTENDS_LIST" value="true" />
<option name="ALIGN_MULTILINE_ARRAY_INITIALIZER_EXPRESSION" value="true" />
<option name="ALIGN_GROUP_FIELD_DECLARATIONS" value="true" />
<option name="ALIGN_CONSECUTIVE_VARIABLE_DECLARATIONS" value="true" />
<option name="ALIGN_CONSECUTIVE_ASSIGNMENTS" value="true" />
<option name="SPACE_WITHIN_ARRAY_INITIALIZER_BRACES" value="true" />
<option name="SPACE_BEFORE_ARRAY_INITIALIZER_LBRACE" value="true" />
<option name="METHOD_CALL_CHAIN_WRAP" value="1" />
<option name="WRAP_FIRST_METHOD_IN_CALL_CHAIN" value="true" />
<option name="BINARY_OPERATION_SIGN_ON_NEXT_LINE" value="true" />
<option name="TERNARY_OPERATION_SIGNS_ON_NEXT_LINE" value="true" />
<option name="KEEP_SIMPLE_BLOCKS_IN_ONE_LINE" value="true" />
<option name="KEEP_SIMPLE_METHODS_IN_ONE_LINE" value="true" />
<option name="KEEP_SIMPLE_LAMBDAS_IN_ONE_LINE" value="true" />
<option name="KEEP_MULTIPLE_EXPRESSIONS_IN_ONE_LINE" value="true" />
<option name="METHOD_ANNOTATION_WRAP" value="0" />
<option name="CLASS_ANNOTATION_WRAP" value="0" />
<option name="FIELD_ANNOTATION_WRAP" value="0" />
<option name="ENUM_CONSTANTS_WRAP" value="5" />
<option name="WRAP_ON_TYPING" value="0" />
<indentOptions>
<option name="INDENT_SIZE" value="2" />
<option name="CONTINUATION_INDENT_SIZE" value="4" />
<option name="TAB_SIZE" value="2" />
</indentOptions>
<arrangement>
<groups>
<group>
<type>GETTERS_AND_SETTERS</type>
<order>KEEP</order>
</group>
<group>
<type>OVERRIDDEN_METHODS</type>
<order>KEEP</order>
</group>
<group>
<type>DEPENDENT_METHODS</type>
<order>BREADTH_FIRST</order>
</group>
</groups>
</arrangement>
</codeStyleSettings>
<codeStyleSettings language="XML">
<indentOptions>
<option name="CONTINUATION_INDENT_SIZE" value="4" />
</indentOptions>
<arrangement>
<rules>
<section>
<rule>
<match>
<AND>
<NAME>xmlns:android</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>xmlns:.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:id</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:name</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>name</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>style</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
<order>ANDROID_ATTRIBUTE_ORDER</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>.*</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
</rules>
</arrangement>
</codeStyleSettings>
<codeStyleSettings language="kotlin">
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
<indentOptions>
<option name="INDENT_SIZE" value="2" />
<option name="CONTINUATION_INDENT_SIZE" value="4" />
<option name="TAB_SIZE" value="2" />
</indentOptions>
</codeStyleSettings>
</code_scheme>
</component>

5
.idea/codeStyles/codeStyleConfig.xml generated Normal file
View File

@@ -0,0 +1,5 @@
<component name="ProjectCodeStyleConfiguration">
<state>
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
</state>
</component>

View File

@@ -55,8 +55,8 @@ protobuf {
}
}
def canonicalVersionCode = 845
def canonicalVersionName = "5.11.3"
def canonicalVersionCode = 853
def canonicalVersionName = "5.13.1"
def postFixSize = 100
def abiPostFix = ['universal' : 0,
@@ -322,6 +322,7 @@ android {
}
dependencies {
implementation 'androidx.fragment:fragment-ktx:1.2.5'
lintChecks project(':lintchecks')
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.1.5'
@@ -384,7 +385,7 @@ dependencies {
implementation 'org.signal:argon2:13.1@aar'
implementation 'org.signal:ringrtc-android:2.9.6'
implementation 'org.signal:ringrtc-android:2.10.1.1'
implementation "me.leolin:ShortcutBadger:1.1.22"
implementation 'se.emilsjolander:stickylistheaders:2.7.0'
@@ -436,8 +437,8 @@ dependencies {
}
implementation 'dnsjava:dnsjava:2.1.9'
flipperImplementation 'com.facebook.flipper:flipper:0.32.2'
flipperImplementation 'com.facebook.soloader:soloader:0.8.2'
flipperImplementation 'com.facebook.flipper:flipper:0.91.0'
flipperImplementation 'com.facebook.soloader:soloader:0.10.1'
testImplementation 'junit:junit:4.12'
testImplementation 'org.assertj:assertj-core:3.11.1'

View File

@@ -107,6 +107,9 @@
<meta-data android:name="com.google.android.gms.version"
android:value="@integer/google_play_services_version" />
<meta-data android:name="com.google.android.gms.car.application"
android:resource="@xml/automotive_app_desc" />
<meta-data android:name="firebase_analytics_collection_deactivated" android:value="true" />
<meta-data android:name="google_analytics_adid_collection_enabled" android:value="false" />
<meta-data android:name="firebase_messaging_auto_init_enabled" android:value="false" />
@@ -305,6 +308,11 @@
android:windowSoftInputMode="stateAlwaysHidden"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
<activity android:name=".recipients.ui.disappearingmessages.RecipientDisappearingMessagesActivity"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
android:theme="@style/Signal.DayNight.NoActionBar"
android:windowSoftInputMode="adjustResize"/>
<activity android:name=".DatabaseMigrationActivity"
android:theme="@style/NoAnimation.Theme.AppCompat.Light.DarkActionBar"
android:launchMode="singleTask"

View File

@@ -11,6 +11,8 @@ import androidx.lifecycle.Observer;
import org.thoughtcrime.securesms.components.voice.VoiceNotePlaybackState;
import org.thoughtcrime.securesms.contactshare.Contact;
import org.thoughtcrime.securesms.conversation.ConversationMessage;
import org.thoughtcrime.securesms.conversation.colors.Colorizable;
import org.thoughtcrime.securesms.conversation.colors.Colorizer;
import org.thoughtcrime.securesms.database.model.InMemoryMessageRecord;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.database.model.MmsMessageRecord;
@@ -29,7 +31,7 @@ import java.util.List;
import java.util.Locale;
import java.util.Set;
public interface BindableConversationItem extends Unbindable, GiphyMp4Playable {
public interface BindableConversationItem extends Unbindable, GiphyMp4Playable, Colorizable {
void bind(@NonNull LifecycleOwner lifecycleOwner,
@NonNull ConversationMessage messageRecord,
@NonNull Optional<MessageRecord> previousMessageRecord,
@@ -43,7 +45,8 @@ public interface BindableConversationItem extends Unbindable, GiphyMp4Playable {
boolean hasWallpaper,
boolean isMessageRequestAccepted,
@NonNull AttachmentMediaSourceFactory attachmentMediaSourceFactory,
boolean canPlayInline);
boolean canPlayInline,
@NonNull Colorizer colorizer);
ConversationMessage getConversationMessage();

View File

@@ -9,6 +9,8 @@ import androidx.annotation.WorkerThread;
import androidx.appcompat.app.AlertDialog;
import androidx.lifecycle.Lifecycle;
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.util.concurrent.SimpleTask;
@@ -30,15 +32,15 @@ public final class BlockUnblockDialog {
AlertDialog.Builder::show);
}
public static void showBlockAndDeleteFor(@NonNull Context context,
@NonNull Lifecycle lifecycle,
@NonNull Recipient recipient,
@NonNull Runnable onBlock,
@NonNull Runnable onBlockAndDelete)
public static void showBlockAndReportSpamFor(@NonNull Context context,
@NonNull Lifecycle lifecycle,
@NonNull Recipient recipient,
@NonNull Runnable onBlock,
@NonNull Runnable onBlockAndReportSpam)
{
SimpleTask.run(lifecycle,
() -> buildBlockFor(context, recipient, onBlock, onBlockAndDelete),
AlertDialog.Builder::show);
() -> buildBlockFor(context, recipient, onBlock, onBlockAndReportSpam),
AlertDialog.Builder::show);
}
public static void showUnblockFor(@NonNull Context context,
@@ -55,11 +57,11 @@ public final class BlockUnblockDialog {
private static AlertDialog.Builder buildBlockFor(@NonNull Context context,
@NonNull Recipient recipient,
@NonNull Runnable onBlock,
@Nullable Runnable onBlockAndDelete)
@Nullable Runnable onBlockAndReportSpam)
{
recipient = recipient.resolve();
AlertDialog.Builder builder = new AlertDialog.Builder(context);
AlertDialog.Builder builder = new MaterialAlertDialogBuilder(context);
Resources resources = context.getResources();
if (recipient.isGroup()) {
@@ -78,10 +80,10 @@ public final class BlockUnblockDialog {
builder.setTitle(resources.getString(R.string.BlockUnblockDialog_block_s, recipient.getDisplayName(context)));
builder.setMessage(R.string.BlockUnblockDialog_blocked_people_wont_be_able_to_call_you_or_send_you_messages);
if (onBlockAndDelete != null) {
if (onBlockAndReportSpam != null) {
builder.setNeutralButton(android.R.string.cancel, null);
builder.setPositiveButton(R.string.BlockUnblockDialog_block_and_delete, (d, w) -> onBlockAndDelete.run());
builder.setNegativeButton(R.string.BlockUnblockDialog_block, (d, w) -> onBlock.run());
builder.setNegativeButton(R.string.BlockUnblockDialog_report_spam_and_block, (d, w) -> onBlockAndReportSpam.run());
builder.setPositiveButton(R.string.BlockUnblockDialog_block, (d, w) -> onBlock.run());
} else {
builder.setPositiveButton(R.string.BlockUnblockDialog_block, ((dialog, which) -> onBlock.run()));
builder.setNegativeButton(android.R.string.cancel, null);
@@ -98,7 +100,7 @@ public final class BlockUnblockDialog {
{
recipient = recipient.resolve();
AlertDialog.Builder builder = new AlertDialog.Builder(context);
AlertDialog.Builder builder = new MaterialAlertDialogBuilder(context);
Resources resources = context.getResources();
if (recipient.isGroup()) {

View File

@@ -1,103 +0,0 @@
package org.thoughtcrime.securesms;
import android.content.Context;
import android.view.LayoutInflater;
import android.view.View;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.appcompat.app.AlertDialog;
import org.thoughtcrime.securesms.util.ExpirationUtil;
import java.util.Arrays;
import cn.carbswang.android.numberpickerview.library.NumberPickerView;
public class ExpirationDialog extends AlertDialog {
protected ExpirationDialog(Context context) {
super(context);
}
protected ExpirationDialog(Context context, int theme) {
super(context, theme);
}
protected ExpirationDialog(Context context, boolean cancelable, OnCancelListener cancelListener) {
super(context, cancelable, cancelListener);
}
public static void show(final Context context,
final int currentExpiration,
final @NonNull OnClickListener listener)
{
final View view = createNumberPickerView(context, currentExpiration);
AlertDialog.Builder builder = new AlertDialog.Builder(context);
builder.setTitle(context.getString(R.string.ExpirationDialog_disappearing_messages));
builder.setView(view);
builder.setPositiveButton(android.R.string.ok, (dialog, which) -> {
int selected = ((NumberPickerView)view.findViewById(R.id.expiration_number_picker)).getValue();
listener.onClick(getExpirationTimes(context, currentExpiration)[selected]);
});
builder.setNegativeButton(android.R.string.cancel, null);
builder.show();
}
private static View createNumberPickerView(final Context context, final int currentExpiration) {
final LayoutInflater inflater = LayoutInflater.from(context);
final View view = inflater.inflate(R.layout.expiration_dialog, null);
final NumberPickerView numberPickerView = view.findViewById(R.id.expiration_number_picker);
final TextView textView = view.findViewById(R.id.expiration_details);
final int[] expirationTimes = getExpirationTimes(context, currentExpiration);
final String[] expirationDisplayValues = new String[expirationTimes.length];
int selectedIndex = expirationTimes.length - 1;
for (int i=0;i<expirationTimes.length;i++) {
expirationDisplayValues[i] = ExpirationUtil.getExpirationDisplayValue(context, expirationTimes[i]);
if ((currentExpiration >= expirationTimes[i]) &&
(i == expirationTimes.length -1 || currentExpiration < expirationTimes[i+1])) {
selectedIndex = i;
}
}
numberPickerView.setDisplayedValues(expirationDisplayValues);
numberPickerView.setMinValue(0);
numberPickerView.setMaxValue(expirationTimes.length-1);
NumberPickerView.OnValueChangeListener listener = (picker, oldVal, newVal) -> {
if (newVal == 0) {
textView.setText(R.string.ExpirationDialog_your_messages_will_not_expire);
} else {
textView.setText(context.getString(R.string.ExpirationDialog_your_messages_will_disappear_s_after_they_have_been_seen, picker.getDisplayedValues()[newVal]));
}
};
numberPickerView.setOnValueChangedListener(listener);
numberPickerView.setValue(selectedIndex);
listener.onValueChange(numberPickerView, selectedIndex, selectedIndex);
return view;
}
private static int[] getExpirationTimes(Context context, int currentExpiration) {
int[] expirationTimes = context.getResources().getIntArray(R.array.expiration_times);
int location = Arrays.binarySearch(expirationTimes, currentExpiration);
if (location < 0) {
int[] temp = Arrays.copyOf(expirationTimes, expirationTimes.length + 1);
temp[temp.length - 1] = currentExpiration;
Arrays.sort(temp);
expirationTimes = temp;
}
return expirationTimes;
}
public interface OnClickListener {
public void onClick(int expirationTime);
}
}

View File

@@ -218,12 +218,6 @@ public class VerifyIdentityActivity extends PassphraseRequiredActivity implement
Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults);
}
private void setActionBarNotificationBarColor(MaterialColor color) {
getSupportActionBar().setBackgroundDrawable(new ColorDrawable(color.toActionBarColor(this)));
WindowUtil.setStatusBarColor(getWindow(), color.toStatusBarColor(this));
}
public static class VerifyDisplayFragment extends Fragment implements CompoundButton.OnCheckedChangeListener {
public static final String RECIPIENT_ID = "recipient_id";
@@ -420,11 +414,9 @@ public class VerifyIdentityActivity extends PassphraseRequiredActivity implement
} else {
Toast.makeText(getActivity(), R.string.VerifyIdentityActivity_your_contact_is_running_an_old_version_of_signal, Toast.LENGTH_LONG).show();
}
} catch (FingerprintParsingException e) {
} catch (Exception e) {
Log.w(TAG, e);
Toast.makeText(getActivity(), R.string.VerifyIdentityActivity_the_scanned_qr_code_is_not_a_correctly_formatted_safety_number, Toast.LENGTH_LONG).show();
} catch (UnsupportedEncodingException e) {
throw new AssertionError(e);
}
}

View File

@@ -44,6 +44,7 @@ import org.thoughtcrime.securesms.components.TooltipPopup;
import org.thoughtcrime.securesms.components.sensors.DeviceOrientationMonitor;
import org.thoughtcrime.securesms.components.webrtc.CallParticipantsListUpdatePopupWindow;
import org.thoughtcrime.securesms.components.webrtc.CallParticipantsState;
import org.thoughtcrime.securesms.components.webrtc.CallToastPopupWindow;
import org.thoughtcrime.securesms.components.webrtc.GroupCallSafetyNumberChangeNotificationUtil;
import org.thoughtcrime.securesms.components.webrtc.WebRtcAudioOutput;
import org.thoughtcrime.securesms.components.webrtc.WebRtcCallView;
@@ -62,11 +63,15 @@ import org.thoughtcrime.securesms.util.EllapsedTimeFormatter;
import org.thoughtcrime.securesms.util.FullscreenHelper;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.Util;
import org.thoughtcrime.securesms.util.livedata.LiveDataUtil;
import org.whispersystems.libsignal.IdentityKey;
import org.whispersystems.libsignal.util.Pair;
import org.whispersystems.signalservice.api.messages.calls.HangupMessage;
import java.util.List;
import static org.thoughtcrime.securesms.components.sensors.Orientation.PORTRAIT_BOTTOM_EDGE;
public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChangeDialog.Callback {
private static final String TAG = Log.tag(WebRtcCallActivity.class);
@@ -253,7 +258,8 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
viewModel.getWebRtcControls().observe(this, callScreen::setWebRtcControls);
viewModel.getEvents().observe(this, this::handleViewModelEvent);
viewModel.getCallTime().observe(this, this::handleCallTime);
viewModel.getCallParticipantsState().observe(this, callScreen::updateCallParticipants);
LiveDataUtil.combineLatest(viewModel.getCallParticipantsState(), viewModel.getOrientation(), (s, o) -> new Pair<>(s, o == PORTRAIT_BOTTOM_EDGE))
.observe(this, p -> callScreen.updateCallParticipants(p.first(), p.second()));
viewModel.getCallParticipantListUpdate().observe(this, participantUpdateWindow::addCallParticipantListUpdate);
viewModel.getSafetyNumberChangeEvent().observe(this, this::handleSafetyNumberChangeEvent);
viewModel.getGroupMembers().observe(this, unused -> updateGroupMembersForGroupCall());
@@ -291,6 +297,12 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
} else if (event instanceof WebRtcCallViewModel.Event.ShowGroupCallSafetyNumberChange) {
SafetyNumberChangeDialog.showForGroupCall(getSupportFragmentManager(), ((WebRtcCallViewModel.Event.ShowGroupCallSafetyNumberChange) event).getIdentityRecords());
return;
} else if (event instanceof WebRtcCallViewModel.Event.SwitchToSpeaker) {
callScreen.switchToSpeakerView();
return;
} else if (event instanceof WebRtcCallViewModel.Event.ShowSwipeToSpeakerHint) {
CallToastPopupWindow.show(callScreen);
return;
}
if (isInPipMode()) {

View File

@@ -91,14 +91,14 @@ public enum MaterialColor {
}
public @ColorRes int toQuoteBarColorResource(@NonNull Context context, boolean outgoing) {
if (outgoing) {
if (!outgoing) {
return isDarkTheme(context) ? tintColor : shadeColor ;
}
return R.color.core_white;
}
public @ColorInt int toQuoteBackgroundColor(@NonNull Context context, boolean outgoing) {
if (outgoing) {
if (!outgoing) {
int color = toConversationColor(context);
int alpha = isDarkTheme(context) ? (int) (0.2 * 255) : (int) (0.4 * 255);
return Color.argb(alpha, Color.red(color), Color.green(color), Color.blue(color));
@@ -108,7 +108,7 @@ public enum MaterialColor {
}
public @ColorInt int toQuoteFooterColor(@NonNull Context context, boolean outgoing) {
if (outgoing) {
if (!outgoing) {
int color = toConversationColor(context);
int alpha = isDarkTheme(context) ? (int) (0.4 * 255) : (int) (0.6 * 255);
return Color.argb(alpha, Color.red(color), Color.green(color), Color.blue(color));

View File

@@ -23,11 +23,11 @@ import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.color.MaterialColor;
import org.thoughtcrime.securesms.contacts.avatars.ContactColors;
import org.thoughtcrime.securesms.contacts.avatars.ContactPhoto;
import org.thoughtcrime.securesms.contacts.avatars.ProfileContactPhoto;
import org.thoughtcrime.securesms.contacts.avatars.ResourceContactPhoto;
import org.thoughtcrime.securesms.conversation.colors.AvatarColor;
import org.thoughtcrime.securesms.conversation.colors.ChatColors;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.groups.ui.managegroup.ManageGroupActivity;
import org.thoughtcrime.securesms.mms.GlideApp;
@@ -73,6 +73,7 @@ public final class AvatarImageView extends AppCompatImageView {
private OnClickListener listener;
private Recipient.FallbackPhotoProvider fallbackPhotoProvider;
private boolean blurred;
private ChatColors chatColors;
private @Nullable RecipientContactPhoto recipientContactPhoto;
private @NonNull Drawable unknownRecipientDrawable;
@@ -97,10 +98,11 @@ public final class AvatarImageView extends AppCompatImageView {
typedArray.recycle();
}
outlinePaint = ThemeUtil.isDarkTheme(getContext()) ? DARK_THEME_OUTLINE_PAINT : LIGHT_THEME_OUTLINE_PAINT;
outlinePaint = ThemeUtil.isDarkTheme(context) ? DARK_THEME_OUTLINE_PAINT : LIGHT_THEME_OUTLINE_PAINT;
unknownRecipientDrawable = new ResourceContactPhoto(R.drawable.ic_profile_outline_40, R.drawable.ic_profile_outline_20).asDrawable(getContext(), ContactColors.UNKNOWN_COLOR.toConversationColor(getContext()), inverted);
unknownRecipientDrawable = new ResourceContactPhoto(R.drawable.ic_profile_outline_40, R.drawable.ic_profile_outline_20).asDrawable(context, AvatarColor.UNKNOWN.colorInt(), inverted);
blurred = false;
chatColors = null;
}
@Override
@@ -171,10 +173,12 @@ public final class AvatarImageView extends AppCompatImageView {
Recipient.self().getProfileAvatar()))
: new RecipientContactPhoto(recipient);
boolean shouldBlur = recipient.shouldBlurAvatar();
boolean shouldBlur = recipient.shouldBlurAvatar();
ChatColors chatColors = recipient.getChatColors();
if (!photo.equals(recipientContactPhoto) || shouldBlur != blurred) {
if (!photo.equals(recipientContactPhoto) || shouldBlur != blurred || !Objects.equals(chatColors, this.chatColors)) {
requestManager.clear(this);
this.chatColors = chatColors;
recipientContactPhoto = photo;
Drawable fallbackContactPhotoDrawable = size == SIZE_SMALL ? photo.recipient.getSmallFallbackContactPhotoDrawable(getContext(), inverted, fallbackPhotoProvider)
@@ -207,12 +211,12 @@ public final class AvatarImageView extends AppCompatImageView {
requestManager.clear(this);
if (fallbackPhotoProvider != null) {
setImageDrawable(fallbackPhotoProvider.getPhotoForRecipientWithoutName()
.asDrawable(getContext(), MaterialColor.STEEL.toAvatarColor(getContext()), inverted));
.asDrawable(getContext(), AvatarColor.UNKNOWN.colorInt(), inverted));
} else {
setImageDrawable(unknownRecipientDrawable);
}
super.setOnClickListener(listener);
disableQuickContact();
}
}
@@ -240,11 +244,11 @@ public final class AvatarImageView extends AppCompatImageView {
public void setImageBytesForGroup(@Nullable byte[] avatarBytes,
@Nullable Recipient.FallbackPhotoProvider fallbackPhotoProvider,
@NonNull MaterialColor color)
@NonNull AvatarColor color)
{
Drawable fallback = Util.firstNonNull(fallbackPhotoProvider, Recipient.DEFAULT_FALLBACK_PHOTO_PROVIDER)
.getPhotoForGroup()
.asDrawable(getContext(), color.toAvatarColor(getContext()));
.asDrawable(getContext(), color.colorInt());
GlideApp.with(this)
.load(avatarBytes)
@@ -285,7 +289,7 @@ public final class AvatarImageView extends AppCompatImageView {
if (other == null) return false;
return other.recipient.equals(recipient) &&
other.recipient.getColor().equals(recipient.getColor()) &&
other.recipient.getChatColors().equals(recipient.getChatColors()) &&
other.ready == ready &&
Objects.equals(other.contactPhoto, contactPhoto);
}

View File

@@ -31,6 +31,7 @@ import org.thoughtcrime.securesms.permissions.Permissions;
import org.thoughtcrime.securesms.service.ExpiringMessageManager;
import org.thoughtcrime.securesms.util.DateUtils;
import org.thoughtcrime.securesms.util.FeatureFlags;
import org.thoughtcrime.securesms.util.Projection;
import org.thoughtcrime.securesms.util.ViewUtil;
import org.thoughtcrime.securesms.util.dualsim.SubscriptionInfoCompat;
import org.thoughtcrime.securesms.util.dualsim.SubscriptionManagerCompat;
@@ -146,6 +147,14 @@ public class ConversationItemFooter extends LinearLayout {
setBackground(null);
}
public @Nullable Projection getProjection() {
if (getVisibility() == VISIBLE) {
return Projection.relativeToViewRoot(this, new Projection.Corners(ViewUtil.dpToPx(11)));
} else {
return null;
}
}
private void presentDate(@NonNull MessageRecord messageRecord, @NonNull Locale locale) {
dateView.forceLayout();
if (messageRecord.isFailed()) {

View File

@@ -19,6 +19,7 @@ import org.thoughtcrime.securesms.mms.GlideRequests;
import org.thoughtcrime.securesms.mms.Slide;
import org.thoughtcrime.securesms.mms.SlideClickListener;
import org.thoughtcrime.securesms.mms.SlidesClickedListener;
import org.thoughtcrime.securesms.util.Projection;
import org.thoughtcrime.securesms.util.ViewUtil;
import java.util.List;
@@ -116,8 +117,8 @@ public class ConversationItemThumbnail extends FrameLayout {
thumbnail.setAlpha(1f);
}
public @Nullable CornerMask getCornerMask() {
return cornerMask;
public @NonNull Projection.Corners getCorners() {
return new Projection.Corners(cornerMask.getRadii());
}
public void setPulseOutliner(@NonNull Outliner outliner) {

View File

@@ -1,13 +1,15 @@
package org.thoughtcrime.securesms.components;
import android.content.Context;
import android.graphics.PorterDuff;
import android.graphics.drawable.Drawable;
import android.graphics.drawable.LayerDrawable;
import android.util.AttributeSet;
import android.view.View;
import android.widget.LinearLayout;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.content.ContextCompat;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.mms.GlideRequests;
@@ -34,14 +36,13 @@ public class ConversationTypingView extends LinearLayout {
indicator = findViewById(R.id.typing_indicator);
}
public void setTypists(@NonNull GlideRequests glideRequests, @NonNull List<Recipient> typists, boolean isGroupThread) {
public void setTypists(@NonNull GlideRequests glideRequests, @NonNull List<Recipient> typists, boolean isGroupThread, boolean hasWallpaper) {
if (typists.isEmpty()) {
indicator.stopAnimation();
return;
}
Recipient typist = typists.get(0);
bubble.getBackground().setColorFilter(typist.getColor().toConversationColor(getContext()), PorterDuff.Mode.MULTIPLY);
if (isGroupThread) {
avatar.setAvatar(glideRequests, typist, true);
@@ -50,6 +51,12 @@ public class ConversationTypingView extends LinearLayout {
avatar.setVisibility(GONE);
}
if (hasWallpaper) {
bubble.setBackgroundColor(ContextCompat.getColor(getContext(), R.color.conversation_item_wallpaper_bubble_color));
} else {
bubble.setBackgroundColor(ContextCompat.getColor(getContext(), R.color.signal_background_secondary));
}
indicator.startAnimation();
}
}

View File

@@ -22,20 +22,12 @@ public class CornerMask {
private final RectF bounds = new RectF();
public CornerMask(@NonNull View view) {
this(view, null);
}
public CornerMask(@NonNull View view, @Nullable CornerMask toClone) {
view.setLayerType(View.LAYER_TYPE_HARDWARE, null);
clearPaint.setColor(Color.BLACK);
clearPaint.setStyle(Paint.Style.FILL);
clearPaint.setAntiAlias(true);
clearPaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.CLEAR));
if (toClone != null) {
System.arraycopy(toClone.radii, 0, radii, 0, radii.length);
}
}
public void mask(Canvas canvas) {
@@ -64,6 +56,13 @@ public class CornerMask {
radii[6] = radii[7] = bottomLeft;
}
public void setRadii(float topLeft, float topRight, float bottomRight, float bottomLeft) {
radii[0] = radii[1] = topLeft;
radii[2] = radii[3] = topRight;
radii[4] = radii[5] = bottomRight;
radii[6] = radii[7] = bottomLeft;
}
public void setTopLeftRadius(int radius) {
radii[0] = radii[1] = radius;
}

View File

@@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.components;
import android.animation.Animator;
import android.animation.ValueAnimator;
import android.content.Context;
import android.graphics.drawable.ColorDrawable;
import android.net.Uri;
import android.text.format.DateUtils;
import android.util.AttributeSet;
@@ -22,6 +23,7 @@ import androidx.annotation.DimenRes;
import androidx.annotation.MainThread;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.content.ContextCompat;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
@@ -33,6 +35,7 @@ import org.thoughtcrime.securesms.components.emoji.EmojiKeyboardProvider;
import org.thoughtcrime.securesms.components.emoji.EmojiToggle;
import org.thoughtcrime.securesms.components.emoji.MediaKeyboard;
import org.thoughtcrime.securesms.conversation.ConversationStickerSuggestionAdapter;
import org.thoughtcrime.securesms.conversation.colors.Colorizer;
import org.thoughtcrime.securesms.database.model.StickerRecord;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.linkpreview.LinkPreview;
@@ -42,7 +45,6 @@ import org.thoughtcrime.securesms.mms.GlideRequests;
import org.thoughtcrime.securesms.mms.QuoteModel;
import org.thoughtcrime.securesms.mms.SlideDeck;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.ViewUtil;
import org.thoughtcrime.securesms.util.concurrent.AssertedSuccessListener;
import org.thoughtcrime.securesms.util.concurrent.ListenableFuture;
@@ -50,6 +52,7 @@ import org.thoughtcrime.securesms.util.concurrent.SettableFuture;
import org.whispersystems.libsignal.util.guava.Optional;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.TimeUnit;
public class InputPanel extends LinearLayout
@@ -74,7 +77,7 @@ public class InputPanel extends LinearLayout
private View buttonToggle;
private View recordingContainer;
private View recordLockCancel;
private View composeContainer;
private ViewGroup composeContainer;
private MicrophoneRecorderView microphoneRecorderView;
private SlideToCancel slideToCancel;
@@ -163,7 +166,7 @@ public class InputPanel extends LinearLayout
@NonNull CharSequence body,
@NonNull SlideDeck attachments)
{
this.quoteView.setQuote(glideRequests, id, author, body, false, attachments);
this.quoteView.setQuote(glideRequests, id, author, body, false, attachments, null);
int originalHeight = this.quoteView.getVisibility() == VISIBLE ? this.quoteView.getMeasuredHeight()
: 0;
@@ -290,11 +293,11 @@ public class InputPanel extends LinearLayout
public void setWallpaperEnabled(boolean enabled) {
if (enabled) {
setBackgroundColor(getContext().getResources().getColor(R.color.wallpaper_compose_background));
composeContainer.setBackgroundResource(R.drawable.compose_background_wallpaper);
setBackground(new ColorDrawable(getContext().getResources().getColor(R.color.wallpaper_compose_background)));
composeContainer.setBackground(Objects.requireNonNull(ContextCompat.getDrawable(getContext(), R.drawable.compose_background_wallpaper)));
} else {
setBackgroundColor(getResources().getColor(R.color.signal_background_primary));
composeContainer.setBackgroundResource(R.drawable.compose_background);
setBackground(new ColorDrawable(getContext().getResources().getColor(R.color.signal_background_primary)));
composeContainer.setBackground(Objects.requireNonNull(ContextCompat.getDrawable(getContext(), R.drawable.compose_background)));
}
}

View File

@@ -17,6 +17,7 @@ import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;
import androidx.core.content.ContextCompat;
import com.annimon.stream.Stream;
import com.bumptech.glide.load.engine.DiskCacheStrategy;
@@ -25,6 +26,7 @@ import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.attachments.Attachment;
import org.thoughtcrime.securesms.components.mention.MentionAnnotation;
import org.thoughtcrime.securesms.conversation.colors.ChatColors;
import org.thoughtcrime.securesms.database.model.Mention;
import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader.DecryptableUri;
import org.thoughtcrime.securesms.mms.GlideRequests;
@@ -33,6 +35,7 @@ import org.thoughtcrime.securesms.mms.SlideDeck;
import org.thoughtcrime.securesms.recipients.LiveRecipient;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientForeverObserver;
import org.thoughtcrime.securesms.util.Projection;
import org.thoughtcrime.securesms.util.ThemeUtil;
import java.util.List;
@@ -49,7 +52,7 @@ public class QuoteView extends FrameLayout implements RecipientForeverObserver {
private ViewGroup footerView;
private TextView authorView;
private TextView bodyView;
private ImageView quoteBarView;
private View quoteBarView;
private ImageView thumbnailView;
private View attachmentVideoOverlayView;
private ViewGroup attachmentContainerView;
@@ -152,7 +155,8 @@ public class QuoteView extends FrameLayout implements RecipientForeverObserver {
@NonNull Recipient author,
@Nullable CharSequence body,
boolean originalMissing,
@NonNull SlideDeck attachments)
@NonNull SlideDeck attachments,
@Nullable ChatColors chatColors)
{
if (this.author != null) this.author.removeForeverObserver(this);
@@ -166,6 +170,12 @@ public class QuoteView extends FrameLayout implements RecipientForeverObserver {
setQuoteText(body, attachments);
setQuoteAttachment(glideRequests, attachments);
setQuoteMissingFooter(originalMissing);
if (Build.VERSION.SDK_INT < 21 && messageType == MESSAGE_TYPE_INCOMING && chatColors != null) {
this.setBackgroundColor(chatColors.asSingleColor());
} else {
this.setBackground(null);
}
}
public void setTopCornerSizes(boolean topLeftLarge, boolean topRightLarge) {
@@ -188,15 +198,23 @@ public class QuoteView extends FrameLayout implements RecipientForeverObserver {
setQuoteAuthor(recipient);
}
public @NonNull Projection getProjection(@NonNull ViewGroup parent) {
return Projection.relativeToParent(parent, this, getCorners());
}
public @NonNull Projection.Corners getCorners() {
return new Projection.Corners(cornerMask.getRadii());
}
private void setQuoteAuthor(@NonNull Recipient author) {
boolean outgoing = messageType != MESSAGE_TYPE_INCOMING;
boolean preview = messageType == MESSAGE_TYPE_PREVIEW;
authorView.setText(author.isSelf() ? getContext().getString(R.string.QuoteView_you)
: author.getDisplayName(getContext()));
// We use the raw color resource because Android 4.x was struggling with tints here
quoteBarView.setImageResource(author.getColor().toQuoteBarColorResource(getContext(), outgoing));
mainView.setBackgroundColor(author.getColor().toQuoteBackgroundColor(getContext(), outgoing));
quoteBarView.setBackgroundColor(ContextCompat.getColor(getContext(), outgoing ? R.color.core_white : android.R.color.transparent));
mainView.setBackgroundColor(ContextCompat.getColor(getContext(), preview ? R.color.quote_preview_background : R.color.quote_view_background));
}
private void setQuoteText(@Nullable CharSequence body, @NonNull SlideDeck attachments) {
@@ -272,7 +290,7 @@ public class QuoteView extends FrameLayout implements RecipientForeverObserver {
private void setQuoteMissingFooter(boolean missing) {
footerView.setVisibility(missing ? VISIBLE : GONE);
footerView.setBackgroundColor(author.get().getColor().toQuoteFooterColor(getContext(), messageType != MESSAGE_TYPE_INCOMING));
footerView.setBackgroundColor(ContextCompat.getColor(getContext(), R.color.quote_view_background));
}
public long getQuoteId() {

View File

@@ -0,0 +1,137 @@
package org.thoughtcrime.securesms.components;
import android.graphics.Canvas;
import android.graphics.ColorFilter;
import android.graphics.LinearGradient;
import android.graphics.Paint;
import android.graphics.PixelFormat;
import android.graphics.Point;
import android.graphics.Rect;
import android.graphics.Shader;
import android.graphics.drawable.Drawable;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import java.util.Arrays;
import kotlin.jvm.functions.Function1;
import kotlin.jvm.functions.Function2;
/**
* Drawable which renders a gradient at a specified angle. Note that this drawable does
* not implement drawable state, and all the baggage that comes with a normal Drawable
* override, so this may not work in every scenario.
*
* Essentially, this drawable creates a LinearGradient shader using the given colors and
* positions, but makes it larger than the bounds, such that it can be rotated and still
* fill the bounds with a gradient.
*
* If you wish to apply clipping to this drawable, it is recommended to either use it with
* a CardView or utilize {@link org.thoughtcrime.securesms.util.CustomDrawWrapperKt#customizeOnDraw(Drawable, Function2)}
*/
public final class RotatableGradientDrawable extends Drawable {
/**
* From investigation into how Gradients are rendered vs how they are rendered in
* designs, in order to match spec, we need to rotate gradients by 225 degrees. (180 + 45)
*
* This puts 0 at the bottom (0, -1) of the surface area.
*/
private static final float DEGREE_OFFSET = 225f;
private final float degrees;
private final int[] colors;
private final float[] positions;
private final Rect fillRect = new Rect();
private final Paint fillPaint = new Paint(Paint.ANTI_ALIAS_FLAG | Paint.DITHER_FLAG);
/**
* @param degrees Gradient rotation in degrees, relative to a vector pointed from the center to bottom center
* @param colors The colors of the gradient
* @param positions The positions of the colors. Values should be between 0f and 1f and this array should be the
* same length as colors.
*/
public RotatableGradientDrawable(float degrees, int[] colors, @Nullable float[] positions) {
this.degrees = degrees + DEGREE_OFFSET;
this.colors = colors;
this.positions = positions;
}
@Override
public void setBounds(int left, int top, int right, int bottom) {
super.setBounds(left, top, right, bottom);
Point topLeft = new Point(left, top);
Point topRight = new Point(right, top);
Point bottomLeft = new Point(left, bottom);
Point bottomRight = new Point(right, bottom);
Point origin = new Point(getBounds().width() / 2, getBounds().height() / 2);
Point rotationTopLeft = cornerPrime(origin, topLeft, degrees);
Point rotationTopRight = cornerPrime(origin, topRight, degrees);
Point rotationBottomLeft = cornerPrime(origin, bottomLeft, degrees);
Point rotationBottomRight = cornerPrime(origin, bottomRight, degrees);
fillRect.left = Integer.MAX_VALUE;
fillRect.top = Integer.MAX_VALUE;
fillRect.right = Integer.MIN_VALUE;
fillRect.bottom = Integer.MIN_VALUE;
for (Point point : Arrays.asList(topLeft, topRight, bottomLeft, bottomRight, rotationTopLeft, rotationTopRight, rotationBottomLeft, rotationBottomRight)) {
if (point.x < fillRect.left) {
fillRect.left = point.x;
}
if (point.x > fillRect.right) {
fillRect.right = point.x;
}
if (point.y < fillRect.top) {
fillRect.top = point.y;
}
if (point.y > fillRect.bottom) {
fillRect.bottom = point.y;
}
}
fillPaint.setShader(new LinearGradient(fillRect.left, fillRect.top, fillRect.right, fillRect.bottom, colors, positions, Shader.TileMode.CLAMP));
}
private static Point cornerPrime(@NonNull Point origin, @NonNull Point corner, float degrees) {
return new Point(xPrime(origin, corner, Math.toRadians(degrees)), yPrime(origin, corner, Math.toRadians(degrees)));
}
private static int xPrime(@NonNull Point origin, @NonNull Point corner, double theta) {
return (int) Math.ceil(((corner.x - origin.x) * Math.cos(theta)) - ((corner.y - origin.y) * Math.sin(theta)) + origin.x);
}
private static int yPrime(@NonNull Point origin, @NonNull Point corner, double theta) {
return (int) Math.ceil(((corner.x - origin.x) * Math.sin(theta)) + ((corner.y - origin.y) * Math.cos(theta)) + origin.y);
}
@Override
public void draw(Canvas canvas) {
int save = canvas.save();
canvas.rotate(degrees, getBounds().width() / 2f, getBounds().height() / 2f);
canvas.drawRect(fillRect, fillPaint);
canvas.restoreToCount(save);
}
@Override
public void setAlpha(int alpha) {
// Not supported
}
@Override
public void setColorFilter(@Nullable ColorFilter colorFilter) {
// Not supported
}
@Override
public int getOpacity() {
return PixelFormat.OPAQUE;
}
}

View File

@@ -41,8 +41,8 @@ public class ZoomingImageView extends FrameLayout {
private static final float ZOOM_LEVEL_MIN = 1.0f;
private static final float LARGE_IMAGES_ZOOM_LEVEL_MID = 1.5f;
private static final float LARGE_IMAGES_ZOOM_LEVEL_MAX = 2.0f;
private static final float LARGE_IMAGES_ZOOM_LEVEL_MID = 2.0f;
private static final float LARGE_IMAGES_ZOOM_LEVEL_MAX = 5.0f;
private static final float SMALL_IMAGES_ZOOM_LEVEL_MID = 3.0f;
private static final float SMALL_IMAGES_ZOOM_LEVEL_MAX = 8.0f;

View File

@@ -24,7 +24,6 @@ import org.thoughtcrime.securesms.components.emoji.parsing.EmojiParser;
import org.thoughtcrime.securesms.components.mention.MentionAnnotation;
import org.thoughtcrime.securesms.components.mention.MentionRendererDelegate;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.Util;
import org.whispersystems.libsignal.util.guava.Optional;
@@ -91,7 +90,8 @@ public class EmojiTextView extends AppCompatTextView {
super.onDraw(canvas);
}
@Override public void setText(@Nullable CharSequence text, BufferType type) {
@Override
public void setText(@Nullable CharSequence text, BufferType type) {
EmojiParser.CandidateList candidates = isInEditMode() ? null : EmojiProvider.getCandidates(text);
if (scaleEmojis && candidates != null && candidates.allEmojis) {
@@ -118,23 +118,19 @@ public class EmojiTextView extends AppCompatTextView {
useSystemEmoji = useSystemEmoji();
if (useSystemEmoji || candidates == null || candidates.size() == 0) {
super.setText(new SpannableStringBuilder(Optional.fromNullable(text).or("")).append(Optional.fromNullable(overflowText).or("")), BufferType.NORMAL);
if (getEllipsize() == TextUtils.TruncateAt.END && maxLength > 0) {
ellipsizeAnyTextForMaxLength();
}
super.setText(new SpannableStringBuilder(Optional.fromNullable(text).or("")), BufferType.NORMAL);
} else {
CharSequence emojified = EmojiProvider.emojify(candidates, text, this);
super.setText(new SpannableStringBuilder(emojified).append(Optional.fromNullable(overflowText).or("")), BufferType.SPANNABLE);
super.setText(new SpannableStringBuilder(emojified), BufferType.SPANNABLE);
}
// Android fails to ellipsize spannable strings. (https://issuetracker.google.com/issues/36991688)
// We ellipsize them ourselves by manually truncating the appropriate section.
if (getEllipsize() == TextUtils.TruncateAt.END) {
if (maxLength > 0) {
ellipsizeAnyTextForMaxLength();
} else {
ellipsizeEmojiTextForMaxLines();
}
// Android fails to ellipsize spannable strings. (https://issuetracker.google.com/issues/36991688)
// We ellipsize them ourselves by manually truncating the appropriate section.
if (getText() != null && getText().length() > 0 && getEllipsize() == TextUtils.TruncateAt.END) {
if (maxLength > 0) {
ellipsizeAnyTextForMaxLength();
} else if (getMaxLines() > 0) {
ellipsizeEmojiTextForMaxLines();
}
}
@@ -192,7 +188,8 @@ public class EmojiTextView extends AppCompatTextView {
if (lineCount > maxLines) {
int overflowStart = getLayout().getLineStart(maxLines - 1);
CharSequence overflow = getText().subSequence(overflowStart, getText().length());
CharSequence ellipsized = TextUtils.ellipsize(overflow, getPaint(), getWidth(), TextUtils.TruncateAt.END);
float adjust = overflowText != null ? getPaint().measureText(overflowText, 0, overflowText.length()) : 0f;
CharSequence ellipsized = TextUtils.ellipsize(overflow, getPaint(), getWidth() - adjust, TextUtils.TruncateAt.END);
SpannableStringBuilder newContent = new SpannableStringBuilder();
newContent.append(getText().subSequence(0, overflowStart))

View File

@@ -31,7 +31,7 @@ public final class DeviceOrientationMonitor implements DefaultLifecycleObserver
private final float[] rotationMatrix = new float[9];
private final float[] orientationAngles = new float[3];
private final MutableLiveData<Orientation> orientation = new MutableLiveData<>();
private final MutableLiveData<Orientation> orientation = new MutableLiveData<>(Orientation.PORTRAIT_BOTTOM_EDGE);
public DeviceOrientationMonitor(@NonNull Context context) {
this.sensorManager = ServiceUtil.getSensorManager(context);

View File

@@ -25,7 +25,7 @@ open class DSLSettingsActivity : PassphraseRequiredActivity() {
throw IllegalStateException("No navgraph id was passed to activity")
}
val fragment: NavHostFragment = NavHostFragment.create(navGraphId)
val fragment: NavHostFragment = NavHostFragment.create(navGraphId, intent.getBundleExtra(ARG_START_BUNDLE))
supportFragmentManager.beginTransaction()
.replace(R.id.nav_host_fragment, fragment)
@@ -61,6 +61,7 @@ open class DSLSettingsActivity : PassphraseRequiredActivity() {
companion object {
const val ARG_NAV_GRAPH = "nav_graph"
const val ARG_START_BUNDLE = "start_bundle"
}
private inner class OnBackPressed : OnBackPressedCallback(true) {

View File

@@ -5,6 +5,7 @@ import android.text.method.LinkMovementMethod
import android.text.style.ClickableSpan
import android.view.View
import android.widget.ImageView
import android.widget.RadioButton
import android.widget.TextView
import androidx.annotation.CallSuper
import androidx.core.content.ContextCompat
@@ -26,6 +27,7 @@ class DSLSettingsAdapter : MappingAdapter() {
registerFactory(DividerPreference::class.java, LayoutFactory(::DividerPreferenceViewHolder, R.layout.dsl_divider_item))
registerFactory(SectionHeaderPreference::class.java, LayoutFactory(::SectionHeaderPreferenceViewHolder, R.layout.dsl_section_header))
registerFactory(SwitchPreference::class.java, LayoutFactory(::SwitchPreferenceViewHolder, R.layout.dsl_switch_preference_item))
registerFactory(RadioPreference::class.java, LayoutFactory(::RadioPreferenceViewHolder, R.layout.dsl_radio_preference_item))
}
}
@@ -150,6 +152,19 @@ class SwitchPreferenceViewHolder(itemView: View) : PreferenceViewHolder<SwitchPr
}
}
class RadioPreferenceViewHolder(itemView: View) : PreferenceViewHolder<RadioPreference>(itemView) {
private val radioButton: RadioButton = itemView.findViewById(R.id.radio_widget)
override fun bind(model: RadioPreference) {
super.bind(model)
radioButton.isChecked = model.isChecked
itemView.setOnClickListener {
model.onClick()
}
}
}
class ExternalLinkPreferenceViewHolder(itemView: View) : PreferenceViewHolder<ExternalLinkPreference>(itemView) {
override fun bind(model: ExternalLinkPreference) {
super.bind(model)

View File

@@ -4,6 +4,7 @@ import android.os.Build
import android.os.Bundle
import android.view.View
import android.widget.EdgeEffect
import androidx.annotation.LayoutRes
import androidx.annotation.MenuRes
import androidx.annotation.StringRes
import androidx.appcompat.widget.Toolbar
@@ -14,8 +15,9 @@ import org.thoughtcrime.securesms.R
abstract class DSLSettingsFragment(
@StringRes private val titleId: Int,
@MenuRes private val menuId: Int = -1
) : Fragment(R.layout.dsl_settings_fragment) {
@MenuRes private val menuId: Int = -1,
@LayoutRes layoutId: Int = R.layout.dsl_settings_fragment
) : Fragment(layoutId) {
private lateinit var recyclerView: RecyclerView
private lateinit var toolbarShadowHelper: ToolbarShadowHelper

View File

@@ -42,7 +42,7 @@ class AppearanceSettingsFragment : DSLSettingsFragment(R.string.preferences__app
)
clickPref(
title = DSLSettingsText.from(R.string.preferences__chat_wallpaper),
title = DSLSettingsText.from(R.string.preferences__chat_color_and_wallpaper),
onClick = {
Navigation.findNavController(requireView()).navigate(R.id.action_appearanceSettings_to_wallpaperActivity)
}

View File

@@ -174,7 +174,7 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
summary = DSLSettingsText.from(R.string.preferences__internal_force_censorship_description),
isChecked = state.forceCensorship,
onClick = {
viewModel.setDisableAutoMigrationNotification(!state.forceCensorship)
viewModel.setForceCensorship(!state.forceCensorship)
}
)

View File

@@ -31,8 +31,9 @@ class NotificationsSettingsViewModel(private val sharedPreferences: SharedPrefer
}
fun setMessageNotificationsSound(sound: Uri?) {
SignalStore.settings().messageNotificationSound = sound ?: Uri.EMPTY
NotificationChannels.updateMessageRingtone(ApplicationDependencies.getApplication(), sound)
val messageSound = sound ?: Uri.EMPTY
SignalStore.settings().messageNotificationSound = messageSound
NotificationChannels.updateMessageRingtone(ApplicationDependencies.getApplication(), messageSound)
store.update { getState() }
}

View File

@@ -6,12 +6,15 @@ import android.content.Intent
import android.text.SpannableStringBuilder
import android.text.Spanned
import android.text.style.TextAppearanceSpan
import android.view.View
import android.view.WindowManager
import android.widget.TextView
import android.widget.Toast
import androidx.annotation.StringRes
import androidx.core.content.ContextCompat
import androidx.lifecycle.ViewModelProviders
import androidx.navigation.Navigation
import androidx.navigation.fragment.NavHostFragment
import androidx.preference.PreferenceManager
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import mobi.upod.timedurationpicker.TimeDurationPicker
@@ -19,10 +22,14 @@ import mobi.upod.timedurationpicker.TimeDurationPickerDialog
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.PassphraseChangeActivity
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.ClickPreference
import org.thoughtcrime.securesms.components.settings.ClickPreferenceViewHolder
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.PreferenceModel
import org.thoughtcrime.securesms.components.settings.PreferenceViewHolder
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.crypto.MasterSecretUtil
import org.thoughtcrime.securesms.keyvalue.PhoneNumberPrivacyValues
@@ -30,15 +37,17 @@ import org.thoughtcrime.securesms.keyvalue.PhoneNumberPrivacyValues.PhoneNumberL
import org.thoughtcrime.securesms.service.KeyCachingService
import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.ConversationUtil
import org.thoughtcrime.securesms.util.ExpirationUtil
import org.thoughtcrime.securesms.util.FeatureFlags
import org.thoughtcrime.securesms.util.MappingAdapter
import org.thoughtcrime.securesms.util.ServiceUtil
import org.thoughtcrime.securesms.util.SpanUtil
import org.thoughtcrime.securesms.util.TextSecurePreferences
import java.lang.Integer.max
import java.util.ArrayList
import java.util.LinkedHashMap
import java.util.Locale
import java.util.concurrent.TimeUnit
import kotlin.collections.ArrayList
import kotlin.collections.LinkedHashMap
private val TAG = Log.tag(PrivacySettingsFragment::class.java)
@@ -62,6 +71,8 @@ class PrivacySettingsFragment : DSLSettingsFragment(R.string.preferences__privac
}
override fun bindAdapter(adapter: DSLSettingsAdapter) {
adapter.registerFactory(ValueClickPreference::class.java, MappingAdapter.LayoutFactory(::ValueClickPreferenceViewHolder, R.layout.value_click_preference_item))
val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(requireContext())
val repository = PrivacySettingsRepository()
val factory = PrivacySettingsViewModel.Factory(sharedPreferences, repository)
@@ -127,6 +138,25 @@ class PrivacySettingsFragment : DSLSettingsFragment(R.string.preferences__privac
}
)
if (FeatureFlags.defaultMessageTimer()) {
dividerPref()
sectionHeaderPref(R.string.PrivacySettingsFragment__disappearing_messages)
customPref(
ValueClickPreference(
value = DSLSettingsText.from(ExpirationUtil.getExpirationAbbreviatedDisplayValue(requireContext(), state.universalExpireTimer)),
clickPreference = ClickPreference(
title = DSLSettingsText.from(R.string.PrivacySettingsFragment__default_timer_for_new_changes),
summary = DSLSettingsText.from(R.string.PrivacySettingsFragment__set_a_default_disappearing_message_timer_for_all_new_chats_started_by_you),
onClick = {
NavHostFragment.findNavController(this@PrivacySettingsFragment).navigate(R.id.action_privacySettingsFragment_to_disappearingMessagesTimerSelectFragment)
}
)
)
)
}
dividerPref()
sectionHeaderPref(R.string.PrivacySettingsFragment__app_security)
@@ -141,7 +171,7 @@ class PrivacySettingsFragment : DSLSettingsFragment(R.string.preferences__privac
setTitle(R.string.ApplicationPreferencesActivity_disable_passphrase)
setMessage(R.string.ApplicationPreferencesActivity_this_will_permanently_unlock_signal_and_message_notifications)
setIcon(R.drawable.ic_warning)
setPositiveButton(R.string.ApplicationPreferencesActivity_disable) { dialog, which ->
setPositiveButton(R.string.ApplicationPreferencesActivity_disable) { _, _ ->
MasterSecretUtil.changeMasterSecretPassphrase(
activity,
KeyCachingService.getMasterSecret(context),
@@ -395,4 +425,31 @@ class PrivacySettingsFragment : DSLSettingsFragment(R.string.preferences__privac
show()
}
}
private class ValueClickPreference(
val value: DSLSettingsText,
val clickPreference: ClickPreference
) : PreferenceModel<ValueClickPreference>(
title = clickPreference.title,
summary = clickPreference.summary,
iconId = clickPreference.iconId,
isEnabled = clickPreference.isEnabled
) {
override fun areContentsTheSame(newItem: ValueClickPreference): Boolean {
return super.areContentsTheSame(newItem) &&
clickPreference == newItem.clickPreference &&
value == newItem.value
}
}
private class ValueClickPreferenceViewHolder(itemView: View) : PreferenceViewHolder<ValueClickPreference>(itemView) {
private val clickPreferenceViewHolder = ClickPreferenceViewHolder(itemView)
private val valueText: TextView = findViewById(R.id.value_client_preference_value)
override fun bind(model: ValueClickPreference) {
super.bind(model)
clickPreferenceViewHolder.bind(model.clickPreference)
valueText.text = model.value.resolve(context)
}
}
}

View File

@@ -14,5 +14,6 @@ data class PrivacySettingsState(
val incognitoKeyboard: Boolean,
val isObsoletePasswordEnabled: Boolean,
val isObsoletePasswordTimeoutEnabled: Boolean,
val obsoletePasswordTimeout: Int
val obsoletePasswordTimeout: Int,
val universalExpireTimer: Int
)

View File

@@ -24,6 +24,7 @@ class PrivacySettingsViewModel(
fun refreshBlockedCount() {
repository.getBlockedCount { count ->
store.update { it.copy(blockedCount = count) }
refresh()
}
}
@@ -99,7 +100,8 @@ class PrivacySettingsViewModel(
findMeByPhoneNumber = SignalStore.phoneNumberPrivacy().phoneNumberListingMode,
isObsoletePasswordEnabled = !TextSecurePreferences.isPasswordDisabled(ApplicationDependencies.getApplication()),
isObsoletePasswordTimeoutEnabled = TextSecurePreferences.isPassphraseTimeoutEnabled(ApplicationDependencies.getApplication()),
obsoletePasswordTimeout = TextSecurePreferences.getPassphraseTimeoutInterval(ApplicationDependencies.getApplication())
obsoletePasswordTimeout = TextSecurePreferences.getPassphraseTimeoutInterval(ApplicationDependencies.getApplication()),
universalExpireTimer = SignalStore.settings().universalExpireTimer
)
}

View File

@@ -3,9 +3,12 @@ package org.thoughtcrime.securesms.components.settings.app.privacy.advanced
import android.app.ProgressDialog
import android.graphics.PorterDuff
import android.graphics.PorterDuffColorFilter
import android.graphics.drawable.Drawable
import android.text.SpannableStringBuilder
import android.widget.TextView
import android.widget.Toast
import androidx.core.content.ContextCompat
import androidx.core.widget.TextViewCompat
import androidx.lifecycle.ViewModelProviders
import androidx.preference.PreferenceManager
import com.google.android.material.dialog.MaterialAlertDialogBuilder
@@ -98,16 +101,28 @@ class AdvancedPrivacySettingsFragment : DSLSettingsFragment(R.string.preferences
isChecked = state.isPushEnabled
) {
if (state.isPushEnabled) {
MaterialAlertDialogBuilder(requireContext()).apply {
setIcon(R.drawable.ic_info_outline)
setTitle(R.string.ApplicationPreferencesActivity_disable_signal_messages_and_calls)
val builder = MaterialAlertDialogBuilder(requireContext()).apply {
setMessage(R.string.ApplicationPreferencesActivity_disable_signal_messages_and_calls_by_unregistering)
setNegativeButton(android.R.string.cancel, null)
setPositiveButton(
android.R.string.ok
) { _, _ -> viewModel.disablePushMessages() }
show()
}
val icon: Drawable = requireNotNull(ContextCompat.getDrawable(builder.context, R.drawable.ic_info_outline))
icon.setBounds(0, 0, ViewUtil.dpToPx(32), ViewUtil.dpToPx(32))
val title = TextView(builder.context)
val padding = ViewUtil.dpToPx(16)
title.setText(R.string.ApplicationPreferencesActivity_disable_signal_messages_and_calls)
title.setPadding(padding, padding, padding, padding)
title.compoundDrawablePadding = padding / 2
TextViewCompat.setTextAppearance(title, R.style.TextAppearance_Signal_Title2_MaterialDialog)
TextViewCompat.setCompoundDrawablesRelative(title, icon, null, null, null)
builder
.setCustomTitle(title)
.show()
} else {
startActivity(RegistrationNavigationActivity.newIntentForReRegistration(requireContext()))
}

View File

@@ -0,0 +1,52 @@
package org.thoughtcrime.securesms.components.settings.app.privacy.expire
import android.app.Dialog
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.FragmentManager
import androidx.lifecycle.ViewModelProvider
import androidx.navigation.fragment.NavHostFragment
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.thoughtcrime.securesms.R
/**
* Dialog for selecting a custom expire timer value.
*/
class CustomExpireTimerSelectDialog : DialogFragment() {
private lateinit var viewModel: ExpireTimerSettingsViewModel
private lateinit var selector: CustomExpireTimerSelectorView
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val dialogView: View = LayoutInflater.from(context).inflate(R.layout.custom_expire_timer_select_dialog, null, false)
selector = dialogView.findViewById(R.id.custom_expire_timer_select_dialog_selector)
val builder = MaterialAlertDialogBuilder(requireContext(), R.style.Signal_ThemeOverlay_Dialog_Rounded)
return builder.setTitle(R.string.ExpireTimerSettingsFragment__custom_time)
.setView(dialogView)
.setPositiveButton(R.string.ExpireTimerSettingsFragment__set) { _, _ ->
viewModel.select(selector.getTimer())
}
.setNegativeButton(android.R.string.cancel, null)
.create()
}
override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
viewModel = ViewModelProvider(NavHostFragment.findNavController(this).getViewModelStoreOwner(R.id.app_settings_expire_timer))
.get(ExpireTimerSettingsViewModel::class.java)
viewModel.state.observe(this) { selector.setTimer(it.currentTimer) }
}
companion object {
private const val DIALOG_TAG = "CustomTimerSelectDialog"
fun show(fragmentManager: FragmentManager) {
CustomExpireTimerSelectDialog().show(fragmentManager, DIALOG_TAG)
}
}
}

View File

@@ -0,0 +1,79 @@
package org.thoughtcrime.securesms.components.settings.app.privacy.expire
import android.content.Context
import android.util.AttributeSet
import android.view.Gravity
import android.widget.LinearLayout
import android.widget.NumberPicker
import org.thoughtcrime.securesms.R
import java.util.concurrent.TimeUnit
/**
* Show number pickers for value and units that are valid for expiration timer.
*/
class CustomExpireTimerSelectorView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : LinearLayout(context, attrs, defStyleAttr) {
private val valuePicker: NumberPicker
private val unitPicker: NumberPicker
init {
orientation = HORIZONTAL
gravity = Gravity.CENTER
inflate(context, R.layout.custom_expire_timer_selector_view, this)
valuePicker = findViewById(R.id.custom_expire_timer_selector_value)
unitPicker = findViewById(R.id.custom_expire_timer_selector_unit)
valuePicker.minValue = TimerUnit.get(1).minValue
valuePicker.maxValue = TimerUnit.get(1).maxValue
unitPicker.minValue = 0
unitPicker.maxValue = 4
unitPicker.value = 1
unitPicker.wrapSelectorWheel = false
unitPicker.isLongClickable = false
unitPicker.displayedValues = context.resources.getStringArray(R.array.CustomExpireTimerSelectorView__unit_labels)
unitPicker.setOnValueChangedListener { _, _, newValue -> unitChange(newValue) }
}
fun setTimer(timer: Int?) {
if (timer == null || timer == 0) {
return
}
TimerUnit.values()
.find { (timer / it.valueMultiplier) < it.maxValue }
?.let { timerUnit ->
valuePicker.value = (timer / timerUnit.valueMultiplier).toInt()
unitPicker.value = TimerUnit.values().indexOf(timerUnit)
unitChange(unitPicker.value)
}
}
fun getTimer(): Int {
return valuePicker.value * TimerUnit.get(unitPicker.value).valueMultiplier.toInt()
}
private fun unitChange(newValue: Int) {
val timerUnit: TimerUnit = TimerUnit.values()[newValue]
valuePicker.minValue = timerUnit.minValue
valuePicker.maxValue = timerUnit.maxValue
}
private enum class TimerUnit(val minValue: Int, val maxValue: Int, val valueMultiplier: Long) {
SECONDS(1, 59, TimeUnit.SECONDS.toSeconds(1)),
MINUTES(1, 59, TimeUnit.MINUTES.toSeconds(1)),
HOURS(1, 23, TimeUnit.HOURS.toSeconds(1)),
DAYS(1, 6, TimeUnit.DAYS.toSeconds(1)),
WEEKS(1, 4, TimeUnit.DAYS.toSeconds(7));
companion object {
fun get(value: Int) = values()[value]
}
}
}

View File

@@ -0,0 +1,139 @@
package org.thoughtcrime.securesms.components.settings.app.privacy.expire
import android.app.Activity
import android.content.Intent
import android.os.Bundle
import android.view.View
import android.widget.Toast
import androidx.lifecycle.ViewModelProvider
import androidx.navigation.fragment.NavHostFragment
import androidx.recyclerview.widget.RecyclerView
import com.dd.CircularProgressButton
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.groups.ui.GroupChangeFailureReason
import org.thoughtcrime.securesms.groups.ui.GroupErrors
import org.thoughtcrime.securesms.util.ExpirationUtil
import org.thoughtcrime.securesms.util.ViewUtil
import org.thoughtcrime.securesms.util.livedata.ProcessState
import org.thoughtcrime.securesms.util.livedata.distinctUntilChanged
/**
* Depending on the arguments, can be used to set the universal expire timer, set expire timer
* for a individual or group recipient, or select a value and return it via result.
*/
class ExpireTimerSettingsFragment : DSLSettingsFragment(
titleId = R.string.PrivacySettingsFragment__disappearing_messages,
layoutId = R.layout.expire_timer_settings_fragment
) {
private lateinit var save: CircularProgressButton
private lateinit var viewModel: ExpireTimerSettingsViewModel
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
save = view.findViewById(R.id.timer_select_fragment_save)
save.setOnClickListener { viewModel.save() }
adjustListPaddingForSaveButton(view)
}
private fun adjustListPaddingForSaveButton(view: View) {
val recycler: RecyclerView = view.findViewById(R.id.recycler)
recycler.setPadding(recycler.paddingLeft, recycler.paddingTop, recycler.paddingRight, ViewUtil.dpToPx(80))
recycler.clipToPadding = false
}
override fun bindAdapter(adapter: DSLSettingsAdapter) {
val provider = ViewModelProvider(
NavHostFragment.findNavController(this).getViewModelStoreOwner(R.id.app_settings_expire_timer),
ExpireTimerSettingsViewModel.Factory(requireContext(), arguments.toConfig())
)
viewModel = provider.get(ExpireTimerSettingsViewModel::class.java)
viewModel.state.observe(viewLifecycleOwner) { state ->
adapter.submitList(getConfiguration(state).toMappingModelList())
}
viewModel.state.distinctUntilChanged(ExpireTimerSettingsState::saveState).observe(viewLifecycleOwner) { state ->
when (val saveState: ProcessState<Int> = state.saveState) {
is ProcessState.Working -> {
save.isClickable = false
save.isIndeterminateProgressMode = true
save.progress = 50
}
is ProcessState.Success -> {
if (state.isGroupCreate) {
requireActivity().setResult(Activity.RESULT_OK, Intent().putExtra(FOR_RESULT_VALUE, saveState.result))
}
save.isClickable = false
requireActivity().onNavigateUp()
}
is ProcessState.Failure -> {
val groupChangeFailureReason: GroupChangeFailureReason = saveState.throwable?.let(GroupChangeFailureReason::fromException) ?: GroupChangeFailureReason.OTHER
Toast.makeText(context, GroupErrors.getUserDisplayMessage(groupChangeFailureReason), Toast.LENGTH_LONG).show()
viewModel.resetError()
}
else -> {
save.isClickable = true
save.isIndeterminateProgressMode = false
save.progress = 0
}
}
}
}
private fun getConfiguration(state: ExpireTimerSettingsState): DSLConfiguration {
return configure {
textPref(
summary = DSLSettingsText.from(
if (state.isForRecipient) {
R.string.ExpireTimerSettingsFragment__when_enabled_new_messages_sent_and_received_in_this_chat_will_disappear_after_they_have_been_seen
} else {
R.string.ExpireTimerSettingsFragment__when_enabled_new_messages_sent_and_received_in_new_chats_started_by_you_will_disappear_after_they_have_been_seen
}
)
)
val labels: Array<String> = resources.getStringArray(R.array.ExpireTimerSettingsFragment__labels)
val values: Array<Int> = resources.getIntArray(R.array.ExpireTimerSettingsFragment__values).toTypedArray()
var hasCustomValue = true
labels.zip(values).forEach { (label, value) ->
radioPref(
title = DSLSettingsText.from(label),
isChecked = state.currentTimer == value,
onClick = { viewModel.select(value) }
)
hasCustomValue = hasCustomValue && state.currentTimer != value
}
radioPref(
title = DSLSettingsText.from(R.string.ExpireTimerSettingsFragment__custom_time),
summary = if (hasCustomValue) DSLSettingsText.from(ExpirationUtil.getExpirationDisplayValue(requireContext(), state.currentTimer)) else null,
isChecked = hasCustomValue,
onClick = { NavHostFragment.findNavController(this@ExpireTimerSettingsFragment).navigate(R.id.action_expireTimerSettingsFragment_to_customExpireTimerSelectDialog) }
)
}
}
companion object {
const val FOR_RESULT_VALUE = "for_result_value"
}
}
private fun Bundle?.toConfig(): ExpireTimerSettingsViewModel.Config {
if (this == null) {
return ExpireTimerSettingsViewModel.Config()
}
val safeArguments: ExpireTimerSettingsFragmentArgs = ExpireTimerSettingsFragmentArgs.fromBundle(this)
return ExpireTimerSettingsViewModel.Config(
recipientId = safeArguments.recipientId,
forResultMode = safeArguments.forResultMode,
initialValue = safeArguments.initialValue
)
}

View File

@@ -0,0 +1,53 @@
package org.thoughtcrime.securesms.components.settings.app.privacy.expire
import android.content.Context
import androidx.annotation.WorkerThread
import org.signal.core.util.concurrent.SignalExecutors
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.database.DatabaseFactory
import org.thoughtcrime.securesms.database.ThreadDatabase
import org.thoughtcrime.securesms.groups.GroupChangeException
import org.thoughtcrime.securesms.groups.GroupManager
import org.thoughtcrime.securesms.mms.OutgoingExpirationUpdateMessage
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.sms.MessageSender
import java.io.IOException
private val TAG: String = Log.tag(ExpireTimerSettingsRepository::class.java)
/**
* Provide operations to set expire timer for individuals and groups.
*/
class ExpireTimerSettingsRepository(val context: Context) {
fun setExpiration(recipientId: RecipientId, newExpirationTime: Int, consumer: (Result<Int>) -> Unit) {
SignalExecutors.BOUNDED.execute {
val recipient = Recipient.resolved(recipientId)
if (recipient.groupId.isPresent && recipient.groupId.get().isPush) {
try {
GroupManager.updateGroupTimer(context, recipient.groupId.get().requirePush(), newExpirationTime)
consumer.invoke(Result.success(newExpirationTime))
} catch (e: GroupChangeException) {
Log.w(TAG, e)
consumer.invoke(Result.failure(e))
} catch (e: IOException) {
Log.w(TAG, e)
consumer.invoke(Result.failure(e))
}
} else {
DatabaseFactory.getRecipientDatabase(context).setExpireMessages(recipientId, newExpirationTime)
val outgoingMessage = OutgoingExpirationUpdateMessage(Recipient.resolved(recipientId), System.currentTimeMillis(), newExpirationTime * 1000L)
MessageSender.send(context, outgoingMessage, getThreadId(recipientId), false, null)
consumer.invoke(Result.success(newExpirationTime))
}
}
}
@WorkerThread
private fun getThreadId(recipientId: RecipientId): Long {
val threadDatabase: ThreadDatabase = DatabaseFactory.getThreadDatabase(context)
val recipient: Recipient = Recipient.resolved(recipientId)
return threadDatabase.getThreadIdFor(recipient)
}
}

View File

@@ -0,0 +1,14 @@
package org.thoughtcrime.securesms.components.settings.app.privacy.expire
import org.thoughtcrime.securesms.util.livedata.ProcessState
data class ExpireTimerSettingsState(
val initialTimer: Int = 0,
val userSetTimer: Int? = null,
val saveState: ProcessState<Int> = ProcessState.Idle(),
val isGroupCreate: Boolean = false,
val isForRecipient: Boolean = isGroupCreate,
) {
val currentTimer: Int
get() = userSetTimer ?: initialTimer
}

View File

@@ -0,0 +1,70 @@
package org.thoughtcrime.securesms.components.settings.app.privacy.expire
import android.content.Context
import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.livedata.ProcessState
import org.thoughtcrime.securesms.util.livedata.Store
class ExpireTimerSettingsViewModel(val config: Config, private val repository: ExpireTimerSettingsRepository) : ViewModel() {
private val store = Store<ExpireTimerSettingsState>(ExpireTimerSettingsState(isGroupCreate = config.forResultMode))
private val recipientId: RecipientId? = config.recipientId
val state: LiveData<ExpireTimerSettingsState> = store.stateLiveData
init {
if (recipientId != null) {
store.update(Recipient.live(recipientId).liveData) { r, s -> s.copy(initialTimer = r.expireMessages, isForRecipient = true) }
} else {
store.update { it.copy(initialTimer = config.initialValue ?: SignalStore.settings().universalExpireTimer) }
}
}
fun select(time: Int) {
store.update { it.copy(userSetTimer = time) }
}
fun save() {
val userSetTimer: Int = store.state.currentTimer
if (userSetTimer == store.state.initialTimer) {
store.update { it.copy(saveState = ProcessState.Success(userSetTimer)) }
return
}
store.update { it.copy(saveState = ProcessState.Working()) }
if (recipientId != null) {
repository.setExpiration(recipientId, userSetTimer) { result ->
store.update { it.copy(saveState = ProcessState.fromResult(result)) }
}
} else if (config.forResultMode) {
store.update { it.copy(saveState = ProcessState.Success(userSetTimer)) }
} else {
SignalStore.settings().universalExpireTimer = userSetTimer
store.update { it.copy(saveState = ProcessState.Success(userSetTimer)) }
}
}
fun resetError() {
store.update { it.copy(saveState = ProcessState.Idle()) }
}
class Factory(context: Context, private val config: Config) : ViewModelProvider.Factory {
val repository = ExpireTimerSettingsRepository(context.applicationContext)
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
return requireNotNull(modelClass.cast(ExpireTimerSettingsViewModel(config, repository)))
}
}
data class Config(
val recipientId: RecipientId? = null,
val forResultMode: Boolean = false,
val initialValue: Int? = null
)
}

View File

@@ -56,6 +56,17 @@ class DSLConfiguration {
children.add(preference)
}
fun radioPref(
title: DSLSettingsText,
summary: DSLSettingsText? = null,
isEnabled: Boolean = true,
isChecked: Boolean,
onClick: () -> Unit
) {
val preference = RadioPreference(title, summary, isEnabled, isChecked, onClick)
children.add(preference)
}
fun clickPref(
title: DSLSettingsText,
summary: DSLSettingsText? = null,
@@ -131,7 +142,7 @@ class TextPreference(
) : PreferenceModel<TextPreference>(title = title, summary = summary)
class DividerPreference : PreferenceModel<DividerPreference>() {
override fun areItemsTheSame(newItem: DividerPreference) = false
override fun areItemsTheSame(newItem: DividerPreference) = true
}
class RadioListPreference(
@@ -175,11 +186,23 @@ class SwitchPreference(
}
}
class RadioPreference(
title: DSLSettingsText,
summary: DSLSettingsText? = null,
isEnabled: Boolean,
val isChecked: Boolean,
val onClick: () -> Unit
) : PreferenceModel<RadioPreference>(title = title, summary = summary, isEnabled = isEnabled) {
override fun areContentsTheSame(newItem: RadioPreference): Boolean {
return super.areContentsTheSame(newItem) && isChecked == newItem.isChecked
}
}
class ClickPreference(
override val title: DSLSettingsText,
override val summary: DSLSettingsText?,
@DrawableRes override val iconId: Int,
isEnabled: Boolean,
override val summary: DSLSettingsText? = null,
@DrawableRes override val iconId: Int = UNSET,
isEnabled: Boolean = true,
val onClick: () -> Unit
) : PreferenceModel<ClickPreference>(title = title, summary = summary, iconId = iconId, isEnabled = isEnabled)

View File

@@ -65,7 +65,7 @@ class VoiceNoteMediaDescriptionCompatFactory {
extras.putString(EXTRA_AVATAR_RECIPIENT_ID, avatarRecipient.getId().serialize());
extras.putLong(EXTRA_MESSAGE_POSITION, startingPosition);
extras.putLong(EXTRA_THREAD_ID, messageRecord.getThreadId());
extras.putString(EXTRA_COLOR, threadRecipient.getColor().serialize());
extras.putLong(EXTRA_COLOR, threadRecipient.getChatColors().asSingleColor());
extras.putLong(EXTRA_MESSAGE_ID, messageRecord.getId());
NotificationPrivacyPreference preference = SignalStore.settings().getMessageNotificationsPrivacy();

View File

@@ -19,6 +19,8 @@ import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.color.MaterialColor;
import org.thoughtcrime.securesms.contacts.avatars.ContactColors;
import org.thoughtcrime.securesms.conversation.ConversationIntents;
import org.thoughtcrime.securesms.conversation.colors.ChatColors;
import org.thoughtcrime.securesms.conversation.colors.ChatColorsPalette;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.notifications.NotificationChannels;
import org.thoughtcrime.securesms.recipients.Recipient;
@@ -99,14 +101,13 @@ class VoiceNoteNotificationManager {
int startingPosition = (int) controller.getMetadata().getLong(VoiceNoteMediaDescriptionCompatFactory.EXTRA_MESSAGE_POSITION);
long threadId = controller.getMetadata().getLong(VoiceNoteMediaDescriptionCompatFactory.EXTRA_THREAD_ID);
MaterialColor color;
try {
color = MaterialColor.fromSerialized(controller.getMetadata().getString(VoiceNoteMediaDescriptionCompatFactory.EXTRA_COLOR));
} catch (MaterialColor.UnknownColorException e) {
color = ContactColors.UNKNOWN_COLOR;
int color = (int) controller.getMetadata().getLong(VoiceNoteMediaDescriptionCompatFactory.EXTRA_COLOR);
if (color == 0) {
color = ChatColorsPalette.UNKNOWN_CONTACT.asSingleColor();
}
notificationManager.setColor(color.toNotificationColor(context));
notificationManager.setColor(color);
Intent conversationActivity = ConversationIntents.createBuilder(context, recipientId, threadId)
.withStartingPosition(startingPosition)

View File

@@ -11,18 +11,43 @@ import org.webrtc.VideoSink;
import java.util.WeakHashMap;
/**
* Video sink implementation that handles broadcasting a single source video track to
* multiple {@link VideoSink} consumers.
*
* Also has logic to manage rotating frames before forwarding to prevent each renderer
* from having to copy the frame for rotation.
*/
public class BroadcastVideoSink implements VideoSink {
private final EglBase eglBase;
private final WeakHashMap<VideoSink, Boolean> sinks;
private final WeakHashMap<Object, Point> requestingSizes;
private boolean dirtySizes;
private int deviceOrientationDegrees;
private boolean rotateToRightSide;
private boolean forceRotate;
private boolean rotateWithDevice;
public BroadcastVideoSink(@Nullable EglBase eglBase) {
this.eglBase = eglBase;
this.sinks = new WeakHashMap<>();
this.requestingSizes = new WeakHashMap<>();
this.dirtySizes = true;
public BroadcastVideoSink() {
this(null, false, true, 0);
}
/**
* @param eglBase Rendering context
* @param forceRotate Always rotate video frames regardless of frame dimension
* @param rotateWithDevice Rotate video frame to match device orientation
* @param deviceOrientationDegrees Device orientation in degrees
*/
public BroadcastVideoSink(@Nullable EglBase eglBase, boolean forceRotate, boolean rotateWithDevice, int deviceOrientationDegrees) {
this.eglBase = eglBase;
this.sinks = new WeakHashMap<>();
this.requestingSizes = new WeakHashMap<>();
this.dirtySizes = true;
this.deviceOrientationDegrees = deviceOrientationDegrees;
this.rotateToRightSide = false;
this.forceRotate = forceRotate;
this.rotateWithDevice = rotateWithDevice;
}
public @Nullable EglBase getEglBase() {
@@ -37,13 +62,58 @@ public class BroadcastVideoSink implements VideoSink {
sinks.remove(sink);
}
public void setForceRotate(boolean forceRotate) {
this.forceRotate = forceRotate;
}
public void setRotateWithDevice(boolean rotateWithDevice) {
this.rotateWithDevice = rotateWithDevice;
}
/**
* Set the specific rotation desired when not rotating with device.
*
* Really only needed for properly rotating self camera views.
*/
public void setRotateToRightSide(boolean rotateToRightSide) {
this.rotateToRightSide = rotateToRightSide;
}
public void setDeviceOrientationDegrees(int deviceOrientationDegrees) {
this.deviceOrientationDegrees = deviceOrientationDegrees;
}
@Override
public synchronized void onFrame(@NonNull VideoFrame videoFrame) {
if (videoFrame.getRotatedHeight() < videoFrame.getRotatedWidth() || forceRotate) {
int rotation = calculateRotation();
if (rotation > 0) {
rotation += rotateWithDevice ? videoFrame.getRotation() : 0;
videoFrame = new VideoFrame(videoFrame.getBuffer(), rotation % 360, videoFrame.getTimestampNs());
}
}
for (VideoSink sink : sinks.keySet()) {
sink.onFrame(videoFrame);
}
}
private int calculateRotation() {
if (forceRotate && (deviceOrientationDegrees == 0 || deviceOrientationDegrees == 180)) {
return 0;
}
if (rotateWithDevice) {
if (forceRotate) {
return deviceOrientationDegrees;
} else {
return deviceOrientationDegrees != 0 && deviceOrientationDegrees != 180 ? deviceOrientationDegrees : 270;
}
}
return rotateToRightSide ? 90 : 270;
}
void putRequestingSize(@NonNull Object object, @NonNull Point size) {
synchronized (requestingSizes) {
requestingSizes.put(object, size);

View File

@@ -25,6 +25,7 @@ import org.thoughtcrime.securesms.contacts.avatars.ContactPhoto;
import org.thoughtcrime.securesms.contacts.avatars.FallbackContactPhoto;
import org.thoughtcrime.securesms.contacts.avatars.ProfileContactPhoto;
import org.thoughtcrime.securesms.contacts.avatars.ResourceContactPhoto;
import org.thoughtcrime.securesms.conversation.colors.ChatColors;
import org.thoughtcrime.securesms.events.CallParticipant;
import org.thoughtcrime.securesms.mms.GlideApp;
import org.thoughtcrime.securesms.recipients.Recipient;
@@ -54,6 +55,7 @@ public class CallParticipantView extends ConstraintLayout {
private AppCompatImageView backgroundAvatar;
private AvatarImageView avatar;
private View rendererFrame;
private TextureViewRenderer renderer;
private ImageView pipAvatar;
private ContactPhoto contactPhoto;
@@ -83,6 +85,7 @@ public class CallParticipantView extends ConstraintLayout {
backgroundAvatar = findViewById(R.id.call_participant_background_avatar);
avatar = findViewById(R.id.call_participant_item_avatar);
pipAvatar = findViewById(R.id.call_participant_item_pip_avatar);
rendererFrame = findViewById(R.id.call_participant_renderer_frame);
renderer = findViewById(R.id.call_participant_renderer);
audioMuted = findViewById(R.id.call_participant_mic_muted);
infoOverlay = findViewById(R.id.call_participant_info_overlay);
@@ -108,6 +111,7 @@ public class CallParticipantView extends ConstraintLayout {
infoMode = participant.getRecipient().isBlocked() || isMissingMediaKeys(participant);
if (infoMode) {
rendererFrame.setVisibility(View.GONE);
renderer.setVisibility(View.GONE);
renderer.attachBroadcastVideoSink(null);
audioMuted.setVisibility(View.GONE);
@@ -130,7 +134,10 @@ public class CallParticipantView extends ConstraintLayout {
} else {
infoOverlay.setVisibility(View.GONE);
renderer.setVisibility(participant.isVideoEnabled() ? View.VISIBLE : View.GONE);
boolean hasContentToRender = participant.isVideoEnabled() || participant.isScreenSharing();
rendererFrame.setVisibility(hasContentToRender ? View.VISIBLE : View.GONE);
renderer.setVisibility(hasContentToRender ? View.VISIBLE : View.GONE);
if (participant.isVideoEnabled()) {
if (participant.getVideoSink().getEglBase() != null) {
@@ -219,7 +226,10 @@ public class CallParticipantView extends ConstraintLayout {
.into(pipAvatar);
pipAvatar.setScaleType(contactPhoto == null ? ImageView.ScaleType.CENTER_INSIDE : ImageView.ScaleType.CENTER_CROP);
pipAvatar.setBackgroundColor(recipient.getColor().toActionBarColor(getContext()));
ChatColors chatColors = recipient.getChatColors();
pipAvatar.setBackground(chatColors.getChatBubbleMask());
}
private void showBlockedDialog(@NonNull Recipient recipient) {

View File

@@ -16,6 +16,7 @@ import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.events.CallParticipant;
import org.thoughtcrime.securesms.util.Util;
import org.thoughtcrime.securesms.util.ViewUtil;
import org.webrtc.RendererCommon;
import java.util.Collections;
import java.util.List;
@@ -29,9 +30,10 @@ public class CallParticipantsLayout extends FlexboxLayout {
private static final int MULTIPLE_PARTICIPANT_SPACING = ViewUtil.dpToPx(3);
private static final int CORNER_RADIUS = ViewUtil.dpToPx(10);
private List<CallParticipant> callParticipants = Collections.emptyList();
private List<CallParticipant> callParticipants = Collections.emptyList();
private CallParticipant focusedParticipant = null;
private boolean shouldRenderInPip;
private boolean isPortrait;
public CallParticipantsLayout(@NonNull Context context) {
super(context);
@@ -45,10 +47,11 @@ public class CallParticipantsLayout extends FlexboxLayout {
super(context, attrs, defStyleAttr);
}
void update(@NonNull List<CallParticipant> callParticipants, @NonNull CallParticipant focusedParticipant, boolean shouldRenderInPip) {
void update(@NonNull List<CallParticipant> callParticipants, @NonNull CallParticipant focusedParticipant, boolean shouldRenderInPip, boolean isPortrait) {
this.callParticipants = callParticipants;
this.focusedParticipant = focusedParticipant;
this.shouldRenderInPip = shouldRenderInPip;
this.isPortrait = isPortrait;
updateLayout();
}
@@ -104,6 +107,11 @@ public class CallParticipantsLayout extends FlexboxLayout {
callParticipantView.setCallParticipant(participant);
callParticipantView.setRenderInPip(shouldRenderInPip);
if (participant.isScreenSharing()) {
callParticipantView.setScalingType(RendererCommon.ScalingType.SCALE_ASPECT_FIT);
} else {
callParticipantView.setScalingType(isPortrait || count < 3 ? RendererCommon.ScalingType.SCALE_ASPECT_FILL : RendererCommon.ScalingType.SCALE_ASPECT_BALANCED);
}
if (count > 1) {
view.setPadding(MULTIPLE_PARTICIPANT_SPACING, MULTIPLE_PARTICIPANT_SPACING, MULTIPLE_PARTICIPANT_SPACING, MULTIPLE_PARTICIPANT_SPACING);

View File

@@ -3,7 +3,6 @@ package org.thoughtcrime.securesms.components.webrtc;
import android.content.Context;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.annimon.stream.ComparatorCompat;
import com.annimon.stream.OptionalLong;
@@ -28,16 +27,16 @@ public final class CallParticipantsState {
private static final int SMALL_GROUP_MAX = 6;
public static final CallParticipantsState STARTING_STATE = new CallParticipantsState(WebRtcViewModel.State.CALL_DISCONNECTED,
WebRtcViewModel.GroupCallState.IDLE,
new ParticipantCollection(SMALL_GROUP_MAX),
CallParticipant.createLocal(CameraState.UNKNOWN, new BroadcastVideoSink(null), false),
null,
WebRtcLocalRenderState.GONE,
false,
false,
false,
OptionalLong.empty());
public static final CallParticipantsState STARTING_STATE = new CallParticipantsState(WebRtcViewModel.State.CALL_DISCONNECTED,
WebRtcViewModel.GroupCallState.IDLE,
new ParticipantCollection(SMALL_GROUP_MAX),
CallParticipant.createLocal(CameraState.UNKNOWN, new BroadcastVideoSink(), false),
CallParticipant.EMPTY,
WebRtcLocalRenderState.GONE,
false,
false,
false,
OptionalLong.empty());
private final WebRtcViewModel.State callState;
private final WebRtcViewModel.GroupCallState groupCallState;
@@ -54,7 +53,7 @@ public final class CallParticipantsState {
@NonNull WebRtcViewModel.GroupCallState groupCallState,
@NonNull ParticipantCollection remoteParticipants,
@NonNull CallParticipant localParticipant,
@Nullable CallParticipant focusedParticipant,
@NonNull CallParticipant focusedParticipant,
@NonNull WebRtcLocalRenderState localRenderState,
boolean isInPipMode,
boolean showVideoForOutgoing,
@@ -105,23 +104,38 @@ public final class CallParticipantsState {
switch (remoteParticipants.size()) {
case 0:
return context.getString(R.string.WebRtcCallView__no_one_else_is_here);
case 1:
case 1: {
if (callState == WebRtcViewModel.State.CALL_PRE_JOIN && groupCallState.isNotIdle()) {
return context.getString(R.string.WebRtcCallView__s_is_in_this_call, remoteParticipants.get(0).getShortRecipientDisplayName(context));
} else {
return remoteParticipants.get(0).getRecipientDisplayName(context);
if (focusedParticipant != CallParticipant.EMPTY && focusedParticipant.isScreenSharing()) {
return context.getString(R.string.WebRtcCallView__s_is_presenting, focusedParticipant.getShortRecipientDisplayName(context));
} else {
return remoteParticipants.get(0).getRecipientDisplayName(context);
}
}
case 2:
return context.getString(R.string.WebRtcCallView__s_and_s_are_in_this_call,
remoteParticipants.get(0).getShortRecipientDisplayName(context),
remoteParticipants.get(1).getShortRecipientDisplayName(context));
default:
int others = remoteParticipants.size() - 2;
return context.getResources().getQuantityString(R.plurals.WebRtcCallView__s_s_and_d_others_are_in_this_call,
others,
remoteParticipants.get(0).getShortRecipientDisplayName(context),
remoteParticipants.get(1).getShortRecipientDisplayName(context),
others);
}
case 2: {
if (focusedParticipant != CallParticipant.EMPTY && focusedParticipant.isScreenSharing()) {
return context.getString(R.string.WebRtcCallView__s_is_presenting, focusedParticipant.getShortRecipientDisplayName(context));
} else {
return context.getString(R.string.WebRtcCallView__s_and_s_are_in_this_call,
remoteParticipants.get(0).getShortRecipientDisplayName(context),
remoteParticipants.get(1).getShortRecipientDisplayName(context));
}
}
default: {
if (focusedParticipant != CallParticipant.EMPTY && focusedParticipant.isScreenSharing()) {
return context.getString(R.string.WebRtcCallView__s_is_presenting, focusedParticipant.getShortRecipientDisplayName(context));
} else {
int others = remoteParticipants.size() - 2;
return context.getResources().getQuantityString(R.plurals.WebRtcCallView__s_s_and_d_others_are_in_this_call,
others,
remoteParticipants.get(0).getShortRecipientDisplayName(context),
remoteParticipants.get(1).getShortRecipientDisplayName(context),
others);
}
}
}
}
@@ -133,7 +147,7 @@ public final class CallParticipantsState {
return localParticipant;
}
public @Nullable CallParticipant getFocusedParticipant() {
public @NonNull CallParticipant getFocusedParticipant() {
return focusedParticipant;
}
@@ -149,8 +163,16 @@ public final class CallParticipantsState {
return isInPipMode;
}
public boolean isViewingFocusedParticipant() {
return isViewingFocusedParticipant;
}
public boolean needsNewRequestSizes() {
return Stream.of(getAllRemoteParticipants()).anyMatch(p -> p.getVideoSink().needsNewRequestingSize());
if (groupCallState.isNotIdle()) {
return Stream.of(getAllRemoteParticipants()).anyMatch(p -> p.getVideoSink().needsNewRequestingSize());
} else {
return false;
}
}
public @NonNull OptionalLong getRemoteDevicesCount() {
@@ -184,16 +206,11 @@ public final class CallParticipantsState {
oldState.isViewingFocusedParticipant,
oldState.getLocalRenderState() == WebRtcLocalRenderState.EXPANDED);
List<CallParticipant> participantsByLastSpoke = new ArrayList<>(webRtcViewModel.getRemoteParticipants());
Collections.sort(participantsByLastSpoke, ComparatorCompat.reversed((p1, p2) -> Long.compare(p1.getLastSpoke(), p2.getLastSpoke())));
CallParticipant focused = participantsByLastSpoke.isEmpty() ? null : participantsByLastSpoke.get(0);
return new CallParticipantsState(webRtcViewModel.getState(),
webRtcViewModel.getGroupState(),
oldState.remoteParticipants.getNext(webRtcViewModel.getRemoteParticipants()),
webRtcViewModel.getLocalParticipant(),
focused,
getFocusedParticipant(webRtcViewModel.getRemoteParticipants()),
localRenderState,
oldState.isInPipMode,
newShowVideoForOutgoing,
@@ -211,13 +228,11 @@ public final class CallParticipantsState {
oldState.isViewingFocusedParticipant,
oldState.getLocalRenderState() == WebRtcLocalRenderState.EXPANDED);
CallParticipant focused = oldState.remoteParticipants.isEmpty() ? null : oldState.remoteParticipants.get(0);
return new CallParticipantsState(oldState.callState,
oldState.groupCallState,
oldState.remoteParticipants,
oldState.localParticipant,
focused,
oldState.focusedParticipant,
localRenderState,
isInPip,
oldState.showVideoForOutgoing,
@@ -248,8 +263,6 @@ public final class CallParticipantsState {
}
public static @NonNull CallParticipantsState update(@NonNull CallParticipantsState oldState, @NonNull SelectedPage selectedPage) {
CallParticipant focused = oldState.remoteParticipants.isEmpty() ? null : oldState.remoteParticipants.get(0);
WebRtcLocalRenderState localRenderState = determineLocalRenderMode(oldState.localParticipant,
oldState.isInPipMode,
oldState.showVideoForOutgoing,
@@ -263,7 +276,7 @@ public final class CallParticipantsState {
oldState.groupCallState,
oldState.remoteParticipants,
oldState.localParticipant,
focused,
oldState.focusedParticipant,
localRenderState,
oldState.isInPipMode,
oldState.showVideoForOutgoing,
@@ -304,6 +317,16 @@ public final class CallParticipantsState {
return localRenderState;
}
private static @NonNull CallParticipant getFocusedParticipant(@NonNull List<CallParticipant> participants) {
List<CallParticipant> participantsByLastSpoke = new ArrayList<>(participants);
Collections.sort(participantsByLastSpoke, ComparatorCompat.reversed((p1, p2) -> Long.compare(p1.getLastSpoke(), p2.getLastSpoke())));
return participantsByLastSpoke.isEmpty() ? CallParticipant.EMPTY
: participantsByLastSpoke.stream()
.filter(CallParticipant::isScreenSharing)
.findAny().orElse(participantsByLastSpoke.get(0));
}
public enum SelectedPage {
GRID,
FOCUSED

View File

@@ -0,0 +1,54 @@
package org.thoughtcrime.securesms.components.webrtc;
import android.view.Gravity;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.PopupWindow;
import androidx.annotation.NonNull;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.util.ViewUtil;
import java.util.concurrent.TimeUnit;
/**
* Top screen toast to be shown to the user for 3 seconds.
*
* Currently hard coded to show specific text, but could be easily expanded to be customizable
* if desired. Based on {@link CallParticipantsListUpdatePopupWindow}.
*/
public class CallToastPopupWindow extends PopupWindow {
private static final long DURATION = TimeUnit.SECONDS.toMillis(3);
private final ViewGroup parent;
public static void show(@NonNull ViewGroup viewGroup) {
CallToastPopupWindow toast = new CallToastPopupWindow(viewGroup);
toast.show();
}
private CallToastPopupWindow(@NonNull ViewGroup parent) {
super(LayoutInflater.from(parent.getContext()).inflate(R.layout.call_toast_popup_window, parent, false),
ViewGroup.LayoutParams.MATCH_PARENT,
ViewUtil.dpToPx(94));
this.parent = parent;
setAnimationStyle(R.style.PopupAnimation);
}
public void show() {
showAtLocation(parent, Gravity.TOP | Gravity.START, 0, 0);
measureChild();
update();
getContentView().postDelayed(this::dismiss, DURATION);
}
private void measureChild() {
getContentView().measure(View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED),
View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED));
}
}

View File

@@ -1,24 +0,0 @@
package org.thoughtcrime.securesms.components.webrtc;
import androidx.annotation.NonNull;
import org.webrtc.VideoFrame;
import org.webrtc.VideoSink;
public final class OrientationAwareVideoSink implements VideoSink {
private final VideoSink delegate;
public OrientationAwareVideoSink(@NonNull VideoSink delegate) {
this.delegate = delegate;
}
@Override
public void onFrame(VideoFrame videoFrame) {
if (videoFrame.getRotatedHeight() < videoFrame.getRotatedWidth()) {
delegate.onFrame(new VideoFrame(videoFrame.getBuffer(), 270, videoFrame.getTimestampNs()));
} else {
delegate.onFrame(videoFrame);
}
}
}

View File

@@ -14,29 +14,34 @@ class WebRtcCallParticipantsPage {
private final CallParticipant focusedParticipant;
private final boolean isSpeaker;
private final boolean isRenderInPip;
private final boolean isPortrait;
static WebRtcCallParticipantsPage forMultipleParticipants(@NonNull List<CallParticipant> callParticipants,
@NonNull CallParticipant focusedParticipant,
boolean isRenderInPip)
boolean isRenderInPip,
boolean isPortrait)
{
return new WebRtcCallParticipantsPage(callParticipants, focusedParticipant, false, isRenderInPip);
return new WebRtcCallParticipantsPage(callParticipants, focusedParticipant, false, isRenderInPip, isPortrait);
}
static WebRtcCallParticipantsPage forSingleParticipant(@NonNull CallParticipant singleParticipant,
boolean isRenderInPip)
boolean isRenderInPip,
boolean isPortrait)
{
return new WebRtcCallParticipantsPage(Collections.singletonList(singleParticipant), singleParticipant, true, isRenderInPip);
return new WebRtcCallParticipantsPage(Collections.singletonList(singleParticipant), singleParticipant, true, isRenderInPip, isPortrait);
}
private WebRtcCallParticipantsPage(@NonNull List<CallParticipant> callParticipants,
@NonNull CallParticipant focusedParticipant,
@NonNull CallParticipant focusedParticipant,
boolean isSpeaker,
boolean isRenderInPip)
boolean isRenderInPip,
boolean isPortrait)
{
this.callParticipants = callParticipants;
this.focusedParticipant = focusedParticipant;
this.isSpeaker = isSpeaker;
this.isRenderInPip = isRenderInPip;
this.isPortrait = isPortrait;
}
public @NonNull List<CallParticipant> getCallParticipants() {
@@ -55,19 +60,24 @@ class WebRtcCallParticipantsPage {
return isSpeaker;
}
public boolean isPortrait() {
return isPortrait;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
WebRtcCallParticipantsPage that = (WebRtcCallParticipantsPage) o;
return isSpeaker == that.isSpeaker &&
isRenderInPip == that.isRenderInPip &&
focusedParticipant.equals(that.focusedParticipant) &&
callParticipants.equals(that.callParticipants);
isRenderInPip == that.isRenderInPip &&
focusedParticipant.equals(that.focusedParticipant) &&
callParticipants.equals(that.callParticipants) &&
isPortrait == that.isPortrait;
}
@Override
public int hashCode() {
return Objects.hash(callParticipants, isSpeaker, focusedParticipant, isRenderInPip);
return Objects.hash(callParticipants, isSpeaker, focusedParticipant, isRenderInPip, isPortrait);
}
}

View File

@@ -10,6 +10,8 @@ import androidx.recyclerview.widget.ListAdapter;
import androidx.recyclerview.widget.RecyclerView;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.events.CallParticipant;
import org.webrtc.RendererCommon;
class WebRtcCallParticipantsPagerAdapter extends ListAdapter<WebRtcCallParticipantsPage, WebRtcCallParticipantsPagerAdapter.ViewHolder> {
@@ -84,7 +86,7 @@ class WebRtcCallParticipantsPagerAdapter extends ListAdapter<WebRtcCallParticipa
@Override
void bind(WebRtcCallParticipantsPage page) {
callParticipantsLayout.update(page.getCallParticipants(), page.getFocusedParticipant(), page.isRenderInPip());
callParticipantsLayout.update(page.getCallParticipants(), page.getFocusedParticipant(), page.isRenderInPip(), page.isPortrait());
}
}
@@ -107,8 +109,14 @@ class WebRtcCallParticipantsPagerAdapter extends ListAdapter<WebRtcCallParticipa
@Override
void bind(WebRtcCallParticipantsPage page) {
callParticipantView.setCallParticipant(page.getCallParticipants().get(0));
CallParticipant participant = page.getCallParticipants().get(0);
callParticipantView.setCallParticipant(participant);
callParticipantView.setRenderInPip(page.isRenderInPip());
if (participant.isScreenSharing()) {
callParticipantView.setScalingType(RendererCommon.ScalingType.SCALE_ASPECT_FIT);
} else {
callParticipantView.setScalingType(RendererCommon.ScalingType.SCALE_ASPECT_FILL);
}
}
}

View File

@@ -11,6 +11,7 @@ import androidx.recyclerview.widget.RecyclerView;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.events.CallParticipant;
import org.webrtc.RendererCommon;
class WebRtcCallParticipantsRecyclerAdapter extends ListAdapter<CallParticipant, WebRtcCallParticipantsRecyclerAdapter.ViewHolder> {
@@ -61,6 +62,7 @@ class WebRtcCallParticipantsRecyclerAdapter extends ListAdapter<CallParticipant,
void bind(@NonNull CallParticipant callParticipant) {
callParticipantView.setCallParticipant(callParticipant);
callParticipantView.setRenderInPip(true);
callParticipantView.setScalingType(RendererCommon.ScalingType.SCALE_ASPECT_FILL);
}
}

View File

@@ -1,9 +1,5 @@
package org.thoughtcrime.securesms.components.webrtc;
import android.animation.Animator;
import android.animation.AnimatorInflater;
import android.animation.AnimatorSet;
import android.animation.ValueAnimator;
import android.content.Context;
import android.graphics.ColorMatrix;
import android.graphics.ColorMatrixColorFilter;
@@ -12,13 +8,7 @@ import android.util.AttributeSet;
import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewPropertyAnimator;
import android.view.animation.AlphaAnimation;
import android.view.animation.Animation;
import android.view.animation.AnimationSet;
import android.view.animation.AnimationUtils;
import android.view.animation.ScaleAnimation;
import android.view.animation.TranslateAnimation;
import android.widget.FrameLayout;
import android.widget.ImageView;
import android.widget.TextView;
@@ -43,10 +33,10 @@ import com.bumptech.glide.load.engine.DiskCacheStrategy;
import com.bumptech.glide.load.resource.bitmap.CenterCrop;
import com.google.android.material.button.MaterialButton;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.animation.ResizeAnimation;
import org.thoughtcrime.securesms.components.AccessibleToggleButton;
import org.thoughtcrime.securesms.components.sensors.Orientation;
import org.thoughtcrime.securesms.contacts.avatars.ProfileContactPhoto;
import org.thoughtcrime.securesms.events.CallParticipant;
import org.thoughtcrime.securesms.events.WebRtcViewModel;
@@ -67,6 +57,8 @@ import java.util.HashSet;
import java.util.List;
import java.util.Set;
import static org.thoughtcrime.securesms.components.sensors.Orientation.PORTRAIT_BOTTOM_EDGE;
public class WebRtcCallView extends FrameLayout {
private static final long TRANSITION_DURATION_MILLIS = 250;
@@ -313,15 +305,15 @@ public class WebRtcCallView extends FrameLayout {
micToggle.setChecked(isMicEnabled, false);
}
public void updateCallParticipants(@NonNull CallParticipantsState state) {
public void updateCallParticipants(@NonNull CallParticipantsState state, boolean isPortrait) {
List<WebRtcCallParticipantsPage> pages = new ArrayList<>(2);
if (!state.getGridParticipants().isEmpty()) {
pages.add(WebRtcCallParticipantsPage.forMultipleParticipants(state.getGridParticipants(), state.getFocusedParticipant(), state.isInPipMode()));
pages.add(WebRtcCallParticipantsPage.forMultipleParticipants(state.getGridParticipants(), state.getFocusedParticipant(), state.isInPipMode(), isPortrait));
}
if (state.getFocusedParticipant() != null && state.getAllRemoteParticipants().size() > 1) {
pages.add(WebRtcCallParticipantsPage.forSingleParticipant(state.getFocusedParticipant(), state.isInPipMode()));
if (state.getFocusedParticipant() != CallParticipant.EMPTY && state.getAllRemoteParticipants().size() > 1) {
pages.add(WebRtcCallParticipantsPage.forSingleParticipant(state.getFocusedParticipant(), state.isInPipMode(), isPortrait));
}
if ((state.getGroupCallState().isNotIdle() && state.getRemoteDevicesCount().orElse(0) > 0) || state.getGroupCallState().isConnected()) {
@@ -839,6 +831,12 @@ public class WebRtcCallView extends FrameLayout {
return true;
}
public void switchToSpeakerView() {
if (pagerAdapter.getItemCount() > 0) {
callParticipantsPager.setCurrentItem(pagerAdapter.getItemCount() - 1, false);
}
}
public interface ControlsListener {
void onStartCall(boolean isVideoCall);
void onCancelStartCall();

View File

@@ -61,19 +61,15 @@ public class WebRtcCallViewModel extends ViewModel {
private boolean answerWithVideoAvailable = false;
private Runnable elapsedTimeRunnable = this::handleTick;
private boolean canEnterPipMode = false;
private List<CallParticipant> previousParticipantsList = Collections.emptyList();
private boolean callStarting = false;
private List<CallParticipant> previousParticipantsList = Collections.emptyList();
private boolean callStarting = false;
private boolean switchOnFirstScreenShare = true;
private boolean showScreenShareTip = true;
private final WebRtcCallRepository repository = new WebRtcCallRepository(ApplicationDependencies.getApplication());
private WebRtcCallViewModel(@NonNull DeviceOrientationMonitor deviceOrientationMonitor) {
orientation = LiveDataUtil.combineLatest(deviceOrientationMonitor.getOrientation(), webRtcControls, (deviceOrientation, controls) -> {
if (controls.canRotateControls()) {
return deviceOrientation;
} else {
return Orientation.PORTRAIT_BOTTOM_EDGE;
}
});
orientation = deviceOrientationMonitor.getOrientation();
}
public LiveData<Orientation> getOrientation() {
@@ -150,7 +146,16 @@ public class WebRtcCallViewModel extends ViewModel {
SignalStore.tooltips().markGroupCallSpeakerViewSeen();
}
//noinspection ConstantConditions
CallParticipantsState state = participantsState.getValue();
if (state != null &&
showScreenShareTip &&
state.getFocusedParticipant().isScreenSharing() &&
state.isViewingFocusedParticipant() &&
page == CallParticipantsState.SelectedPage.GRID) {
showScreenShareTip = false;
events.setValue(new Event.ShowSwipeToSpeakerHint());
}
participantsState.setValue(CallParticipantsState.update(participantsState.getValue(), page));
}
@@ -179,8 +184,16 @@ public class WebRtcCallViewModel extends ViewModel {
microphoneEnabled.setValue(localParticipant.isMicrophoneEnabled());
//noinspection ConstantConditions
participantsState.setValue(CallParticipantsState.update(participantsState.getValue(), webRtcViewModel, enableVideo));
CallParticipantsState state = participantsState.getValue();
if (state != null) {
boolean wasScreenSharing = state.getFocusedParticipant().isScreenSharing();
CallParticipantsState newState = CallParticipantsState.update(state, webRtcViewModel, enableVideo);
participantsState.setValue(newState);
if (switchOnFirstScreenShare && !wasScreenSharing && newState.getFocusedParticipant().isScreenSharing()) {
switchOnFirstScreenShare = false;
events.setValue(new Event.SwitchToSpeaker());
}
}
if (webRtcViewModel.getGroupState().isConnected()) {
if (!containsPlaceholders(previousParticipantsList)) {
@@ -394,6 +407,12 @@ public class WebRtcCallViewModel extends ViewModel {
return identityRecords;
}
}
public static class SwitchToSpeaker extends Event {
}
public static class ShowSwipeToSpeakerHint extends Event {
}
}
public static class SafetyNumberChangeEvent {

View File

@@ -51,10 +51,6 @@ public final class WebRtcControls {
this.participantLimit = participantLimit;
}
boolean canRotateControls() {
return !isGroupCall();
}
boolean displayErrorControls() {
return isError();
}

View File

@@ -10,14 +10,16 @@ import org.thoughtcrime.securesms.util.viewholders.RecipientViewHolder;
public class CallParticipantViewHolder extends RecipientViewHolder<CallParticipantViewState> {
private final ImageView videoMuted;
private final ImageView audioMuted;
private final View videoMuted;
private final View audioMuted;
private final View screenSharing;
public CallParticipantViewHolder(@NonNull View itemView) {
super(itemView, null);
videoMuted = itemView.findViewById(R.id.call_participant_video_muted);
audioMuted = itemView.findViewById(R.id.call_participant_audio_muted);
videoMuted = findViewById(R.id.call_participant_video_muted);
audioMuted = findViewById(R.id.call_participant_audio_muted);
screenSharing = findViewById(R.id.call_participant_screen_sharing);
}
@Override
@@ -26,5 +28,6 @@ public class CallParticipantViewHolder extends RecipientViewHolder<CallParticipa
videoMuted.setVisibility(model.getVideoMutedVisibility());
audioMuted.setVisibility(model.getAudioMutedVisibility());
screenSharing.setVisibility(model.getScreenSharingVisibility());
}
}

View File

@@ -36,6 +36,10 @@ public final class CallParticipantViewState extends RecipientMappingModel<CallPa
return callParticipant.isMicrophoneEnabled() ? View.GONE : View.VISIBLE;
}
public int getScreenSharingVisibility() {
return callParticipant.isScreenSharing() ? View.VISIBLE : View.GONE;
}
@Override
public boolean areItemsTheSame(@NonNull CallParticipantViewState newItem) {
return callParticipant.getCallParticipantId().equals(newItem.callParticipant.getCallParticipantId());

View File

@@ -0,0 +1,56 @@
package org.thoughtcrime.securesms.conversation
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Path
import android.graphics.Region
import android.graphics.drawable.ColorDrawable
import android.graphics.drawable.Drawable
import android.graphics.drawable.LayerDrawable
import org.thoughtcrime.securesms.util.Projection
/**
* Drawable which clips out the given projection
*/
class ClipProjectionDrawable(wrapped: Drawable) : LayerDrawable(arrayOf(wrapped)) {
constructor() : this(ColorDrawable(Color.TRANSPARENT))
init {
setId(0, 0)
}
private val clipPath = Path()
private var projections: List<Projection> = listOf()
fun setWrappedDrawable(drawable: Drawable) {
setDrawableByLayerId(0, drawable)
}
fun setProjections(projections: Set<Projection>) {
this.projections = projections.toList()
invalidateSelf()
}
fun clearProjections() {
this.projections = listOf()
invalidateSelf()
}
override fun draw(canvas: Canvas) {
if (projections.isNotEmpty()) {
canvas.save()
clipPath.rewind()
projections.forEach {
it.applyToPath(clipPath)
}
canvas.clipPath(clipPath, Region.Op.DIFFERENCE)
super.draw(canvas)
canvas.restore()
} else {
super.draw(canvas)
}
}
}

View File

@@ -92,7 +92,6 @@ import org.signal.core.util.ThreadUtil;
import org.signal.core.util.concurrent.SignalExecutors;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.BlockUnblockDialog;
import org.thoughtcrime.securesms.ExpirationDialog;
import org.thoughtcrime.securesms.GroupMembersDialog;
import org.thoughtcrime.securesms.MainActivity;
import org.thoughtcrime.securesms.MuteDialog;
@@ -169,11 +168,8 @@ import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.events.GroupCallPeekEvent;
import org.thoughtcrime.securesms.events.ReminderUpdateEvent;
import org.thoughtcrime.securesms.giph.ui.GiphyActivity;
import org.thoughtcrime.securesms.groups.GroupChangeException;
import org.thoughtcrime.securesms.groups.GroupId;
import org.thoughtcrime.securesms.groups.GroupManager;
import org.thoughtcrime.securesms.groups.ui.GroupChangeFailureReason;
import org.thoughtcrime.securesms.groups.ui.GroupChangeResult;
import org.thoughtcrime.securesms.groups.ui.GroupErrors;
import org.thoughtcrime.securesms.groups.ui.LeaveGroupDialog;
import org.thoughtcrime.securesms.groups.ui.invitesandrequests.ManagePendingAndRequestingMembersActivity;
@@ -211,7 +207,6 @@ import org.thoughtcrime.securesms.mms.GlideRequests;
import org.thoughtcrime.securesms.mms.ImageSlide;
import org.thoughtcrime.securesms.mms.LocationSlide;
import org.thoughtcrime.securesms.mms.MediaConstraints;
import org.thoughtcrime.securesms.mms.OutgoingExpirationUpdateMessage;
import org.thoughtcrime.securesms.mms.OutgoingMediaMessage;
import org.thoughtcrime.securesms.mms.OutgoingSecureMediaMessage;
import org.thoughtcrime.securesms.mms.QuoteId;
@@ -237,6 +232,7 @@ import org.thoughtcrime.securesms.recipients.RecipientExporter;
import org.thoughtcrime.securesms.recipients.RecipientFormattingException;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.recipients.RecipientUtil;
import org.thoughtcrime.securesms.recipients.ui.disappearingmessages.RecipientDisappearingMessagesActivity;
import org.thoughtcrime.securesms.recipients.ui.managerecipient.ManageRecipientActivity;
import org.thoughtcrime.securesms.registration.RegistrationNavigationActivity;
import org.thoughtcrime.securesms.service.KeyCachingService;
@@ -249,7 +245,6 @@ import org.thoughtcrime.securesms.stickers.StickerLocator;
import org.thoughtcrime.securesms.stickers.StickerManagementActivity;
import org.thoughtcrime.securesms.stickers.StickerPackInstallEvent;
import org.thoughtcrime.securesms.stickers.StickerSearchRepository;
import org.thoughtcrime.securesms.storage.StorageSyncHelper;
import org.thoughtcrime.securesms.util.AsynchronousCallback;
import org.thoughtcrime.securesms.util.Base64;
import org.thoughtcrime.securesms.util.BitmapUtil;
@@ -1102,7 +1097,7 @@ public class ConversationActivity extends PassphraseRequiredActivity
AttachmentManager.selectGallery(this, MEDIA_SENDER, recipient.get(), composeText.getTextTrimmed(), sendButton.getSelectedTransport());
break;
case GIF:
AttachmentManager.selectGif(this, PICK_GIF, !isSecureText, recipient.get().getColor().toConversationColor(this));
AttachmentManager.selectGif(this, PICK_GIF, !isSecureText, recipient.get().getChatColors().asSingleColor());
break;
case FILE:
AttachmentManager.selectDocument(this, PICK_DOCUMENT);
@@ -1138,42 +1133,11 @@ public class ConversationActivity extends PassphraseRequiredActivity
//////// Event Handlers
private void handleSelectMessageExpiration() {
boolean activeGroup = isActiveGroup();
if (isPushGroupConversation() && !activeGroup) {
if (isPushGroupConversation() && !isActiveGroup()) {
return;
}
final long thread = this.threadId;
ExpirationDialog.show(this, recipient.get().getExpireMessages(),
expirationTime ->
SimpleTask.run(
getLifecycle(),
() -> {
if (activeGroup) {
try {
GroupManager.updateGroupTimer(ConversationActivity.this, getRecipient().requireGroupId().requirePush(), expirationTime);
} catch (GroupChangeException | IOException e) {
Log.w(TAG, e);
return GroupChangeResult.failure(GroupChangeFailureReason.fromException(e));
}
} else {
DatabaseFactory.getRecipientDatabase(ConversationActivity.this).setExpireMessages(recipient.getId(), expirationTime);
OutgoingExpirationUpdateMessage outgoingMessage = new OutgoingExpirationUpdateMessage(getRecipient(), System.currentTimeMillis(), expirationTime * 1000L);
MessageSender.send(ConversationActivity.this, outgoingMessage, thread, false, null);
}
return GroupChangeResult.SUCCESS;
},
(changeResult) -> {
if (!changeResult.isSuccess()) {
Toast.makeText(ConversationActivity.this, GroupErrors.getUserDisplayMessage(changeResult.getFailureReason()), Toast.LENGTH_SHORT).show();
} else {
invalidateOptionsMenu();
if (fragment != null) fragment.setLastSeen(0);
}
})
);
startActivity(RecipientDisappearingMessagesActivity.forRecipient(this, recipient.getId()));
}
private void handleMuteNotifications() {
@@ -1288,7 +1252,7 @@ public class ConversationActivity extends PassphraseRequiredActivity
GlideApp.with(this)
.asBitmap()
.load(recipient.getContactPhoto())
.error(recipient.getFallbackContactPhoto().asDrawable(this, recipient.getColor().toAvatarColor(this), false))
.error(recipient.getFallbackContactPhoto().asDrawable(this, recipient.getAvatarColor().colorInt(), false))
.into(new CustomTarget<Bitmap>() {
@Override
public void onLoadFailed(@Nullable Drawable errorDrawable) {
@@ -1787,7 +1751,7 @@ public class ConversationActivity extends PassphraseRequiredActivity
}
Log.i(TAG, "Resolved registered state: " + registeredState);
boolean signalEnabled = TextSecurePreferences.isPushRegistered(context);
boolean signalEnabled = Recipient.self().isRegistered();
if (registeredState == RegisteredState.UNKNOWN) {
try {
@@ -2413,6 +2377,17 @@ public class ConversationActivity extends PassphraseRequiredActivity
if (groupCallViewModel != null) {
groupCallViewModel.onRecipientChange(recipient);
}
if (this.threadId == -1) {
SimpleTask.run(() -> DatabaseFactory.getThreadDatabase(this).getThreadIdIfExistsFor(recipient.getId()), threadId -> {
if (this.threadId != threadId) {
Log.d(TAG, "Thread id changed via recipient change");
this.threadId = threadId;
fragment.reload(recipient, this.threadId);
setVisibleThread(this.threadId);
}
});
}
}
@Subscribe(threadMode = ThreadMode.MAIN)
@@ -3369,6 +3344,10 @@ public class ConversationActivity extends PassphraseRequiredActivity
case ACCEPTED:
hideMessageRequestBusy();
break;
case BLOCKED_AND_REPORTED:
hideMessageRequestBusy();
Toast.makeText(this, R.string.ConversationActivity__reported_as_spam_and_blocked, Toast.LENGTH_SHORT).show();
break;
case DELETED:
case BLOCKED:
hideMessageRequestBusy();
@@ -3625,7 +3604,7 @@ public class ConversationActivity extends PassphraseRequiredActivity
return;
}
BlockUnblockDialog.showBlockAndDeleteFor(this, getLifecycle(), recipient, requestModel::onBlock, requestModel::onBlockAndDelete);
BlockUnblockDialog.showBlockAndReportSpamFor(this, getLifecycle(), recipient, requestModel::onBlock, requestModel::onBlockAndReportSpam);
}
private void onMessageRequestUnblockClicked(@NonNull MessageRequestViewModel requestModel) {

View File

@@ -43,13 +43,15 @@ import org.signal.core.util.logging.Log;
import org.signal.paging.PagingController;
import org.thoughtcrime.securesms.BindableConversationItem;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.MaskView;
import org.thoughtcrime.securesms.conversation.colors.Colorizable;
import org.thoughtcrime.securesms.conversation.colors.Colorizer;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4Playable;
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4PlaybackPolicyEnforcer;
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4Projection;
import org.thoughtcrime.securesms.util.Projection;
import org.thoughtcrime.securesms.mms.GlideRequests;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.util.CachedInflater;
import org.thoughtcrime.securesms.util.DateUtils;
import org.thoughtcrime.securesms.util.StickyHeaderDecoration;
@@ -122,13 +124,15 @@ public class ConversationAdapter
private boolean hasWallpaper;
private boolean isMessageRequestAccepted;
private ConversationMessage inlineContent;
private Colorizer colorizer;
ConversationAdapter(@NonNull LifecycleOwner lifecycleOwner,
@NonNull GlideRequests glideRequests,
@NonNull Locale locale,
@Nullable ItemClickListener clickListener,
@NonNull Recipient recipient,
@NonNull AttachmentMediaSourceFactory attachmentMediaSourceFactory)
@NonNull AttachmentMediaSourceFactory attachmentMediaSourceFactory,
@NonNull Colorizer colorizer)
{
super(new DiffUtil.ItemCallback<ConversationMessage>() {
@Override
@@ -156,6 +160,7 @@ public class ConversationAdapter
this.hasWallpaper = recipient.hasWallpaper();
this.isMessageRequestAccepted = true;
this.attachmentMediaSourceFactory = attachmentMediaSourceFactory;
this.colorizer = colorizer;
setHasStableIds(true);
}
@@ -270,7 +275,8 @@ public class ConversationAdapter
hasWallpaper,
isMessageRequestAccepted,
attachmentMediaSourceFactory,
conversationMessage == inlineContent);
conversationMessage == inlineContent,
colorizer);
if (conversationMessage == recordToPulse) {
recordToPulse = null;
@@ -374,6 +380,10 @@ public class ConversationAdapter
this.pagingController = pagingController;
}
public boolean isForRecipientId(@NonNull RecipientId recipientId) {
return recipient.getId().equals(recipientId);
}
void onBindLastSeenViewHolder(StickyHeaderViewHolder viewHolder, int position) {
viewHolder.setText(viewHolder.itemView.getContext().getResources().getQuantityString(R.plurals.ConversationAdapter_n_unread_messages, (position + 1), (position + 1)));
@@ -630,7 +640,7 @@ public class ConversationAdapter
}
}
final static class ConversationViewHolder extends RecyclerView.ViewHolder implements GiphyMp4Playable {
final static class ConversationViewHolder extends RecyclerView.ViewHolder implements GiphyMp4Playable, Colorizable {
public ConversationViewHolder(final @NonNull View itemView) {
super(itemView);
}
@@ -660,7 +670,7 @@ public class ConversationAdapter
}
@NonNull
public @Override GiphyMp4Projection getProjection(@NonNull RecyclerView recyclerView) {
public @Override Projection getProjection(@NonNull ViewGroup recyclerView) {
return getBindable().getProjection(recyclerView);
}
@@ -668,6 +678,11 @@ public class ConversationAdapter
public boolean canPlayContent() {
return getBindable().canPlayContent();
}
@Override
public @NonNull List<Projection> getColorizerProjections() {
return getBindable().getColorizerProjections();
}
}
static class StickyHeaderViewHolder extends RecyclerView.ViewHolder {

View File

@@ -1,11 +1,7 @@
package org.thoughtcrime.securesms.conversation;
import android.content.Context;
import android.content.res.ColorStateList;
import android.graphics.PorterDuff;
import android.graphics.drawable.LayerDrawable;
import android.text.TextUtils;
import android.text.method.LinkMovementMethod;
import android.util.AttributeSet;
import android.view.View;
import android.widget.TextView;
@@ -13,17 +9,17 @@ import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.constraintlayout.widget.ConstraintLayout;
import androidx.core.content.ContextCompat;
import androidx.core.widget.ImageViewCompat;
import org.signal.core.util.concurrent.SignalExecutors;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.AvatarImageView;
import org.thoughtcrime.securesms.components.emoji.EmojiTextView;
import org.thoughtcrime.securesms.contacts.avatars.FallbackContactPhoto;
import org.thoughtcrime.securesms.contacts.avatars.ResourceContactPhoto;
import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.mms.GlideRequests;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.util.LongClickMovementMethod;
public class ConversationBannerView extends ConstraintLayout {
@@ -31,7 +27,7 @@ public class ConversationBannerView extends ConstraintLayout {
private TextView contactTitle;
private TextView contactAbout;
private TextView contactSubtitle;
private TextView contactDescription;
private EmojiTextView contactDescription;
private View tapToView;
public ConversationBannerView(Context context) {
@@ -91,6 +87,10 @@ public class ConversationBannerView extends ConstraintLayout {
contactDescription.setVisibility(TextUtils.isEmpty(description) ? GONE : VISIBLE);
}
public @NonNull EmojiTextView getDescription() {
return contactDescription;
}
public void showBackgroundBubble(boolean enabled) {
if (enabled) {
setBackgroundResource(R.drawable.wallpaper_bubble_background_12);
@@ -112,7 +112,7 @@ public class ConversationBannerView extends ConstraintLayout {
}
public void setLinkifyDescription(boolean enable) {
contactDescription.setMovementMethod(enable ? LinkMovementMethod.getInstance() : null);
contactDescription.setMovementMethod(enable ? LongClickMovementMethod.getInstance(getContext()) : null);
}
private static final class FallbackPhotoProvider extends Recipient.FallbackPhotoProvider {

View File

@@ -14,6 +14,7 @@ final class ConversationData {
private final int jumpToPosition;
private final int threadSize;
private final MessageRequestData messageRequestData;
private final boolean showUniversalExpireTimerMessage;
ConversationData(long threadId,
long lastSeen,
@@ -22,16 +23,18 @@ final class ConversationData {
boolean hasSent,
int jumpToPosition,
int threadSize,
@NonNull MessageRequestData messageRequestData)
@NonNull MessageRequestData messageRequestData,
boolean showUniversalExpireTimerMessage)
{
this.threadId = threadId;
this.lastSeen = lastSeen;
this.lastSeenPosition = lastSeenPosition;
this.lastScrolledPosition = lastScrolledPosition;
this.hasSent = hasSent;
this.jumpToPosition = jumpToPosition;
this.threadSize = threadSize;
this.messageRequestData = messageRequestData;
this.threadId = threadId;
this.lastSeen = lastSeen;
this.lastSeenPosition = lastSeenPosition;
this.lastScrolledPosition = lastScrolledPosition;
this.hasSent = hasSent;
this.jumpToPosition = jumpToPosition;
this.threadSize = threadSize;
this.messageRequestData = messageRequestData;
this.showUniversalExpireTimerMessage = showUniversalExpireTimerMessage;
}
public long getThreadId() {
@@ -74,6 +77,10 @@ final class ConversationData {
return messageRequestData;
}
public boolean showUniversalExpireTimerMessage() {
return showUniversalExpireTimerMessage;
}
static final class MessageRequestData {
private final boolean messageRequestAccepted;

View File

@@ -35,17 +35,21 @@ class ConversationDataSource implements PagedDataSource<ConversationMessage> {
private final Context context;
private final long threadId;
private final MessageRequestData messageRequestData;
private final boolean showUniversalExpireTimerUpdate;
ConversationDataSource(@NonNull Context context, long threadId, @NonNull MessageRequestData messageRequestData) {
this.context = context;
this.threadId = threadId;
this.messageRequestData = messageRequestData;
ConversationDataSource(@NonNull Context context, long threadId, @NonNull MessageRequestData messageRequestData, boolean showUniversalExpireTimerUpdate) {
this.context = context;
this.threadId = threadId;
this.messageRequestData = messageRequestData;
this.showUniversalExpireTimerUpdate = showUniversalExpireTimerUpdate;
}
@Override
public int size() {
long startTime = System.currentTimeMillis();
int size = DatabaseFactory.getMmsSmsDatabase(context).getConversationCount(threadId) + (messageRequestData.includeWarningUpdateMessage() ? 1 : 0);
int size = DatabaseFactory.getMmsSmsDatabase(context).getConversationCount(threadId) +
(messageRequestData.includeWarningUpdateMessage() ? 1 : 0) +
(showUniversalExpireTimerUpdate ? 1 : 0);
Log.d(TAG, "size() for thread " + threadId + ": " + (System.currentTimeMillis() - startTime) + " ms");
@@ -71,6 +75,10 @@ class ConversationDataSource implements PagedDataSource<ConversationMessage> {
records.add(new InMemoryMessageRecord.NoGroupsInCommon(threadId, messageRequestData.isGroup()));
}
if (showUniversalExpireTimerUpdate) {
records.add(new InMemoryMessageRecord.UniversalExpireTimerUpdate(threadId));
}
stopwatch.split("messages");
mentionHelper.fetchMentions(context);

View File

@@ -89,6 +89,8 @@ import org.thoughtcrime.securesms.contactshare.SharedContactDetailsActivity;
import org.thoughtcrime.securesms.conversation.ConversationAdapter.ItemClickListener;
import org.thoughtcrime.securesms.conversation.ConversationAdapter.StickyHeaderViewHolder;
import org.thoughtcrime.securesms.conversation.ConversationMessage.ConversationMessageFactory;
import org.thoughtcrime.securesms.conversation.colors.Colorizer;
import org.thoughtcrime.securesms.conversation.colors.ColorizerView;
import org.thoughtcrime.securesms.conversation.ui.error.EnableCallNotificationSettingsDialog;
import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.database.MessageDatabase;
@@ -100,9 +102,10 @@ import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.database.model.MmsMessageRecord;
import org.thoughtcrime.securesms.database.model.ReactionRecord;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4Playable;
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4PlaybackController;
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4PlaybackPolicy;
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4ProjectionPlayerHolder;
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4PlaybackController;
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4ProjectionRecycler;
import org.thoughtcrime.securesms.groups.GroupId;
import org.thoughtcrime.securesms.groups.GroupMigrationMembershipChange;
@@ -200,7 +203,6 @@ public class ConversationFragment extends LoggingFragment {
private ConversationScrollToView scrollToMentionButton;
private TextView scrollDateHeader;
private ConversationBannerView conversationBanner;
private ConversationBannerView emptyConversationBanner;
private MessageRequestViewModel messageRequestViewModel;
private MessageCountsViewModel messageCountsViewModel;
private ConversationViewModel conversationViewModel;
@@ -214,9 +216,11 @@ public class ConversationFragment extends LoggingFragment {
private int pulsePosition = -1;
private VoiceNoteMediaController voiceNoteMediaController;
private View toolbarShadow;
private ColorizerView colorizerView;
private Stopwatch startupStopwatch;
private GiphyMp4ProjectionRecycler giphyMp4ProjectionRecycler;
private Colorizer colorizer;
public static void prepare(@NonNull Context context) {
FrameLayout parent = new FrameLayout(context);
@@ -240,15 +244,18 @@ public class ConversationFragment extends LoggingFragment {
@Override
public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle bundle) {
final View view = inflater.inflate(R.layout.conversation_fragment, container, false);
videoContainer = view.findViewById(R.id.video_container);
list = view.findViewById(android.R.id.list);
composeDivider = view.findViewById(R.id.compose_divider);
videoContainer = view.findViewById(R.id.video_container);
list = view.findViewById(android.R.id.list);
composeDivider = view.findViewById(R.id.compose_divider);
scrollToBottomButton = view.findViewById(R.id.scroll_to_bottom);
scrollToMentionButton = view.findViewById(R.id.scroll_to_mention);
scrollDateHeader = view.findViewById(R.id.scroll_date_header);
emptyConversationBanner = view.findViewById(R.id.empty_conversation_banner);
toolbarShadow = requireActivity().findViewById(R.id.conversation_toolbar_shadow);
scrollToBottomButton = view.findViewById(R.id.scroll_to_bottom);
scrollToMentionButton = view.findViewById(R.id.scroll_to_mention);
scrollDateHeader = view.findViewById(R.id.scroll_date_header);
toolbarShadow = requireActivity().findViewById(R.id.conversation_toolbar_shadow);
colorizerView = view.findViewById(R.id.conversation_colorizer_view);
ConversationIntents.Args args = ConversationIntents.Args.from(requireActivity().getIntent());
colorizerView.setBackground(args.getChatColors().getChatBubbleMask());
final LinearLayoutManager layoutManager = new SmoothScrollingLinearLayoutManager(getActivity(), true);
list.setHasFixedSize(false);
@@ -274,7 +281,7 @@ public class ConversationFragment extends LoggingFragment {
conversationMessage.getMessageRecord(),
messageRequestViewModel.shouldShowMessageRequest()),
this::handleReplyMessage,
giphyMp4ProjectionRecycler
this::onViewHolderPositionTranslated
).attachToRecyclerView(list);
setupListLayoutListeners();
@@ -312,6 +319,19 @@ public class ConversationFragment extends LoggingFragment {
updateToolbarDependentMargins();
colorizer = new Colorizer(colorizerView);
colorizer.attachToRecyclerView(list);
conversationViewModel.getChatColors().observe(getViewLifecycleOwner(), chatColors -> colorizer.onChatColorsChanged(chatColors));
conversationViewModel.getNameColorsMap().observe(getViewLifecycleOwner(), nameColorsMap -> {
colorizer.onNameColorsChanged(nameColorsMap);
ConversationAdapter adapter = getListAdapter();
if (adapter != null) {
adapter.notifyDataSetChanged();
}
});
return view;
}
@@ -354,10 +374,12 @@ public class ConversationFragment extends LoggingFragment {
private void setListVerticalTranslation() {
if (list.canScrollVertically(1) || list.canScrollVertically(-1) || list.getChildCount() == 0) {
list.setTranslationY(0);
colorizerView.setTranslationY(0);
list.setOverScrollMode(RecyclerView.OVER_SCROLL_IF_CONTENT_SCROLLS);
} else {
int chTop = list.getChildAt(list.getChildCount() - 1).getTop();
list.setTranslationY(Math.min(0, -chTop));
colorizerView.setTranslationY(Math.min(0, -chTop));
list.setOverScrollMode(RecyclerView.OVER_SCROLL_NEVER);
}
@@ -469,6 +491,16 @@ public class ConversationFragment extends LoggingFragment {
}
}
private void onViewHolderPositionTranslated(@NonNull RecyclerView recyclerView, @NonNull RecyclerView.ViewHolder viewHolder) {
if (viewHolder instanceof GiphyMp4Playable) {
giphyMp4ProjectionRecycler.updateDisplay(recyclerView, (GiphyMp4Playable) viewHolder);
}
if (colorizer != null) {
colorizer.applyClipPathsToMaskedGradient(recyclerView);
}
}
private int getStartPosition() {
return conversationViewModel.getArgs().getStartingPosition();
}
@@ -483,7 +515,6 @@ public class ConversationFragment extends LoggingFragment {
messageRequestViewModel.getRecipientInfo().observe(getViewLifecycleOwner(), recipientInfo -> {
presentMessageRequestProfileView(requireContext(), recipientInfo, conversationBanner);
presentMessageRequestProfileView(requireContext(), recipientInfo, emptyConversationBanner);
});
messageRequestViewModel.getMessageData().observe(getViewLifecycleOwner(), data -> {
@@ -544,13 +575,15 @@ public class ConversationFragment extends LoggingFragment {
} else {
conversationBanner.setLinkifyDescription(true);
boolean linkifyWebLinks = recipientInfo.getMessageRequestState() == MessageRequestState.NONE;
conversationBanner.setDescription(GroupDescriptionUtil.style(context,
recipientInfo.getGroupDescription(),
linkifyWebLinks,
() -> GroupDescriptionDialog.show(getChildFragmentManager(),
recipient.getDisplayName(context),
recipientInfo.getGroupDescription(),
linkifyWebLinks)));
conversationBanner.showDescription();
GroupDescriptionUtil.setText(context,
conversationBanner.getDescription(),
recipientInfo.getGroupDescription(),
linkifyWebLinks,
() -> GroupDescriptionDialog.show(getChildFragmentManager(),
recipient.getDisplayName(context),
recipientInfo.getGroupDescription(),
linkifyWebLinks));
}
} else {
final String description;
@@ -606,9 +639,18 @@ public class ConversationFragment extends LoggingFragment {
}
private void initializeListAdapter() {
if (this.recipient != null && this.threadId != -1) {
if (threadId == -1) {
toolbarShadow.setVisibility(View.GONE);
}
if (this.recipient != null) {
if (getListAdapter() != null && getListAdapter().isForRecipientId(this.recipient.getId())) {
Log.d(TAG, "List adapter already initialized for " + this.recipient.getId());
return;
}
Log.d(TAG, "Initializing adapter for " + recipient.getId());
ConversationAdapter adapter = new ConversationAdapter(this, GlideApp.with(this), locale, selectionClickListener, this.recipient.get(), new AttachmentMediaSourceFactory(requireContext()));
ConversationAdapter adapter = new ConversationAdapter(this, GlideApp.with(this), locale, selectionClickListener, this.recipient.get(), new AttachmentMediaSourceFactory(requireContext()), colorizer);
adapter.setPagingController(conversationViewModel.getPagingController());
list.setAdapter(adapter);
setInlineDateDecoration(adapter);
@@ -618,8 +660,6 @@ public class ConversationFragment extends LoggingFragment {
setLastSeen(conversationViewModel.getLastSeen());
emptyConversationBanner.setVisibility(View.GONE);
adapter.registerAdapterDataObserver(new RecyclerView.AdapterDataObserver() {
@Override
public void onItemRangeInserted(int positionStart, int itemCount) {
@@ -631,9 +671,6 @@ public class ConversationFragment extends LoggingFragment {
});
}
});
} else if (threadId == -1) {
emptyConversationBanner.setVisibility(View.VISIBLE);
toolbarShadow.setVisibility(View.GONE);
}
}
@@ -664,7 +701,8 @@ public class ConversationFragment extends LoggingFragment {
replacedByIncomingMessage = false;
}
typingView.setTypists(GlideApp.with(ConversationFragment.this), recipients, recipient.get().isGroup());
Recipient resolved = recipient.get();
typingView.setTypists(GlideApp.with(ConversationFragment.this), recipients, resolved.isGroup(), resolved.hasWallpaper());
ConversationAdapter adapter = getListAdapter();
@@ -726,6 +764,7 @@ public class ConversationFragment extends LoggingFragment {
menu.findItem(R.id.menu_context_save_attachment).setVisible(menuState.shouldShowSaveAttachmentAction());
menu.findItem(R.id.menu_context_resend).setVisible(menuState.shouldShowResendAction());
menu.findItem(R.id.menu_context_copy).setVisible(menuState.shouldShowCopyAction());
menu.findItem(R.id.menu_context_delete_message).setVisible(menuState.shouldShowDeleteAction());
}
private @Nullable ConversationAdapter getListAdapter() {
@@ -756,7 +795,9 @@ public class ConversationFragment extends LoggingFragment {
snapToTopDataObserver.requestScrollPosition(0);
conversationViewModel.onConversationDataAvailable(recipient.getId(), threadId, -1);
messageCountsViewModel.setThreadId(threadId);
markReadHelper = new MarkReadHelper(threadId, requireContext());
initializeListAdapter();
initializeTypingObserver();
}
}
@@ -1118,6 +1159,10 @@ public class ConversationFragment extends LoggingFragment {
}
}
public @NonNull Colorizer getColorizer() {
return Objects.requireNonNull(colorizer);
}
@SuppressWarnings("CodeBlock2Expr")
public void jumpToMessage(@NonNull RecipientId author, long timestamp, @Nullable Runnable onMessageNotFound) {
SimpleTask.run(getLifecycle(), () -> {
@@ -1229,7 +1274,6 @@ public class ConversationFragment extends LoggingFragment {
toolbar.getGlobalVisibleRect(rect);
ViewUtil.setTopMargin(scrollDateHeader, rect.bottom + ViewUtil.dpToPx(8));
ViewUtil.setTopMargin(conversationBanner, rect.bottom + ViewUtil.dpToPx(16));
ViewUtil.setTopMargin(emptyConversationBanner, rect.bottom + ViewUtil.dpToPx(16));
toolbar.getViewTreeObserver().removeOnGlobalLayoutListener(this);
}
});

View File

@@ -7,6 +7,7 @@ import android.net.Uri;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.thoughtcrime.securesms.conversation.colors.ChatColors;
import org.thoughtcrime.securesms.database.ThreadDatabase;
import org.thoughtcrime.securesms.mediasend.Media;
import org.thoughtcrime.securesms.recipients.Recipient;
@@ -67,8 +68,8 @@ public class ConversationIntents {
private final StickerLocator stickerLocator;
private final boolean isBorderless;
private final int distributionType;
private final int startingPosition;
private final boolean firstTimeInSelfCreatedGroup;
private final int startingPosition;
private final boolean firstTimeInSelfCreatedGroup;
static Args from(@NonNull Intent intent) {
if (isBubbleIntent(intent)) {
@@ -155,6 +156,10 @@ public class ConversationIntents {
// TODO [greyson][wallpaper] Is it worth it to do this beforehand?
return Recipient.resolved(recipientId).getWallpaper();
}
public @NonNull ChatColors getChatColors() {
return Recipient.resolved(recipientId).getChatColors();
}
}
public final static class Builder {

View File

@@ -55,8 +55,8 @@ import androidx.annotation.Nullable;
import androidx.appcompat.app.AlertDialog;
import androidx.core.content.ContextCompat;
import androidx.lifecycle.LifecycleOwner;
import androidx.recyclerview.widget.RecyclerView;
import com.annimon.stream.Collectors;
import com.annimon.stream.Stream;
import com.google.android.exoplayer2.source.MediaSource;
@@ -72,7 +72,6 @@ import org.thoughtcrime.securesms.components.AvatarImageView;
import org.thoughtcrime.securesms.components.BorderlessImageView;
import org.thoughtcrime.securesms.components.ConversationItemFooter;
import org.thoughtcrime.securesms.components.ConversationItemThumbnail;
import org.thoughtcrime.securesms.components.CornerMask;
import org.thoughtcrime.securesms.components.DocumentView;
import org.thoughtcrime.securesms.components.LinkPreviewView;
import org.thoughtcrime.securesms.components.Outliner;
@@ -81,6 +80,8 @@ import org.thoughtcrime.securesms.components.SharedContactView;
import org.thoughtcrime.securesms.components.emoji.EmojiTextView;
import org.thoughtcrime.securesms.components.mention.MentionAnnotation;
import org.thoughtcrime.securesms.contactshare.Contact;
import org.thoughtcrime.securesms.conversation.colors.ChatColors;
import org.thoughtcrime.securesms.conversation.colors.Colorizer;
import org.thoughtcrime.securesms.database.AttachmentDatabase;
import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.database.MessageDatabase;
@@ -92,7 +93,6 @@ import org.thoughtcrime.securesms.database.model.Quote;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4PlaybackPolicy;
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4PlaybackPolicyEnforcer;
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4Projection;
import org.thoughtcrime.securesms.jobs.AttachmentDownloadJob;
import org.thoughtcrime.securesms.jobs.MmsDownloadJob;
import org.thoughtcrime.securesms.jobs.MmsSendJob;
@@ -121,9 +121,9 @@ import org.thoughtcrime.securesms.util.DynamicTheme;
import org.thoughtcrime.securesms.util.FeatureFlags;
import org.thoughtcrime.securesms.util.InterceptableLongClickCopyLinkSpan;
import org.thoughtcrime.securesms.util.LongClickMovementMethod;
import org.thoughtcrime.securesms.util.Projection;
import org.thoughtcrime.securesms.util.SearchUtil;
import org.thoughtcrime.securesms.util.StringUtil;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.UrlClickHandler;
import org.thoughtcrime.securesms.util.Util;
import org.thoughtcrime.securesms.util.VibrateUtil;
@@ -135,6 +135,7 @@ import org.whispersystems.libsignal.util.guava.Optional;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
@@ -160,13 +161,14 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
private static final Rect SWIPE_RECT = new Rect();
private ConversationMessage conversationMessage;
private MessageRecord messageRecord;
private Locale locale;
private boolean groupThread;
private LiveRecipient recipient;
private GlideRequests glideRequests;
private ValueAnimator pulseOutlinerAlphaAnimator;
private ClipProjectionDrawable backgroundDrawable;
private ConversationMessage conversationMessage;
private MessageRecord messageRecord;
private Locale locale;
private boolean groupThread;
private LiveRecipient recipient;
private GlideRequests glideRequests;
private ValueAnimator pulseOutlinerAlphaAnimator;
protected ConversationItemBodyBubble bodyBubble;
protected View reply;
@@ -212,8 +214,11 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
private final Context context;
private MediaSource mediaSource;
private boolean canPlayContent;
private MediaSource mediaSource;
private boolean canPlayContent;
private Projection.Corners bodyBubbleCorners;
private Colorizer colorizer;
private boolean hasWallpaper;
public ConversationItem(Context context) {
this(context, null);
@@ -235,6 +240,8 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
initializeAttributes();
this.backgroundDrawable = new ClipProjectionDrawable(Objects.requireNonNull(ContextCompat.getDrawable(getContext(),
R.drawable.conversation_item_background)));
this.bodyText = findViewById(R.id.conversation_item_body);
this.footer = findViewById(R.id.conversation_item_footer);
this.stickerFooter = findViewById(R.id.conversation_item_sticker_footer);
@@ -276,7 +283,8 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
boolean hasWallpaper,
boolean isMessageRequestAccepted,
@NonNull AttachmentMediaSourceFactory attachmentMediaSourceFactory,
boolean allowedToPlayInline)
boolean allowedToPlayInline,
@NonNull Colorizer colorizer)
{
if (this.recipient != null) this.recipient.removeForeverObserver(this);
if (this.conversationRecipient != null) this.conversationRecipient.removeForeverObserver(this);
@@ -293,6 +301,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
this.recipient = messageRecord.getIndividualRecipient().live();
this.canPlayContent = false;
this.mediaSource = null;
this.colorizer = colorizer;
this.recipient.observeForever(this);
this.conversationRecipient.observeForever(this);
@@ -301,14 +310,14 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
setMessageShape(messageRecord, previousMessageRecord, nextMessageRecord, groupThread);
setMediaAttributes(messageRecord, previousMessageRecord, nextMessageRecord, groupThread, hasWallpaper, isMessageRequestAccepted, attachmentMediaSourceFactory, allowedToPlayInline);
setBodyText(messageRecord, searchQuery, isMessageRequestAccepted);
setBubbleState(messageRecord, hasWallpaper);
setBubbleState(messageRecord, messageRecord.getRecipient(), hasWallpaper, colorizer);
setInteractionState(conversationMessage, pulse);
setStatusIcons(messageRecord, hasWallpaper);
setContactPhoto(recipient.get());
setGroupMessageStatus(messageRecord, recipient.get());
setGroupAuthorColor(messageRecord, hasWallpaper);
setGroupAuthorColor(messageRecord, hasWallpaper, colorizer);
setAuthor(messageRecord, previousMessageRecord, nextMessageRecord, groupThread, hasWallpaper);
setQuote(messageRecord, previousMessageRecord, nextMessageRecord, groupThread);
setQuote(messageRecord, previousMessageRecord, nextMessageRecord, groupThread, messageRecord.getRecipient().getChatColors());
setMessageSpacing(context, messageRecord, previousMessageRecord, nextMessageRecord, groupThread);
setReactions(messageRecord);
setFooter(messageRecord, nextMessageRecord, locale, groupThread, hasWallpaper);
@@ -390,7 +399,10 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
@Override
public void onRecipientChanged(@NonNull Recipient modified) {
setBubbleState(messageRecord, modified.hasWallpaper());
if (conversationRecipient.getId().equals(modified.getId())) {
setBubbleState(messageRecord, modified, modified.hasWallpaper(), colorizer);
}
if (recipient.getId().equals(modified.getId())) {
setContactPhoto(modified);
setGroupMessageStatus(messageRecord, modified);
@@ -438,11 +450,18 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
/// MessageRecord Attribute Parsers
private void setBubbleState(MessageRecord messageRecord, boolean hasWallpaper) {
private void setBubbleState(MessageRecord messageRecord, @NonNull Recipient recipient, boolean hasWallpaper, @NonNull Colorizer colorizer) {
this.hasWallpaper = hasWallpaper;
bodyText.setTextColor(ContextCompat.getColor(getContext(), R.color.signal_text_primary));
bodyText.setLinkTextColor(ContextCompat.getColor(getContext(), R.color.signal_text_primary));
if (messageRecord.isOutgoing() && !messageRecord.isRemoteDelete()) {
bodyBubble.getBackground().setColorFilter(getDefaultBubbleColor(hasWallpaper), PorterDuff.Mode.MULTIPLY);
footer.setTextColor(ContextCompat.getColor(context, R.color.signal_text_secondary));
footer.setIconColor(ContextCompat.getColor(context, R.color.signal_icon_tint_secondary));
bodyBubble.getBackground().setColorFilter(recipient.getChatColors().getChatBubbleColorFilter());
bodyText.setTextColor(colorizer.getOutgoingBodyTextColor(context));
bodyText.setLinkTextColor(colorizer.getOutgoingBodyTextColor(context));
footer.setTextColor(colorizer.getOutgoingFooterTextColor(context));
footer.setIconColor(colorizer.getOutgoingFooterIconColor(context));
footer.setOnlyShowSendingStatus(false, messageRecord);
} else if (messageRecord.isRemoteDelete() || (isViewOnceMessage(messageRecord) && ViewOnceUtil.isViewed((MmsMessageRecord) messageRecord))) {
if (hasWallpaper) {
@@ -454,9 +473,9 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
footer.setTextColor(ContextCompat.getColor(context, R.color.signal_text_secondary));
footer.setOnlyShowSendingStatus(messageRecord.isRemoteDelete(), messageRecord);
} else {
bodyBubble.getBackground().setColorFilter(messageRecord.getRecipient().getColor().toConversationColor(context), PorterDuff.Mode.MULTIPLY);
footer.setTextColor(ContextCompat.getColor(context, R.color.conversation_item_received_text_secondary_color));
footer.setIconColor(ContextCompat.getColor(context, R.color.conversation_item_received_text_secondary_color));
bodyBubble.getBackground().setColorFilter(getDefaultBubbleColor(hasWallpaper), PorterDuff.Mode.SRC_IN);
footer.setTextColor(ContextCompat.getColor(context, R.color.signal_text_secondary));
footer.setIconColor(ContextCompat.getColor(context, R.color.signal_text_secondary));
footer.setOnlyShowSendingStatus(false, messageRecord);
}
@@ -490,7 +509,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
private void setAudioViewTint(MessageRecord messageRecord) {
if (hasAudio(messageRecord)) {
if (messageRecord.isOutgoing()) {
if (!messageRecord.isOutgoing()) {
if (DynamicTheme.isDarkTheme(context)) {
audioViewStub.get().setTint(Color.WHITE);
} else {
@@ -504,7 +523,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
private void setInteractionState(ConversationMessage conversationMessage, boolean pulseMention) {
if (batchSelected.contains(conversationMessage)) {
setBackgroundResource(R.drawable.conversation_item_background);
setBackground(backgroundDrawable);
setSelected(true);
} else if (pulseMention) {
setBackground(null);
@@ -683,9 +702,9 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
}
if (messageRecord.isOutgoing()) {
bodyText.setMentionBackgroundTint(ContextCompat.getColor(context, isDarkTheme(context) ? R.color.core_grey_60 : R.color.core_grey_20));
bodyText.setMentionBackgroundTint(ContextCompat.getColor(context, R.color.transparent_black_25));
} else {
bodyText.setMentionBackgroundTint(ContextCompat.getColor(context, R.color.transparent_black_40));
bodyText.setMentionBackgroundTint(ContextCompat.getColor(context, isDarkTheme(context) ? R.color.core_grey_60 : R.color.core_grey_20));
}
bodyText.setText(StringUtil.trim(styledText));
@@ -704,6 +723,10 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
{
boolean showControls = !messageRecord.isFailed();
bodyBubble.setQuoteViewProjection(null);
bodyBubble.setVideoPlayerProjection(null);
updateBackgroundDrawableProjections();
if (eventListener != null && audioViewStub.resolved()) {
Log.d(TAG, "setMediaAttributes: unregistering voice note callbacks for audio slide " + audioViewStub.get().getAudioSlideUri());
eventListener.onUnregisterVoiceNoteCallbacks(audioViewStub.get().getPlaybackStateObserver());
@@ -875,8 +898,13 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
mediaThumbnailStub.get().setOnLongClickListener(passthroughClickListener);
mediaThumbnailStub.get().setOnClickListener(passthroughClickListener);
mediaThumbnailStub.get().showShade(TextUtils.isEmpty(messageRecord.getDisplayBody(getContext())) && !hasExtraText(messageRecord));
mediaThumbnailStub.get().setConversationColor(messageRecord.isOutgoing() ? getDefaultBubbleColor(hasWallpaper)
: messageRecord.getRecipient().getColor().toConversationColor(context));
if (!messageRecord.isOutgoing()) {
mediaThumbnailStub.get().setConversationColor(getDefaultBubbleColor(hasWallpaper));
} else {
mediaThumbnailStub.get().setConversationColor(Color.TRANSPARENT);
}
mediaThumbnailStub.get().setBorderless(false);
setThumbnailCorners(messageRecord, previousRecord, nextRecord, isGroupThread);
@@ -1068,14 +1096,14 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
}
}
private void setQuote(@NonNull MessageRecord current, @NonNull Optional<MessageRecord> previous, @NonNull Optional<MessageRecord> next, boolean isGroupThread) {
private void setQuote(@NonNull MessageRecord current, @NonNull Optional<MessageRecord> previous, @NonNull Optional<MessageRecord> next, boolean isGroupThread, @NonNull ChatColors chatColors) {
if (current.isMms() && !current.isMmsNotification() && ((MediaMmsMessageRecord)current).getQuote() != null) {
if (quoteView == null) {
throw new AssertionError();
}
Quote quote = ((MediaMmsMessageRecord)current).getQuote();
//noinspection ConstantConditions
quoteView.setQuote(glideRequests, quote.getId(), Recipient.live(quote.getAuthor()).get(), quote.getDisplayText(), quote.isOriginalMissing(), quote.getAttachment());
quoteView.setQuote(glideRequests, quote.getId(), Recipient.live(quote.getAuthor()).get(), quote.getDisplayText(), quote.isOriginalMissing(), quote.getAttachment(), chatColors);
quoteView.setVisibility(View.VISIBLE);
quoteView.getLayoutParams().width = ViewGroup.LayoutParams.WRAP_CONTENT;
@@ -1177,11 +1205,11 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
if (hasWallpaper && hasNoBubble((messageRecord))) {
if (messageRecord.isOutgoing()) {
activeFooter.enableBubbleBackground(R.drawable.wallpaper_bubble_background_tintable_11, getDefaultBubbleColor(hasWallpaper));
activeFooter.disableBubbleBackground();
activeFooter.setTextColor(ContextCompat.getColor(context, R.color.conversation_item_sent_text_secondary_color));
activeFooter.setIconColor(ContextCompat.getColor(context, R.color.conversation_item_sent_text_secondary_color));
} else {
activeFooter.enableBubbleBackground(R.drawable.wallpaper_bubble_background_tintable_11, messageRecord.getRecipient().getColor().toConversationColor(context));
activeFooter.setTextColor(ContextCompat.getColor(context, R.color.conversation_item_received_text_secondary_color));
activeFooter.setIconColor(ContextCompat.getColor(context, R.color.conversation_item_received_text_secondary_color));
activeFooter.enableBubbleBackground(R.drawable.wallpaper_bubble_background_tintable_11, getDefaultBubbleColor(hasWallpaper));
}
} else if (hasNoBubble(messageRecord)){
activeFooter.disableBubbleBackground();
@@ -1228,17 +1256,9 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
}
}
private void setGroupAuthorColor(@NonNull MessageRecord messageRecord, boolean hasWallpaper) {
private void setGroupAuthorColor(@NonNull MessageRecord messageRecord, boolean hasWallpaper, @NonNull Colorizer colorizer) {
if (groupSender != null) {
int stickerAuthorColor = ContextCompat.getColor(context, R.color.signal_text_primary);
if (shouldDrawBodyBubbleOutline(messageRecord, false)) {
groupSender.setTextColor(stickerAuthorColor);
} else if (!hasWallpaper && hasNoBubble(messageRecord)) {
groupSender.setTextColor(stickerAuthorColor);
} else {
groupSender.setTextColor(ContextCompat.getColor(context, R.color.conversation_item_received_text_primary_color));
}
groupSender.setTextColor(colorizer.getIncomingGroupSenderColor(getContext(), messageRecord.getIndividualRecipient()));
}
}
@@ -1254,7 +1274,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
if (hasWallpaper && hasNoBubble(current)) {
groupSenderHolder.setBackgroundResource(R.drawable.wallpaper_bubble_background_tintable_11);
groupSenderHolder.getBackground().setColorFilter(messageRecord.getRecipient().getColor().toConversationColor(context), PorterDuff.Mode.MULTIPLY);
groupSenderHolder.getBackground().setColorFilter(getDefaultBubbleColor(hasWallpaper), PorterDuff.Mode.MULTIPLY);
} else {
groupSenderHolder.setBackground(null);
}
@@ -1297,40 +1317,48 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
background = R.drawable.message_bubble_background_sent_alone;
outliner.setRadius(bigRadius);
pulseOutliner.setRadius(bigRadius);
bodyBubbleCorners = new Projection.Corners(bigRadius);
} else {
background = R.drawable.message_bubble_background_received_alone;
outliner.setRadius(bigRadius);
pulseOutliner.setRadius(bigRadius);
bodyBubbleCorners = null;
}
} else if (isStartOfMessageCluster(current, previous, isGroupThread)) {
if (current.isOutgoing()) {
background = R.drawable.message_bubble_background_sent_start;
setOutlinerRadii(outliner, bigRadius, bigRadius, smallRadius, bigRadius);
setOutlinerRadii(pulseOutliner, bigRadius, bigRadius, smallRadius, bigRadius);
bodyBubbleCorners = new Projection.Corners(bigRadius, bigRadius, smallRadius, bigRadius);
} else {
background = R.drawable.message_bubble_background_received_start;
setOutlinerRadii(outliner, bigRadius, bigRadius, bigRadius, smallRadius);
setOutlinerRadii(pulseOutliner, bigRadius, bigRadius, bigRadius, smallRadius);
bodyBubbleCorners = null;
}
} else if (isEndOfMessageCluster(current, next, isGroupThread)) {
if (current.isOutgoing()) {
background = R.drawable.message_bubble_background_sent_end;
setOutlinerRadii(outliner, bigRadius, smallRadius, bigRadius, bigRadius);
setOutlinerRadii(pulseOutliner, bigRadius, smallRadius, bigRadius, bigRadius);
bodyBubbleCorners = new Projection.Corners(bigRadius, smallRadius, bigRadius, bigRadius);
} else {
background = R.drawable.message_bubble_background_received_end;
setOutlinerRadii(outliner, smallRadius, bigRadius, bigRadius, bigRadius);
setOutlinerRadii(pulseOutliner, smallRadius, bigRadius, bigRadius, bigRadius);
bodyBubbleCorners = null;
}
} else {
if (current.isOutgoing()) {
background = R.drawable.message_bubble_background_sent_middle;
setOutlinerRadii(outliner, bigRadius, smallRadius, smallRadius, bigRadius);
setOutlinerRadii(pulseOutliner, bigRadius, smallRadius, smallRadius, bigRadius);
bodyBubbleCorners = new Projection.Corners(bigRadius, smallRadius, smallRadius, bigRadius);
} else {
background = R.drawable.message_bubble_background_received_middle;
setOutlinerRadii(outliner, smallRadius, bigRadius, bigRadius, smallRadius);
setOutlinerRadii(pulseOutliner, smallRadius, bigRadius, bigRadius, smallRadius);
bodyBubbleCorners = null;
}
}
@@ -1440,7 +1468,8 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
public void showProjectionArea() {
if (mediaThumbnailStub != null && mediaThumbnailStub.resolved()) {
mediaThumbnailStub.get().showThumbnailView();
bodyBubble.setMask(null);
bodyBubble.setVideoPlayerProjection(null);
updateBackgroundDrawableProjections();
}
}
@@ -1449,7 +1478,8 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
if (mediaThumbnailStub != null && mediaThumbnailStub.resolved()) {
mediaThumbnailStub.get().hideThumbnailView();
mediaThumbnailStub.get().getDrawingRect(thumbnailMaskingRect);
bodyBubble.setMask(thumbnailMaskingRect);
bodyBubble.setVideoPlayerProjection(Projection.relativeToViewWithCommonRoot(mediaThumbnailStub.get(), bodyBubble, null));
updateBackgroundDrawableProjections();
}
}
@@ -1475,9 +1505,9 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
}
@Override
public @NonNull GiphyMp4Projection getProjection(@NonNull RecyclerView recyclerView) {
return GiphyMp4Projection.forView(recyclerView, mediaThumbnailStub.get(), mediaThumbnailStub.get().getCornerMask())
.translateX(bodyBubble.getTranslationX());
public @NonNull Projection getProjection(@NonNull ViewGroup recyclerView) {
return Projection.relativeToParent(recyclerView, mediaThumbnailStub.get(), mediaThumbnailStub.get().getCorners())
.translateX(bodyBubble.getTranslationX());
}
@Override
@@ -1494,8 +1524,56 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
return rect;
}
public @NonNull CornerMask getThumbnailCornerMask(@NonNull View view) {
return new CornerMask(view, mediaThumbnailStub.get().getCornerMask());
public @NonNull Projection.Corners getThumbnailCorners() {
return mediaThumbnailStub.get().getCorners();
}
@Override
public @NonNull List<Projection> getColorizerProjections() {
List<Projection> projections = new LinkedList<>();
if (messageRecord.isOutgoing() &&
!hasNoBubble(messageRecord) &&
!messageRecord.isRemoteDelete() &&
bodyBubbleCorners != null)
{
projections.add(Projection.relativeToViewRoot(bodyBubble, bodyBubbleCorners).translateX(bodyBubble.getTranslationX()));
}
if (messageRecord.isOutgoing() &&
hasNoBubble(messageRecord) &&
hasWallpaper)
{
Projection footerProjection = getActiveFooter(messageRecord).getProjection();
if (footerProjection != null) {
projections.add(footerProjection.translateX(bodyBubble.getTranslationX()));
}
}
if (!messageRecord.isOutgoing() &&
hasQuote(messageRecord) &&
quoteView != null)
{
bodyBubble.setQuoteViewProjection(quoteView.getProjection(bodyBubble));
projections.add(quoteView.getProjection((ViewGroup) getRootView()).translateX(bodyBubble.getTranslationX()));
}
return projections;
}
private void updateBackgroundDrawableProjections() {
Set<Projection> projections = Stream.of(bodyBubble.getProjections())
.map(p -> Projection.translateFromDescendantToParentCoords(p, bodyBubble, this))
.collect(Collectors.toSet());
if (messageRecord.isOutgoing() &&
!hasNoBubble(messageRecord) &&
bodyBubbleCorners != null)
{
projections.add(Projection.relativeToParent(this, bodyBubble, bodyBubbleCorners).translateX(bodyBubble.getTranslationX()));
}
backgroundDrawable.setProjections(projections);
}
private class SharedContactEventListener implements SharedContactView.EventListener {

View File

@@ -2,7 +2,6 @@ package org.thoughtcrime.securesms.conversation;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Rect;
import android.graphics.drawable.Drawable;
import android.util.AttributeSet;
import android.widget.LinearLayout;
@@ -10,19 +9,27 @@ import android.widget.LinearLayout;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.annimon.stream.Collectors;
import com.annimon.stream.Stream;
import org.thoughtcrime.securesms.components.Outliner;
import org.thoughtcrime.securesms.util.Projection;
import org.thoughtcrime.securesms.util.Util;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
public class ConversationItemBodyBubble extends LinearLayout {
@Nullable private List<Outliner> outliners = Collections.emptyList();
@Nullable private OnSizeChangedListener sizeChangedListener;
private MaskDrawable maskDrawable;
private Rect mask;
private ClipProjectionDrawable clipProjectionDrawable;
private Projection quoteViewProjection;
private Projection videoPlayerProjection;
public ConversationItemBodyBubble(Context context) {
super(context);
@@ -46,14 +53,26 @@ public class ConversationItemBodyBubble extends LinearLayout {
@Override
public void setBackground(Drawable background) {
maskDrawable = new MaskDrawable(background);
maskDrawable.setMask(mask);
super.setBackground(maskDrawable);
clipProjectionDrawable = new ClipProjectionDrawable(background);
clipProjectionDrawable.setProjections(getProjections());
super.setBackground(clipProjectionDrawable);
}
public void setMask(@Nullable Rect mask) {
this.mask = mask;
maskDrawable.setMask(mask);
public void setQuoteViewProjection(@Nullable Projection quoteViewProjection) {
this.quoteViewProjection = quoteViewProjection;
clipProjectionDrawable.setProjections(getProjections());
}
public void setVideoPlayerProjection(@Nullable Projection videoPlayerProjection) {
this.videoPlayerProjection = videoPlayerProjection;
clipProjectionDrawable.setProjections(getProjections());
}
public @NonNull Set<Projection> getProjections() {
return Stream.of(quoteViewProjection, videoPlayerProjection)
.filterNot(Objects::isNull)
.collect(Collectors.toSet());
}
@Override

View File

@@ -2,23 +2,30 @@ package org.thoughtcrime.securesms.conversation;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.Path;
import android.view.View;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.recyclerview.widget.RecyclerView;
import org.thoughtcrime.securesms.components.CornerMask;
import com.annimon.stream.Stream;
import org.thoughtcrime.securesms.components.MaskView;
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4Projection;
import org.thoughtcrime.securesms.util.Projection;
import java.util.Arrays;
import java.util.List;
/**
* Masking area to ensure proper rendering of Reactions overlay.
*/
public final class ConversationItemMaskTarget extends MaskView.MaskTarget {
private final ConversationItem conversationItem;
private final View videoContainer;
private final Paint paint;
public ConversationItemMaskTarget(@NonNull ConversationItem conversationItem,
@Nullable View videoContainer)
@@ -26,6 +33,10 @@ public final class ConversationItemMaskTarget extends MaskView.MaskTarget {
super(conversationItem);
this.conversationItem = conversationItem;
this.videoContainer = videoContainer;
this.paint = new Paint(Paint.ANTI_ALIAS_FLAG);
paint.setColor(Color.BLACK);
paint.setStyle(Paint.Style.FILL);
}
@Override
@@ -41,19 +52,16 @@ public final class ConversationItemMaskTarget extends MaskView.MaskTarget {
protected void draw(@NonNull Canvas canvas) {
super.draw(canvas);
if (videoContainer == null) {
return;
List<Projection> projections = Stream.of(conversationItem.getColorizerProjections()).map(p ->
Projection.translateFromRootToDescendantCoords(p, conversationItem)
).toList();
if (videoContainer != null) {
projections.add(conversationItem.getProjection((RecyclerView) conversationItem.getParent()));
}
GiphyMp4Projection projection = conversationItem.getProjection((RecyclerView) conversationItem.getParent());
CornerMask cornerMask = projection.getCornerMask();
canvas.clipRect(conversationItem.bodyBubble.getLeft(),
conversationItem.bodyBubble.getTop(),
conversationItem.bodyBubble.getRight(),
conversationItem.bodyBubble.getTop() + projection.getHeight());
canvas.drawColor(Color.BLACK);
cornerMask.mask(canvas);
for (Projection projection : projections) {
canvas.drawPath(projection.getPath(), paint);
}
}
}

View File

@@ -16,7 +16,7 @@ import org.thoughtcrime.securesms.giph.mp4.GiphyMp4Playable;
import org.thoughtcrime.securesms.util.AccessibilityUtil;
import org.thoughtcrime.securesms.util.ServiceUtil;
class ConversationItemSwipeCallback extends ItemTouchHelper.SimpleCallback {
public class ConversationItemSwipeCallback extends ItemTouchHelper.SimpleCallback {
private static float SWIPE_SUCCESS_DX = ConversationSwipeAnimationHelper.TRIGGER_DX;
private static long SWIPE_SUCCESS_VIBE_TIME_MS = 10;
@@ -30,17 +30,17 @@ class ConversationItemSwipeCallback extends ItemTouchHelper.SimpleCallback {
private final SwipeAvailabilityProvider swipeAvailabilityProvider;
private final ConversationItemTouchListener itemTouchListener;
private final OnSwipeListener onSwipeListener;
private final GiphyMp4DisplayUpdater giphyMp4DisplayUpdater;
private final OnViewHolderTranslated onViewHolderTranslated;
ConversationItemSwipeCallback(@NonNull SwipeAvailabilityProvider swipeAvailabilityProvider,
@NonNull OnSwipeListener onSwipeListener,
@NonNull GiphyMp4DisplayUpdater giphyMp4DisplayUpdater)
@NonNull OnViewHolderTranslated onViewHolderTranslated)
{
super(0, ItemTouchHelper.END);
this.itemTouchListener = new ConversationItemTouchListener(this::updateLatestDownCoordinate);
this.swipeAvailabilityProvider = swipeAvailabilityProvider;
this.onSwipeListener = onSwipeListener;
this.giphyMp4DisplayUpdater = giphyMp4DisplayUpdater;
this.onViewHolderTranslated = onViewHolderTranslated;
this.shouldTriggerSwipeFeedback = true;
this.canTriggerSwipe = true;
}
@@ -93,14 +93,14 @@ class ConversationItemSwipeCallback extends ItemTouchHelper.SimpleCallback {
if (actionState == ItemTouchHelper.ACTION_STATE_SWIPE && isCorrectSwipeDir) {
ConversationSwipeAnimationHelper.update((ConversationItem) viewHolder.itemView, Math.abs(dx), sign);
updateVideoPlayer(recyclerView, viewHolder);
dispatchTranslationUpdate(recyclerView, viewHolder);
handleSwipeFeedback((ConversationItem) viewHolder.itemView, Math.abs(dx));
if (canTriggerSwipe) {
setTouchListener(recyclerView, viewHolder, Math.abs(dx));
}
} else if (actionState == ItemTouchHelper.ACTION_STATE_IDLE || dx == 0) {
ConversationSwipeAnimationHelper.update((ConversationItem) viewHolder.itemView, 0, 1);
updateVideoPlayer(recyclerView, viewHolder);
dispatchTranslationUpdate(recyclerView, viewHolder);
}
if (dx == 0) {
@@ -109,10 +109,8 @@ class ConversationItemSwipeCallback extends ItemTouchHelper.SimpleCallback {
}
}
private void updateVideoPlayer(@NonNull RecyclerView recyclerView, @NonNull RecyclerView.ViewHolder viewHolder) {
if (viewHolder instanceof GiphyMp4Playable) {
giphyMp4DisplayUpdater.updateDisplay(recyclerView, (GiphyMp4Playable) viewHolder);
}
private void dispatchTranslationUpdate(@NonNull RecyclerView recyclerView, @NonNull RecyclerView.ViewHolder viewHolder) {
onViewHolderTranslated.onViewHolderTranslated(recyclerView, viewHolder);
}
private void handleSwipeFeedback(@NonNull ConversationItem item, float dx) {
@@ -174,7 +172,7 @@ class ConversationItemSwipeCallback extends ItemTouchHelper.SimpleCallback {
ConversationSwipeAnimationHelper.update((ConversationItem) viewHolder.itemView,
0f,
getSignFromDirection(viewHolder.itemView));
updateVideoPlayer(recyclerView, viewHolder);
dispatchTranslationUpdate(recyclerView, viewHolder);
}
}
@@ -211,4 +209,8 @@ class ConversationItemSwipeCallback extends ItemTouchHelper.SimpleCallback {
interface OnSwipeListener {
void onSwipe(ConversationMessage conversationMessage);
}
public interface OnViewHolderTranslated {
void onViewHolderTranslated(@NonNull RecyclerView recyclerView, @NonNull RecyclerView.ViewHolder viewHolder);
}
}

View File

@@ -13,6 +13,7 @@ import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.database.GroupDatabase;
import org.thoughtcrime.securesms.database.ThreadDatabase;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientUtil;
import org.thoughtcrime.securesms.util.BubbleUtil;
@@ -32,11 +33,11 @@ class ConversationRepository {
this.executor = SignalExecutors.BOUNDED;
}
LiveData<ConversationData> getConversationData(long threadId, int jumpToPosition) {
LiveData<ConversationData> getConversationData(long threadId, @NonNull Recipient recipient, int jumpToPosition) {
MutableLiveData<ConversationData> liveData = new MutableLiveData<>();
executor.execute(() -> {
liveData.postValue(getConversationDataInternal(threadId, jumpToPosition));
liveData.postValue(getConversationDataInternal(threadId, recipient, jumpToPosition));
});
return liveData;
@@ -53,16 +54,17 @@ class ConversationRepository {
}
}
private @NonNull ConversationData getConversationDataInternal(long threadId, int jumpToPosition) {
ThreadDatabase.ConversationMetadata metadata = DatabaseFactory.getThreadDatabase(context).getConversationMetadata(threadId);
int threadSize = DatabaseFactory.getMmsSmsDatabase(context).getConversationCount(threadId);
long lastSeen = metadata.getLastSeen();
boolean hasSent = metadata.hasSent();
int lastSeenPosition = 0;
long lastScrolled = metadata.getLastScrolled();
int lastScrolledPosition = 0;
boolean isMessageRequestAccepted = RecipientUtil.isMessageRequestAccepted(context, threadId);
ConversationData.MessageRequestData messageRequestData = new ConversationData.MessageRequestData(isMessageRequestAccepted);
private @NonNull ConversationData getConversationDataInternal(long threadId, @NonNull Recipient conversationRecipient, int jumpToPosition) {
ThreadDatabase.ConversationMetadata metadata = DatabaseFactory.getThreadDatabase(context).getConversationMetadata(threadId);
int threadSize = DatabaseFactory.getMmsSmsDatabase(context).getConversationCount(threadId);
long lastSeen = metadata.getLastSeen();
boolean hasSent = metadata.hasSent();
int lastSeenPosition = 0;
long lastScrolled = metadata.getLastScrolled();
int lastScrolledPosition = 0;
boolean isMessageRequestAccepted = RecipientUtil.isMessageRequestAccepted(context, threadId);
ConversationData.MessageRequestData messageRequestData = new ConversationData.MessageRequestData(isMessageRequestAccepted);
boolean showUniversalExpireTimerUpdate = false;
if (lastSeen > 0) {
lastSeenPosition = DatabaseFactory.getMmsSmsDatabase(context).getMessagePositionOnOrAfterTimestamp(threadId, lastSeen);
@@ -79,9 +81,8 @@ class ConversationRepository {
if (!isMessageRequestAccepted) {
boolean isGroup = false;
boolean recipientIsKnownOrHasGroupsInCommon = false;
Recipient threadRecipient = DatabaseFactory.getThreadDatabase(context).getRecipientForThreadId(threadId);
if (threadRecipient.isGroup()) {
Optional<GroupDatabase.GroupRecord> group = DatabaseFactory.getGroupDatabase(context).getGroup(threadRecipient.getId());
if (conversationRecipient.isGroup()) {
Optional<GroupDatabase.GroupRecord> group = DatabaseFactory.getGroupDatabase(context).getGroup(conversationRecipient.getId());
if (group.isPresent()) {
List<Recipient> recipients = Recipient.resolvedList(group.get().getMembers());
for (Recipient recipient : recipients) {
@@ -92,12 +93,20 @@ class ConversationRepository {
}
}
isGroup = true;
} else if (threadRecipient.hasGroupsInCommon()) {
} else if (conversationRecipient.hasGroupsInCommon()) {
recipientIsKnownOrHasGroupsInCommon = true;
}
messageRequestData = new ConversationData.MessageRequestData(isMessageRequestAccepted, recipientIsKnownOrHasGroupsInCommon, isGroup);
}
return new ConversationData(threadId, lastSeen, lastSeenPosition, lastScrolledPosition, hasSent, jumpToPosition, threadSize, messageRequestData);
if (SignalStore.settings().getUniversalExpireTimer() != 0 &&
conversationRecipient.getExpireMessages() == 0 &&
!conversationRecipient.isGroup() &&
(threadId == -1 || !DatabaseFactory.getMmsSmsDatabase(context).hasMeaningfulMessage(threadId)))
{
showUniversalExpireTimerUpdate = true;
}
return new ConversationData(threadId, lastSeen, lastSeenPosition, lastScrolledPosition, hasSent, jumpToPosition, threadSize, messageRequestData, showUniversalExpireTimerUpdate);
}
}

View File

@@ -16,7 +16,6 @@ import androidx.core.content.ContextCompat;
import androidx.lifecycle.LifecycleOwner;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.Observer;
import androidx.recyclerview.widget.RecyclerView;
import com.google.android.material.button.MaterialButton;
@@ -24,6 +23,7 @@ import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.BindableConversationItem;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.VerifyIdentityActivity;
import org.thoughtcrime.securesms.conversation.colors.Colorizer;
import org.thoughtcrime.securesms.conversation.ui.error.EnableCallNotificationSettingsDialog;
import org.thoughtcrime.securesms.database.IdentityDatabase.IdentityRecord;
import org.thoughtcrime.securesms.database.model.GroupCallUpdateDetailsUtil;
@@ -31,12 +31,12 @@ import org.thoughtcrime.securesms.database.model.InMemoryMessageRecord;
import org.thoughtcrime.securesms.database.model.LiveUpdateMessage;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.database.model.UpdateDescription;
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4Projection;
import org.thoughtcrime.securesms.mms.GlideRequests;
import org.thoughtcrime.securesms.recipients.LiveRecipient;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.util.DateUtils;
import org.thoughtcrime.securesms.util.IdentityUtil;
import org.thoughtcrime.securesms.util.Projection;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.ThemeUtil;
import org.thoughtcrime.securesms.util.Util;
@@ -47,6 +47,8 @@ import org.thoughtcrime.securesms.video.exo.AttachmentMediaSourceFactory;
import org.whispersystems.libsignal.util.guava.Optional;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.Set;
@@ -109,7 +111,8 @@ public final class ConversationUpdateItem extends FrameLayout
boolean hasWallpaper,
boolean isMessageRequestAccepted,
@NonNull AttachmentMediaSourceFactory attachmentMediaSourceFactory,
boolean allowedToPlayInline)
boolean allowedToPlayInline,
@NonNull Colorizer colorizer)
{
this.batchSelected = batchSelected;
@@ -206,7 +209,7 @@ public final class ConversationUpdateItem extends FrameLayout
}
@Override
public @NonNull GiphyMp4Projection getProjection(@NonNull RecyclerView recyclerView) {
public @NonNull Projection getProjection(@NonNull ViewGroup recyclerView) {
throw new UnsupportedOperationException("ConversationUpdateItems cannot be projected into.");
}
@@ -215,6 +218,11 @@ public final class ConversationUpdateItem extends FrameLayout
return false;
}
@Override
public @NonNull List<Projection> getColorizerProjections() {
return Collections.emptyList();
}
static final class RecipientObserverManager {
private final Observer<Recipient> recipientObserver;

View File

@@ -10,6 +10,8 @@ import androidx.lifecycle.Transformations;
import androidx.lifecycle.ViewModel;
import androidx.lifecycle.ViewModelProvider;
import com.annimon.stream.Stream;
import org.greenrobot.eventbus.EventBus;
import org.greenrobot.eventbus.Subscribe;
import org.greenrobot.eventbus.ThreadMode;
@@ -18,8 +20,13 @@ import org.signal.paging.PagedData;
import org.signal.paging.PagingConfig;
import org.signal.paging.PagingController;
import org.signal.paging.ProxyPagingController;
import org.thoughtcrime.securesms.conversation.colors.ChatColors;
import org.thoughtcrime.securesms.conversation.colors.ChatColorsPalette;
import org.thoughtcrime.securesms.conversation.colors.NameColor;
import org.thoughtcrime.securesms.database.DatabaseObserver;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.groups.LiveGroup;
import org.thoughtcrime.securesms.groups.ui.GroupMemberEntry;
import org.thoughtcrime.securesms.mediasend.Media;
import org.thoughtcrime.securesms.mediasend.MediaRepository;
import org.thoughtcrime.securesms.ratelimit.RecaptchaRequiredEvent;
@@ -30,8 +37,11 @@ import org.thoughtcrime.securesms.util.livedata.LiveDataUtil;
import org.thoughtcrime.securesms.wallpaper.ChatWallpaper;
import org.whispersystems.libsignal.util.Pair;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
public class ConversationViewModel extends ViewModel {
@@ -52,6 +62,7 @@ public class ConversationViewModel extends ViewModel {
private final MutableLiveData<RecipientId> recipientId;
private final LiveData<ChatWallpaper> wallpaper;
private final SingleLiveEvent<Event> events;
private final LiveData<ChatColors> chatColors;
private ConversationIntents.Args args;
private int jumpToPosition;
@@ -69,8 +80,11 @@ public class ConversationViewModel extends ViewModel {
this.pagingController = new ProxyPagingController();
this.messageObserver = pagingController::onDataInvalidated;
LiveData<ConversationData> metadata = Transformations.switchMap(threadId, thread -> {
LiveData<ConversationData> conversationData = conversationRepository.getConversationData(thread, jumpToPosition);
LiveData<Recipient> recipientLiveData = LiveDataUtil.mapAsync(recipientId, Recipient::resolved);
LiveData<ThreadAndRecipient> threadAndRecipient = LiveDataUtil.combineLatest(threadId, recipientLiveData, ThreadAndRecipient::new);
LiveData<ConversationData> metadata = Transformations.switchMap(threadAndRecipient, d -> {
LiveData<ConversationData> conversationData = conversationRepository.getConversationData(d.threadId, d.recipient, jumpToPosition);
jumpToPosition = -1;
@@ -94,12 +108,11 @@ public class ConversationViewModel extends ViewModel {
ApplicationDependencies.getDatabaseObserver().unregisterObserver(messageObserver);
ApplicationDependencies.getDatabaseObserver().registerConversationObserver(data.getThreadId(), messageObserver);
ConversationDataSource dataSource = new ConversationDataSource(context, data.getThreadId(), messageRequestData);
PagingConfig config = new PagingConfig.Builder()
.setPageSize(25)
.setBufferPages(3)
.setStartIndex(Math.max(startPosition, 0))
.build();
ConversationDataSource dataSource = new ConversationDataSource(context, data.getThreadId(), messageRequestData, data.showUniversalExpireTimerMessage());
PagingConfig config = new PagingConfig.Builder().setPageSize(25)
.setBufferPages(3)
.setStartIndex(Math.max(startPosition, 0))
.build();
Log.d(TAG, "Starting at position: " + startPosition + " || jumpToPosition: " + data.getJumpToPosition() + ", lastSeenPosition: " + data.getLastSeenPosition() + ", lastScrolledPosition: " + data.getLastScrolledPosition());
return new Pair<>(data.getThreadId(), PagedData.create(dataSource, config));
@@ -112,11 +125,15 @@ public class ConversationViewModel extends ViewModel {
conversationMetadata = Transformations.switchMap(messages, m -> metadata);
canShowAsBubble = LiveDataUtil.mapAsync(threadId, conversationRepository::canShowAsBubble);
wallpaper = Transformations.distinctUntilChanged(Transformations.map(Transformations.switchMap(recipientId,
id -> Recipient.live(id).getLiveData()),
Recipient::getWallpaper));
wallpaper = LiveDataUtil.mapDistinct(Transformations.switchMap(recipientId,
id -> Recipient.live(id).getLiveData()),
Recipient::getWallpaper);
EventBus.getDefault().register(this);
chatColors = LiveDataUtil.mapDistinct(Transformations.switchMap(recipientId,
id -> Recipient.live(id).getLiveData()),
Recipient::getChatColors);
}
void onAttachmentKeyboardOpen() {
@@ -157,6 +174,10 @@ public class ConversationViewModel extends ViewModel {
return events;
}
@NonNull LiveData<ChatColors> getChatColors() {
return chatColors;
}
void setHasUnreadMentions(boolean hasUnreadMentions) {
this.hasUnreadMentions.setValue(hasUnreadMentions);
}
@@ -181,6 +202,35 @@ public class ConversationViewModel extends ViewModel {
return pagingController;
}
@NonNull LiveData<Map<RecipientId, NameColor>> getNameColorsMap() {
LiveData<Recipient> recipient = Transformations.switchMap(recipientId, r -> Recipient.live(r).getLiveData());
LiveData<Recipient> groupRecipients = LiveDataUtil.filter(recipient, Recipient::isGroup);
LiveData<List<GroupMemberEntry.FullMember>> groupMembers = Transformations.switchMap(groupRecipients, r -> new LiveGroup(r.getGroupId().get()).getFullMembers());
return Transformations.map(groupMembers, members -> {
List<GroupMemberEntry.FullMember> sorted = Stream.of(members)
.filter(member -> !Objects.equals(member.getMember(), Recipient.self()))
.sortBy(this::getMemberIdentifier)
.toList();
List<NameColor> names = ChatColorsPalette.Names.getAll();
Map<RecipientId, NameColor> colors = new HashMap<>();
for (int i = 0; i < sorted.size(); i++) {
colors.put(sorted.get(i).getMember().getId(), names.get(i % names.size()));
}
return colors;
});
}
private @NonNull String getMemberIdentifier(@NonNull GroupMemberEntry.FullMember fullMember) {
return fullMember.getMember()
.getUuid()
.transform(UUID::toString)
.or(fullMember.getMember().getE164())
.or("");
}
long getLastSeen() {
return conversationMetadata.getValue() != null ? conversationMetadata.getValue().getLastSeen() : 0;
}
@@ -213,9 +263,20 @@ public class ConversationViewModel extends ViewModel {
SHOW_RECAPTCHA
}
private static class ThreadAndRecipient {
private final long threadId;
private final Recipient recipient;
public ThreadAndRecipient(long threadId, Recipient recipient) {
this.threadId = threadId;
this.recipient = recipient;
}
}
static class Factory extends ViewModelProvider.NewInstanceFactory {
@Override
public @NonNull<T extends ViewModel> T create(@NonNull Class<T> modelClass) {
public @NonNull <T extends ViewModel> T create(@NonNull Class<T> modelClass) {
//noinspection ConstantConditions
return modelClass.cast(new ConversationViewModel());
}

View File

@@ -13,6 +13,8 @@ import androidx.annotation.Nullable;
/**
* Drawable which lets you punch a hole through another drawable.
*
* TODO: Remove in favor of ClipProjectionDrawable
*/
public final class MaskDrawable extends Drawable {

View File

@@ -17,6 +17,7 @@ final class MenuState {
private final boolean saveAttachment;
private final boolean resend;
private final boolean copy;
private final boolean delete;
private MenuState(@NonNull Builder builder) {
forward = builder.forward;
@@ -25,6 +26,7 @@ final class MenuState {
saveAttachment = builder.saveAttachment;
resend = builder.resend;
copy = builder.copy;
delete = builder.delete;
}
boolean shouldShowForwardAction() {
@@ -51,6 +53,10 @@ final class MenuState {
return copy;
}
boolean shouldShowDeleteAction() {
return delete;
}
static MenuState getMenuState(@NonNull Recipient conversationRecipient,
@NonNull Set<MessageRecord> messageRecords,
boolean shouldShowMessageRequest)
@@ -62,11 +68,14 @@ final class MenuState {
boolean sharedContact = false;
boolean viewOnce = false;
boolean remoteDelete = false;
boolean hasInMemory = false;
for (MessageRecord messageRecord : messageRecords) {
if (isActionMessage(messageRecord))
{
if (isActionMessage(messageRecord)) {
actionMessage = true;
if (messageRecord.isInMemoryMessageRecord()) {
hasInMemory = true;
}
}
if (messageRecord.getBody().length() > 0) {
@@ -109,6 +118,7 @@ final class MenuState {
}
return builder.shouldShowCopyAction(!actionMessage && !remoteDelete && hasText)
.shouldShowDeleteAction(!hasInMemory)
.build();
}
@@ -134,7 +144,8 @@ final class MenuState {
messageRecord.isIdentityDefault() ||
messageRecord.isProfileChange() ||
messageRecord.isGroupV1MigrationEvent() ||
messageRecord.isFailedDecryptionType();
messageRecord.isFailedDecryptionType() ||
messageRecord.isInMemoryMessageRecord();
}
private final static class Builder {
@@ -145,6 +156,7 @@ final class MenuState {
private boolean saveAttachment;
private boolean resend;
private boolean copy;
private boolean delete;
@NonNull Builder shouldShowForwardAction(boolean forward) {
this.forward = forward;
@@ -176,6 +188,11 @@ final class MenuState {
return this;
}
@NonNull Builder shouldShowDeleteAction(boolean delete) {
this.delete = delete;
return this;
}
@NonNull
MenuState build() {
return new MenuState(this);

View File

@@ -0,0 +1,153 @@
package org.thoughtcrime.securesms.conversation.colors;
import androidx.annotation.ColorInt;
import androidx.annotation.NonNull;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
/**
* A serializable set of color constants that can be used for avatars.
*/
public enum AvatarColor {
C000("C000", 0xFFD00B0B),
C010("C010", 0xFFC72A0A),
C020("C020", 0xFFB34209),
C030("C030", 0xFF9C5711),
C040("C040", 0xFF866118),
C050("C050", 0xFF76681E),
C060("C060", 0xFF6C6C13),
C070("C070", 0xFF5E6E0C),
C080("C080", 0xFF507406),
C090("C090", 0xFF3D7406),
C100("C100", 0xFF2D7906),
C110("C110", 0xFF1A7906),
C120("C120", 0xFF067906),
C130("C130", 0xFF067919),
C140("C140", 0xFF06792D),
C150("C150", 0xFF067940),
C160("C160", 0xFF067953),
C170("C170", 0xFF067462),
C180("C180", 0xFF067474),
C190("C190", 0xFF077288),
C200("C200", 0xFF086DA0),
C210("C210", 0xFF0A69C7),
C220("C220", 0xFF0D59F2),
C230("C230", 0xFF3454F4),
C240("C240", 0xFF5151F6),
C250("C250", 0xFF6447F5),
C260("C260", 0xFF7A3DF5),
C270("C270", 0xFF8F2AF4),
C280("C280", 0xFFA20CED),
C290("C290", 0xFFAF0BD0),
C300("C300", 0xFFB80AB8),
C310("C310", 0xFFC20AA3),
C320("C320", 0xFFC70A88),
C330("C330", 0xFFCB0B6B),
C340("C340", 0xFFD00B4D),
C350("C350", 0xFFD00B2C),
CRIMSON("crimson", ChatColorsPalette.Bubbles.CRIMSON.asSingleColor()),
VERMILLION("vermillion", ChatColorsPalette.Bubbles.VERMILION.asSingleColor()),
BURLAP("burlap", ChatColorsPalette.Bubbles.BURLAP.asSingleColor()),
FOREST("forest", ChatColorsPalette.Bubbles.FOREST.asSingleColor()),
WINTERGREEN("wintergreen", ChatColorsPalette.Bubbles.WINTERGREEN.asSingleColor()),
TEAL("teal", ChatColorsPalette.Bubbles.TEAL.asSingleColor()),
BLUE("blue", ChatColorsPalette.Bubbles.BLUE.asSingleColor()),
INDIGO("indigo", ChatColorsPalette.Bubbles.INDIGO.asSingleColor()),
VIOLET("violet", ChatColorsPalette.Bubbles.VIOLET.asSingleColor()),
PLUM("plum", ChatColorsPalette.Bubbles.PLUM.asSingleColor()),
TAUPE("taupe", ChatColorsPalette.Bubbles.TAUPE.asSingleColor()),
STEEL("steel", ChatColorsPalette.Bubbles.STEEL.asSingleColor()),
ULTRAMARINE("ultramarine", ChatColorsPalette.Bubbles.ULTRAMARINE.asSingleColor()),
UNKNOWN("unknown", ChatColorsPalette.Bubbles.STEEL.asSingleColor());
/** Fast map of name to enum, while also giving us a location to map old colors to new ones. */
private static final Map<String, AvatarColor> NAME_MAP = new HashMap<>();
static {
for (AvatarColor color : AvatarColor.values()) {
NAME_MAP.put(color.serialize(), color);
}
NAME_MAP.put("red", CRIMSON);
NAME_MAP.put("orange", VERMILLION);
NAME_MAP.put("deep_orange", VERMILLION);
NAME_MAP.put("brown", BURLAP);
NAME_MAP.put("green", FOREST);
NAME_MAP.put("light_green", WINTERGREEN);
NAME_MAP.put("teal", TEAL);
NAME_MAP.put("blue", BLUE);
NAME_MAP.put("indigo", INDIGO);
NAME_MAP.put("purple", VIOLET);
NAME_MAP.put("deep_purple", VIOLET);
NAME_MAP.put("pink", PLUM);
NAME_MAP.put("blue_grey", TAUPE);
NAME_MAP.put("grey", STEEL);
NAME_MAP.put("ultramarine", ULTRAMARINE);
}
/** Colors that can be assigned via {@link #random()}. */
private static final AvatarColor[] RANDOM_OPTIONS = new AvatarColor[] {
C000,
C010,
C020,
C030,
C040,
C050,
C060,
C070,
C080,
C090,
C100,
C110,
C120,
C130,
C140,
C150,
C160,
C170,
C180,
C190,
C200,
C210,
C220,
C230,
C240,
C250,
C260,
C270,
C280,
C290,
C300,
C310,
C320,
C330,
C340,
C350,
};
private final String name;
private final int color;
AvatarColor(@NonNull String name, @ColorInt int color) {
this.name = name;
this.color = color;
}
public @ColorInt int colorInt() {
return color;
}
public static @NonNull AvatarColor random() {
int position = (int) Math.floor(Math.random() * RANDOM_OPTIONS.length);
return RANDOM_OPTIONS[position];
}
public @NonNull String serialize() {
return name;
}
public static @NonNull AvatarColor deserialize(@NonNull String name) {
return Objects.requireNonNull(NAME_MAP.getOrDefault(name, C000));
}
}

View File

@@ -0,0 +1,249 @@
package org.thoughtcrime.securesms.conversation.colors
import android.graphics.Color
import android.graphics.ColorFilter
import android.graphics.Path
import android.graphics.PorterDuff
import android.graphics.PorterDuffColorFilter
import android.graphics.drawable.ColorDrawable
import android.graphics.drawable.Drawable
import android.graphics.drawable.ShapeDrawable
import android.graphics.drawable.shapes.OvalShape
import android.os.Build
import androidx.annotation.ColorInt
import com.google.common.base.Objects
import org.signal.core.util.ColorUtil
import org.thoughtcrime.securesms.components.RotatableGradientDrawable
import org.thoughtcrime.securesms.database.model.databaseprotos.ChatColor
import org.thoughtcrime.securesms.util.customizeOnDraw
import kotlin.math.min
/**
* ChatColors represent how to render the avatar and bubbles in a given context.
*
* @param id The identifier for this chat color. It is either BuiltIn, NotSet, or Custom(long)
* @param linearGradient The LinearGradient to render. Null if this is for a single color.
* @param singleColor The single color to render. Null if this is for a linear gradient.
*/
class ChatColors private constructor(
val id: Id,
private val linearGradient: LinearGradient?,
private val singleColor: Int?
) {
/**
* Returns the Drawable to render the linear gradient, or null if this ChatColors is a single color.
*/
val chatBubbleMask: Drawable
get() {
return when {
Build.VERSION.SDK_INT < 21 -> {
ColorDrawable(Color.TRANSPARENT)
}
linearGradient != null -> {
RotatableGradientDrawable(
linearGradient.degrees,
linearGradient.colors,
linearGradient.positions
)
}
else -> {
ColorDrawable(asSingleColor())
}
}
}
/**
* Returns the ColorFilter to apply to a conversation bubble or other relevant piece of UI.
*/
val chatBubbleColorFilter: ColorFilter = if (Build.VERSION.SDK_INT >= 21) {
PorterDuffColorFilter(Color.TRANSPARENT, PorterDuff.Mode.SRC_IN)
} else {
PorterDuffColorFilter(asSingleColor(), PorterDuff.Mode.SRC_IN)
}
@ColorInt
fun asSingleColor(): Int {
if (singleColor != null) {
return singleColor
}
if (linearGradient != null) {
val start = linearGradient.colors.first()
val end = linearGradient.colors.last()
return ColorUtil.blendARGB(start, end, 0.5f)
}
throw AssertionError()
}
fun serialize(): ChatColor {
val builder: ChatColor.Builder = ChatColor.newBuilder()
if (linearGradient != null) {
val gradientBuilder = ChatColor.LinearGradient.newBuilder()
gradientBuilder.rotation = linearGradient.degrees
linearGradient.colors.forEach { gradientBuilder.addColors(it) }
linearGradient.positions.forEach { gradientBuilder.addPositions(it) }
builder.setLinearGradient(gradientBuilder)
}
if (singleColor != null) {
builder.setSingleColor(ChatColor.SingleColor.newBuilder().setColor(singleColor))
}
return builder.build()
}
fun getColors(): IntArray {
return linearGradient?.colors ?: if (singleColor != null) {
intArrayOf(singleColor)
} else {
throw AssertionError()
}
}
fun getDegrees(): Float {
return linearGradient?.degrees ?: 180f
}
fun asCircle(): Drawable {
if (Build.VERSION.SDK_INT < 21) {
return ShapeDrawable(OvalShape()).apply {
paint.color = asSingleColor()
}
}
val toWrap: Drawable = chatBubbleMask
val path = Path()
return toWrap.customizeOnDraw { wrapped, canvas ->
canvas.save()
path.rewind()
path.addCircle(
wrapped.bounds.exactCenterX(),
wrapped.bounds.exactCenterY(),
min(wrapped.bounds.exactCenterX(), wrapped.bounds.exactCenterY()),
Path.Direction.CW
)
canvas.clipPath(path)
wrapped.draw(canvas)
canvas.restore()
}
}
fun withId(id: Id): ChatColors = ChatColors(id, linearGradient, singleColor)
override fun equals(other: Any?): Boolean {
val otherChatColors: ChatColors = (other as? ChatColors) ?: return false
if (id != otherChatColors.id) return false
if (linearGradient != otherChatColors.linearGradient) return false
if (singleColor != otherChatColors.singleColor) return false
return true
}
override fun hashCode(): Int {
return Objects.hashCode(linearGradient, singleColor, id)
}
companion object {
@JvmStatic
fun forChatColor(id: Id, chatColor: ChatColor): ChatColors {
assert(chatColor.hasSingleColor() xor chatColor.hasLinearGradient())
return if (chatColor.hasLinearGradient()) {
val linearGradient = LinearGradient(
chatColor.linearGradient.rotation,
chatColor.linearGradient.colorsList.toIntArray(),
chatColor.linearGradient.positionsList.toFloatArray()
)
forGradient(id, linearGradient)
} else {
val singleColor = chatColor.singleColor.color
forColor(id, singleColor)
}
}
@JvmStatic
fun forGradient(id: Id, linearGradient: LinearGradient): ChatColors =
ChatColors(id, linearGradient, null)
@JvmStatic
fun forColor(id: Id, @ColorInt color: Int): ChatColors =
ChatColors(id, null, color)
}
sealed class Id(val longValue: Long) {
/**
* Represents user selection of 'auto'.
*/
object Auto : Id(-2)
/**
* Represents a built in color.
*/
object BuiltIn : Id(-1)
/**
* Represents an unsaved or un-set option.
*/
object NotSet : Id(0)
/**
* Represents a custom created ChatColors.
*/
class Custom internal constructor(id: Long) : Id(id)
override fun equals(other: Any?): Boolean {
return longValue == (other as? Id)?.longValue
}
override fun hashCode(): Int {
return Objects.hashCode(longValue)
}
companion object {
@JvmStatic
fun forLongValue(longValue: Long): Id {
return when (longValue) {
-2L -> Auto
-1L -> BuiltIn
0L -> NotSet
else -> Custom(longValue)
}
}
}
}
data class LinearGradient(
val degrees: Float,
val colors: IntArray,
val positions: FloatArray
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as LinearGradient
if (!colors.contentEquals(other.colors)) return false
if (!positions.contentEquals(other.positions)) return false
return true
}
override fun hashCode(): Int {
var result = colors.contentHashCode()
result = 31 * result + positions.contentHashCode()
return result
}
}
}

View File

@@ -0,0 +1,76 @@
package org.thoughtcrime.securesms.conversation.colors
import com.google.common.collect.BiMap
import com.google.common.collect.ImmutableBiMap
import com.google.common.collect.ImmutableMap
import org.thoughtcrime.securesms.color.MaterialColor
import org.thoughtcrime.securesms.wallpaper.ChatWallpaper
import org.thoughtcrime.securesms.wallpaper.GradientChatWallpaper
import org.thoughtcrime.securesms.wallpaper.SingleColorChatWallpaper
/**
* Contains mappings to get the relevant chat colors for either a legacy MaterialColor or a built-in wallpaper.
*/
object ChatColorsMapper {
private val materialColorToChatColorsBiMap: BiMap<MaterialColor, ChatColors> = ImmutableBiMap.Builder<MaterialColor, ChatColors>().apply {
put(MaterialColor.CRIMSON, ChatColorsPalette.Bubbles.CRIMSON)
put(MaterialColor.VERMILLION, ChatColorsPalette.Bubbles.VERMILION)
put(MaterialColor.BURLAP, ChatColorsPalette.Bubbles.BURLAP)
put(MaterialColor.FOREST, ChatColorsPalette.Bubbles.FOREST)
put(MaterialColor.WINTERGREEN, ChatColorsPalette.Bubbles.WINTERGREEN)
put(MaterialColor.TEAL, ChatColorsPalette.Bubbles.TEAL)
put(MaterialColor.BLUE, ChatColorsPalette.Bubbles.BLUE)
put(MaterialColor.INDIGO, ChatColorsPalette.Bubbles.INDIGO)
put(MaterialColor.VIOLET, ChatColorsPalette.Bubbles.VIOLET)
put(MaterialColor.PLUM, ChatColorsPalette.Bubbles.PLUM)
put(MaterialColor.TAUPE, ChatColorsPalette.Bubbles.TAUPE)
put(MaterialColor.STEEL, ChatColorsPalette.Bubbles.STEEL)
put(MaterialColor.ULTRAMARINE, ChatColorsPalette.Bubbles.ULTRAMARINE)
}.build()
private val wallpaperToChatColorsMap: Map<ChatWallpaper, ChatColors> = ImmutableMap.Builder<ChatWallpaper, ChatColors>().apply {
put(SingleColorChatWallpaper.BLUSH, ChatColorsPalette.Bubbles.CRIMSON)
put(SingleColorChatWallpaper.COPPER, ChatColorsPalette.Bubbles.VERMILION)
put(SingleColorChatWallpaper.DUST, ChatColorsPalette.Bubbles.BURLAP)
put(SingleColorChatWallpaper.CELADON, ChatColorsPalette.Bubbles.FOREST)
put(SingleColorChatWallpaper.RAINFOREST, ChatColorsPalette.Bubbles.WINTERGREEN)
put(SingleColorChatWallpaper.PACIFIC, ChatColorsPalette.Bubbles.TEAL)
put(SingleColorChatWallpaper.FROST, ChatColorsPalette.Bubbles.BLUE)
put(SingleColorChatWallpaper.NAVY, ChatColorsPalette.Bubbles.INDIGO)
put(SingleColorChatWallpaper.LILAC, ChatColorsPalette.Bubbles.VIOLET)
put(SingleColorChatWallpaper.PINK, ChatColorsPalette.Bubbles.PLUM)
put(SingleColorChatWallpaper.EGGPLANT, ChatColorsPalette.Bubbles.TAUPE)
put(SingleColorChatWallpaper.SILVER, ChatColorsPalette.Bubbles.STEEL)
put(GradientChatWallpaper.SUNSET, ChatColorsPalette.Bubbles.EMBER)
put(GradientChatWallpaper.NOIR, ChatColorsPalette.Bubbles.MIDNIGHT)
put(GradientChatWallpaper.HEATMAP, ChatColorsPalette.Bubbles.INFRARED)
put(GradientChatWallpaper.AQUA, ChatColorsPalette.Bubbles.LAGOON)
put(GradientChatWallpaper.IRIDESCENT, ChatColorsPalette.Bubbles.FLUORESCENT)
put(GradientChatWallpaper.MONSTERA, ChatColorsPalette.Bubbles.BASIL)
put(GradientChatWallpaper.BLISS, ChatColorsPalette.Bubbles.SUBLIME)
put(GradientChatWallpaper.SKY, ChatColorsPalette.Bubbles.SEA)
put(GradientChatWallpaper.PEACH, ChatColorsPalette.Bubbles.TANGERINE)
}.build()
@JvmStatic
val entrySet: Set<MutableMap.MutableEntry<MaterialColor, ChatColors>>
get() = materialColorToChatColorsBiMap.entries
@JvmStatic
fun getChatColors(materialColor: MaterialColor): ChatColors {
return materialColorToChatColorsBiMap[materialColor] ?: ChatColorsPalette.Bubbles.default
}
@JvmStatic
fun getChatColors(wallpaper: ChatWallpaper): ChatColors {
return wallpaperToChatColorsMap.entries.find { (key, _) ->
key.isSameSource(wallpaper)
}?.value ?: ChatColorsPalette.Bubbles.default
}
@JvmStatic
fun getMaterialColor(chatColors: ChatColors): MaterialColor {
return materialColorToChatColorsBiMap.inverse()[chatColors] ?: MaterialColor.ULTRAMARINE
}
}

View File

@@ -0,0 +1,229 @@
package org.thoughtcrime.securesms.conversation.colors
/**
* Namespaced collection of supported bubble colors and name colors.
*/
object ChatColorsPalette {
object Bubbles {
// region Default
@JvmField
val ULTRAMARINE = ChatColors.forGradient(
ChatColors.Id.BuiltIn,
ChatColors.LinearGradient(
180.0f,
intArrayOf(0xFF0552F0.toInt(), 0xFF2C6BED.toInt()),
floatArrayOf(0f, 1f)
)
)
// endregion
// region Solids
@JvmField
val CRIMSON = ChatColors.forColor(ChatColors.Id.BuiltIn, 0xFFCF163E.toInt())
@JvmField
val VERMILION = ChatColors.forColor(ChatColors.Id.BuiltIn, 0xFFC73F0A.toInt())
@JvmField
val BURLAP = ChatColors.forColor(ChatColors.Id.BuiltIn, 0xFF6F6A58.toInt())
@JvmField
val FOREST = ChatColors.forColor(ChatColors.Id.BuiltIn, 0xFF3B7845.toInt())
@JvmField
val WINTERGREEN = ChatColors.forColor(ChatColors.Id.BuiltIn, 0xFF1D8663.toInt())
@JvmField
val TEAL = ChatColors.forColor(ChatColors.Id.BuiltIn, 0xFF077D92.toInt())
@JvmField
val BLUE = ChatColors.forColor(ChatColors.Id.BuiltIn, 0xFF336BA3.toInt())
@JvmField
val INDIGO = ChatColors.forColor(ChatColors.Id.BuiltIn, 0xFF6058CA.toInt())
@JvmField
val VIOLET = ChatColors.forColor(ChatColors.Id.BuiltIn, 0xFF9932CB.toInt())
@JvmField
val PLUM = ChatColors.forColor(ChatColors.Id.BuiltIn, 0xFFAA377A.toInt())
@JvmField
val TAUPE = ChatColors.forColor(
ChatColors.Id.BuiltIn, 0xFF8F616A.toInt()
)
@JvmField
val STEEL = ChatColors.forColor(
ChatColors.Id.BuiltIn, 0xFF71717F.toInt()
)
// endregion
// region Gradients
@JvmField
val EMBER = ChatColors.forGradient(
ChatColors.Id.BuiltIn,
ChatColors.LinearGradient(
168f,
intArrayOf(0xFFE57C00.toInt(), 0xFF5E0000.toInt()),
floatArrayOf(0f, 1f)
)
)
@JvmField
val MIDNIGHT = ChatColors.forGradient(
ChatColors.Id.BuiltIn,
ChatColors.LinearGradient(
180f,
intArrayOf(0xFF2C2C3A.toInt(), 0xFF787891.toInt()),
floatArrayOf(0f, 1f)
)
)
@JvmField
val INFRARED = ChatColors.forGradient(
ChatColors.Id.BuiltIn,
ChatColors.LinearGradient(
192f,
intArrayOf(0xFFF65560.toInt(), 0xFF442CED.toInt()),
floatArrayOf(0f, 1f)
)
)
@JvmField
val LAGOON = ChatColors.forGradient(
ChatColors.Id.BuiltIn,
ChatColors.LinearGradient(
180f,
intArrayOf(0xFF004066.toInt(), 0xFF32867D.toInt()),
floatArrayOf(0f, 1f)
)
)
@JvmField
val FLUORESCENT = ChatColors.forGradient(
ChatColors.Id.BuiltIn,
ChatColors.LinearGradient(
192f,
intArrayOf(0xFFEC13DD.toInt(), 0xFF1B36C6.toInt()),
floatArrayOf(0f, 1f)
)
)
@JvmField
val BASIL = ChatColors.forGradient(
ChatColors.Id.BuiltIn,
ChatColors.LinearGradient(
180f,
intArrayOf(0xFF2F9373.toInt(), 0xFF077343.toInt()),
floatArrayOf(0f, 1f)
)
)
@JvmField
val SUBLIME = ChatColors.forGradient(
ChatColors.Id.BuiltIn,
ChatColors.LinearGradient(
180f,
intArrayOf(
0xFF6281D5.toInt(), 0xFF974460.toInt()
),
floatArrayOf(0f, 1f)
)
)
@JvmField
val SEA = ChatColors.forGradient(
ChatColors.Id.BuiltIn,
ChatColors.LinearGradient(
180f,
intArrayOf(0xFF498FD4.toInt(), 0xFF2C66A0.toInt()),
floatArrayOf(0f, 1f)
)
)
@JvmField
val TANGERINE = ChatColors.forGradient(
ChatColors.Id.BuiltIn,
ChatColors.LinearGradient(
192f,
intArrayOf(0xFFDB7133.toInt(), 0xFF911231.toInt()),
floatArrayOf(0f, 1f)
)
)
// endregion
@JvmStatic
val default = ULTRAMARINE
val solids = listOf(
CRIMSON,
VERMILION,
BURLAP,
FOREST,
WINTERGREEN,
TEAL,
BLUE,
INDIGO,
VIOLET,
PLUM,
TAUPE,
STEEL
)
val gradients =
listOf(EMBER, MIDNIGHT, INFRARED, LAGOON, FLUORESCENT, BASIL, SUBLIME, SEA, TANGERINE)
val all = listOf(default) + solids + gradients
}
object Names {
@JvmStatic
val all = listOf(
NameColor(lightColor = 0xFFD00B0B.toInt(), darkColor = 0xFFF76E6E.toInt()),
NameColor(lightColor = 0xFF067906.toInt(), darkColor = 0xFF0AB80A.toInt()),
NameColor(lightColor = 0xFF5151F6.toInt(), darkColor = 0xFF8B8BF9.toInt()),
NameColor(lightColor = 0xFF866118.toInt(), darkColor = 0xFFD08F0B.toInt()),
NameColor(lightColor = 0xFF067953.toInt(), darkColor = 0xFF09B37B.toInt()),
NameColor(lightColor = 0xFFA20CED.toInt(), darkColor = 0xFFCB72F8.toInt()),
NameColor(lightColor = 0xFF507406.toInt(), darkColor = 0xFF77AE09.toInt()),
NameColor(lightColor = 0xFF086DA0.toInt(), darkColor = 0xFF0DA6F2.toInt()),
NameColor(lightColor = 0xFFC70A88.toInt(), darkColor = 0xFFF76EC9.toInt()),
NameColor(lightColor = 0xFFB34209.toInt(), darkColor = 0xFFF4702F.toInt()),
NameColor(lightColor = 0xFF06792D.toInt(), darkColor = 0xFF0AB844.toInt()),
NameColor(lightColor = 0xFF7A3DF5.toInt(), darkColor = 0xFFAC86F9.toInt()),
NameColor(lightColor = 0xFF6C6C13.toInt(), darkColor = 0xFFA5A509.toInt()),
NameColor(lightColor = 0xFF067474.toInt(), darkColor = 0xFF09AEAE.toInt()),
NameColor(lightColor = 0xFFB80AB8.toInt(), darkColor = 0xFFF75FF7.toInt()),
NameColor(lightColor = 0xFF2D7906.toInt(), darkColor = 0xFF42B309.toInt()),
NameColor(lightColor = 0xFF0D59F2.toInt(), darkColor = 0xFF6495F7.toInt()),
NameColor(lightColor = 0xFFD00B4D.toInt(), darkColor = 0xFFF76998.toInt()),
NameColor(lightColor = 0xFFC72A0A.toInt(), darkColor = 0xFFF67055.toInt()),
NameColor(lightColor = 0xFF067919.toInt(), darkColor = 0xFF0AB827.toInt()),
NameColor(lightColor = 0xFF6447F5.toInt(), darkColor = 0xFF9986F9.toInt()),
NameColor(lightColor = 0xFF76681E.toInt(), darkColor = 0xFFB89B0A.toInt()),
NameColor(lightColor = 0xFF067462.toInt(), darkColor = 0xFF09B397.toInt()),
NameColor(lightColor = 0xFFAF0BD0.toInt(), darkColor = 0xFFE06EF7.toInt()),
NameColor(lightColor = 0xFF3D7406.toInt(), darkColor = 0xFF5EB309.toInt()),
NameColor(lightColor = 0xFF0A69C7.toInt(), darkColor = 0xFF429CF5.toInt()),
NameColor(lightColor = 0xFFCB0B6B.toInt(), darkColor = 0xFFF76EB2.toInt()),
NameColor(lightColor = 0xFF9C5711.toInt(), darkColor = 0xFFE97A0C.toInt()),
NameColor(lightColor = 0xFF067940.toInt(), darkColor = 0xFF09B35E.toInt()),
NameColor(lightColor = 0xFF8F2AF4.toInt(), darkColor = 0xFFBD81F8.toInt()),
NameColor(lightColor = 0xFF5E6E0C.toInt(), darkColor = 0xFF8FAA09.toInt()),
NameColor(lightColor = 0xFF077288.toInt(), darkColor = 0xFF0BABCB.toInt()),
NameColor(lightColor = 0xFFC20AA3.toInt(), darkColor = 0xFFF75FDD.toInt()),
NameColor(lightColor = 0xFF1A7906.toInt(), darkColor = 0xFF27B80A.toInt()),
NameColor(lightColor = 0xFF3454F4.toInt(), darkColor = 0xFF778DF8.toInt()),
NameColor(lightColor = 0xFFD00B2C.toInt(), darkColor = 0xFFF76E85.toInt())
)
}
@JvmField
val UNKNOWN_CONTACT = Bubbles.STEEL
}

View File

@@ -0,0 +1,11 @@
package org.thoughtcrime.securesms.conversation.colors
import org.thoughtcrime.securesms.util.Projection
/**
* Denotes that a class can be colorized. The class is responsible for
* generating its own projection.
*/
interface Colorizable {
val colorizerProjections: List<Projection>
}

View File

@@ -0,0 +1,92 @@
package org.thoughtcrime.securesms.conversation.colors
import android.content.Context
import android.graphics.Color
import android.os.Build
import android.view.View
import androidx.annotation.ColorInt
import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.Projection
/**
* Helper class for all things ChatColors.
*
* - Maintains a mapping for group recipient colors
* - Gives easy access to different bubble colors
* - Watches and responds to RecyclerView scroll and layout changes to update a ColorizerView
*/
class Colorizer(private val colorizerView: ColorizerView) : RecyclerView.OnScrollListener(), View.OnLayoutChangeListener {
private val groupSenderColors: MutableMap<RecipientId, NameColor> = mutableMapOf()
@ColorInt
fun getOutgoingBodyTextColor(context: Context): Int {
return ContextCompat.getColor(context, R.color.white)
}
@ColorInt
fun getOutgoingFooterTextColor(context: Context): Int {
return ContextCompat.getColor(context, R.color.conversation_item_outgoing_footer_fg)
}
@ColorInt
fun getOutgoingFooterIconColor(context: Context): Int {
return ContextCompat.getColor(context, R.color.conversation_item_outgoing_footer_fg)
}
@ColorInt
fun getIncomingGroupSenderColor(context: Context, recipient: Recipient): Int = groupSenderColors[recipient.id]?.getColor(context) ?: Color.TRANSPARENT
fun attachToRecyclerView(recyclerView: RecyclerView) {
recyclerView.addOnScrollListener(this)
recyclerView.addOnLayoutChangeListener(this)
}
fun onNameColorsChanged(nameColorMap: Map<RecipientId, NameColor>) {
groupSenderColors.clear()
groupSenderColors.putAll(nameColorMap)
}
fun onChatColorsChanged(chatColors: ChatColors) {
colorizerView.background = chatColors.chatBubbleMask
}
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
applyClipPathsToMaskedGradient(recyclerView)
}
override fun onLayoutChange(v: View?, left: Int, top: Int, right: Int, bottom: Int, oldLeft: Int, oldTop: Int, oldRight: Int, oldBottom: Int) {
applyClipPathsToMaskedGradient(v as RecyclerView)
}
fun applyClipPathsToMaskedGradient(recyclerView: RecyclerView) {
if (Build.VERSION.SDK_INT < 21) {
return
}
val layoutManager = recyclerView.layoutManager as LinearLayoutManager
val firstVisibleItemPosition: Int = layoutManager.findFirstVisibleItemPosition()
val lastVisibleItemPosition: Int = layoutManager.findLastVisibleItemPosition()
val projections: List<Projection> = (firstVisibleItemPosition..lastVisibleItemPosition)
.mapNotNull { recyclerView.findViewHolderForAdapterPosition(it) as? Colorizable }
.map {
it.colorizerProjections
.map { p -> Projection.translateFromRootToDescendantCoords(p, colorizerView) }
}
.flatten()
if (projections.isNotEmpty()) {
colorizerView.visibility = View.VISIBLE
colorizerView.setProjections(projections)
} else {
colorizerView.visibility = View.GONE
}
}
}

View File

@@ -0,0 +1,43 @@
package org.thoughtcrime.securesms.conversation.colors
import android.content.Context
import android.graphics.Canvas
import android.graphics.Path
import android.util.AttributeSet
import android.view.View
import org.thoughtcrime.securesms.util.Projection
/**
* ColorizerView takes a list of projections and uses them to create a mask over it's background.
*/
class ColorizerView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {
private val clipPath = Path()
private var projections: List<Projection> = listOf()
fun setProjections(projections: List<Projection>) {
this.projections = projections
invalidate()
}
override fun draw(canvas: Canvas) {
if (projections.isNotEmpty()) {
canvas.save()
clipPath.rewind()
projections.forEach {
it.applyToPath(clipPath)
}
canvas.clipPath(clipPath)
super.draw(canvas)
canvas.restore()
} else {
super.draw(canvas)
}
}
}

View File

@@ -0,0 +1,22 @@
package org.thoughtcrime.securesms.conversation.colors
import android.content.Context
import androidx.annotation.ColorInt
import org.thoughtcrime.securesms.util.ThemeUtil
/**
* Class which stores information for a Recipient's name color in a group.
*/
class NameColor(
@ColorInt private val lightColor: Int,
@ColorInt private val darkColor: Int
) {
@ColorInt
fun getColor(context: Context): Int {
return if (ThemeUtil.isDarkTheme(context)) {
darkColor
} else {
lightColor
}
}
}

View File

@@ -0,0 +1,21 @@
package org.thoughtcrime.securesms.conversation.colors.ui
import org.thoughtcrime.securesms.conversation.colors.ChatColors
import org.thoughtcrime.securesms.util.MappingModel
class ChatColorMappingModel(
val chatColors: ChatColors,
val isSelected: Boolean,
val isAuto: Boolean
) : MappingModel<ChatColorMappingModel> {
val isCustom: Boolean = chatColors.id is ChatColors.Id.Custom
override fun areItemsTheSame(newItem: ChatColorMappingModel): Boolean {
return chatColors == newItem.chatColors && isAuto == newItem.isAuto
}
override fun areContentsTheSame(newItem: ChatColorMappingModel): Boolean {
return areItemsTheSame(newItem) && isSelected == newItem.isSelected
}
}

View File

@@ -0,0 +1,178 @@
package org.thoughtcrime.securesms.conversation.colors.ui
import android.content.Context
import android.content.res.TypedArray
import android.graphics.PorterDuff
import android.graphics.PorterDuffColorFilter
import android.graphics.drawable.Drawable
import android.util.AttributeSet
import android.view.View
import android.widget.ImageView
import android.widget.TextView
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.content.ContextCompat
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.DeliveryStatusView
import org.thoughtcrime.securesms.conversation.colors.ChatColors
import org.thoughtcrime.securesms.conversation.colors.Colorizer
import org.thoughtcrime.securesms.conversation.colors.ColorizerView
import org.thoughtcrime.securesms.util.DateUtils
import org.thoughtcrime.securesms.util.Projection
import org.thoughtcrime.securesms.util.ThemeUtil
import org.thoughtcrime.securesms.util.ViewUtil
import org.thoughtcrime.securesms.wallpaper.ChatWallpaper
import java.util.Locale
private val TAG = Log.tag(ChatColorPreviewView::class.java)
class ChatColorPreviewView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : ConstraintLayout(context, attrs, defStyleAttr) {
private val wallpaper: ImageView
private val wallpaperDim: View
private val colorizerView: ColorizerView
private val recv1: Bubble
private val sent1: Bubble
private val recv2: Bubble
private val sent2: Bubble
private val bubbleCount: Int
private val colorizer: Colorizer
private var chatColors: ChatColors? = null
init {
inflate(context, R.layout.chat_colors_preview_view, this)
var typedArray: TypedArray? = null
try {
typedArray = context.obtainStyledAttributes(attrs, R.styleable.ChatColorPreviewView, 0, 0)
bubbleCount = typedArray.getInteger(R.styleable.ChatColorPreviewView_ccpv_chat_bubble_count, 2)
assert(bubbleCount == 2 || bubbleCount == 4) {
Log.e(TAG, "Bubble count must be 2 or 4")
}
recv1 = Bubble(
findViewById(R.id.bubble_1),
findViewById(R.id.bubble_1_text),
findViewById(R.id.bubble_1_time),
null
)
sent1 = Bubble(
findViewById(R.id.bubble_2),
findViewById(R.id.bubble_2_text),
findViewById(R.id.bubble_2_time),
findViewById(R.id.bubble_2_delivery)
)
recv2 = Bubble(
findViewById(R.id.bubble_3),
findViewById(R.id.bubble_3_text),
findViewById(R.id.bubble_3_time),
null
)
sent2 = Bubble(
findViewById(R.id.bubble_4),
findViewById(R.id.bubble_4_text),
findViewById(R.id.bubble_4_time),
findViewById(R.id.bubble_4_delivery)
)
val now: String = DateUtils.getExtendedRelativeTimeSpanString(context, Locale.getDefault(), System.currentTimeMillis())
listOf(sent1, sent2, recv1, recv2).forEach {
it.time.text = now
it.delivery?.setRead()
}
if (bubbleCount == 2) {
recv2.bubble.visibility = View.GONE
sent2.bubble.visibility = View.GONE
}
wallpaper = findViewById(R.id.wallpaper)
wallpaperDim = findViewById(R.id.wallpaper_dim)
colorizerView = findViewById(R.id.colorizer)
colorizer = Colorizer(colorizerView)
} finally {
typedArray?.recycle()
}
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
if (chatColors != null) {
setChatColors(requireNotNull(chatColors))
}
}
fun setWallpaper(chatWallpaper: ChatWallpaper?) {
if (chatWallpaper != null) {
chatWallpaper.loadInto(wallpaper)
if (ThemeUtil.isDarkTheme(context)) {
wallpaperDim.alpha = chatWallpaper.dimLevelForDarkTheme
} else {
wallpaperDim.alpha = 0f
}
} else {
wallpaper.background = null
wallpaperDim.alpha = 0f
}
val backgroundColor = if (chatWallpaper != null) {
R.color.conversation_item_wallpaper_bubble_color
} else {
R.color.signal_background_secondary
}
listOf(recv1, recv2).forEach {
it.bubble.background.colorFilter = PorterDuffColorFilter(
ContextCompat.getColor(context, backgroundColor),
PorterDuff.Mode.SRC_IN
)
}
}
fun setChatColors(chatColors: ChatColors) {
this.chatColors = chatColors
val sentBubbles = listOf(sent1, sent2)
sentBubbles.forEach {
it.bubble.background.colorFilter = chatColors.chatBubbleColorFilter
}
val mask: Drawable = chatColors.chatBubbleMask
val bubbles = if (bubbleCount == 4) {
listOf(sent1, sent2)
} else {
listOf(sent1)
}
val projections = bubbles.map {
Projection.relativeToViewWithCommonRoot(it.bubble, colorizerView, Projection.Corners(ViewUtil.dpToPx(10).toFloat()))
}
colorizerView.setProjections(projections)
colorizerView.visibility = View.VISIBLE
colorizerView.background = mask
sentBubbles.forEach {
it.body.setTextColor(colorizer.getOutgoingBodyTextColor(context))
it.time.setTextColor(colorizer.getOutgoingFooterTextColor(context))
it.delivery?.setTint(colorizer.getOutgoingFooterIconColor(context))
}
}
private class Bubble(val bubble: View, val body: TextView, val time: TextView, val delivery: DeliveryStatusView?)
}

View File

@@ -0,0 +1,110 @@
package org.thoughtcrime.securesms.conversation.colors.ui
import android.content.Context
import android.view.View
import android.widget.ImageView
import android.widget.TextView
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.conversation.colors.ChatColors
import org.thoughtcrime.securesms.util.MappingAdapter
import org.thoughtcrime.securesms.util.MappingViewHolder
import org.thoughtcrime.securesms.util.ViewUtil
import org.thoughtcrime.securesms.util.withFixedSize
class ChatColorSelectionAdapter(
context: Context,
private val callbacks: Callbacks
) : MappingAdapter() {
init {
val popupWindow = ChatSelectionContextMenu(context)
registerFactory(
ChatColorMappingModel::class.java,
LayoutFactory(
{ v -> ChatColorMappingViewHolder(v, popupWindow, callbacks) },
R.layout.chat_color_selection_adapter_item
)
)
registerFactory(
CustomColorMappingModel::class.java,
LayoutFactory(
{ v -> CustomColorMappingViewHolder(v, callbacks::onAdd) },
R.layout.chat_color_custom_adapter_item
)
)
}
class CustomColorMappingViewHolder(
itemView: View,
onClicked: () -> Unit
) : MappingViewHolder<CustomColorMappingModel>(itemView) {
init {
itemView.setOnClickListener { onClicked() }
}
override fun bind(model: CustomColorMappingModel) = Unit
}
class ChatColorMappingViewHolder(
itemView: View,
private val popupWindow: ChatSelectionContextMenu,
private val callbacks: Callbacks
) : MappingViewHolder<ChatColorMappingModel>(itemView) {
private val preview: ImageView = itemView.findViewById(R.id.chat_color)
private val auto: TextView = itemView.findViewById(R.id.auto)
private val edit: View = itemView.findViewById(R.id.edit)
override fun bind(model: ChatColorMappingModel) {
itemView.isSelected = model.isSelected
auto.visibility = if (model.isAuto) View.VISIBLE else View.GONE
edit.visibility = if (model.isCustom && model.isSelected) View.VISIBLE else View.GONE
preview.setOnClickListener {
if (model.isCustom && model.isSelected) {
callbacks.onEdit(model.chatColors)
} else {
callbacks.onSelect(model.chatColors)
}
}
if (model.isCustom) {
preview.setOnLongClickListener {
popupWindow.callback = CallbackBinder(callbacks, model.chatColors)
popupWindow.show(itemView)
true
}
} else {
preview.setOnLongClickListener(null)
preview.isLongClickable = false
}
val mask = model.chatColors.asCircle()
preview.setImageDrawable(mask.withFixedSize(ViewUtil.dpToPx(56)))
}
}
class CallbackBinder(private val callbacks: Callbacks, private val chatColors: ChatColors) : ChatSelectionContextMenu.Callback {
override fun onEditPressed() {
callbacks.onEdit(chatColors)
}
override fun onDuplicatePressed() {
callbacks.onDuplicate(chatColors)
}
override fun onDeletePressed() {
callbacks.onDelete(chatColors)
}
}
interface Callbacks {
fun onSelect(chatColors: ChatColors)
fun onEdit(chatColors: ChatColors)
fun onDuplicate(chatColors: ChatColors)
fun onDelete(chatColors: ChatColors)
fun onAdd()
}
}

View File

@@ -0,0 +1,104 @@
package org.thoughtcrime.securesms.conversation.colors.ui
import android.os.Bundle
import android.view.View
import androidx.appcompat.widget.Toolbar
import androidx.fragment.app.Fragment
import androidx.navigation.Navigation
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.conversation.colors.ChatColors
class ChatColorSelectionFragment : Fragment(R.layout.chat_color_selection_fragment) {
private lateinit var viewModel: ChatColorSelectionViewModel
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val args: ChatColorSelectionFragmentArgs = ChatColorSelectionFragmentArgs.fromBundle(requireArguments())
viewModel = ChatColorSelectionViewModel.getOrCreate(requireActivity(), args.recipientId)
val toolbar: Toolbar = view.findViewById(R.id.toolbar)
val preview: ChatColorPreviewView = view.findViewById(R.id.preview)
val recycler: RecyclerView = view.findViewById(R.id.recycler)
val adapter = ChatColorSelectionAdapter(
requireContext(),
Callbacks(args, view)
)
recycler.itemAnimator = null
recycler.adapter = adapter
toolbar.setNavigationOnClickListener {
Navigation.findNavController(it).popBackStack()
}
viewModel.state.observe(viewLifecycleOwner) { state ->
preview.setWallpaper(state.wallpaper)
if (state.chatColors != null) {
preview.setChatColors(state.chatColors)
}
adapter.submitList(state.chatColorModels)
}
viewModel.events.observe(viewLifecycleOwner) { event ->
if (event is ChatColorSelectionViewModel.Event.ConfirmDeletion) {
showWarningDialog(event)
}
}
}
override fun onResume() {
super.onResume()
viewModel.refresh()
}
private fun showWarningDialog(confirmDeletion: ChatColorSelectionViewModel.Event.ConfirmDeletion) {
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.ChatColorSelectionFragment__delete_color)
.setMessage(resources.getQuantityString(R.plurals.ChatColorSelectionFragment__this_custom_color_is_used, confirmDeletion.usageCount, confirmDeletion.usageCount))
.setPositiveButton(R.string.delete) { dialog, _ ->
viewModel.deleteNow(confirmDeletion.chatColors)
dialog.dismiss()
}
.setNegativeButton(android.R.string.cancel) { dialog, _ ->
dialog.dismiss()
}
.show()
}
inner class Callbacks(
private val args: ChatColorSelectionFragmentArgs,
private val view: View
) : ChatColorSelectionAdapter.Callbacks {
override fun onSelect(chatColors: ChatColors) {
viewModel.save(chatColors)
}
override fun onEdit(chatColors: ChatColors) {
val startPage = if (chatColors.getColors().size == 1) 0 else 1
val directions = ChatColorSelectionFragmentDirections
.actionChatColorSelectionFragmentToCustomChatColorCreatorFragment(args.recipientId, startPage)
.setChatColorId(chatColors.id.longValue)
Navigation.findNavController(view).navigate(directions)
}
override fun onDuplicate(chatColors: ChatColors) {
viewModel.duplicate(chatColors)
}
override fun onDelete(chatColors: ChatColors) {
viewModel.startDeletion(chatColors)
}
override fun onAdd() {
val directions = ChatColorSelectionFragmentDirections.actionChatColorSelectionFragmentToCustomChatColorCreatorFragment(args.recipientId, 0)
Navigation.findNavController(view).navigate(directions)
}
}
}

View File

@@ -0,0 +1,103 @@
package org.thoughtcrime.securesms.conversation.colors.ui
import android.content.Context
import org.signal.core.util.concurrent.SignalExecutors
import org.thoughtcrime.securesms.conversation.colors.ChatColors
import org.thoughtcrime.securesms.conversation.colors.ChatColorsPalette
import org.thoughtcrime.securesms.database.DatabaseFactory
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.wallpaper.ChatWallpaper
sealed class ChatColorSelectionRepository(context: Context) {
protected val context: Context = context.applicationContext
abstract fun getWallpaper(consumer: (ChatWallpaper?) -> Unit)
abstract fun getChatColors(consumer: (ChatColors) -> Unit)
abstract fun save(chatColors: ChatColors, onSaved: () -> Unit)
fun duplicate(chatColors: ChatColors) {
SignalExecutors.BOUNDED.execute {
val duplicate = chatColors.withId(ChatColors.Id.NotSet)
DatabaseFactory.getChatColorsDatabase(context).saveChatColors(duplicate)
}
}
fun getUsageCount(chatColors: ChatColors, consumer: (Int) -> Unit) {
SignalExecutors.BOUNDED.execute {
consumer(DatabaseFactory.getRecipientDatabase(context).getColorUsageCount(chatColors))
}
}
fun delete(chatColors: ChatColors, onDeleted: () -> Unit) {
SignalExecutors.BOUNDED.execute {
DatabaseFactory.getChatColorsDatabase(context).deleteChatColors(chatColors)
onDeleted()
}
}
private class Global(context: Context) : ChatColorSelectionRepository(context) {
override fun getWallpaper(consumer: (ChatWallpaper?) -> Unit) {
consumer(SignalStore.wallpaper().wallpaper)
}
override fun getChatColors(consumer: (ChatColors) -> Unit) {
if (SignalStore.chatColorsValues().hasChatColors) {
consumer(requireNotNull(SignalStore.chatColorsValues().chatColors))
} else {
getWallpaper { wallpaper ->
if (wallpaper != null) {
consumer(wallpaper.autoChatColors)
} else {
consumer(ChatColorsPalette.Bubbles.default.withId(ChatColors.Id.Auto))
}
}
}
}
override fun save(chatColors: ChatColors, onSaved: () -> Unit) {
if (chatColors.id == ChatColors.Id.Auto) {
SignalStore.chatColorsValues().chatColors = null
} else {
SignalStore.chatColorsValues().chatColors = chatColors
}
onSaved()
}
}
private class Single(context: Context, private val recipientId: RecipientId) : ChatColorSelectionRepository(context) {
override fun getWallpaper(consumer: (ChatWallpaper?) -> Unit) {
SignalExecutors.BOUNDED.execute {
val recipient = Recipient.resolved(recipientId)
consumer(recipient.wallpaper)
}
}
override fun getChatColors(consumer: (ChatColors) -> Unit) {
SignalExecutors.BOUNDED.execute {
val recipient = Recipient.resolved(recipientId)
consumer(recipient.chatColors)
}
}
override fun save(chatColors: ChatColors, onSaved: () -> Unit) {
SignalExecutors.BOUNDED.execute {
val recipientDatabase = DatabaseFactory.getRecipientDatabase(context)
recipientDatabase.setColor(recipientId, chatColors)
onSaved()
}
}
}
companion object {
fun create(context: Context, recipientId: RecipientId?): ChatColorSelectionRepository {
return if (recipientId != null) {
Single(context, recipientId)
} else {
Global(context)
}
}
}
}

View File

@@ -0,0 +1,45 @@
package org.thoughtcrime.securesms.conversation.colors.ui
import org.thoughtcrime.securesms.conversation.colors.ChatColors
import org.thoughtcrime.securesms.conversation.colors.ChatColorsPalette
import org.thoughtcrime.securesms.util.MappingModelList
import org.thoughtcrime.securesms.wallpaper.ChatWallpaper
data class ChatColorSelectionState(
val wallpaper: ChatWallpaper? = null,
val chatColors: ChatColors? = null,
private val chatColorOptions: List<ChatColors> = listOf()
) {
val chatColorModels: MappingModelList
init {
val models: List<ChatColorMappingModel> = chatColorOptions.map { chatColors ->
ChatColorMappingModel(
chatColors,
chatColors == this.chatColors,
false
)
}.toList()
val defaultModel: ChatColorMappingModel = if (wallpaper != null) {
ChatColorMappingModel(
wallpaper.autoChatColors,
chatColors?.id == ChatColors.Id.Auto,
true
)
} else {
ChatColorMappingModel(
ChatColorsPalette.Bubbles.default.withId(ChatColors.Id.Auto),
chatColors?.id == ChatColors.Id.Auto,
true
)
}
chatColorModels = MappingModelList().apply {
add(defaultModel)
addAll(models)
add(CustomColorMappingModel())
}
}
}

View File

@@ -0,0 +1,74 @@
package org.thoughtcrime.securesms.conversation.colors.ui
import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.ViewModelProviders
import org.thoughtcrime.securesms.conversation.colors.ChatColors
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.SingleLiveEvent
import org.thoughtcrime.securesms.util.livedata.Store
class ChatColorSelectionViewModel(private val repository: ChatColorSelectionRepository) : ViewModel() {
private val store = Store<ChatColorSelectionState>(ChatColorSelectionState())
private val chatColors = ChatColorsOptionsLiveData()
private val internalEvents = SingleLiveEvent<Event>()
val state: LiveData<ChatColorSelectionState> = store.stateLiveData
val events: LiveData<Event> = internalEvents
init {
store.update(chatColors) { colors, state -> state.copy(chatColorOptions = colors) }
}
fun refresh() {
repository.getWallpaper { wallpaper ->
store.update { it.copy(wallpaper = wallpaper) }
}
repository.getChatColors { chatColors ->
store.update { it.copy(chatColors = chatColors) }
}
}
fun save(chatColors: ChatColors) {
repository.save(chatColors, this::refresh)
}
fun duplicate(chatColors: ChatColors) {
repository.duplicate(chatColors)
}
fun startDeletion(chatColors: ChatColors) {
repository.getUsageCount(chatColors) {
if (it > 0) {
internalEvents.postValue(Event.ConfirmDeletion(it, chatColors))
} else {
deleteNow(chatColors)
}
}
}
fun deleteNow(chatColors: ChatColors) {
repository.delete(chatColors, this::refresh)
}
class Factory(private val repository: ChatColorSelectionRepository) : ViewModelProvider.Factory {
override fun <T : ViewModel?> create(modelClass: Class<T>): T = requireNotNull(modelClass.cast(ChatColorSelectionViewModel(repository)))
}
companion object {
fun getOrCreate(activity: FragmentActivity, recipientId: RecipientId?): ChatColorSelectionViewModel {
val repository = ChatColorSelectionRepository.create(activity, recipientId)
val viewModelFactory = Factory(repository)
return ViewModelProviders.of(activity, viewModelFactory).get(ChatColorSelectionViewModel::class.java)
}
}
sealed class Event {
class ConfirmDeletion(val usageCount: Int, val chatColors: ChatColors) : Event()
}
}

View File

@@ -0,0 +1,38 @@
package org.thoughtcrime.securesms.conversation.colors.ui
import androidx.lifecycle.LiveData
import org.signal.core.util.concurrent.SignalExecutors
import org.thoughtcrime.securesms.conversation.colors.ChatColors
import org.thoughtcrime.securesms.conversation.colors.ChatColorsPalette
import org.thoughtcrime.securesms.database.ChatColorsDatabase
import org.thoughtcrime.securesms.database.DatabaseFactory
import org.thoughtcrime.securesms.database.DatabaseObserver
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.util.concurrent.SerialMonoLifoExecutor
import java.util.concurrent.Executor
class ChatColorsOptionsLiveData : LiveData<List<ChatColors>>() {
private val chatColorsDatabase: ChatColorsDatabase = DatabaseFactory.getChatColorsDatabase(ApplicationDependencies.getApplication())
private val observer: DatabaseObserver.Observer = DatabaseObserver.Observer { refreshChatColors() }
private val executor: Executor = SerialMonoLifoExecutor(SignalExecutors.BOUNDED)
override fun onActive() {
refreshChatColors()
ApplicationDependencies.getDatabaseObserver().registerChatColorsObserver(observer)
}
override fun onInactive() {
ApplicationDependencies.getDatabaseObserver().unregisterObserver(observer)
}
private fun refreshChatColors() {
executor.execute {
val options = mutableListOf<ChatColors>().apply {
addAll(ChatColorsPalette.Bubbles.all)
addAll(chatColorsDatabase.getSavedChatColors())
}
postValue(options)
}
}
}

View File

@@ -0,0 +1,73 @@
package org.thoughtcrime.securesms.conversation.colors.ui
import android.content.Context
import android.graphics.Rect
import android.os.Build
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.PopupWindow
import androidx.core.content.ContextCompat
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.util.ViewUtil
class ChatSelectionContextMenu(val context: Context) : PopupWindow(context) {
var callback: Callback? = null
init {
contentView = LayoutInflater.from(context).inflate(R.layout.chat_colors_fragment_context_menu, null, false)
if (Build.VERSION.SDK_INT >= 21) {
elevation = ViewUtil.dpToPx(8).toFloat()
}
isOutsideTouchable = false
isFocusable = true
width = ViewUtil.dpToPx(280)
setBackgroundDrawable(ContextCompat.getDrawable(context, R.drawable.round_background))
val edit: View = contentView.findViewById(R.id.context_menu_edit)
val duplicate: View = contentView.findViewById(R.id.context_menu_duplicate)
val delete: View = contentView.findViewById(R.id.context_menu_delete)
edit.setOnClickListener {
dismiss()
callback?.onEditPressed()
}
duplicate.setOnClickListener {
dismiss()
callback?.onDuplicatePressed()
}
delete.setOnClickListener {
dismiss()
callback?.onDeletePressed()
}
}
fun show(anchor: View) {
val rect = Rect()
val root: ViewGroup = anchor.rootView as ViewGroup
anchor.getDrawingRect(rect)
root.offsetDescendantRectToMyCoords(anchor, rect)
contentView.measure(0, 0)
if (rect.bottom + contentView.measuredHeight > root.bottom) {
showAsDropDown(anchor, 0, -(contentView.measuredHeight + anchor.height))
} else {
showAsDropDown(anchor, 0, 0)
}
}
interface Callback {
fun onEditPressed()
fun onDuplicatePressed()
fun onDeletePressed()
}
}

View File

@@ -0,0 +1,13 @@
package org.thoughtcrime.securesms.conversation.colors.ui
import org.thoughtcrime.securesms.util.MappingModel
class CustomColorMappingModel : MappingModel<CustomColorMappingModel> {
override fun areItemsTheSame(newItem: CustomColorMappingModel): Boolean {
return true
}
override fun areContentsTheSame(newItem: CustomColorMappingModel): Boolean {
return true
}
}

View File

@@ -0,0 +1,47 @@
package org.thoughtcrime.securesms.conversation.colors.ui.custom
import android.os.Build
import android.os.Bundle
import android.view.View
import androidx.appcompat.widget.Toolbar
import androidx.fragment.app.Fragment
import androidx.navigation.Navigation
import androidx.viewpager2.widget.ViewPager2
import com.google.android.material.tabs.TabLayout
import com.google.android.material.tabs.TabLayoutMediator
import org.thoughtcrime.securesms.R
class CustomChatColorCreatorFragment : Fragment(R.layout.custom_chat_color_creator_fragment) {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val toolbar: Toolbar = view.findViewById(R.id.toolbar)
val tabLayout: TabLayout = view.findViewById(R.id.tab_layout)
val pager: ViewPager2 = view.findViewById(R.id.pager)
val adapter = CustomChatColorPagerAdapter(this, requireArguments())
val tabLayoutMediator = TabLayoutMediator(tabLayout, pager) { tab, position ->
tab.setText(
if (position == 0) {
R.string.CustomChatColorCreatorFragment__solid
} else {
R.string.CustomChatColorCreatorFragment__gradient
}
)
}
toolbar.setNavigationOnClickListener {
Navigation.findNavController(it).popBackStack()
}
pager.isUserInputEnabled = false
pager.adapter = adapter
if (Build.VERSION.SDK_INT < 21) {
tabLayout.visibility = View.GONE
} else {
tabLayoutMediator.attach()
}
val startPage = CustomChatColorCreatorFragmentArgs.fromBundle(requireArguments()).startPage
pager.setCurrentItem(startPage, false)
}
}

View File

@@ -0,0 +1,366 @@
package org.thoughtcrime.securesms.conversation.colors.ui.custom
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.ColorFilter
import android.graphics.Paint
import android.graphics.Path
import android.graphics.PixelFormat
import android.graphics.PointF
import android.graphics.RectF
import android.graphics.drawable.Drawable
import android.graphics.drawable.GradientDrawable
import android.os.Bundle
import android.view.View
import android.widget.SeekBar
import androidx.annotation.ColorInt
import androidx.annotation.Dimension
import androidx.appcompat.widget.AppCompatSeekBar
import androidx.core.content.ContextCompat
import androidx.core.graphics.ColorUtils
import androidx.fragment.app.Fragment
import androidx.lifecycle.ViewModelProviders
import androidx.navigation.Navigation
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.conversation.colors.ChatColors
import org.thoughtcrime.securesms.conversation.colors.ui.ChatColorPreviewView
import org.thoughtcrime.securesms.conversation.colors.ui.ChatColorSelectionViewModel
import org.thoughtcrime.securesms.util.Util
import org.thoughtcrime.securesms.util.ViewUtil
import org.thoughtcrime.securesms.util.customizeOnDraw
private const val MAX_SEEK_DIVISIONS = 1023
private const val MAX_HUE = 360
private const val PAGE_ARG = "page"
private const val SINGLE_PAGE = 0
private const val GRADIENT_PAGE = 1
class CustomChatColorCreatorPageFragment :
Fragment(R.layout.custom_chat_color_creator_fragment_page) {
private lateinit var hueSlider: AppCompatSeekBar
private lateinit var saturationSlider: AppCompatSeekBar
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val args: CustomChatColorCreatorFragmentArgs = CustomChatColorCreatorFragmentArgs.fromBundle(requireArguments())
val chatColorSelectionViewModel: ChatColorSelectionViewModel = ChatColorSelectionViewModel.getOrCreate(requireActivity(), args.recipientId)
val page: Int = requireArguments().getInt(PAGE_ARG)
val factory: CustomChatColorCreatorViewModel.Factory = CustomChatColorCreatorViewModel.Factory(MAX_SEEK_DIVISIONS, ChatColors.Id.forLongValue(args.chatColorId), args.recipientId, createRepository())
val viewModel: CustomChatColorCreatorViewModel = ViewModelProviders.of(
requireParentFragment(),
factory
)[CustomChatColorCreatorViewModel::class.java]
val preview: ChatColorPreviewView = view.findViewById(R.id.chat_color_preview)
val hueThumb = ThumbDrawable(requireContext())
val saturationThumb = ThumbDrawable(requireContext())
val gradientTool: CustomChatColorGradientToolView = view.findViewById(R.id.gradient_tool)
val save: View = view.findViewById(R.id.save)
if (page == SINGLE_PAGE) {
gradientTool.visibility = View.GONE
} else {
gradientTool.setListener(object : CustomChatColorGradientToolView.Listener {
override fun onDegreesChanged(degrees: Float) {
viewModel.setDegrees(degrees)
}
override fun onSelectedEdgeChanged(edge: CustomChatColorEdge) {
viewModel.setSelectedEdge(edge)
}
})
}
hueSlider = view.findViewById(R.id.hue_slider)
saturationSlider = view.findViewById(R.id.saturation_slider)
hueSlider.thumb = hueThumb
saturationSlider.thumb = saturationThumb
hueSlider.max = MAX_SEEK_DIVISIONS
saturationSlider.max = MAX_SEEK_DIVISIONS
val colors: IntArray = (0..MAX_SEEK_DIVISIONS).map { hue ->
ColorUtils.HSLToColor(
floatArrayOf(
hue.toHue(MAX_SEEK_DIVISIONS),
1f,
calculateLightness(hue.toFloat(), valueFor60To80 = 0.4f)
)
)
}.toIntArray()
val hueGradientDrawable = GradientDrawable(GradientDrawable.Orientation.LEFT_RIGHT, colors)
hueSlider.progressDrawable = hueGradientDrawable.forSeekBar()
val saturationProgressDrawable = GradientDrawable().apply {
orientation = GradientDrawable.Orientation.LEFT_RIGHT
}
saturationSlider.progressDrawable = saturationProgressDrawable.forSeekBar()
hueSlider.setOnSeekBarChangeListener(
OnProgressChangedListener {
viewModel.setHueProgress(it)
}
)
saturationSlider.setOnSeekBarChangeListener(
OnProgressChangedListener {
viewModel.setSaturationProgress(it)
}
)
viewModel.events.observe(viewLifecycleOwner) { event ->
when (event) {
is CustomChatColorCreatorViewModel.Event.SaveNow -> {
viewModel.saveNow(event.chatColors) { colors ->
chatColorSelectionViewModel.save(colors)
}
Navigation.findNavController(requireParentFragment().requireView()).popBackStack()
}
is CustomChatColorCreatorViewModel.Event.Warn -> MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.CustomChatColorCreatorFragmentPage__edit_color)
.setMessage(resources.getQuantityString(R.plurals.CustomChatColorCreatorFragmentPage__this_color_is_used, event.usageCount, event.usageCount))
.setPositiveButton(R.string.save) { dialog, _ ->
dialog.dismiss()
viewModel.saveNow(event.chatColors) { colors ->
chatColorSelectionViewModel.save(colors)
}
Navigation.findNavController(requireParentFragment().requireView()).popBackStack()
}
.setNegativeButton(android.R.string.cancel) { dialog, _ ->
dialog.dismiss()
}
.show()
}
}
viewModel.state.observe(viewLifecycleOwner) { state ->
if (state.loading) {
return@observe
}
val sliderState: ColorSlidersState = requireNotNull(state.sliderStates[state.selectedEdge])
hueSlider.progress = sliderState.huePosition
saturationSlider.progress = sliderState.saturationPosition
val color: Int = sliderState.getColor()
hueThumb.setColor(sliderState.getHueColor())
saturationThumb.setColor(color)
saturationProgressDrawable.colors = sliderState.getSaturationColors()
preview.setWallpaper(state.wallpaper)
if (page == 0) {
val chatColor = ChatColors.forColor(ChatColors.Id.NotSet, color)
preview.setChatColors(chatColor)
save.setOnClickListener {
viewModel.startSave(chatColor)
}
} else {
val topEdgeColor: ColorSlidersState = requireNotNull(state.sliderStates[CustomChatColorEdge.TOP])
val bottomEdgeColor: ColorSlidersState = requireNotNull(state.sliderStates[CustomChatColorEdge.BOTTOM])
val chatColor: ChatColors = ChatColors.forGradient(
ChatColors.Id.NotSet,
ChatColors.LinearGradient(
state.degrees,
intArrayOf(topEdgeColor.getColor(), bottomEdgeColor.getColor()),
floatArrayOf(0f, 1f)
),
)
preview.setChatColors(chatColor)
gradientTool.setSelectedEdge(state.selectedEdge)
gradientTool.setDegrees(state.degrees)
gradientTool.setTopColor(topEdgeColor.getColor())
gradientTool.setBottomColor(bottomEdgeColor.getColor())
save.setOnClickListener {
viewModel.startSave(chatColor)
}
}
}
}
private fun createRepository(): CustomChatColorCreatorRepository {
return CustomChatColorCreatorRepository(requireContext())
}
@ColorInt
private fun ColorSlidersState.getHueColor(): Int {
val hue = huePosition.toHue(MAX_SEEK_DIVISIONS)
return ColorUtils.HSLToColor(
floatArrayOf(
hue,
1f,
calculateLightness(hue, 0.4f)
)
)
}
@ColorInt
private fun ColorSlidersState.getColor(): Int {
val hue = huePosition.toHue(MAX_SEEK_DIVISIONS)
return ColorUtils.HSLToColor(
floatArrayOf(
hue,
saturationPosition.toUnit(MAX_SEEK_DIVISIONS),
calculateLightness(hue)
)
)
}
private fun ColorSlidersState.getSaturationColors(): IntArray {
val hue = huePosition.toHue(MAX_SEEK_DIVISIONS)
val level = calculateLightness(hue)
return listOf(0f, 1f).map {
ColorUtils.HSLToColor(
floatArrayOf(
hue, it, level
)
)
}.toIntArray()
}
private fun calculateLightness(hue: Float, valueFor60To80: Float = 0.3f): Float {
val point1 = PointF()
val point2 = PointF()
if (hue >= 0f && hue < 60f) {
point1.set(0f, 0.45f)
point2.set(60f, valueFor60To80)
} else if (hue >= 60f && hue < 180f) {
return valueFor60To80
} else if (hue >= 180f && hue < 240f) {
point1.set(180f, valueFor60To80)
point2.set(240f, 0.5f)
} else if (hue >= 240f && hue < 300f) {
point1.set(240f, 0.5f)
point2.set(300f, 0.4f)
} else if (hue >= 300f && hue < 360f) {
point1.set(300f, 0.4f)
point2.set(360f, 0.45f)
} else {
return 0.45f
}
return interpolate(point1, point2, hue)
}
private fun interpolate(point1: PointF, point2: PointF, x: Float): Float {
return ((point1.y * (point2.x - x)) + (point2.y * (x - point1.x))) / (point2.x - point1.x)
}
private fun Number.toHue(max: Number): Float {
return Util.clamp(toFloat() * (MAX_HUE / max.toFloat()), 0f, MAX_HUE.toFloat())
}
private fun Number.toUnit(max: Number): Float {
return Util.clamp(toFloat() / max.toFloat(), 0f, 1f)
}
private fun Drawable.forSeekBar(): Drawable {
val height: Int = ViewUtil.dpToPx(8)
val radii: FloatArray = (1..8).map { 50f }.toFloatArray()
val bounds = RectF()
val clipPath = Path()
return customizeOnDraw { wrapped, canvas ->
canvas.save()
bounds.set(this.bounds)
bounds.inset(0f, (height / 2f) + 1)
clipPath.rewind()
clipPath.addRoundRect(bounds, radii, Path.Direction.CW)
canvas.clipPath(clipPath)
wrapped.draw(canvas)
canvas.restore()
}
}
private class OnProgressChangedListener(private val updateFn: (Int) -> Unit) :
SeekBar.OnSeekBarChangeListener {
override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) {
updateFn(progress)
}
override fun onStartTrackingTouch(seekBar: SeekBar?) = Unit
override fun onStopTrackingTouch(seekBar: SeekBar?) = Unit
}
private class ThumbDrawable(context: Context) : Drawable() {
private val borderPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = ContextCompat.getColor(context, R.color.signal_background_primary)
}
private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.TRANSPARENT
}
private val borderWidth: Int = ViewUtil.dpToPx(THUMB_MARGIN)
private val thumbInnerSize: Int = ViewUtil.dpToPx(THUMB_INNER_SIZE)
private val innerRadius: Float = thumbInnerSize / 2f
private val thumbSize: Float = (thumbInnerSize + borderWidth).toFloat()
private val thumbRadius: Float = thumbSize / 2f
override fun getIntrinsicHeight(): Int = ViewUtil.dpToPx(48)
override fun getIntrinsicWidth(): Int = ViewUtil.dpToPx(48)
fun setColor(@ColorInt color: Int) {
paint.color = color
invalidateSelf()
}
override fun draw(canvas: Canvas) {
canvas.drawCircle(
(bounds.width() / 2f) + bounds.left,
(bounds.height() / 2f) + bounds.top,
thumbRadius,
borderPaint
)
canvas.drawCircle(
(bounds.width() / 2f) + bounds.left,
(bounds.height() / 2f) + bounds.top,
innerRadius,
paint
)
}
override fun setAlpha(alpha: Int) = Unit
override fun setColorFilter(colorFilter: ColorFilter?) = Unit
override fun getOpacity(): Int = PixelFormat.TRANSPARENT
companion object {
@Dimension(unit = Dimension.DP)
private val THUMB_INNER_SIZE = 16
@Dimension(unit = Dimension.DP)
private val THUMB_MARGIN = 1
}
}
companion object {
fun forSingle(bundle: Bundle): Fragment = forPage(SINGLE_PAGE, bundle)
fun forGradient(bundle: Bundle): Fragment = forPage(GRADIENT_PAGE, bundle)
private fun forPage(page: Int, bundle: Bundle): Fragment = CustomChatColorCreatorPageFragment().apply {
arguments = Bundle().apply {
putInt(PAGE_ARG, page)
putAll(bundle)
}
}
}
}

View File

@@ -0,0 +1,49 @@
package org.thoughtcrime.securesms.conversation.colors.ui.custom
import android.content.Context
import org.signal.core.util.concurrent.SignalExecutors
import org.thoughtcrime.securesms.conversation.colors.ChatColors
import org.thoughtcrime.securesms.database.DatabaseFactory
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.wallpaper.ChatWallpaper
class CustomChatColorCreatorRepository(private val context: Context) {
fun loadColors(chatColorsId: ChatColors.Id, consumer: (ChatColors) -> Unit) {
SignalExecutors.BOUNDED.execute {
val chatColorsDatabase = DatabaseFactory.getChatColorsDatabase(context)
val chatColors = chatColorsDatabase.getById(chatColorsId)
consumer(chatColors)
}
}
fun getWallpaper(recipientId: RecipientId?, consumer: (ChatWallpaper?) -> Unit) {
SignalExecutors.BOUNDED.execute {
if (recipientId != null) {
val recipient = Recipient.resolved(recipientId)
consumer(recipient.wallpaper)
} else {
consumer(SignalStore.wallpaper().wallpaper)
}
}
}
fun setChatColors(chatColors: ChatColors, consumer: (ChatColors) -> Unit) {
SignalExecutors.BOUNDED.execute {
val chatColorsDatabase = DatabaseFactory.getChatColorsDatabase(context)
val savedColors = chatColorsDatabase.saveChatColors(chatColors)
consumer(savedColors)
}
}
fun getUsageCount(chatColors: ChatColors, consumer: (Int) -> Unit) {
SignalExecutors.BOUNDED.execute {
val recipientsDatabase = DatabaseFactory.getRecipientDatabase(context)
consumer(recipientsDatabase.getColorUsageCount(chatColors))
}
}
}

View File

@@ -0,0 +1,14 @@
package org.thoughtcrime.securesms.conversation.colors.ui.custom
import org.thoughtcrime.securesms.wallpaper.ChatWallpaper
import java.util.EnumMap
data class CustomChatColorCreatorState(
val loading: Boolean,
val wallpaper: ChatWallpaper?,
val sliderStates: EnumMap<CustomChatColorEdge, ColorSlidersState>,
val selectedEdge: CustomChatColorEdge,
val degrees: Float
)
data class ColorSlidersState(val huePosition: Int, val saturationPosition: Int)

View File

@@ -0,0 +1,153 @@
package org.thoughtcrime.securesms.conversation.colors.ui.custom
import androidx.core.graphics.ColorUtils
import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import org.thoughtcrime.securesms.conversation.colors.ChatColors
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.SingleLiveEvent
import org.thoughtcrime.securesms.util.livedata.Store
import java.util.EnumMap
import kotlin.math.roundToInt
class CustomChatColorCreatorViewModel(
private val maxSliderValue: Int,
private val chatColorsId: ChatColors.Id,
private val recipientId: RecipientId?,
private val repository: CustomChatColorCreatorRepository
) : ViewModel() {
private val store = Store<CustomChatColorCreatorState>(getInitialState())
private val internalEvents = SingleLiveEvent<Event>()
val state: LiveData<CustomChatColorCreatorState> = store.stateLiveData
val events: LiveData<Event> = internalEvents
init {
repository.getWallpaper(recipientId) { wallpaper ->
store.update { it.copy(wallpaper = wallpaper) }
}
if (chatColorsId is ChatColors.Id.Custom) {
repository.loadColors(chatColorsId) {
val colors: IntArray = it.getColors()
val topColor: Int = colors.first()
val bottomColor: Int = colors.last()
val topHsl = floatArrayOf(0f, 0f, 0f)
val bottomHsl = floatArrayOf(0f, 0f, 0f)
ColorUtils.colorToHSL(topColor, topHsl)
ColorUtils.colorToHSL(bottomColor, bottomHsl)
val topHue: Float = topHsl[0]
val topSaturation: Float = topHsl[1]
val bottomHue: Float = bottomHsl[0]
val bottomSaturation: Float = bottomHsl[1]
val topEdge = ColorSlidersState(
huePosition = ((topHue / 360f) * maxSliderValue).roundToInt(),
saturationPosition = (topSaturation * maxSliderValue).roundToInt()
)
val bottomEdge = ColorSlidersState(
huePosition = ((bottomHue / 360f) * maxSliderValue).roundToInt(),
saturationPosition = (bottomSaturation * maxSliderValue).roundToInt()
)
store.update { state ->
state.copy(
degrees = it.getDegrees(),
loading = false,
sliderStates = EnumMap(
mapOf(
CustomChatColorEdge.TOP to topEdge,
CustomChatColorEdge.BOTTOM to bottomEdge
)
)
)
}
}
}
}
fun setHueProgress(progress: Int) {
store.update { state ->
state.copy(
sliderStates = state.sliderStates.apply {
val oldData: ColorSlidersState = requireNotNull(get(state.selectedEdge))
put(state.selectedEdge, oldData.copy(huePosition = progress))
}
)
}
}
fun setSaturationProgress(progress: Int) {
store.update { state ->
state.copy(
sliderStates = state.sliderStates.apply {
val oldData: ColorSlidersState = requireNotNull(get(state.selectedEdge))
put(state.selectedEdge, oldData.copy(saturationPosition = progress))
}
)
}
}
fun setDegrees(degrees: Float) {
store.update { it.copy(degrees = degrees) }
}
fun setSelectedEdge(selectedEdge: CustomChatColorEdge) {
store.update { it.copy(selectedEdge = selectedEdge) }
}
fun startSave(chatColors: ChatColors) {
if (chatColors.id is ChatColors.Id.Custom) {
repository.getUsageCount(chatColors) {
if (it > 0) {
internalEvents.postValue(Event.Warn(it, chatColors))
} else {
internalEvents.postValue(Event.SaveNow(chatColors))
}
}
} else {
internalEvents.postValue(Event.SaveNow(chatColors))
}
}
fun saveNow(chatColors: ChatColors, onSaved: (ChatColors) -> Unit) {
repository.setChatColors(chatColors.withId(chatColorsId), onSaved)
}
private fun getInitialState() = CustomChatColorCreatorState(
loading = chatColorsId is ChatColors.Id.Custom,
wallpaper = null,
sliderStates = EnumMap(
mapOf(
CustomChatColorEdge.TOP to ColorSlidersState(maxSliderValue / 2, maxSliderValue / 2),
CustomChatColorEdge.BOTTOM to ColorSlidersState(maxSliderValue / 2, maxSliderValue / 2)
)
),
selectedEdge = CustomChatColorEdge.BOTTOM,
degrees = 180f
)
class Factory(
private val maxSliderValue: Int,
private val chatColorsId: ChatColors.Id,
private val recipientId: RecipientId?,
private val chatColorCreatorRepository: CustomChatColorCreatorRepository
) : ViewModelProvider.Factory {
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
return requireNotNull(modelClass.cast(CustomChatColorCreatorViewModel(maxSliderValue, chatColorsId, recipientId, chatColorCreatorRepository)))
}
}
sealed class Event {
class Warn(val usageCount: Int, val chatColors: ChatColors) : Event()
class SaveNow(val chatColors: ChatColors) : Event()
}
}

View File

@@ -0,0 +1,5 @@
package org.thoughtcrime.securesms.conversation.colors.ui.custom
enum class CustomChatColorEdge {
TOP, BOTTOM
}

View File

@@ -0,0 +1,335 @@
package org.thoughtcrime.securesms.conversation.colors.ui.custom
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.PointF
import android.graphics.Rect
import android.graphics.RectF
import android.util.AttributeSet
import android.view.GestureDetector
import android.view.MotionEvent
import android.view.View
import androidx.annotation.ColorInt
import androidx.annotation.Dimension
import androidx.core.content.ContextCompat
import androidx.core.view.GestureDetectorCompat
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.util.ViewUtil
import kotlin.math.abs
import kotlin.math.atan
import kotlin.math.atan2
import kotlin.math.pow
import kotlin.math.sqrt
import kotlin.math.tan
/**
* Renders the gradient customization tool.
*
* The Gradient customization tool is two selectable circles on either side
* of a rectangle with a pipe connecting them, a TOP and a BOTTOM (an edge)
*
* The user can then swap between the selected edge via a touch-down and can
* drag the selected edge such that it traces around the outline of the square.
* The other edge traces along the opposite side of the rectangle.
*
* The way the position along the edge is determined is by dividing the rectangle
* into 8 right-angled triangles, all joining at the center. Using the specified
* angle, we can determine which "octant" the top edge should be in, and can
* determine its distance from the center point of the relevant edge, and use
* similar logic to determine where the bottom edge lies.
*
* All of the math assumes an origin at the dead center of the view, and
* that 0deg corresponds to a vector pointing directly towards the right hand
* side of the view. This doesn't quite line up with what the gradient rendering
* math requires, so we apply a simple function to degrees when it comes into or
* leaves this tool (see `Float.invert`)
*/
class CustomChatColorGradientToolView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {
private val clipRect = Rect()
private val rect = RectF()
private val center = PointF()
private val top = PointF()
private val bottom = PointF()
private var selectedEdge: CustomChatColorEdge = CustomChatColorEdge.TOP
private var degrees: Float = 18f
private var listener: Listener? = null
private val thumbRadius: Float = ViewUtil.dpToPx(THUMB_RADIUS).toFloat()
private val thumbBorder: Float = ViewUtil.dpToPx(THUMB_BORDER).toFloat()
private val thumbBorderSelected: Float = ViewUtil.dpToPx(THUMB_BORDER_SELECTED).toFloat()
private val opaqueThumbRadius: Float = ViewUtil.dpToPx(OPAQUE_THUMB_RADIUS).toFloat()
private val opaqueThumbPadding: Float = ViewUtil.dpToPx(OPAGUE_THUMB_PADDING).toFloat()
private val opaqueThumbPaddingSelected: Float = ViewUtil.dpToPx(OPAGUE_THUMB_PADDING_SELECTED).toFloat()
private val pipeWidth: Float = ViewUtil.dpToPx(PIPE_WIDTH).toFloat()
private val pipeBorder: Float = ViewUtil.dpToPx(PIPE_BORDER).toFloat()
private val topColorPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.RED
}
private val bottomColorPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.BLUE
}
private val backgroundPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = ContextCompat.getColor(context, R.color.signal_background_primary)
}
private val thumbBorderPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = ContextCompat.getColor(context, R.color.signal_inverse_transparent_10)
}
private val thumbBorderPaintSelected = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = ContextCompat.getColor(context, R.color.signal_inverse_transparent_60)
}
private val pipePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
strokeWidth = pipeWidth - pipeBorder * 2
style = Paint.Style.STROKE
color = ContextCompat.getColor(context, R.color.signal_background_primary)
}
private val pipeBorderPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
strokeWidth = pipeWidth
style = Paint.Style.STROKE
color = ContextCompat.getColor(context, R.color.signal_inverse_transparent_10)
}
val gestureDetectorCompat = GestureDetectorCompat(context, GestureListener())
fun setTopColor(@ColorInt color: Int) {
topColorPaint.color = color
invalidate()
}
fun setBottomColor(@ColorInt color: Int) {
bottomColorPaint.color = color
invalidate()
}
fun setSelectedEdge(selectedEdge: CustomChatColorEdge) {
if (this.selectedEdge == selectedEdge) {
return
}
this.selectedEdge = selectedEdge
invalidate()
listener?.onSelectedEdgeChanged(selectedEdge)
}
fun setDegrees(degrees: Float) {
setDegreesInternal(degrees.invertDegrees())
}
private fun setDegreesInternal(degrees: Float) {
if (this.degrees == degrees) {
return
}
this.degrees = degrees
invalidate()
listener?.onDegreesChanged(degrees.invertDegrees())
}
private fun Float.invertDegrees(): Float = 360f - rotate(90f)
fun setListener(listener: Listener) {
this.listener = listener
}
override fun onTouchEvent(event: MotionEvent?): Boolean {
return gestureDetectorCompat.onTouchEvent(event)
}
override fun onDraw(canvas: Canvas) {
canvas.getClipBounds(clipRect)
rect.set(clipRect)
rect.inset(thumbRadius, thumbRadius)
center.set(rect.width() / 2f, rect.height() / 2f)
val alpha = atan((rect.height() / rect.width())).toDegrees()
val beta = (360.0 - alpha * 4) / 4f
if (degrees < alpha) {
// Right top
val a = center.x
val b = a * tan(degrees.toRadians())
top.set(rect.width(), center.y - b)
bottom.set(0f, center.y + b)
} else if (degrees < 90f) {
// Top right
val phi = 90f - degrees
val a = center.y
val b = a * tan(phi.toRadians())
top.set(center.x + b, 0f)
bottom.set(center.x - b, rect.height())
} else if (degrees < (90f + beta)) {
// Top left
val phi = degrees - 90f
val a = center.y
val b = a * tan(phi.toRadians())
top.set(center.x - b, 0f)
bottom.set(center.x + b, rect.height())
} else if (degrees < 180f) {
// left top
val phi = 180f - degrees
val a = center.x
val b = a * tan(phi.toRadians())
top.set(0f, center.y - b)
bottom.set(rect.width(), center.y + b)
} else if (degrees < (180f + alpha)) {
// left bottom
val phi = degrees - 180f
val a = center.x
val b = a * tan(phi.toRadians())
top.set(0f, center.y + b)
bottom.set(rect.width(), center.y - b)
} else if (degrees < 270f) {
// bottom left
val phi = 270f - degrees
val a = center.y
val b = a * tan(phi.toRadians())
top.set(center.x - b, rect.height())
bottom.set(center.x + b, 0f)
} else if (degrees < (270f + beta)) {
// bottom right
val phi = degrees - 270f
val a = center.y
val b = a * tan(phi.toRadians())
top.set(center.x + b, rect.height())
bottom.set(center.x - b, 0f)
} else {
// right bottom
val phi = 360f - degrees
val a = center.x
val b = a * tan(phi.toRadians())
top.set(rect.width(), center.y + b)
bottom.set(0f, center.y - b)
}
val (selected, other) = when (selectedEdge) {
CustomChatColorEdge.TOP -> top to bottom
CustomChatColorEdge.BOTTOM -> bottom to top
}
val (selectedPaint, otherPaint) = when (selectedEdge) {
CustomChatColorEdge.TOP -> topColorPaint to bottomColorPaint
CustomChatColorEdge.BOTTOM -> bottomColorPaint to topColorPaint
}
canvas.apply {
save()
translate(rect.top, rect.left)
drawLine(selected.x, selected.y, other.x, other.y, pipeBorderPaint)
drawLine(selected.x, selected.y, other.x, other.y, pipePaint)
drawCircle(other.x, other.y, opaqueThumbRadius + thumbBorder, thumbBorderPaint)
drawCircle(other.x, other.y, opaqueThumbRadius, backgroundPaint)
drawCircle(other.x, other.y, opaqueThumbRadius - opaqueThumbPadding, otherPaint)
drawCircle(selected.x, selected.y, opaqueThumbRadius + thumbBorderSelected, thumbBorderPaintSelected)
drawCircle(selected.x, selected.y, opaqueThumbRadius, backgroundPaint)
drawCircle(selected.x, selected.y, opaqueThumbRadius - opaqueThumbPaddingSelected, selectedPaint)
restore()
}
top.offset(rect.top, rect.left)
bottom.offset(rect.top, rect.left)
}
private fun Float.toDegrees(): Float = this * (180f / Math.PI.toFloat())
private fun Float.toRadians(): Float = this * (Math.PI.toFloat() / 180f)
private fun PointF.distance(other: PointF): Float = abs(sqrt((this.x - other.x).pow(2) + (this.y - other.y).pow(2)))
private fun PointF.dotProduct(other: PointF): Float = (this.x * other.x) + (this.y * other.y)
private fun PointF.determinate(other: PointF): Float = (this.x * other.y) - (this.y * other.x)
private inner class GestureListener : GestureDetector.SimpleOnGestureListener() {
var activePointerId: Int = MotionEvent.INVALID_POINTER_ID
override fun onDown(e: MotionEvent): Boolean {
activePointerId = e.getPointerId(0)
val touchPoint = PointF(e.getX(activePointerId), e.getY(activePointerId))
val distanceFromTop = touchPoint.distance(top)
if (distanceFromTop <= thumbRadius) {
setSelectedEdge(CustomChatColorEdge.TOP)
return true
}
val distanceFromBottom = touchPoint.distance(bottom)
if (distanceFromBottom <= thumbRadius) {
setSelectedEdge(CustomChatColorEdge.BOTTOM)
return true
}
return false
}
override fun onScroll(
e1: MotionEvent,
e2: MotionEvent,
distanceX: Float,
distanceY: Float
): Boolean {
val a = PointF(e2.getX(activePointerId) - center.x, e2.getY(activePointerId) - center.y)
val b = PointF(center.x, 0f)
val dot = a.dotProduct(b)
val det = a.determinate(b)
val offset = if (selectedEdge == CustomChatColorEdge.BOTTOM) 180f else 0f
val degrees = (atan2(det, dot).toDegrees() + 360f + offset) % 360f
setDegreesInternal(degrees)
return true
}
}
private fun Float.rotate(degrees: Float): Float = (this + degrees + 360f) % 360f
interface Listener {
fun onDegreesChanged(degrees: Float)
fun onSelectedEdgeChanged(edge: CustomChatColorEdge)
}
companion object {
@Dimension(unit = Dimension.DP)
private const val THUMB_RADIUS = 24
@Dimension(unit = Dimension.DP)
private const val THUMB_BORDER = 1
@Dimension(unit = Dimension.DP)
private const val THUMB_BORDER_SELECTED = 4
@Dimension(unit = Dimension.DP)
private const val OPAQUE_THUMB_RADIUS = 20
@Dimension(unit = Dimension.DP)
private const val OPAGUE_THUMB_PADDING = 2
@Dimension(unit = Dimension.DP)
private const val OPAGUE_THUMB_PADDING_SELECTED = 1
@Dimension(unit = Dimension.DP)
private const val PIPE_WIDTH = 6
@Dimension(unit = Dimension.DP)
private const val PIPE_BORDER = 1
}
}

View File

@@ -0,0 +1,19 @@
package org.thoughtcrime.securesms.conversation.colors.ui.custom
import android.os.Bundle
import androidx.fragment.app.Fragment
import androidx.viewpager2.adapter.FragmentStateAdapter
class CustomChatColorPagerAdapter(parentFragment: Fragment, private val arguments: Bundle) : FragmentStateAdapter(parentFragment) {
override fun getItemCount(): Int = 2
override fun createFragment(position: Int): Fragment {
return when (position) {
0 -> CustomChatColorCreatorPageFragment.forSingle(arguments)
1 -> CustomChatColorCreatorPageFragment.forGradient(arguments)
else -> {
throw AssertionError()
}
}
}
}

View File

@@ -393,7 +393,7 @@ public final class ConversationListItem extends ConstraintLayout
private void setRippleColor(Recipient recipient) {
if (Build.VERSION.SDK_INT >= 21) {
((RippleDrawable)(getBackground()).mutate())
.setColor(ColorStateList.valueOf(recipient.getColor().toConversationColor(getContext())));
.setColor(ColorStateList.valueOf(recipient.getChatColors().asSingleColor()));
}
}

View File

@@ -0,0 +1,139 @@
package org.thoughtcrime.securesms.database
import android.content.ContentValues
import android.content.Context
import android.database.Cursor
import org.thoughtcrime.securesms.conversation.colors.ChatColors
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper
import org.thoughtcrime.securesms.database.model.databaseprotos.ChatColor
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.CursorUtil
import org.thoughtcrime.securesms.util.SqlUtil
class ChatColorsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Database(context, databaseHelper) {
companion object {
private const val TABLE_NAME = "chat_colors"
private const val ID = "_id"
private const val CHAT_COLORS = "chat_colors"
@JvmField
val CREATE_TABLE = """
CREATE TABLE $TABLE_NAME (
$ID INTEGER PRIMARY KEY AUTOINCREMENT,
$CHAT_COLORS BLOB
)
""".trimIndent()
}
fun getById(chatColorsId: ChatColors.Id): ChatColors {
val db = databaseHelper.readableDatabase
val projection = arrayOf(ID, CHAT_COLORS)
val args = SqlUtil.buildArgs(chatColorsId.longValue)
db.query(TABLE_NAME, projection, ID_WHERE, args, null, null, null)?.use {
if (it.moveToFirst()) {
return it.getChatColors()
}
}
throw IllegalArgumentException("Could not locate chat color $chatColorsId")
}
fun saveChatColors(chatColors: ChatColors): ChatColors {
return when (chatColors.id) {
is ChatColors.Id.Auto -> throw AssertionError("Saving 'auto' does not make sense")
is ChatColors.Id.BuiltIn -> chatColors
is ChatColors.Id.NotSet -> insertChatColors(chatColors)
is ChatColors.Id.Custom -> updateChatColors(chatColors)
}
}
fun getSavedChatColors(): List<ChatColors> {
val db = databaseHelper.readableDatabase
val projection = arrayOf(ID, CHAT_COLORS)
val result = mutableListOf<ChatColors>()
db.query(TABLE_NAME, projection, null, null, null, null, null)?.use {
while (it.moveToNext()) {
result.add(it.getChatColors())
}
}
return result
}
private fun insertChatColors(chatColors: ChatColors): ChatColors {
if (chatColors.id != ChatColors.Id.NotSet) {
throw IllegalArgumentException("Bad chat colors to insert.")
}
val db: SQLiteDatabase = databaseHelper.writableDatabase
val values = ContentValues(1).apply {
put(CHAT_COLORS, chatColors.serialize().toByteArray())
}
val rowId = db.insert(TABLE_NAME, null, values)
if (rowId == -1L) {
throw IllegalStateException("Failed to insert ChatColor into database")
}
notifyListeners()
return chatColors.withId(ChatColors.Id.forLongValue(rowId))
}
private fun updateChatColors(chatColors: ChatColors): ChatColors {
if (chatColors.id == ChatColors.Id.NotSet || chatColors.id == ChatColors.Id.BuiltIn) {
throw IllegalArgumentException("Bad chat colors to update.")
}
val db: SQLiteDatabase = databaseHelper.writableDatabase
val values = ContentValues(1).apply {
put(CHAT_COLORS, chatColors.serialize().toByteArray())
}
val rowsUpdated = db.update(TABLE_NAME, values, ID_WHERE, SqlUtil.buildArgs(chatColors.id.longValue))
if (rowsUpdated < 1) {
throw IllegalStateException("Failed to update ChatColor in database")
}
if (SignalStore.chatColorsValues().chatColors?.id == chatColors.id) {
SignalStore.chatColorsValues().chatColors = chatColors
}
val recipientDatabase = DatabaseFactory.getRecipientDatabase(context)
recipientDatabase.onUpdatedChatColors(chatColors)
notifyListeners()
return chatColors
}
fun deleteChatColors(chatColors: ChatColors) {
if (chatColors.id == ChatColors.Id.NotSet || chatColors.id == ChatColors.Id.BuiltIn) {
throw IllegalArgumentException("Cannot delete this chat color")
}
val db: SQLiteDatabase = databaseHelper.writableDatabase
db.delete(TABLE_NAME, ID_WHERE, SqlUtil.buildArgs(chatColors.id.longValue))
if (SignalStore.chatColorsValues().chatColors?.id == chatColors.id) {
SignalStore.chatColorsValues().chatColors = null
}
val recipientDatabase = DatabaseFactory.getRecipientDatabase(context)
recipientDatabase.onDeletedChatColors(chatColors)
notifyListeners()
}
private fun notifyListeners() {
ApplicationDependencies.getDatabaseObserver().notifyChatColorsListeners()
}
private fun Cursor.getId(): Long = CursorUtil.requireLong(this, ID)
private fun Cursor.getChatColors(): ChatColors = ChatColors.forChatColor(
ChatColors.Id.forLongValue(getId()),
ChatColor.parseFrom(CursorUtil.requireBlob(this, CHAT_COLORS))
)
}

View File

@@ -64,6 +64,7 @@ public class DatabaseFactory {
private final RemappedRecordsDatabase remappedRecordsDatabase;
private final MentionDatabase mentionDatabase;
private final PaymentDatabase paymentDatabase;
private final ChatColorsDatabase chatColorsDatabase;
public static DatabaseFactory getInstance(Context context) {
if (instance == null) {
@@ -174,6 +175,10 @@ public class DatabaseFactory {
return getInstance(context).databaseHelper.getReadableDatabase().getSqlCipherDatabase();
}
public static ChatColorsDatabase getChatColorsDatabase(Context context) {
return getInstance(context).chatColorsDatabase;
}
public static void upgradeRestored(Context context, SQLiteDatabase database){
synchronized (lock) {
getInstance(context).databaseHelper.onUpgrade(database, database.getVersion(), -1);
@@ -223,6 +228,7 @@ public class DatabaseFactory {
this.remappedRecordsDatabase = new RemappedRecordsDatabase(context, databaseHelper);
this.mentionDatabase = new MentionDatabase(context, databaseHelper);
this.paymentDatabase = new PaymentDatabase(context, databaseHelper);
this.chatColorsDatabase = new ChatColorsDatabase(context, databaseHelper);
}
public void onApplicationLevelUpgrade(@NonNull Context context, @NonNull MasterSecret masterSecret,

Some files were not shown because too many files have changed in this diff Show More