mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-13 13:33:20 +01:00
Compare commits
61 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
811bef8c35 | ||
|
|
057107ea7a | ||
|
|
273e5f9168 | ||
|
|
35930fb23a | ||
|
|
c794b5c2e7 | ||
|
|
e74d502ae6 | ||
|
|
e5ce6e3e2e | ||
|
|
65020dde1a | ||
|
|
98f432d23c | ||
|
|
2651b789dd | ||
|
|
dbabac34b0 | ||
|
|
6866b7a277 | ||
|
|
03c19f54c2 | ||
|
|
ba510ca77d | ||
|
|
bb7409fd91 | ||
|
|
23e5da4d95 | ||
|
|
fb1b46b67e | ||
|
|
7a21e6b5f8 | ||
|
|
6342a45b4e | ||
|
|
bcc5d485ab | ||
|
|
36fe150678 | ||
|
|
54f92ae466 | ||
|
|
b9b2924939 | ||
|
|
513e5b45c5 | ||
|
|
1fad5e2c1e | ||
|
|
5a28cf616d | ||
|
|
c08199659b | ||
|
|
ca508514a7 | ||
|
|
da2038dd46 | ||
|
|
f02e2d23d0 | ||
|
|
ef1c25c3d3 | ||
|
|
152cc27394 | ||
|
|
c582aca465 | ||
|
|
80e85fb49a | ||
|
|
d660e22e61 | ||
|
|
51856c4f06 | ||
|
|
fd37da42f9 | ||
|
|
11df2bc51f | ||
|
|
6770d21cf7 | ||
|
|
f490d1f6d2 | ||
|
|
f890ae8ddc | ||
|
|
5d5d61d8ed | ||
|
|
75589f1b2d | ||
|
|
6225c676e2 | ||
|
|
9b18668f49 | ||
|
|
2f80e7f1ff | ||
|
|
790413680d | ||
|
|
47e9a4ec29 | ||
|
|
defd5e8047 | ||
|
|
8c6a88374b | ||
|
|
7343613bea | ||
|
|
155dda1fa4 | ||
|
|
3c74306c8d | ||
|
|
13ecd9eee6 | ||
|
|
c48f3b4582 | ||
|
|
30c007194d | ||
|
|
ef5b68eb35 | ||
|
|
c47dcd5720 | ||
|
|
ed3c5ab479 | ||
|
|
a697b6c3d4 | ||
|
|
3965df78c9 |
190
.idea/codeStyles/Project.xml
generated
Normal file
190
.idea/codeStyles/Project.xml
generated
Normal 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
5
.idea/codeStyles/codeStyleConfig.xml
generated
Normal file
@@ -0,0 +1,5 @@
|
||||
<component name="ProjectCodeStyleConfiguration">
|
||||
<state>
|
||||
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
|
||||
</state>
|
||||
</component>
|
||||
@@ -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'
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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()))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -51,10 +51,6 @@ public final class WebRtcControls {
|
||||
this.participantLimit = participantLimit;
|
||||
}
|
||||
|
||||
boolean canRotateControls() {
|
||||
return !isGroupCall();
|
||||
}
|
||||
|
||||
boolean displayErrorControls() {
|
||||
return isError();
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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?)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package org.thoughtcrime.securesms.conversation.colors.ui.custom
|
||||
|
||||
enum class CustomChatColorEdge {
|
||||
TOP, BOTTOM
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
)
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user