mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-02-16 07:57:38 +00:00
Compare commits
107 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8004565c84 | ||
|
|
a101dc4fd1 | ||
|
|
57f730d8ee | ||
|
|
3543cc80ba | ||
|
|
71613d9db1 | ||
|
|
4a0e6a3eb2 | ||
|
|
f1a87518e1 | ||
|
|
61f880fd78 | ||
|
|
09904e7a16 | ||
|
|
94658e9090 | ||
|
|
a47448b6c6 | ||
|
|
7e4b9b685a | ||
|
|
64922a8e51 | ||
|
|
f65f4704c9 | ||
|
|
b04ca202f6 | ||
|
|
83086a5a2b | ||
|
|
51a521594f | ||
|
|
0a7a7cf5a9 | ||
|
|
6bd689504c | ||
|
|
efec40ff57 | ||
|
|
69716dde4a | ||
|
|
e90fa05d60 | ||
|
|
580c000bda | ||
|
|
2f3d04d3e8 | ||
|
|
bf37d412e9 | ||
|
|
fd115ebb72 | ||
|
|
b9657208fe | ||
|
|
5d6d78a51e | ||
|
|
916006e664 | ||
|
|
55c69cd50a | ||
|
|
14565b0864 | ||
|
|
a157c1ae1d | ||
|
|
a4d458f969 | ||
|
|
3f53abedab | ||
|
|
68a2d5ed20 | ||
|
|
35e9e31a7b | ||
|
|
444d947743 | ||
|
|
c427dbad08 | ||
|
|
2cefe813e4 | ||
|
|
123ffe42c3 | ||
|
|
da20e66ecd | ||
|
|
901440017a | ||
|
|
0be76a37fe | ||
|
|
36dadc8777 | ||
|
|
182749c101 | ||
|
|
d9228bd911 | ||
|
|
a361fcc8f3 | ||
|
|
ff4f0b9f42 | ||
|
|
060dffc9cc | ||
|
|
172cc302fc | ||
|
|
416e62112f | ||
|
|
e584a90f81 | ||
|
|
9876ffb5e4 | ||
|
|
53e10f2cad | ||
|
|
cb79f75ac1 | ||
|
|
5ec9c1cd90 | ||
|
|
1f28a30ace | ||
|
|
7715917436 | ||
|
|
f79b445fdf | ||
|
|
14484deabe | ||
|
|
3ac395d33e | ||
|
|
f83b520ca9 | ||
|
|
0123f9aa87 | ||
|
|
06b64fe619 | ||
|
|
1bb87834d8 | ||
|
|
ae4167ddae | ||
|
|
383beafdef | ||
|
|
062e88b24f | ||
|
|
8299d49042 | ||
|
|
4677883838 | ||
|
|
7f0a0bef5a | ||
|
|
acc825971b | ||
|
|
62040d06b4 | ||
|
|
0921ebe5f1 | ||
|
|
3d0e15e2b8 | ||
|
|
5372f79c40 | ||
|
|
92e8f9de0e | ||
|
|
c3cf846a10 | ||
|
|
5826b0c068 | ||
|
|
2d7c043398 | ||
|
|
e20d6b63cf | ||
|
|
b85c5eb54a | ||
|
|
a1c8573fad | ||
|
|
90a27d2227 | ||
|
|
c54c6018b2 | ||
|
|
7419570f94 | ||
|
|
8860f792c4 | ||
|
|
e47db0d532 | ||
|
|
ab5d3badc2 | ||
|
|
fce362960f | ||
|
|
5bf23dcfb3 | ||
|
|
65c7dc6ca2 | ||
|
|
e30a8b6954 | ||
|
|
838e318200 | ||
|
|
62ee411901 | ||
|
|
ceefd2d92f | ||
|
|
e3870f5656 | ||
|
|
6e7022ab70 | ||
|
|
031d1551e7 | ||
|
|
6755b25361 | ||
|
|
11d0a73675 | ||
|
|
44119b6437 | ||
|
|
d4a3b442f4 | ||
|
|
aba5774446 | ||
|
|
911dd9efb1 | ||
|
|
f2a490b07e | ||
|
|
5675f080f2 |
3
.idea/codeStyles/Project.xml
generated
3
.idea/codeStyles/Project.xml
generated
@@ -10,6 +10,9 @@
|
||||
<option name="ALIGN_MULTILINE_TEXT_BLOCKS" value="true" />
|
||||
</JavaCodeStyleSettings>
|
||||
<JetCodeStyleSettings>
|
||||
<option name="PACKAGES_TO_USE_STAR_IMPORTS">
|
||||
<value />
|
||||
</option>
|
||||
<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" />
|
||||
|
||||
@@ -57,8 +57,8 @@ protobuf {
|
||||
}
|
||||
}
|
||||
|
||||
def canonicalVersionCode = 870
|
||||
def canonicalVersionName = "5.15.3"
|
||||
def canonicalVersionCode = 879
|
||||
def canonicalVersionName = "5.17.1"
|
||||
|
||||
def postFixSize = 100
|
||||
def abiPostFix = ['universal' : 0,
|
||||
@@ -262,6 +262,15 @@ android {
|
||||
buildConfigField "String", "BUILD_DISTRIBUTION_TYPE", "\"internal\""
|
||||
}
|
||||
|
||||
nightly {
|
||||
dimension 'distribution'
|
||||
versionNameSuffix "-nightly-untagged-${getDateSuffix()}"
|
||||
ext.websiteUpdateUrl = "null"
|
||||
buildConfigField "boolean", "PLAY_STORE_DISABLED", "false"
|
||||
buildConfigField "String", "NOPLAY_UPDATE_URL", "$ext.websiteUpdateUrl"
|
||||
buildConfigField "String", "BUILD_DISTRIBUTION_TYPE", "\"internal\""
|
||||
}
|
||||
|
||||
study {
|
||||
dimension 'distribution'
|
||||
|
||||
@@ -308,13 +317,21 @@ android {
|
||||
|
||||
android.applicationVariants.all { variant ->
|
||||
variant.outputs.each { output ->
|
||||
output.outputFileName = output.outputFileName.replace(".apk", "-${variant.versionName}.apk")
|
||||
def abiName = output.getFilter("ABI") ?: 'universal'
|
||||
def postFix = abiPostFix.get(abiName, 0)
|
||||
if (output.baseName.contains('nightly')) {
|
||||
output.versionCodeOverride = canonicalVersionCode * postFixSize + 5
|
||||
def tag = getCurrentGitTag()
|
||||
if (tag != null && tag.length() > 0) {
|
||||
output.versionNameOverride = tag
|
||||
}
|
||||
} else {
|
||||
output.outputFileName = output.outputFileName.replace(".apk", "-${variant.versionName}.apk")
|
||||
def abiName = output.getFilter("ABI") ?: 'universal'
|
||||
def postFix = abiPostFix.get(abiName, 0)
|
||||
|
||||
if (postFix >= postFixSize) throw new AssertionError("postFix is too large")
|
||||
if (postFix >= postFixSize) throw new AssertionError("postFix is too large")
|
||||
|
||||
output.versionCodeOverride = canonicalVersionCode * postFixSize + postFix
|
||||
output.versionCodeOverride = canonicalVersionCode * postFixSize + postFix
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -327,6 +344,12 @@ android {
|
||||
variant.setIgnore(true)
|
||||
} else if (distribution != 'study' && buildType == 'mock') {
|
||||
variant.setIgnore(true)
|
||||
} else if (distribution == 'internal' && buildType != 'flipper' && buildType != 'perf' && buildType != 'release') {
|
||||
variant.setIgnore(true)
|
||||
} else if (distribution == 'nightly' && environment != 'prod') {
|
||||
variant.setIgnore(true)
|
||||
} else if (distribution == 'nightly' && buildType != 'flipper' && buildType != 'perf' && buildType != 'release') {
|
||||
variant.setIgnore(true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -344,6 +367,7 @@ android {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation 'androidx.core:core-ktx:1.5.0'
|
||||
implementation 'androidx.fragment:fragment-ktx:1.2.5'
|
||||
lintChecks project(':lintchecks')
|
||||
|
||||
@@ -374,6 +398,7 @@ dependencies {
|
||||
implementation "androidx.concurrent:concurrent-futures:1.0.0"
|
||||
implementation "androidx.autofill:autofill:1.0.0"
|
||||
implementation "androidx.biometric:biometric:1.1.0"
|
||||
implementation "androidx.sharetarget:sharetarget:1.1.0"
|
||||
|
||||
implementation ('com.google.firebase:firebase-messaging:22.0.0') {
|
||||
exclude group: 'com.google.firebase', module: 'firebase-core'
|
||||
@@ -398,7 +423,7 @@ dependencies {
|
||||
implementation project(':device-transfer')
|
||||
|
||||
implementation 'org.signal:zkgroup-android:0.7.0'
|
||||
implementation 'org.whispersystems:signal-client-android:0.8.0'
|
||||
implementation 'org.whispersystems:signal-client-android:0.8.3'
|
||||
implementation 'com.google.protobuf:protobuf-javalite:3.10.0'
|
||||
|
||||
implementation('com.mobilecoin:android-sdk:1.1.0') {
|
||||
@@ -407,7 +432,7 @@ dependencies {
|
||||
|
||||
implementation 'org.signal:argon2:13.1@aar'
|
||||
|
||||
implementation 'org.signal:ringrtc-android:2.10.1.1'
|
||||
implementation 'org.signal:ringrtc-android:2.10.6'
|
||||
|
||||
implementation "me.leolin:ShortcutBadger:1.1.22"
|
||||
implementation 'se.emilsjolander:stickylistheaders:2.7.0'
|
||||
@@ -477,11 +502,16 @@ dependencies {
|
||||
testImplementation 'org.robolectric:shadows-multidex:4.4'
|
||||
testImplementation 'org.hamcrest:hamcrest:2.2'
|
||||
|
||||
testImplementation(testFixtures(project(":libsignal-service")))
|
||||
|
||||
androidTestImplementation 'androidx.test.ext:junit:1.1.1'
|
||||
androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0'
|
||||
|
||||
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
|
||||
implementation "com.fasterxml.jackson.module:jackson-module-kotlin:2.12.0"
|
||||
|
||||
implementation 'io.reactivex.rxjava3:rxandroid:3.0.0'
|
||||
implementation 'io.reactivex.rxjava3:rxkotlin:3.0.1'
|
||||
}
|
||||
|
||||
dependencyVerification {
|
||||
@@ -578,6 +608,26 @@ def getGitHash() {
|
||||
return stdout.toString().trim()
|
||||
}
|
||||
|
||||
def getCurrentGitTag() {
|
||||
if (!(new File('.git').exists())) {
|
||||
return ''
|
||||
}
|
||||
|
||||
def stdout = new ByteArrayOutputStream()
|
||||
exec {
|
||||
commandLine 'git', 'tag', '--points-at', 'HEAD'
|
||||
standardOutput = stdout
|
||||
}
|
||||
|
||||
def output = stdout.toString().trim()
|
||||
|
||||
if (output != null && output.size() > 0) {
|
||||
return output.split('\n')[0];
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
tasks.withType(Test) {
|
||||
testLogging {
|
||||
events "failed"
|
||||
@@ -598,3 +648,9 @@ def loadKeystoreProperties(filename) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
def getDateSuffix() {
|
||||
def date = new Date()
|
||||
def formattedDate = date.format('yyyy-MM-dd-HH:mm')
|
||||
return formattedDate
|
||||
}
|
||||
|
||||
@@ -198,7 +198,7 @@
|
||||
|
||||
<meta-data
|
||||
android:name="android.service.chooser.chooser_target_service"
|
||||
android:value=".service.DirectShareService" />
|
||||
android:value="androidx.sharetarget.ChooserTargetServiceCompat" />
|
||||
|
||||
</activity>
|
||||
|
||||
@@ -319,7 +319,7 @@
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
|
||||
|
||||
<activity android:name=".migrations.ApplicationMigrationActivity"
|
||||
android:theme="@style/NoAnimation.Theme.AppCompat.Light.DarkActionBar"
|
||||
android:theme="@style/Theme.Signal.DayNight.NoActionBar"
|
||||
android:launchMode="singleTask"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
|
||||
|
||||
@@ -651,13 +651,6 @@
|
||||
<meta-data android:name="android.provider.CONTACTS_STRUCTURE" android:resource="@xml/contactsformat" />
|
||||
</service>
|
||||
|
||||
<service android:name=".service.DirectShareService"
|
||||
android:permission="android.permission.BIND_CHOOSER_TARGET_SERVICE">
|
||||
<intent-filter>
|
||||
<action android:name="android.service.chooser.ChooserTargetService" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<service android:name=".service.GenericForegroundService"/>
|
||||
|
||||
<service android:name=".gcm.FcmFetchService" />
|
||||
|
||||
@@ -512,7 +512,12 @@ final class SignalCameraXModule {
|
||||
return rotationDegrees;
|
||||
}
|
||||
|
||||
@SuppressLint("UnsafeExperimentalUsageError")
|
||||
public void invalidateView() {
|
||||
if (mPreview != null) {
|
||||
mPreview.setTargetRotation(getDisplaySurfaceRotation()); // Fixes issue #10940 (rotation not updated on phones using "Legacy API")
|
||||
}
|
||||
|
||||
updateViewInfo();
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,8 @@ import androidx.multidex.MultiDexApplication;
|
||||
|
||||
import com.google.android.gms.security.ProviderInstaller;
|
||||
|
||||
import net.sqlcipher.database.SQLiteDatabase;
|
||||
|
||||
import org.conscrypt.Conscrypt;
|
||||
import org.signal.aesgcmprovider.AesGcmProvider;
|
||||
import org.signal.core.util.concurrent.SignalExecutors;
|
||||
@@ -37,6 +39,7 @@ import org.signal.core.util.tracing.Tracer;
|
||||
import org.signal.glide.SignalGlideCodecs;
|
||||
import org.signal.ringrtc.CallManager;
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||
import org.thoughtcrime.securesms.database.SqlCipherLibraryLoader;
|
||||
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencyProvider;
|
||||
@@ -86,6 +89,9 @@ import org.whispersystems.libsignal.logging.SignalProtocolLoggerProvider;
|
||||
import java.security.Security;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import io.reactivex.rxjava3.plugins.RxJavaPlugins;
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers;
|
||||
|
||||
/**
|
||||
* Will be called once when the TextSecure process is created.
|
||||
*
|
||||
@@ -119,11 +125,15 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
|
||||
|
||||
AppStartup.getInstance().addBlocking("security-provider", this::initializeSecurityProvider)
|
||||
.addBlocking("logging", () -> {
|
||||
initializeLogging();
|
||||
Log.i(TAG, "onCreate()");
|
||||
initializeLogging();
|
||||
Log.i(TAG, "onCreate()");
|
||||
})
|
||||
.addBlocking("crash-handling", this::initializeCrashHandling)
|
||||
.addBlocking("eat-db", () -> DatabaseFactory.getInstance(this))
|
||||
.addBlocking("sqlcipher-init", () -> SqlCipherLibraryLoader.load(this))
|
||||
.addBlocking("rx-init", () -> {
|
||||
RxJavaPlugins.setInitIoSchedulerHandler(schedulerSupplier -> Schedulers.from(SignalExecutors.BOUNDED_IO, true, false));
|
||||
RxJavaPlugins.setInitComputationSchedulerHandler(schedulerSupplier -> Schedulers.from(SignalExecutors.BOUNDED, true, false));
|
||||
})
|
||||
.addBlocking("app-dependencies", this::initializeAppDependencies)
|
||||
.addBlocking("notification-channels", () -> NotificationChannels.create(this))
|
||||
.addBlocking("first-launch", this::initializeFirstEverAppLaunch)
|
||||
|
||||
@@ -70,11 +70,13 @@ public interface BindableConversationItem extends Unbindable, GiphyMp4Playable,
|
||||
void onGroupMemberClicked(@NonNull RecipientId recipientId, @NonNull GroupId groupId);
|
||||
void onMessageWithErrorClicked(@NonNull MessageRecord messageRecord);
|
||||
void onMessageWithRecaptchaNeededClicked(@NonNull MessageRecord messageRecord);
|
||||
void onIncomingIdentityMismatchClicked(@NonNull RecipientId recipientId);
|
||||
void onRegisterVoiceNoteCallbacks(@NonNull Observer<VoiceNotePlaybackState> onPlaybackStartObserver);
|
||||
void onUnregisterVoiceNoteCallbacks(@NonNull Observer<VoiceNotePlaybackState> onPlaybackStartObserver);
|
||||
void onVoiceNotePause(@NonNull Uri uri);
|
||||
void onVoiceNotePlay(@NonNull Uri uri, long messageId, double position);
|
||||
void onVoiceNoteSeekTo(@NonNull Uri uri, double position);
|
||||
void onVoiceNotePlaybackSpeedChanged(@NonNull Uri uri, float speed);
|
||||
void onGroupMigrationLearnMoreClicked(@NonNull GroupMigrationMembershipChange membershipChange);
|
||||
void onChatSessionRefreshLearnMoreClicked();
|
||||
void onBadDecryptLearnMoreClicked(@NonNull RecipientId author);
|
||||
|
||||
@@ -1,176 +0,0 @@
|
||||
package org.thoughtcrime.securesms;
|
||||
|
||||
import android.annotation.SuppressLint;
|
||||
import android.content.Context;
|
||||
import android.content.DialogInterface;
|
||||
import android.os.AsyncTask;
|
||||
import android.text.SpannableString;
|
||||
import android.text.Spanned;
|
||||
import android.text.method.LinkMovementMethod;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.crypto.ReentrantSessionLock;
|
||||
import org.thoughtcrime.securesms.crypto.storage.TextSecureIdentityKeyStore;
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||
import org.thoughtcrime.securesms.database.MessageDatabase;
|
||||
import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatch;
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.jobs.PushDecryptMessageJob;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientUtil;
|
||||
import org.thoughtcrime.securesms.sms.MessageSender;
|
||||
import org.thoughtcrime.securesms.util.Base64;
|
||||
import org.thoughtcrime.securesms.util.VerifySpan;
|
||||
import org.whispersystems.libsignal.SignalProtocolAddress;
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
import org.whispersystems.signalservice.api.SignalSessionLock;
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceEnvelope;
|
||||
import org.whispersystems.signalservice.internal.push.SignalServiceProtos;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
public class ConfirmIdentityDialog extends AlertDialog {
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
private static final String TAG = Log.tag(ConfirmIdentityDialog.class);
|
||||
|
||||
private OnClickListener callback;
|
||||
|
||||
public ConfirmIdentityDialog(Context context,
|
||||
MessageRecord messageRecord,
|
||||
IdentityKeyMismatch mismatch)
|
||||
{
|
||||
super(context);
|
||||
|
||||
Recipient recipient = Recipient.resolved(mismatch.getRecipientId(context));
|
||||
String name = recipient.getDisplayName(context);
|
||||
String introduction = context.getString(R.string.ConfirmIdentityDialog_your_safety_number_with_s_has_changed, name, name);
|
||||
SpannableString spannableString = new SpannableString(introduction + " " +
|
||||
context.getString(R.string.ConfirmIdentityDialog_you_may_wish_to_verify_your_safety_number_with_this_contact));
|
||||
|
||||
spannableString.setSpan(new VerifySpan(context, mismatch),
|
||||
introduction.length()+1, spannableString.length(),
|
||||
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||
|
||||
setTitle(name);
|
||||
setMessage(spannableString);
|
||||
|
||||
setButton(AlertDialog.BUTTON_POSITIVE, context.getString(R.string.ConfirmIdentityDialog_accept), new AcceptListener(messageRecord, mismatch, recipient.getId()));
|
||||
setButton(AlertDialog.BUTTON_NEGATIVE, context.getString(android.R.string.cancel), new CancelListener());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void show() {
|
||||
super.show();
|
||||
((TextView)this.findViewById(android.R.id.message))
|
||||
.setMovementMethod(LinkMovementMethod.getInstance());
|
||||
}
|
||||
|
||||
public void setCallback(OnClickListener callback) {
|
||||
this.callback = callback;
|
||||
}
|
||||
|
||||
private class AcceptListener implements OnClickListener {
|
||||
|
||||
private final MessageRecord messageRecord;
|
||||
private final IdentityKeyMismatch mismatch;
|
||||
private final RecipientId recipientId;
|
||||
|
||||
private AcceptListener(MessageRecord messageRecord, IdentityKeyMismatch mismatch, RecipientId recipientId) {
|
||||
this.messageRecord = messageRecord;
|
||||
this.mismatch = mismatch;
|
||||
this.recipientId = recipientId;
|
||||
}
|
||||
|
||||
@SuppressLint("StaticFieldLeak")
|
||||
@Override
|
||||
public void onClick(DialogInterface dialog, int which) {
|
||||
new AsyncTask<Void, Void, Void>()
|
||||
{
|
||||
@Override
|
||||
protected Void doInBackground(Void... params) {
|
||||
try (SignalSessionLock.Lock unused = ReentrantSessionLock.INSTANCE.acquire()) {
|
||||
SignalProtocolAddress mismatchAddress = new SignalProtocolAddress(Recipient.resolved(recipientId).requireServiceId(), 1);
|
||||
TextSecureIdentityKeyStore identityKeyStore = new TextSecureIdentityKeyStore(getContext());
|
||||
|
||||
identityKeyStore.saveIdentity(mismatchAddress, mismatch.getIdentityKey(), true);
|
||||
}
|
||||
|
||||
processMessageRecord(messageRecord);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private void processMessageRecord(MessageRecord messageRecord) {
|
||||
if (messageRecord.isOutgoing()) processOutgoingMessageRecord(messageRecord);
|
||||
else processIncomingMessageRecord(messageRecord);
|
||||
}
|
||||
|
||||
private void processOutgoingMessageRecord(MessageRecord messageRecord) {
|
||||
MessageDatabase smsDatabase = DatabaseFactory.getSmsDatabase(getContext());
|
||||
MessageDatabase mmsDatabase = DatabaseFactory.getMmsDatabase(getContext());
|
||||
|
||||
if (messageRecord.isMms()) {
|
||||
mmsDatabase.removeMismatchedIdentity(messageRecord.getId(),
|
||||
mismatch.getRecipientId(getContext()),
|
||||
mismatch.getIdentityKey());
|
||||
|
||||
if (messageRecord.getRecipient().isPushGroup()) {
|
||||
MessageSender.resendGroupMessage(getContext(), messageRecord, Recipient.resolved(mismatch.getRecipientId(getContext())).getId());
|
||||
} else {
|
||||
MessageSender.resend(getContext(), messageRecord);
|
||||
}
|
||||
} else {
|
||||
smsDatabase.removeMismatchedIdentity(messageRecord.getId(),
|
||||
mismatch.getRecipientId(getContext()),
|
||||
mismatch.getIdentityKey());
|
||||
|
||||
MessageSender.resend(getContext(), messageRecord);
|
||||
}
|
||||
}
|
||||
|
||||
private void processIncomingMessageRecord(MessageRecord messageRecord) {
|
||||
try {
|
||||
MessageDatabase smsDatabase = DatabaseFactory.getSmsDatabase(getContext());
|
||||
|
||||
smsDatabase.removeMismatchedIdentity(messageRecord.getId(),
|
||||
mismatch.getRecipientId(getContext()),
|
||||
mismatch.getIdentityKey());
|
||||
|
||||
boolean legacy = !messageRecord.isContentBundleKeyExchange();
|
||||
|
||||
SignalServiceEnvelope envelope = new SignalServiceEnvelope(SignalServiceProtos.Envelope.Type.PREKEY_BUNDLE_VALUE,
|
||||
Optional.of(RecipientUtil.toSignalServiceAddress(getContext(), messageRecord.getIndividualRecipient())),
|
||||
messageRecord.getRecipientDeviceId(),
|
||||
messageRecord.getDateSent(),
|
||||
legacy ? Base64.decode(messageRecord.getBody()) : null,
|
||||
!legacy ? Base64.decode(messageRecord.getBody()) : null,
|
||||
0,
|
||||
0,
|
||||
null);
|
||||
|
||||
ApplicationDependencies.getJobManager().add(new PushDecryptMessageJob(getContext(), envelope, messageRecord.getId()));
|
||||
} catch (IOException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
}
|
||||
|
||||
}.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
|
||||
|
||||
if (callback != null) callback.onClick(null, 0);
|
||||
}
|
||||
}
|
||||
|
||||
private class CancelListener implements OnClickListener {
|
||||
@Override
|
||||
public void onClick(DialogInterface dialog, int which) {
|
||||
if (callback != null) callback.onClick(null, 0);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -20,13 +20,13 @@ import android.content.Context;
|
||||
import android.os.AsyncTask;
|
||||
import android.os.Bundle;
|
||||
|
||||
import androidx.appcompat.widget.Toolbar;
|
||||
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout;
|
||||
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.components.ContactFilterToolbar;
|
||||
import org.thoughtcrime.securesms.components.ContactFilterView;
|
||||
import org.thoughtcrime.securesms.contacts.ContactsCursorLoader.DisplayMode;
|
||||
import org.thoughtcrime.securesms.contacts.sync.DirectoryHelper;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme;
|
||||
import org.thoughtcrime.securesms.util.DynamicTheme;
|
||||
@@ -56,7 +56,8 @@ public abstract class ContactSelectionActivity extends PassphraseRequiredActivit
|
||||
|
||||
protected ContactSelectionListFragment contactsFragment;
|
||||
|
||||
private ContactFilterToolbar toolbar;
|
||||
private Toolbar toolbar;
|
||||
private ContactFilterView contactFilterView;
|
||||
|
||||
@Override
|
||||
protected void onPreCreate() {
|
||||
@@ -73,6 +74,7 @@ public abstract class ContactSelectionActivity extends PassphraseRequiredActivit
|
||||
|
||||
setContentView(getIntent().getIntExtra(EXTRA_LAYOUT_RES_ID, R.layout.contact_selection_activity));
|
||||
|
||||
initializeContactFilterView();
|
||||
initializeToolbar();
|
||||
initializeResources();
|
||||
initializeSearch();
|
||||
@@ -84,16 +86,23 @@ public abstract class ContactSelectionActivity extends PassphraseRequiredActivit
|
||||
dynamicTheme.onResume(this);
|
||||
}
|
||||
|
||||
protected ContactFilterToolbar getToolbar() {
|
||||
protected Toolbar getToolbar() {
|
||||
return toolbar;
|
||||
}
|
||||
|
||||
protected ContactFilterView getContactFilterView() {
|
||||
return contactFilterView;
|
||||
}
|
||||
|
||||
private void initializeContactFilterView() {
|
||||
this.contactFilterView = findViewById(R.id.contact_filter_edit_text);
|
||||
}
|
||||
|
||||
private void initializeToolbar() {
|
||||
this.toolbar = findViewById(R.id.toolbar);
|
||||
setSupportActionBar(toolbar);
|
||||
|
||||
getSupportActionBar().setDisplayHomeAsUpEnabled(false);
|
||||
getSupportActionBar().setDisplayShowTitleEnabled(false);
|
||||
getSupportActionBar().setIcon(null);
|
||||
getSupportActionBar().setLogo(null);
|
||||
}
|
||||
@@ -104,7 +113,7 @@ public abstract class ContactSelectionActivity extends PassphraseRequiredActivit
|
||||
}
|
||||
|
||||
private void initializeSearch() {
|
||||
toolbar.setOnFilterChangedListener(filter -> contactsFragment.setQueryFilter(filter));
|
||||
contactFilterView.setOnFilterChangedListener(filter -> contactsFragment.setQueryFilter(filter));
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -155,7 +164,7 @@ public abstract class ContactSelectionActivity extends PassphraseRequiredActivit
|
||||
ContactSelectionActivity activity = this.activity.get();
|
||||
|
||||
if (activity != null && !activity.isFinishing()) {
|
||||
activity.toolbar.clear();
|
||||
activity.contactFilterView.clear();
|
||||
activity.contactsFragment.resetQueryFilter();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,13 +57,14 @@ import com.pnikosis.materialishprogress.ProgressWheel;
|
||||
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.components.RecyclerViewFastScroller;
|
||||
import org.thoughtcrime.securesms.components.emoji.WarningTextView;
|
||||
import org.thoughtcrime.securesms.components.recyclerview.ToolbarShadowAnimationHelper;
|
||||
import org.thoughtcrime.securesms.contacts.AbstractContactsCursorLoader;
|
||||
import org.thoughtcrime.securesms.contacts.ContactChip;
|
||||
import org.thoughtcrime.securesms.contacts.ContactSelectionListAdapter;
|
||||
import org.thoughtcrime.securesms.contacts.ContactSelectionListItem;
|
||||
import org.thoughtcrime.securesms.contacts.ContactsCursorLoader;
|
||||
import org.thoughtcrime.securesms.contacts.ContactsCursorLoader.DisplayMode;
|
||||
import org.thoughtcrime.securesms.contacts.LetterHeaderDecoration;
|
||||
import org.thoughtcrime.securesms.contacts.SelectedContact;
|
||||
import org.thoughtcrime.securesms.contacts.sync.DirectoryHelper;
|
||||
import org.thoughtcrime.securesms.groups.SelectionLimits;
|
||||
@@ -74,7 +75,6 @@ import org.thoughtcrime.securesms.permissions.Permissions;
|
||||
import org.thoughtcrime.securesms.recipients.LiveRecipient;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.util.StickyHeaderDecoration;
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
import org.thoughtcrime.securesms.util.UsernameUtil;
|
||||
import org.thoughtcrime.securesms.util.ViewUtil;
|
||||
@@ -131,9 +131,10 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
||||
private ContactSelectionListAdapter cursorRecyclerViewAdapter;
|
||||
private ChipGroup chipGroup;
|
||||
private HorizontalScrollView chipGroupScrollContainer;
|
||||
private WarningTextView groupLimit;
|
||||
private OnSelectionLimitReachedListener onSelectionLimitReachedListener;
|
||||
private AbstractContactsCursorLoaderFactoryProvider cursorFactoryProvider;
|
||||
private View shadowView;
|
||||
private ToolbarShadowAnimationHelper toolbarShadowAnimationHelper;
|
||||
|
||||
|
||||
@Nullable private FixedViewsAdapter headerAdapter;
|
||||
@@ -233,9 +234,12 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
||||
showContactsProgress = view.findViewById(R.id.progress);
|
||||
chipGroup = view.findViewById(R.id.chipGroup);
|
||||
chipGroupScrollContainer = view.findViewById(R.id.chipGroupScrollContainer);
|
||||
groupLimit = view.findViewById(R.id.group_limit);
|
||||
constraintLayout = view.findViewById(R.id.container);
|
||||
shadowView = view.findViewById(R.id.toolbar_shadow);
|
||||
|
||||
toolbarShadowAnimationHelper = new ToolbarShadowAnimationHelper(shadowView);
|
||||
|
||||
recyclerView.addOnScrollListener(toolbarShadowAnimationHelper);
|
||||
recyclerView.setLayoutManager(new LinearLayoutManager(getActivity()));
|
||||
recyclerView.setItemAnimator(new DefaultItemAnimator() {
|
||||
@Override
|
||||
@@ -272,8 +276,6 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
||||
|
||||
currentSelection = getCurrentSelection();
|
||||
|
||||
updateGroupLimit(getChipCount());
|
||||
|
||||
return view;
|
||||
}
|
||||
|
||||
@@ -281,13 +283,6 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
||||
return getArguments() != null ? getArguments() : new Bundle();
|
||||
}
|
||||
|
||||
private void updateGroupLimit(int chipCount) {
|
||||
int members = currentSelection.size() + chipCount;
|
||||
groupLimit.setText(getResources().getQuantityString(R.plurals.ContactSelectionListFragment_d_members, members, members));
|
||||
groupLimit.setVisibility(isMulti && !hideCount ? View.VISIBLE : View.GONE);
|
||||
groupLimit.setWarning(selectionWarningLimitExceeded());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
|
||||
Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults);
|
||||
@@ -309,6 +304,14 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
||||
return cursorRecyclerViewAdapter.getSelectedContactsCount();
|
||||
}
|
||||
|
||||
public int getTotalMemberCount() {
|
||||
if (cursorRecyclerViewAdapter == null) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return cursorRecyclerViewAdapter.getSelectedContactsCount() + cursorRecyclerViewAdapter.getCurrentContactsCount();
|
||||
}
|
||||
|
||||
private Set<RecipientId> getCurrentSelection() {
|
||||
List<RecipientId> currentSelection = safeArguments().getParcelableArrayList(CURRENT_SELECTION);
|
||||
if (currentSelection == null) {
|
||||
@@ -349,8 +352,8 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
||||
concatenateAdapter.addAdapter(footerAdapter);
|
||||
}
|
||||
|
||||
recyclerView.addItemDecoration(new LetterHeaderDecoration(requireContext(), this::hideLetterHeaders));
|
||||
recyclerView.setAdapter(concatenateAdapter);
|
||||
recyclerView.addItemDecoration(new StickyHeaderDecoration(concatenateAdapter, true, true, 0));
|
||||
recyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() {
|
||||
@Override
|
||||
public void onScrollStateChanged(@NonNull RecyclerView recyclerView, int newState) {
|
||||
@@ -361,6 +364,14 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (onContactSelectedListener != null) {
|
||||
onContactSelectedListener.onSelectionChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private boolean hideLetterHeaders() {
|
||||
return hasQueryFilter() || shouldDisplayRecents();
|
||||
}
|
||||
|
||||
private View createInviteActionView(@NonNull ListCallback listCallback) {
|
||||
@@ -429,7 +440,7 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
||||
public @NonNull Loader<Cursor> onCreateLoader(int id, Bundle args) {
|
||||
FragmentActivity activity = requireActivity();
|
||||
int displayMode = safeArguments().getInt(DISPLAY_MODE, activity.getIntent().getIntExtra(DISPLAY_MODE, DisplayMode.FLAG_ALL));
|
||||
boolean displayRecents = safeArguments().getBoolean(RECENTS, activity.getIntent().getBooleanExtra(RECENTS, false));
|
||||
boolean displayRecents = shouldDisplayRecents();
|
||||
|
||||
if (cursorFactoryProvider != null) {
|
||||
return cursorFactoryProvider.get().create();
|
||||
@@ -475,6 +486,10 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
||||
fastScroller.setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
private boolean shouldDisplayRecents() {
|
||||
return safeArguments().getBoolean(RECENTS, requireActivity().getIntent().getBooleanExtra(RECENTS, false));
|
||||
}
|
||||
|
||||
@SuppressLint("StaticFieldLeak")
|
||||
private void handleContactPermissionGranted() {
|
||||
final Context context = requireContext();
|
||||
@@ -606,12 +621,19 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
||||
if (isMulti) {
|
||||
addChipForSelectedContact(selectedContact);
|
||||
}
|
||||
if (onContactSelectedListener != null) {
|
||||
onContactSelectedListener.onSelectionChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private void markContactUnselected(@NonNull SelectedContact selectedContact) {
|
||||
cursorRecyclerViewAdapter.removeFromSelectedContacts(selectedContact);
|
||||
cursorRecyclerViewAdapter.notifyItemRangeChanged(0, cursorRecyclerViewAdapter.getItemCount(), ContactSelectionListAdapter.PAYLOAD_SELECTION_CHANGE);
|
||||
removeChipForContact(selectedContact);
|
||||
|
||||
if (onContactSelectedListener != null) {
|
||||
onContactSelectedListener.onSelectionChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private void removeChipForContact(@NonNull SelectedContact contact) {
|
||||
@@ -622,8 +644,6 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
||||
}
|
||||
}
|
||||
|
||||
updateGroupLimit(getChipCount());
|
||||
|
||||
if (getChipCount() == 0) {
|
||||
setChipGroupVisibility(ConstraintSet.GONE);
|
||||
}
|
||||
@@ -673,7 +693,6 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
||||
|
||||
private void addChip(@NonNull ContactChip chip) {
|
||||
chipGroup.addView(chip);
|
||||
updateGroupLimit(getChipCount());
|
||||
if (selectionWarningLimitReachedExactly()) {
|
||||
if (onSelectionLimitReachedListener != null) {
|
||||
onSelectionLimitReachedListener.onSuggestedLimitReached(selectionLimit.getRecommendedLimit());
|
||||
@@ -726,6 +745,7 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
||||
/** @return True if the contact is allowed to be selected, otherwise false. */
|
||||
boolean onBeforeContactSelected(Optional<RecipientId> recipientId, String number);
|
||||
void onContactDeselected(Optional<RecipientId> recipientId, String number);
|
||||
void onSelectionChanged();
|
||||
}
|
||||
|
||||
public interface OnSelectionLimitReachedListener {
|
||||
|
||||
@@ -7,8 +7,6 @@ import android.graphics.PorterDuff;
|
||||
import android.os.AsyncTask;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.text.Editable;
|
||||
import android.text.TextWatcher;
|
||||
import android.view.View;
|
||||
import android.view.View.OnClickListener;
|
||||
import android.view.ViewGroup;
|
||||
@@ -24,8 +22,8 @@ import androidx.appcompat.widget.Toolbar;
|
||||
import androidx.core.content.ContextCompat;
|
||||
import androidx.interpolator.view.animation.FastOutSlowInInterpolator;
|
||||
|
||||
import org.thoughtcrime.securesms.components.ContactFilterToolbar;
|
||||
import org.thoughtcrime.securesms.components.ContactFilterToolbar.OnFilterChangedListener;
|
||||
import org.thoughtcrime.securesms.components.ContactFilterView;
|
||||
import org.thoughtcrime.securesms.components.ContactFilterView.OnFilterChangedListener;
|
||||
import org.thoughtcrime.securesms.contacts.ContactsCursorLoader.DisplayMode;
|
||||
import org.thoughtcrime.securesms.contacts.SelectedContact;
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||
@@ -100,7 +98,8 @@ public class InviteActivity extends PassphraseRequiredActivity implements Contac
|
||||
View shareButton = findViewById(R.id.share_button);
|
||||
Button smsButton = findViewById(R.id.sms_button);
|
||||
Button smsCancelButton = findViewById(R.id.cancel_sms_button);
|
||||
ContactFilterToolbar contactFilter = findViewById(R.id.contact_filter);
|
||||
Toolbar smsToolbar = findViewById(R.id.sms_send_frame_toolbar);
|
||||
ContactFilterView contactFilter = findViewById(R.id.contact_filter_edit_text);
|
||||
|
||||
inviteText = findViewById(R.id.invite_text);
|
||||
smsSendFrame = findViewById(R.id.sms_send_frame);
|
||||
@@ -121,7 +120,7 @@ public class InviteActivity extends PassphraseRequiredActivity implements Contac
|
||||
smsCancelButton.setOnClickListener(new SmsCancelClickListener());
|
||||
smsSendButton.setOnClickListener(new SmsSendClickListener());
|
||||
contactFilter.setOnFilterChangedListener(new ContactFilterChangedListener());
|
||||
contactFilter.setNavigationIcon(R.drawable.ic_search_conversation_24);
|
||||
smsToolbar.setNavigationIcon(R.drawable.ic_search_conversation_24);
|
||||
|
||||
if (Util.isDefaultSmsProvider(this)) {
|
||||
shareButton.setOnClickListener(new ShareClickListener());
|
||||
@@ -150,6 +149,10 @@ public class InviteActivity extends PassphraseRequiredActivity implements Contac
|
||||
updateSmsButtonText(contactsFragment.getSelectedContacts().size());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onSelectionChanged() {
|
||||
}
|
||||
|
||||
private void sendSmsInvites() {
|
||||
new SendSmsInvitesAsyncTask(this, inviteText.getText().toString())
|
||||
.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR,
|
||||
|
||||
@@ -9,6 +9,8 @@ import android.os.Bundle;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaController;
|
||||
import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaControllerOwner;
|
||||
import org.thoughtcrime.securesms.devicetransfer.olddevice.OldDeviceTransferLockedDialog;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.util.AppStartup;
|
||||
@@ -17,13 +19,15 @@ import org.thoughtcrime.securesms.util.CommunicationActions;
|
||||
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme;
|
||||
import org.thoughtcrime.securesms.util.DynamicTheme;
|
||||
|
||||
public class MainActivity extends PassphraseRequiredActivity {
|
||||
public class MainActivity extends PassphraseRequiredActivity implements VoiceNoteMediaControllerOwner {
|
||||
|
||||
public static final int RESULT_CONFIG_CHANGED = Activity.RESULT_FIRST_USER + 901;
|
||||
|
||||
private final DynamicTheme dynamicTheme = new DynamicNoActionBarTheme();
|
||||
private final MainNavigator navigator = new MainNavigator(this);
|
||||
|
||||
private VoiceNoteMediaController mediaController;
|
||||
|
||||
public static @NonNull Intent clearTop(@NonNull Context context) {
|
||||
Intent intent = new Intent(context, MainActivity.class);
|
||||
|
||||
@@ -40,6 +44,7 @@ public class MainActivity extends PassphraseRequiredActivity {
|
||||
super.onCreate(savedInstanceState, ready);
|
||||
setContentView(R.layout.main_activity);
|
||||
|
||||
mediaController = new VoiceNoteMediaController(this);
|
||||
navigator.onCreate(savedInstanceState);
|
||||
|
||||
handleGroupLinkInIntent(getIntent());
|
||||
@@ -109,4 +114,9 @@ public class MainActivity extends PassphraseRequiredActivity {
|
||||
CommunicationActions.handlePotentialProxyLinkUrl(this, data.toString());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull VoiceNoteMediaController getVoiceNoteMediaController() {
|
||||
return mediaController;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
|
||||
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
public class MuteDialog extends AlertDialog {
|
||||
@@ -29,7 +31,7 @@ public class MuteDialog extends AlertDialog {
|
||||
}
|
||||
|
||||
public static void show(final Context context, final @NonNull MuteSelectionListener listener, @Nullable Runnable cancelListener) {
|
||||
AlertDialog.Builder builder = new AlertDialog.Builder(context);
|
||||
AlertDialog.Builder builder = new MaterialAlertDialogBuilder(context);
|
||||
builder.setTitle(R.string.MuteDialog_mute_notifications);
|
||||
builder.setItems(R.array.mute_durations, new DialogInterface.OnClickListener() {
|
||||
@Override
|
||||
|
||||
@@ -56,6 +56,7 @@ public class NewConversationActivity extends ContactSelectionActivity
|
||||
super.onCreate(bundle, ready);
|
||||
assert getSupportActionBar() != null;
|
||||
getSupportActionBar().setDisplayHomeAsUpEnabled(true);
|
||||
getSupportActionBar().setTitle(R.string.NewConversationActivity__new_message);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -96,6 +97,10 @@ public class NewConversationActivity extends ContactSelectionActivity
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onSelectionChanged() {
|
||||
}
|
||||
|
||||
private void launch(Recipient recipient) {
|
||||
long existingThread = DatabaseFactory.getThreadDatabase(this).getThreadIdIfExistsFor(recipient.getId());
|
||||
Intent intent = ConversationIntents.createBuilder(this, recipient.getId(), existingThread)
|
||||
|
||||
@@ -65,4 +65,8 @@ public class PushContactSelectionActivity extends ContactSelectionActivity {
|
||||
setResult(RESULT_OK, resultIntent);
|
||||
finish();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onSelectionChanged() {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import android.util.AttributeSet
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.core.animation.addListener
|
||||
import androidx.fragment.app.FragmentContainerView
|
||||
|
||||
private const val BOUNDS = "signal.wipedowntransition.bottom"
|
||||
@@ -51,6 +52,12 @@ class WipeDownTransition(context: Context, attrs: AttributeSet?) : Transition(co
|
||||
val startBottom: Rect = startValues.values[BOUNDS] as? Rect ?: Rect().apply { view.getLocalVisibleRect(this) }
|
||||
val endBottom: Rect = endValues.values[BOUNDS] as? Rect ?: Rect().apply { view.getLocalVisibleRect(this) }
|
||||
|
||||
return ObjectAnimator.ofObject(view, "clipBounds", RectEvaluator(), startBottom, endBottom)
|
||||
return ObjectAnimator.ofObject(view, "clipBounds", RectEvaluator(), startBottom, endBottom).apply {
|
||||
addListener(
|
||||
onEnd = {
|
||||
view.clipBounds = null
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,11 +11,11 @@ import androidx.annotation.NonNull;
|
||||
import org.signal.core.util.ThreadUtil;
|
||||
import org.signal.core.util.concurrent.SignalExecutors;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.components.voice.VoiceNoteDraft;
|
||||
import org.thoughtcrime.securesms.providers.BlobProvider;
|
||||
import org.thoughtcrime.securesms.util.MediaUtil;
|
||||
import org.thoughtcrime.securesms.util.concurrent.ListenableFuture;
|
||||
import org.thoughtcrime.securesms.util.concurrent.SettableFuture;
|
||||
import org.whispersystems.libsignal.util.Pair;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
@@ -51,7 +51,7 @@ public class AudioRecorder {
|
||||
captureUri = BlobProvider.getInstance()
|
||||
.forData(new ParcelFileDescriptor.AutoCloseInputStream(fds[0]), 0)
|
||||
.withMimeType(MediaUtil.AUDIO_AAC)
|
||||
.createForSingleSessionOnDiskAsync(context, () -> Log.i(TAG, "Write successful."), e -> Log.w(TAG, "Error during recording", e));
|
||||
.createForDraftAttachmentAsync(context, () -> Log.i(TAG, "Write successful."), e -> Log.w(TAG, "Error during recording", e));
|
||||
audioCodec = new AudioCodec();
|
||||
|
||||
audioCodec.start(new ParcelFileDescriptor.AutoCloseOutputStream(fds[1]));
|
||||
@@ -61,10 +61,10 @@ public class AudioRecorder {
|
||||
});
|
||||
}
|
||||
|
||||
public @NonNull ListenableFuture<Pair<Uri, Long>> stopRecording() {
|
||||
public @NonNull ListenableFuture<VoiceNoteDraft> stopRecording() {
|
||||
Log.i(TAG, "stopRecording()");
|
||||
|
||||
final SettableFuture<Pair<Uri, Long>> future = new SettableFuture<>();
|
||||
final SettableFuture<VoiceNoteDraft> future = new SettableFuture<>();
|
||||
|
||||
executor.execute(() -> {
|
||||
if (audioCodec == null) {
|
||||
@@ -76,7 +76,7 @@ public class AudioRecorder {
|
||||
|
||||
try {
|
||||
long size = MediaUtil.getMediaSize(context, captureUri);
|
||||
sendToFuture(future, new Pair<>(captureUri, size));
|
||||
sendToFuture(future, new VoiceNoteDraft(captureUri, size));
|
||||
} catch (IOException ioe) {
|
||||
Log.w(TAG, ioe);
|
||||
sendToFuture(future, ioe);
|
||||
|
||||
@@ -65,12 +65,6 @@ public final class AudioWaveForm {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!(attachment instanceof DatabaseAttachment)) {
|
||||
Log.i(TAG, "Not yet in database");
|
||||
ThreadUtil.runOnMain(onFailure);
|
||||
return;
|
||||
}
|
||||
|
||||
String cacheKey = uri.toString();
|
||||
AudioFileInfo cached = WAVE_FORM_CACHE.get(cacheKey);
|
||||
if (cached != null) {
|
||||
@@ -104,26 +98,46 @@ public final class AudioWaveForm {
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
AttachmentDatabase attachmentDatabase = DatabaseFactory.getAttachmentDatabase(context);
|
||||
DatabaseAttachment dbAttachment = (DatabaseAttachment) attachment;
|
||||
long startTime = System.currentTimeMillis();
|
||||
if (attachment instanceof DatabaseAttachment) {
|
||||
try {
|
||||
AttachmentDatabase attachmentDatabase = DatabaseFactory.getAttachmentDatabase(context);
|
||||
DatabaseAttachment dbAttachment = (DatabaseAttachment) attachment;
|
||||
long startTime = System.currentTimeMillis();
|
||||
|
||||
attachmentDatabase.writeAudioHash(dbAttachment.getAttachmentId(), AudioWaveFormData.getDefaultInstance());
|
||||
attachmentDatabase.writeAudioHash(dbAttachment.getAttachmentId(), AudioWaveFormData.getDefaultInstance());
|
||||
|
||||
Log.i(TAG, String.format("Starting wave form generation (%s)", cacheKey));
|
||||
Log.i(TAG, String.format("Starting wave form generation (%s)", cacheKey));
|
||||
|
||||
AudioFileInfo fileInfo = generateWaveForm(uri);
|
||||
AudioFileInfo fileInfo = generateWaveForm(uri);
|
||||
|
||||
Log.i(TAG, String.format(Locale.US, "Audio wave form generation time %d ms (%s)", System.currentTimeMillis() - startTime, cacheKey));
|
||||
Log.i(TAG, String.format(Locale.US, "Audio wave form generation time %d ms (%s)", System.currentTimeMillis() - startTime, cacheKey));
|
||||
|
||||
attachmentDatabase.writeAudioHash(dbAttachment.getAttachmentId(), fileInfo.toDatabaseProtobuf());
|
||||
attachmentDatabase.writeAudioHash(dbAttachment.getAttachmentId(), fileInfo.toDatabaseProtobuf());
|
||||
|
||||
WAVE_FORM_CACHE.put(cacheKey, fileInfo);
|
||||
ThreadUtil.runOnMain(() -> onSuccess.accept(fileInfo));
|
||||
} catch (Throwable e) {
|
||||
Log.w(TAG, "Failed to create audio wave form for " + cacheKey, e);
|
||||
ThreadUtil.runOnMain(onFailure);
|
||||
WAVE_FORM_CACHE.put(cacheKey, fileInfo);
|
||||
ThreadUtil.runOnMain(() -> onSuccess.accept(fileInfo));
|
||||
} catch (Throwable e) {
|
||||
Log.w(TAG, "Failed to create audio wave form for " + cacheKey, e);
|
||||
ThreadUtil.runOnMain(onFailure);
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
Log.i(TAG, "Not in database and not cached. Generating wave form on-the-fly.");
|
||||
|
||||
long startTime = System.currentTimeMillis();
|
||||
|
||||
Log.i(TAG, String.format("Starting wave form generation (%s)", cacheKey));
|
||||
|
||||
AudioFileInfo fileInfo = generateWaveForm(uri);
|
||||
|
||||
Log.i(TAG, String.format(Locale.US, "Audio wave form generation time %d ms (%s)", System.currentTimeMillis() - startTime, cacheKey));
|
||||
|
||||
WAVE_FORM_CACHE.put(cacheKey, fileInfo);
|
||||
ThreadUtil.runOnMain(() -> onSuccess.accept(fileInfo));
|
||||
} catch (IOException e) {
|
||||
Log.w(TAG, "Failed to create audio wave form for " + cacheKey, e);
|
||||
ThreadUtil.runOnMain(onFailure);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import android.content.Intent;
|
||||
import android.graphics.Color;
|
||||
import android.os.Bundle;
|
||||
import android.view.View;
|
||||
import android.widget.ViewSwitcher;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.StringRes;
|
||||
@@ -18,7 +17,7 @@ import com.google.android.material.snackbar.Snackbar;
|
||||
import org.thoughtcrime.securesms.ContactSelectionListFragment;
|
||||
import org.thoughtcrime.securesms.PassphraseRequiredActivity;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.components.ContactFilterToolbar;
|
||||
import org.thoughtcrime.securesms.components.ContactFilterView;
|
||||
import org.thoughtcrime.securesms.contacts.ContactsCursorLoader;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
@@ -47,27 +46,26 @@ public class BlockedUsersActivity extends PassphraseRequiredActivity implements
|
||||
|
||||
viewModel = ViewModelProviders.of(this, factory).get(BlockedUsersViewModel.class);
|
||||
|
||||
ViewSwitcher viewSwitcher = findViewById(R.id.toolbar_switcher);
|
||||
Toolbar toolbar = findViewById(R.id.toolbar);
|
||||
ContactFilterToolbar contactFilterToolbar = findViewById(R.id.filter_toolbar);
|
||||
View container = findViewById(R.id.fragment_container);
|
||||
Toolbar toolbar = findViewById(R.id.toolbar);
|
||||
ContactFilterView contactFilterView = findViewById(R.id.contact_filter_edit_text);
|
||||
View container = findViewById(R.id.fragment_container);
|
||||
|
||||
toolbar.setNavigationOnClickListener(unused -> onBackPressed());
|
||||
contactFilterToolbar.setNavigationOnClickListener(unused -> onBackPressed());
|
||||
contactFilterToolbar.setOnFilterChangedListener(query -> {
|
||||
contactFilterView.setOnFilterChangedListener(query -> {
|
||||
Fragment fragment = getSupportFragmentManager().findFragmentByTag(CONTACT_SELECTION_FRAGMENT);
|
||||
if (fragment != null) {
|
||||
((ContactSelectionListFragment) fragment).setQueryFilter(query);
|
||||
}
|
||||
});
|
||||
contactFilterToolbar.setHint(R.string.BlockedUsersActivity__add_blocked_user);
|
||||
contactFilterView.setHint(R.string.BlockedUsersActivity__add_blocked_user);
|
||||
|
||||
//noinspection CodeBlock2Expr
|
||||
getSupportFragmentManager().addOnBackStackChangedListener(() -> {
|
||||
viewSwitcher.setDisplayedChild(getSupportFragmentManager().getBackStackEntryCount());
|
||||
|
||||
if (getSupportFragmentManager().getBackStackEntryCount() == 1) {
|
||||
contactFilterToolbar.focusAndShowKeyboard();
|
||||
contactFilterView.setVisibility(View.VISIBLE);
|
||||
contactFilterView.focusAndShowKeyboard();
|
||||
} else {
|
||||
contactFilterView.setVisibility(View.GONE);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -119,6 +117,10 @@ public class BlockedUsersActivity extends PassphraseRequiredActivity implements
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onSelectionChanged() {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handleAddUserToBlockedList() {
|
||||
ContactSelectionListFragment fragment = new ContactSelectionListFragment();
|
||||
@@ -164,6 +166,6 @@ public class BlockedUsersActivity extends PassphraseRequiredActivity implements
|
||||
throw new IllegalArgumentException("Unsupported event type " + event);
|
||||
}
|
||||
|
||||
Snackbar.make(view, getString(messageResId, displayName), Snackbar.LENGTH_SHORT).show();
|
||||
Snackbar.make(view, getString(messageResId, displayName), Snackbar.LENGTH_SHORT).setTextColor(Color.WHITE).show();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,17 +45,21 @@ public final class AudioView extends FrameLayout {
|
||||
|
||||
private static final String TAG = Log.tag(AudioView.class);
|
||||
|
||||
private static final int MODE_NORMAL = 0;
|
||||
private static final int MODE_SMALL = 1;
|
||||
private static final int MODE_DRAFT = 2;
|
||||
|
||||
private static final int FORWARDS = 1;
|
||||
private static final int REVERSE = -1;
|
||||
|
||||
@NonNull private final AnimatingToggle controlToggle;
|
||||
@NonNull private final View progressAndPlay;
|
||||
@NonNull private final LottieAnimationView playPauseButton;
|
||||
@NonNull private final ImageView downloadButton;
|
||||
@NonNull private final ProgressWheel circleProgress;
|
||||
@NonNull private final SeekBar seekBar;
|
||||
private final boolean smallView;
|
||||
private final boolean autoRewind;
|
||||
@NonNull private final AnimatingToggle controlToggle;
|
||||
@NonNull private final View progressAndPlay;
|
||||
@NonNull private final LottieAnimationView playPauseButton;
|
||||
@NonNull private final ImageView downloadButton;
|
||||
@Nullable private final ProgressWheel circleProgress;
|
||||
@NonNull private final SeekBar seekBar;
|
||||
private final boolean smallView;
|
||||
private final boolean autoRewind;
|
||||
|
||||
@Nullable private final TextView duration;
|
||||
|
||||
@@ -87,10 +91,23 @@ public final class AudioView extends FrameLayout {
|
||||
try {
|
||||
typedArray = context.getTheme().obtainStyledAttributes(attrs, R.styleable.AudioView, 0, 0);
|
||||
|
||||
smallView = typedArray.getBoolean(R.styleable.AudioView_small, false);
|
||||
int mode = typedArray.getInteger(R.styleable.AudioView_audioView_mode, MODE_NORMAL);
|
||||
smallView = mode == MODE_SMALL;
|
||||
autoRewind = typedArray.getBoolean(R.styleable.AudioView_autoRewind, false);
|
||||
|
||||
inflate(context, smallView ? R.layout.audio_view_small : R.layout.audio_view, this);
|
||||
switch (mode) {
|
||||
case MODE_NORMAL:
|
||||
inflate(context, R.layout.audio_view, this);
|
||||
break;
|
||||
case MODE_SMALL:
|
||||
inflate(context, R.layout.audio_view_small, this);
|
||||
break;
|
||||
case MODE_DRAFT:
|
||||
inflate(context, R.layout.audio_view_draft, this);
|
||||
break;
|
||||
default:
|
||||
throw new IllegalStateException("Unsupported mode: " + mode);
|
||||
}
|
||||
|
||||
this.controlToggle = findViewById(R.id.control_toggle);
|
||||
this.playPauseButton = findViewById(R.id.play);
|
||||
@@ -110,7 +127,7 @@ public final class AudioView extends FrameLayout {
|
||||
this.waveFormUnplayedBarsColor = typedArray.getColor(R.styleable.AudioView_waveformUnplayedBarsColor, Color.WHITE);
|
||||
this.waveFormThumbTint = typedArray.getColor(R.styleable.AudioView_waveformThumbTint, Color.WHITE);
|
||||
|
||||
progressAndPlay.getBackground().setColorFilter(typedArray.getColor(R.styleable.AudioView_progressAndPlayTint, Color.BLACK), PorterDuff.Mode.SRC_IN);
|
||||
setProgressAndPlayBackgroundTint(typedArray.getColor(R.styleable.AudioView_progressAndPlayTint, Color.BLACK));
|
||||
} finally {
|
||||
if (typedArray != null) {
|
||||
typedArray.recycle();
|
||||
@@ -130,6 +147,10 @@ public final class AudioView extends FrameLayout {
|
||||
EventBus.getDefault().unregister(this);
|
||||
}
|
||||
|
||||
public void setProgressAndPlayBackgroundTint(@ColorInt int color) {
|
||||
progressAndPlay.getBackground().setColorFilter(color, PorterDuff.Mode.SRC_IN);
|
||||
}
|
||||
|
||||
public Observer<VoiceNotePlaybackState> getPlaybackStateObserver() {
|
||||
return playbackStateObserver;
|
||||
}
|
||||
@@ -158,16 +179,20 @@ public final class AudioView extends FrameLayout {
|
||||
controlToggle.displayQuick(downloadButton);
|
||||
seekBar.setEnabled(false);
|
||||
downloadButton.setOnClickListener(new DownloadClickedListener(audio));
|
||||
if (circleProgress.isSpinning()) circleProgress.stopSpinning();
|
||||
circleProgress.setVisibility(View.GONE);
|
||||
if (circleProgress != null) {
|
||||
if (circleProgress.isSpinning()) circleProgress.stopSpinning();
|
||||
circleProgress.setVisibility(View.GONE);
|
||||
}
|
||||
} else if (showControls && audio.getTransferState() == AttachmentDatabase.TRANSFER_PROGRESS_STARTED) {
|
||||
controlToggle.displayQuick(progressAndPlay);
|
||||
seekBar.setEnabled(false);
|
||||
circleProgress.setVisibility(View.VISIBLE);
|
||||
circleProgress.spin();
|
||||
if (circleProgress != null) {
|
||||
circleProgress.setVisibility(View.VISIBLE);
|
||||
circleProgress.spin();
|
||||
}
|
||||
} else {
|
||||
seekBar.setEnabled(true);
|
||||
if (circleProgress.isSpinning()) circleProgress.stopSpinning();
|
||||
if (circleProgress != null && circleProgress.isSpinning()) circleProgress.stopSpinning();
|
||||
showPlayButton();
|
||||
}
|
||||
|
||||
@@ -211,10 +236,11 @@ public final class AudioView extends FrameLayout {
|
||||
|
||||
private void onPlaybackState(@NonNull VoiceNotePlaybackState voiceNotePlaybackState) {
|
||||
onDuration(voiceNotePlaybackState.getUri(), voiceNotePlaybackState.getTrackDuration());
|
||||
onStart(voiceNotePlaybackState.getUri(), voiceNotePlaybackState.isAutoReset());
|
||||
onProgress(voiceNotePlaybackState.getUri(),
|
||||
(double) voiceNotePlaybackState.getPlayheadPositionMillis() / voiceNotePlaybackState.getTrackDuration(),
|
||||
voiceNotePlaybackState.getPlayheadPositionMillis());
|
||||
onSpeedChanged(voiceNotePlaybackState.getUri(), voiceNotePlaybackState.getSpeed());
|
||||
onStart(voiceNotePlaybackState.getUri(), voiceNotePlaybackState.isPlaying(), voiceNotePlaybackState.isAutoReset());
|
||||
}
|
||||
|
||||
private void onDuration(@NonNull Uri uri, long durationMillis) {
|
||||
@@ -223,8 +249,8 @@ public final class AudioView extends FrameLayout {
|
||||
}
|
||||
}
|
||||
|
||||
private void onStart(@NonNull Uri uri, boolean autoReset) {
|
||||
if (!isTarget(uri)) {
|
||||
private void onStart(@NonNull Uri uri, boolean statePlaying, boolean autoReset) {
|
||||
if (!isTarget(uri) || !statePlaying) {
|
||||
if (hasAudioUri()) {
|
||||
onStop(audioSlide.getUri(), autoReset);
|
||||
}
|
||||
@@ -274,6 +300,12 @@ public final class AudioView extends FrameLayout {
|
||||
}
|
||||
}
|
||||
|
||||
private void onSpeedChanged(@NonNull Uri uri, float speed) {
|
||||
if (callbacks != null) {
|
||||
callbacks.onSpeedChanged(speed, isTarget(uri));
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isTarget(@NonNull Uri uri) {
|
||||
return hasAudioUri() && Objects.equals(uri, audioSlide.getUri());
|
||||
}
|
||||
@@ -318,7 +350,7 @@ public final class AudioView extends FrameLayout {
|
||||
duration.setText(getResources().getString(R.string.AudioView_duration, remainingSecs / 60, remainingSecs % 60));
|
||||
}
|
||||
|
||||
if (smallView) {
|
||||
if (smallView && circleProgress != null) {
|
||||
circleProgress.setInstantProgress(seekBar.getProgress() == 0 ? 1 : progress);
|
||||
}
|
||||
}
|
||||
@@ -329,7 +361,10 @@ public final class AudioView extends FrameLayout {
|
||||
new LottieValueCallback<>(new SimpleColorFilter(foregroundTint))));
|
||||
|
||||
this.downloadButton.setColorFilter(foregroundTint, PorterDuff.Mode.SRC_IN);
|
||||
this.circleProgress.setBarColor(foregroundTint);
|
||||
|
||||
if (circleProgress != null) {
|
||||
this.circleProgress.setBarColor(foregroundTint);
|
||||
}
|
||||
|
||||
if (this.duration != null) {
|
||||
this.duration.setTextColor(foregroundTint);
|
||||
@@ -372,11 +407,14 @@ public final class AudioView extends FrameLayout {
|
||||
}
|
||||
|
||||
private void showPlayButton() {
|
||||
if (!smallView) {
|
||||
circleProgress.setVisibility(GONE);
|
||||
} else if (seekBar.getProgress() == 0) {
|
||||
circleProgress.setInstantProgress(1);
|
||||
if (circleProgress != null) {
|
||||
if (!smallView) {
|
||||
circleProgress.setVisibility(GONE);
|
||||
} else if (seekBar.getProgress() == 0) {
|
||||
circleProgress.setInstantProgress(1);
|
||||
}
|
||||
}
|
||||
|
||||
playPauseButton.setVisibility(VISIBLE);
|
||||
controlToggle.displayQuick(progressAndPlay);
|
||||
}
|
||||
@@ -451,6 +489,8 @@ public final class AudioView extends FrameLayout {
|
||||
if (callbacks != null) {
|
||||
if (wasPlaying) {
|
||||
callbacks.onSeekTo(audioSlide.getUri(), getProgress());
|
||||
} else {
|
||||
callbacks.onProgressUpdated(durationMillis, Math.round(durationMillis * getProgress()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -465,7 +505,7 @@ public final class AudioView extends FrameLayout {
|
||||
|
||||
@Subscribe(sticky = true, threadMode = ThreadMode.MAIN)
|
||||
public void onEventAsync(final PartProgressEvent event) {
|
||||
if (audioSlide != null && event.attachment.equals(audioSlide.asAttachment())) {
|
||||
if (audioSlide != null && circleProgress != null && event.attachment.equals(audioSlide.asAttachment())) {
|
||||
circleProgress.setInstantProgress(((float) event.progress) / event.total);
|
||||
}
|
||||
}
|
||||
@@ -475,6 +515,7 @@ public final class AudioView extends FrameLayout {
|
||||
void onPause(@NonNull Uri audioUri);
|
||||
void onSeekTo(@NonNull Uri audioUri, double progress);
|
||||
void onStopAndReset(@NonNull Uri audioUri);
|
||||
void onSpeedChanged(float speed, boolean isPlaying);
|
||||
void onProgressUpdated(long durationMillis, long playheadMillis);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import android.util.AttributeSet;
|
||||
import android.view.TouchDelegate;
|
||||
import android.view.View;
|
||||
import android.widget.EditText;
|
||||
import android.widget.FrameLayout;
|
||||
import android.widget.ImageView;
|
||||
import android.widget.LinearLayout;
|
||||
|
||||
@@ -20,9 +21,8 @@ import androidx.core.widget.TextViewCompat;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.util.ServiceUtil;
|
||||
import org.thoughtcrime.securesms.util.ViewUtil;
|
||||
import org.thoughtcrime.securesms.util.views.DarkOverflowToolbar;
|
||||
|
||||
public final class ContactFilterToolbar extends DarkOverflowToolbar {
|
||||
public final class ContactFilterView extends FrameLayout {
|
||||
private OnFilterChangedListener listener;
|
||||
|
||||
private final EditText searchText;
|
||||
@@ -32,17 +32,17 @@ public final class ContactFilterToolbar extends DarkOverflowToolbar {
|
||||
private final ImageView clearToggle;
|
||||
private final LinearLayout toggleContainer;
|
||||
|
||||
public ContactFilterToolbar(Context context) {
|
||||
public ContactFilterView(Context context) {
|
||||
this(context, null);
|
||||
}
|
||||
|
||||
public ContactFilterToolbar(Context context, AttributeSet attrs) {
|
||||
public ContactFilterView(Context context, AttributeSet attrs) {
|
||||
this(context, attrs, R.attr.toolbarStyle);
|
||||
}
|
||||
|
||||
public ContactFilterToolbar(Context context, AttributeSet attrs, int defStyleAttr) {
|
||||
public ContactFilterView(Context context, AttributeSet attrs, int defStyleAttr) {
|
||||
super(context, attrs, defStyleAttr);
|
||||
inflate(context, R.layout.contact_filter_toolbar, this);
|
||||
inflate(context, R.layout.contact_filter_view, this);
|
||||
|
||||
this.searchText = findViewById(R.id.search_view);
|
||||
this.toggle = findViewById(R.id.button_toggle);
|
||||
@@ -99,8 +99,6 @@ public final class ContactFilterToolbar extends DarkOverflowToolbar {
|
||||
}
|
||||
});
|
||||
|
||||
setLogo(null);
|
||||
setContentInsetStartWithNavigation(0);
|
||||
expandTapArea(toggleContainer, dialpadToggle);
|
||||
applyAttributes(searchText, context, attrs, defStyleAttr);
|
||||
searchText.requestFocus();
|
||||
@@ -4,6 +4,9 @@ import android.content.Context;
|
||||
import android.util.AttributeSet;
|
||||
import android.view.View;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import com.google.android.material.tabs.TabLayout;
|
||||
|
||||
import java.util.List;
|
||||
@@ -15,6 +18,8 @@ public class ControllableTabLayout extends TabLayout {
|
||||
|
||||
private List<View> touchables;
|
||||
|
||||
private NewTabListener newTabListener;
|
||||
|
||||
public ControllableTabLayout(Context context) {
|
||||
super(context);
|
||||
}
|
||||
@@ -39,4 +44,28 @@ public class ControllableTabLayout extends TabLayout {
|
||||
|
||||
super.setEnabled(enabled);
|
||||
}
|
||||
|
||||
public void setNewTabListener(@Nullable NewTabListener newTabListener) {
|
||||
this.newTabListener = newTabListener;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull Tab newTab() {
|
||||
Tab tab = super.newTab();
|
||||
|
||||
if (newTabListener != null) {
|
||||
newTabListener.onNewTab(tab);
|
||||
}
|
||||
|
||||
return tab;
|
||||
}
|
||||
|
||||
/**
|
||||
* Allows implementor to modify tabs when they are created, before they are added to the tab layout.
|
||||
* This is useful for loading custom views, to ensure that time is not spent inflating these views
|
||||
* as the user is switching between pages.
|
||||
*/
|
||||
public interface NewTabListener {
|
||||
void onNewTab(@NonNull Tab tab);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,28 +1,33 @@
|
||||
package org.thoughtcrime.securesms.components;
|
||||
|
||||
import android.Manifest;
|
||||
import android.animation.Animator;
|
||||
import android.annotation.SuppressLint;
|
||||
import android.content.Context;
|
||||
import android.content.res.TypedArray;
|
||||
import android.graphics.PorterDuff;
|
||||
import android.graphics.PorterDuffColorFilter;
|
||||
import android.graphics.Rect;
|
||||
import android.util.AttributeSet;
|
||||
import android.view.View;
|
||||
import android.widget.ImageView;
|
||||
import android.widget.LinearLayout;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.annotation.DrawableRes;
|
||||
import androidx.annotation.LayoutRes;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.constraintlayout.widget.ConstraintLayout;
|
||||
import androidx.constraintlayout.widget.ConstraintSet;
|
||||
|
||||
import com.airbnb.lottie.LottieAnimationView;
|
||||
import com.airbnb.lottie.LottieProperty;
|
||||
import com.airbnb.lottie.model.KeyPath;
|
||||
|
||||
import org.signal.core.util.concurrent.SignalExecutors;
|
||||
import org.thoughtcrime.securesms.ApplicationContext;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.animation.AnimationCompleteListener;
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord;
|
||||
import org.thoughtcrime.securesms.database.model.MmsMessageRecord;
|
||||
@@ -30,7 +35,6 @@ import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
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;
|
||||
@@ -40,17 +44,24 @@ import org.whispersystems.libsignal.util.guava.Optional;
|
||||
import java.util.Locale;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
public class ConversationItemFooter extends LinearLayout {
|
||||
public class ConversationItemFooter extends ConstraintLayout {
|
||||
|
||||
private TextView dateView;
|
||||
private TextView simView;
|
||||
private ExpirationTimerView timerView;
|
||||
private ImageView insecureIndicatorView;
|
||||
private DeliveryStatusView deliveryStatusView;
|
||||
private boolean onlyShowSendingStatus;
|
||||
private View audioSpace;
|
||||
private TextView audioDuration;
|
||||
private LottieAnimationView revealDot;
|
||||
private TextView dateView;
|
||||
private TextView simView;
|
||||
private ExpirationTimerView timerView;
|
||||
private ImageView insecureIndicatorView;
|
||||
private DeliveryStatusView deliveryStatusView;
|
||||
private boolean onlyShowSendingStatus;
|
||||
private TextView audioDuration;
|
||||
private LottieAnimationView revealDot;
|
||||
private PlaybackSpeedToggleTextView playbackSpeedToggleTextView;
|
||||
private boolean isOutgoing;
|
||||
private boolean hasShrunkDate;
|
||||
|
||||
private OnTouchDelegateChangedListener onTouchDelegateChangedListener;
|
||||
|
||||
private final Rect speedToggleHitRect = new Rect();
|
||||
private final int touchTargetSize = ViewUtil.dpToPx(48);
|
||||
|
||||
public ConversationItemFooter(Context context) {
|
||||
super(context);
|
||||
@@ -68,24 +79,55 @@ public class ConversationItemFooter extends LinearLayout {
|
||||
}
|
||||
|
||||
private void init(@Nullable AttributeSet attrs) {
|
||||
inflate(getContext(), R.layout.conversation_item_footer, this);
|
||||
|
||||
dateView = findViewById(R.id.footer_date);
|
||||
simView = findViewById(R.id.footer_sim_info);
|
||||
timerView = findViewById(R.id.footer_expiration_timer);
|
||||
insecureIndicatorView = findViewById(R.id.footer_insecure_indicator);
|
||||
deliveryStatusView = findViewById(R.id.footer_delivery_status);
|
||||
audioDuration = findViewById(R.id.footer_audio_duration);
|
||||
audioSpace = findViewById(R.id.footer_audio_duration_space);
|
||||
revealDot = findViewById(R.id.footer_revealed_dot);
|
||||
|
||||
final TypedArray typedArray;
|
||||
if (attrs != null) {
|
||||
TypedArray typedArray = getContext().getTheme().obtainStyledAttributes(attrs, R.styleable.ConversationItemFooter, 0, 0);
|
||||
typedArray = getContext().getTheme().obtainStyledAttributes(attrs, R.styleable.ConversationItemFooter, 0, 0);
|
||||
} else {
|
||||
typedArray = null;
|
||||
}
|
||||
|
||||
final @LayoutRes int contentId;
|
||||
if (typedArray != null) {
|
||||
int mode = typedArray.getInt(R.styleable.ConversationItemFooter_footer_mode, 0);
|
||||
isOutgoing = mode == 0;
|
||||
|
||||
if (isOutgoing) {
|
||||
contentId = R.layout.conversation_item_footer_outgoing;
|
||||
} else {
|
||||
contentId = R.layout.conversation_item_footer_incoming;
|
||||
}
|
||||
} else {
|
||||
contentId = R.layout.conversation_item_footer_outgoing;
|
||||
isOutgoing = true;
|
||||
}
|
||||
|
||||
inflate(getContext(), contentId, this);
|
||||
|
||||
dateView = findViewById(R.id.footer_date);
|
||||
simView = findViewById(R.id.footer_sim_info);
|
||||
timerView = findViewById(R.id.footer_expiration_timer);
|
||||
insecureIndicatorView = findViewById(R.id.footer_insecure_indicator);
|
||||
deliveryStatusView = findViewById(R.id.footer_delivery_status);
|
||||
audioDuration = findViewById(R.id.footer_audio_duration);
|
||||
revealDot = findViewById(R.id.footer_revealed_dot);
|
||||
playbackSpeedToggleTextView = findViewById(R.id.footer_audio_playback_speed_toggle);
|
||||
|
||||
if (typedArray != null) {
|
||||
setTextColor(typedArray.getInt(R.styleable.ConversationItemFooter_footer_text_color, getResources().getColor(R.color.core_white)));
|
||||
setIconColor(typedArray.getInt(R.styleable.ConversationItemFooter_footer_icon_color, getResources().getColor(R.color.core_white)));
|
||||
setRevealDotColor(typedArray.getInt(R.styleable.ConversationItemFooter_footer_reveal_dot_color, getResources().getColor(R.color.core_white)));
|
||||
typedArray.recycle();
|
||||
}
|
||||
|
||||
dateView.addOnLayoutChangeListener((v, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom) -> {
|
||||
if (oldLeft != left || oldRight != right) {
|
||||
notifyTouchDelegateChanged(getPlaybackSpeedToggleTouchDelegateRect(), playbackSpeedToggleTextView);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public void setOnTouchDelegateChangedListener(@Nullable OnTouchDelegateChangedListener onTouchDelegateChangedListener) {
|
||||
this.onTouchDelegateChangedListener = onTouchDelegateChangedListener;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -108,6 +150,20 @@ public class ConversationItemFooter extends LinearLayout {
|
||||
audioDuration.setText(getResources().getString(R.string.AudioView_duration, remainingSecs / 60, remainingSecs % 60));
|
||||
}
|
||||
|
||||
public void setPlaybackSpeedListener(@Nullable PlaybackSpeedToggleTextView.PlaybackSpeedListener playbackSpeedListener) {
|
||||
playbackSpeedToggleTextView.setPlaybackSpeedListener(playbackSpeedListener);
|
||||
}
|
||||
|
||||
public void setAudioPlaybackSpeed(float playbackSpeed, boolean isPlaying) {
|
||||
if (isPlaying) {
|
||||
showPlaybackSpeedToggle();
|
||||
} else {
|
||||
hidePlaybackSpeedToggle();
|
||||
}
|
||||
|
||||
playbackSpeedToggleTextView.setCurrentSpeed(playbackSpeed);
|
||||
}
|
||||
|
||||
public void setTextColor(int color) {
|
||||
dateView.setTextColor(color);
|
||||
simView.setTextColor(color);
|
||||
@@ -155,6 +211,84 @@ public class ConversationItemFooter extends LinearLayout {
|
||||
}
|
||||
}
|
||||
|
||||
private void notifyTouchDelegateChanged(@NonNull Rect rect, @NonNull View touchDelegate) {
|
||||
if (onTouchDelegateChangedListener != null) {
|
||||
onTouchDelegateChangedListener.onTouchDelegateChanged(rect, touchDelegate);
|
||||
}
|
||||
}
|
||||
|
||||
private void showPlaybackSpeedToggle() {
|
||||
if (hasShrunkDate) {
|
||||
return;
|
||||
}
|
||||
|
||||
hasShrunkDate = true;
|
||||
|
||||
playbackSpeedToggleTextView.animate()
|
||||
.alpha(1f)
|
||||
.scaleX(1f)
|
||||
.scaleY(1f)
|
||||
.setDuration(150L)
|
||||
.setListener(new AnimationCompleteListener() {
|
||||
@Override
|
||||
public void onAnimationEnd(Animator animation) {
|
||||
playbackSpeedToggleTextView.setClickable(true);
|
||||
}
|
||||
});
|
||||
|
||||
if (isOutgoing) {
|
||||
dateView.setMaxWidth(ViewUtil.dpToPx(28));
|
||||
} else {
|
||||
ConstraintSet constraintSet = new ConstraintSet();
|
||||
constraintSet.clone(this);
|
||||
constraintSet.constrainMaxWidth(R.id.date_and_expiry_wrapper, ViewUtil.dpToPx(40));
|
||||
constraintSet.applyTo(this);
|
||||
}
|
||||
}
|
||||
|
||||
private void hidePlaybackSpeedToggle() {
|
||||
if (!hasShrunkDate) {
|
||||
return;
|
||||
}
|
||||
|
||||
hasShrunkDate = false;
|
||||
|
||||
playbackSpeedToggleTextView.animate()
|
||||
.alpha(0f)
|
||||
.scaleX(0.5f)
|
||||
.scaleY(0.5f)
|
||||
.setDuration(150L).setListener(new AnimationCompleteListener() {
|
||||
@Override
|
||||
public void onAnimationEnd(Animator animation) {
|
||||
playbackSpeedToggleTextView.setClickable(false);
|
||||
playbackSpeedToggleTextView.clearRequestedSpeed();
|
||||
}
|
||||
});
|
||||
|
||||
if (isOutgoing) {
|
||||
dateView.setMaxWidth(Integer.MAX_VALUE);
|
||||
} else {
|
||||
ConstraintSet constraintSet = new ConstraintSet();
|
||||
constraintSet.clone(this);
|
||||
constraintSet.constrainMaxWidth(R.id.date_and_expiry_wrapper, -1);
|
||||
constraintSet.applyTo(this);
|
||||
}
|
||||
}
|
||||
|
||||
private @NonNull Rect getPlaybackSpeedToggleTouchDelegateRect() {
|
||||
playbackSpeedToggleTextView.getHitRect(speedToggleHitRect);
|
||||
|
||||
int widthOffset = (touchTargetSize - speedToggleHitRect.width()) / 2;
|
||||
int heightOffset = (touchTargetSize - speedToggleHitRect.height()) / 2;
|
||||
|
||||
speedToggleHitRect.top -= heightOffset;
|
||||
speedToggleHitRect.left -= widthOffset;
|
||||
speedToggleHitRect.right += widthOffset;
|
||||
speedToggleHitRect.bottom += heightOffset;
|
||||
|
||||
return speedToggleHitRect;
|
||||
}
|
||||
|
||||
private void presentDate(@NonNull MessageRecord messageRecord, @NonNull Locale locale) {
|
||||
dateView.forceLayout();
|
||||
if (messageRecord.isFailed()) {
|
||||
@@ -189,7 +323,7 @@ public class ConversationItemFooter extends LinearLayout {
|
||||
simView.setText(getContext().getString(R.string.ConversationItem_from_s, subscriptionInfo.get().getDisplayName()));
|
||||
simView.setVisibility(View.VISIBLE);
|
||||
} else if (subscriptionInfo.isPresent()) {
|
||||
simView.setText(getContext().getString(R.string.ConversationItem_to_s, subscriptionInfo.get().getDisplayName()));
|
||||
simView.setText(getContext().getString(R.string.ConversationItem_to_s, subscriptionInfo.get().getDisplayName()));
|
||||
simView.setVisibility(View.VISIBLE);
|
||||
} else {
|
||||
simView.setVisibility(View.GONE);
|
||||
@@ -218,7 +352,7 @@ public class ConversationItemFooter extends LinearLayout {
|
||||
boolean mms = messageRecord.isMms();
|
||||
|
||||
if (mms) DatabaseFactory.getMmsDatabase(getContext()).markExpireStarted(id);
|
||||
else DatabaseFactory.getSmsDatabase(getContext()).markExpireStarted(id);
|
||||
else DatabaseFactory.getSmsDatabase(getContext()).markExpireStarted(id);
|
||||
|
||||
expirationManager.scheduleDeletion(id, mms, messageRecord.getExpiresIn());
|
||||
});
|
||||
@@ -245,7 +379,7 @@ public class ConversationItemFooter extends LinearLayout {
|
||||
deliveryStatusView.setNone();
|
||||
}
|
||||
} else {
|
||||
if (!messageRecord.isOutgoing()) {
|
||||
if (!messageRecord.isOutgoing()) {
|
||||
deliveryStatusView.setNone();
|
||||
} else if (messageRecord.isPending()) {
|
||||
deliveryStatusView.setPending();
|
||||
@@ -264,11 +398,6 @@ public class ConversationItemFooter extends LinearLayout {
|
||||
MmsMessageRecord mmsMessageRecord = (MmsMessageRecord) messageRecord;
|
||||
|
||||
if (mmsMessageRecord.getSlideDeck().getAudioSlide() != null) {
|
||||
if (messageRecord.isOutgoing()) {
|
||||
moveAudioViewsForOutgoing();
|
||||
} else {
|
||||
moveAudioViewsForIncoming();
|
||||
}
|
||||
showAudioDurationViews();
|
||||
|
||||
if (messageRecord.getViewedReceiptCount() > 0) {
|
||||
@@ -284,41 +413,19 @@ public class ConversationItemFooter extends LinearLayout {
|
||||
}
|
||||
}
|
||||
|
||||
private void moveAudioViewsForOutgoing() {
|
||||
removeView(audioSpace);
|
||||
removeView(audioDuration);
|
||||
removeView(revealDot);
|
||||
addView(audioSpace, 0);
|
||||
addView(revealDot, 0);
|
||||
addView(audioDuration, 0);
|
||||
|
||||
int padStart = ViewUtil.dpToPx(60);
|
||||
int padLeft = ViewUtil.isLtr(this) ? padStart : 0;
|
||||
int padRight = ViewUtil.isRtl(this) ? padStart : 0;
|
||||
|
||||
audioDuration.setPadding(padLeft, 0, padRight, 0);
|
||||
}
|
||||
|
||||
private void moveAudioViewsForIncoming() {
|
||||
removeView(audioSpace);
|
||||
removeView(audioDuration);
|
||||
removeView(revealDot);
|
||||
addView(audioSpace);
|
||||
addView(revealDot);
|
||||
addView(audioDuration);
|
||||
|
||||
audioDuration.setPadding(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
private void showAudioDurationViews() {
|
||||
audioSpace.setVisibility(View.VISIBLE);
|
||||
audioDuration.setVisibility(View.GONE);
|
||||
audioDuration.setVisibility(View.VISIBLE);
|
||||
revealDot.setVisibility(View.VISIBLE);
|
||||
playbackSpeedToggleTextView.setVisibility(View.VISIBLE);
|
||||
}
|
||||
|
||||
private void hideAudioDurationViews() {
|
||||
audioSpace.setVisibility(View.GONE);
|
||||
audioDuration.setVisibility(View.GONE);
|
||||
revealDot.setVisibility(View.GONE);
|
||||
playbackSpeedToggleTextView.setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
public interface OnTouchDelegateChangedListener {
|
||||
void onTouchDelegateChanged(@NonNull Rect delegateRect, @NonNull View delegateView);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
package org.thoughtcrime.securesms.components;
|
||||
|
||||
import android.content.Context;
|
||||
import android.util.AttributeSet;
|
||||
import android.widget.EditText;
|
||||
|
||||
import androidx.annotation.AttrRes;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.core.content.ContextCompat;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
|
||||
/**
|
||||
* Custom styled search view that we can insert into ActionBar menus
|
||||
*/
|
||||
public class DarkSearchView extends androidx.appcompat.widget.SearchView {
|
||||
public DarkSearchView(@NonNull Context context) {
|
||||
this(context, null);
|
||||
}
|
||||
|
||||
public DarkSearchView(@NonNull Context context, @Nullable AttributeSet attrs) {
|
||||
this(context, attrs, R.attr.search_view_style_dark);
|
||||
}
|
||||
|
||||
public DarkSearchView(@NonNull Context context, @Nullable AttributeSet attrs, @AttrRes int defStyleAttr) {
|
||||
super(context, attrs, defStyleAttr);
|
||||
|
||||
EditText searchText = findViewById(androidx.appcompat.R.id.search_src_text);
|
||||
searchText.setTextColor(ContextCompat.getColor(context, R.color.signal_text_toolbar_subtitle));
|
||||
}
|
||||
}
|
||||
@@ -72,8 +72,8 @@ public class FromTextView extends EmojiTextView {
|
||||
|
||||
setText(builder);
|
||||
|
||||
if (recipient.isBlocked()) setCompoundDrawablesWithIntrinsicBounds(R.drawable.ic_block_grey600_18dp, 0, 0, 0);
|
||||
else if (recipient.isMuted()) setCompoundDrawablesWithIntrinsicBounds(getMuted(), null, null, null);
|
||||
if (recipient.isBlocked()) setCompoundDrawablesRelativeWithIntrinsicBounds(R.drawable.ic_block_grey600_18dp, 0, 0, 0);
|
||||
else if (recipient.isMuted()) setCompoundDrawablesRelativeWithIntrinsicBounds(getMuted(), null, null, null);
|
||||
else setCompoundDrawablesWithIntrinsicBounds(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ import androidx.annotation.MainThread;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.core.content.ContextCompat;
|
||||
import androidx.lifecycle.Observer;
|
||||
import androidx.recyclerview.widget.LinearLayoutManager;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
@@ -31,11 +32,13 @@ import org.signal.core.util.ThreadUtil;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.animation.AnimationCompleteListener;
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiKeyboardProvider;
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiEventListener;
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiToggle;
|
||||
import org.thoughtcrime.securesms.components.emoji.MediaKeyboard;
|
||||
import org.thoughtcrime.securesms.components.voice.VoiceNotePlaybackState;
|
||||
import org.thoughtcrime.securesms.conversation.ConversationStickerSuggestionAdapter;
|
||||
import org.thoughtcrime.securesms.conversation.colors.Colorizer;
|
||||
import org.thoughtcrime.securesms.conversation.VoiceNoteDraftView;
|
||||
import org.thoughtcrime.securesms.database.DraftDatabase;
|
||||
import org.thoughtcrime.securesms.database.model.StickerRecord;
|
||||
import org.thoughtcrime.securesms.keyboard.KeyboardPage;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
@@ -52,6 +55,7 @@ import org.thoughtcrime.securesms.util.concurrent.ListenableFuture;
|
||||
import org.thoughtcrime.securesms.util.concurrent.SettableFuture;
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
@@ -59,7 +63,7 @@ import java.util.concurrent.TimeUnit;
|
||||
public class InputPanel extends LinearLayout
|
||||
implements MicrophoneRecorderView.Listener,
|
||||
KeyboardAwareLinearLayout.OnKeyboardShownListener,
|
||||
EmojiKeyboardProvider.EmojiEventListener,
|
||||
EmojiEventListener,
|
||||
ConversationStickerSuggestionAdapter.EventListener
|
||||
{
|
||||
|
||||
@@ -84,6 +88,7 @@ public class InputPanel extends LinearLayout
|
||||
private SlideToCancel slideToCancel;
|
||||
private RecordTime recordTime;
|
||||
private ValueAnimator quoteAnimator;
|
||||
private VoiceNoteDraftView voiceNoteDraftView;
|
||||
|
||||
private @Nullable Listener listener;
|
||||
private boolean emojiVisible;
|
||||
@@ -119,6 +124,7 @@ public class InputPanel extends LinearLayout
|
||||
this.buttonToggle = findViewById(R.id.button_toggle);
|
||||
this.recordingContainer = findViewById(R.id.recording_container);
|
||||
this.recordLockCancel = findViewById(R.id.record_cancel);
|
||||
this.voiceNoteDraftView = findViewById(R.id.voice_note_draft_view);
|
||||
this.slideToCancel = new SlideToCancel(findViewById(R.id.slide_to_cancel));
|
||||
this.microphoneRecorderView = findViewById(R.id.recorder_view);
|
||||
this.microphoneRecorderView.setListener(this);
|
||||
@@ -155,6 +161,7 @@ public class InputPanel extends LinearLayout
|
||||
this.listener = listener;
|
||||
|
||||
mediaKeyboard.setOnClickListener(v -> listener.onEmojiToggle());
|
||||
voiceNoteDraftView.setListener(listener);
|
||||
}
|
||||
|
||||
public void setMediaListener(@NonNull MediaListener listener) {
|
||||
@@ -230,6 +237,10 @@ public class InputPanel extends LinearLayout
|
||||
return animator;
|
||||
}
|
||||
|
||||
public boolean hasSaveableContent() {
|
||||
return getQuote().isPresent() || voiceNoteDraftView.getDraft() != null;
|
||||
}
|
||||
|
||||
public Optional<QuoteModel> getQuote() {
|
||||
if (quoteView.getQuoteId() > 0 && quoteView.getVisibility() == View.VISIBLE) {
|
||||
return Optional.of(new QuoteModel(quoteView.getQuoteId(), quoteView.getAuthor().getId(), quoteView.getBody().toString(), false, quoteView.getAttachments(), quoteView.getMentions()));
|
||||
@@ -317,7 +328,10 @@ public class InputPanel extends LinearLayout
|
||||
recordTime.display();
|
||||
slideToCancel.display();
|
||||
|
||||
if (emojiVisible) ViewUtil.fadeOut(mediaKeyboard, FADE_TIME, View.INVISIBLE);
|
||||
if (emojiVisible) {
|
||||
ViewUtil.fadeOut(mediaKeyboard, FADE_TIME, View.INVISIBLE);
|
||||
}
|
||||
|
||||
ViewUtil.fadeOut(composeText, FADE_TIME, View.INVISIBLE);
|
||||
ViewUtil.fadeOut(quickCameraToggle, FADE_TIME, View.INVISIBLE);
|
||||
ViewUtil.fadeOut(quickAudioToggle, FADE_TIME, View.INVISIBLE);
|
||||
@@ -370,6 +384,10 @@ public class InputPanel extends LinearLayout
|
||||
this.microphoneRecorderView.cancelAction();
|
||||
}
|
||||
|
||||
public @NonNull Observer<VoiceNotePlaybackState> getPlaybackStateObserver() {
|
||||
return voiceNoteDraftView.getPlaybackStateObserver();
|
||||
}
|
||||
|
||||
public void setEnabled(boolean enabled) {
|
||||
composeText.setEnabled(enabled);
|
||||
mediaKeyboard.setEnabled(enabled);
|
||||
@@ -386,11 +404,7 @@ public class InputPanel extends LinearLayout
|
||||
future.addListener(new AssertedSuccessListener<Void>() {
|
||||
@Override
|
||||
public void onSuccess(Void result) {
|
||||
if (emojiVisible) ViewUtil.fadeIn(mediaKeyboard, FADE_TIME);
|
||||
ViewUtil.fadeIn(composeText, FADE_TIME);
|
||||
ViewUtil.fadeIn(quickCameraToggle, FADE_TIME);
|
||||
ViewUtil.fadeIn(quickAudioToggle, FADE_TIME);
|
||||
buttonToggle.animate().alpha(1).setDuration(FADE_TIME).start();
|
||||
fadeInNormalComposeViews();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -431,15 +445,57 @@ public class InputPanel extends LinearLayout
|
||||
microphoneRecorderView.unlockAction();
|
||||
}
|
||||
|
||||
public void showGifMovedTooltip() {
|
||||
TooltipPopup.forTarget(mediaKeyboard)
|
||||
.setBackgroundTint(ContextCompat.getColor(getContext(), R.color.signal_accent_primary))
|
||||
.setTextColor(getResources().getColor(R.color.core_white))
|
||||
.setText(R.string.ConversationActivity__gifs_are_now_here)
|
||||
.show(TooltipPopup.POSITION_ABOVE);
|
||||
public void setVoiceNoteDraft(@Nullable DraftDatabase.Draft voiceNoteDraft) {
|
||||
if (voiceNoteDraft != null) {
|
||||
voiceNoteDraftView.setDraft(voiceNoteDraft);
|
||||
voiceNoteDraftView.setVisibility(VISIBLE);
|
||||
hideNormalComposeViews();
|
||||
} else {
|
||||
voiceNoteDraftView.clearDraft();
|
||||
ViewUtil.fadeOut(voiceNoteDraftView, FADE_TIME);
|
||||
fadeInNormalComposeViews();
|
||||
}
|
||||
}
|
||||
|
||||
public interface Listener {
|
||||
public @Nullable DraftDatabase.Draft getVoiceNoteDraft() {
|
||||
return voiceNoteDraftView.getDraft();
|
||||
}
|
||||
|
||||
private void hideNormalComposeViews() {
|
||||
if (emojiVisible) {
|
||||
Animation animation = mediaKeyboard.getAnimation();
|
||||
if (animation != null) {
|
||||
animation.cancel();
|
||||
}
|
||||
|
||||
mediaKeyboard.setVisibility(View.INVISIBLE);
|
||||
}
|
||||
|
||||
for (Animation animation : Arrays.asList(composeText.getAnimation(), quickCameraToggle.getAnimation(), quickAudioToggle.getAnimation())) {
|
||||
if (animation != null) {
|
||||
animation.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
buttonToggle.animate().cancel();
|
||||
|
||||
composeText.setVisibility(View.INVISIBLE);
|
||||
quickCameraToggle.setVisibility(View.INVISIBLE);
|
||||
quickAudioToggle.setVisibility(View.INVISIBLE);
|
||||
}
|
||||
|
||||
private void fadeInNormalComposeViews() {
|
||||
if (emojiVisible) {
|
||||
ViewUtil.fadeIn(mediaKeyboard, FADE_TIME);
|
||||
}
|
||||
|
||||
ViewUtil.fadeIn(composeText, FADE_TIME);
|
||||
ViewUtil.fadeIn(quickCameraToggle, FADE_TIME);
|
||||
ViewUtil.fadeIn(quickAudioToggle, FADE_TIME);
|
||||
buttonToggle.animate().alpha(1).setDuration(FADE_TIME).start();
|
||||
}
|
||||
|
||||
public interface Listener extends VoiceNoteDraftView.Listener {
|
||||
void onRecorderStarted();
|
||||
void onRecorderLocked();
|
||||
void onRecorderFinished();
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
package org.thoughtcrime.securesms.components
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.view.MotionEvent
|
||||
import android.view.animation.DecelerateInterpolator
|
||||
import androidx.appcompat.widget.AppCompatTextView
|
||||
import org.thoughtcrime.securesms.R
|
||||
|
||||
class PlaybackSpeedToggleTextView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyleAttr: Int = 0
|
||||
) : AppCompatTextView(context, attrs, defStyleAttr) {
|
||||
|
||||
private val speeds: IntArray = context.resources.getIntArray(R.array.PlaybackSpeedToggleTextView__speeds)
|
||||
private val labels: Array<String> = context.resources.getStringArray(R.array.PlaybackSpeedToggleTextView__speed_labels)
|
||||
private var currentSpeedIndex = 0
|
||||
private var requestedSpeed: Float? = null
|
||||
|
||||
var playbackSpeedListener: PlaybackSpeedListener? = null
|
||||
|
||||
init {
|
||||
text = getCurrentLabel()
|
||||
super.setOnClickListener {
|
||||
currentSpeedIndex = getNextSpeedIndex()
|
||||
text = getCurrentLabel()
|
||||
requestedSpeed = getCurrentSpeed()
|
||||
|
||||
playbackSpeedListener?.onPlaybackSpeedChanged(getCurrentSpeed())
|
||||
}
|
||||
|
||||
isClickable = false
|
||||
}
|
||||
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
override fun onTouchEvent(event: MotionEvent?): Boolean {
|
||||
if (isClickable) {
|
||||
when (event?.action) {
|
||||
MotionEvent.ACTION_DOWN -> zoomIn()
|
||||
MotionEvent.ACTION_UP -> zoomOut()
|
||||
MotionEvent.ACTION_CANCEL -> zoomOut()
|
||||
}
|
||||
}
|
||||
|
||||
return super.onTouchEvent(event)
|
||||
}
|
||||
|
||||
fun clearRequestedSpeed() {
|
||||
requestedSpeed = null
|
||||
}
|
||||
|
||||
fun setCurrentSpeed(speed: Float) {
|
||||
if (speed == getCurrentSpeed() || (requestedSpeed != null && requestedSpeed != speed)) {
|
||||
if (requestedSpeed == speed) {
|
||||
requestedSpeed = null
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
requestedSpeed = null
|
||||
|
||||
val outOf100 = (speed * 100).toInt()
|
||||
val index = speeds.indexOf(outOf100)
|
||||
|
||||
if (index != -1) {
|
||||
currentSpeedIndex = index
|
||||
text = getCurrentLabel()
|
||||
} else {
|
||||
throw IllegalArgumentException("Invalid Speed $speed")
|
||||
}
|
||||
}
|
||||
|
||||
private fun getNextSpeedIndex(): Int = (currentSpeedIndex + 1) % speeds.size
|
||||
|
||||
private fun getCurrentSpeed(): Float = speeds[currentSpeedIndex] / 100f
|
||||
|
||||
private fun getCurrentLabel(): String = labels[currentSpeedIndex]
|
||||
|
||||
private fun zoomIn() {
|
||||
animate()
|
||||
.setInterpolator(DecelerateInterpolator())
|
||||
.setDuration(150L)
|
||||
.scaleX(1.2f)
|
||||
.scaleY(1.2f)
|
||||
}
|
||||
|
||||
private fun zoomOut() {
|
||||
animate()
|
||||
.setInterpolator(DecelerateInterpolator())
|
||||
.setDuration(150L)
|
||||
.scaleX(1f)
|
||||
.scaleY(1f)
|
||||
}
|
||||
|
||||
override fun setOnClickListener(l: OnClickListener?) {
|
||||
throw UnsupportedOperationException()
|
||||
}
|
||||
|
||||
interface PlaybackSpeedListener {
|
||||
fun onPlaybackSpeedChanged(speed: Float)
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,17 @@
|
||||
package org.thoughtcrime.securesms.components;
|
||||
|
||||
import android.content.Context;
|
||||
import android.text.InputFilter;
|
||||
import android.util.AttributeSet;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.annotation.AttrRes;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiFilter;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
|
||||
/**
|
||||
* Custom styled search view that we can insert into ActionBar menus
|
||||
@@ -23,5 +27,31 @@ public class SearchView extends androidx.appcompat.widget.SearchView {
|
||||
|
||||
public SearchView(@NonNull Context context, @Nullable AttributeSet attrs, @AttrRes int defStyleAttr) {
|
||||
super(context, attrs, defStyleAttr);
|
||||
initEmojiFilter();
|
||||
}
|
||||
|
||||
private void initEmojiFilter() {
|
||||
if (!isInEditMode() && !SignalStore.settings().isPreferSystemEmoji()) {
|
||||
TextView searchText = findViewById(androidx.appcompat.R.id.search_src_text);
|
||||
if (searchText != null) {
|
||||
searchText.setFilters(appendEmojiFilter(searchText));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private InputFilter[] appendEmojiFilter(@NonNull TextView view) {
|
||||
InputFilter[] originalFilters = view.getFilters();
|
||||
InputFilter[] result;
|
||||
|
||||
if (originalFilters != null) {
|
||||
result = new InputFilter[originalFilters.length + 1];
|
||||
System.arraycopy(originalFilters, 0, result, 1, originalFilters.length);
|
||||
} else {
|
||||
result = new InputFilter[1];
|
||||
}
|
||||
|
||||
result[0] = new EmojiFilter(view);
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
package org.thoughtcrime.securesms.components.emoji;
|
||||
|
||||
import android.view.KeyEvent;
|
||||
|
||||
public interface EmojiEventListener {
|
||||
void onEmojiSelected(String emoji);
|
||||
|
||||
void onKeyEvent(KeyEvent keyEvent);
|
||||
}
|
||||
@@ -10,9 +10,10 @@ import org.thoughtcrime.securesms.components.emoji.EmojiPageViewGridAdapter.Emoj
|
||||
import org.thoughtcrime.securesms.util.InsetItemDecoration
|
||||
import org.thoughtcrime.securesms.util.ViewUtil
|
||||
|
||||
private val EDGE_LENGTH: Int = ViewUtil.dpToPx(7)
|
||||
private val HORIZONTAL_INSET: Int = ViewUtil.dpToPx(11)
|
||||
private val VERTICAL_INSET: Int = ViewUtil.dpToPx(8)
|
||||
private val EDGE_LENGTH: Int = ViewUtil.dpToPx(6)
|
||||
private val HORIZONTAL_INSET: Int = ViewUtil.dpToPx(6)
|
||||
private val EMOJI_VERTICAL_INSET: Int = ViewUtil.dpToPx(5)
|
||||
private val HEADER_VERTICAL_INSET: Int = ViewUtil.dpToPx(8)
|
||||
|
||||
/**
|
||||
* Use super class to add insets to the emojis and use the [onDrawOver] to draw the variation
|
||||
@@ -41,11 +42,12 @@ class EmojiItemDecoration(private val allowVariations: Boolean, private val vari
|
||||
|
||||
private class SetInset : InsetItemDecoration.SetInset() {
|
||||
override fun setInset(outRect: Rect, view: View, parent: RecyclerView) {
|
||||
val isFirstHeader = view.javaClass == AppCompatTextView::class.java && getPosition(view, parent) == 0
|
||||
val isHeader = view.javaClass == AppCompatTextView::class.java
|
||||
|
||||
outRect.left = HORIZONTAL_INSET
|
||||
outRect.right = HORIZONTAL_INSET
|
||||
outRect.top = if (isFirstHeader) 0 else VERTICAL_INSET
|
||||
outRect.bottom = VERTICAL_INSET
|
||||
outRect.top = if (isHeader) HEADER_VERTICAL_INSET else EMOJI_VERTICAL_INSET
|
||||
outRect.bottom = if (isHeader) 0 else EMOJI_VERTICAL_INSET
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,180 +0,0 @@
|
||||
package org.thoughtcrime.securesms.components.emoji;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.drawable.Drawable;
|
||||
import android.view.KeyEvent;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.ImageView;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.viewpager.widget.PagerAdapter;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiPageViewGridAdapter.VariationSelectorListener;
|
||||
import org.thoughtcrime.securesms.emoji.EmojiSource;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.mms.GlideRequests;
|
||||
import org.thoughtcrime.securesms.util.ResUtil;
|
||||
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* A provider to select emoji in the {@link org.thoughtcrime.securesms.components.emoji.MediaKeyboard}.
|
||||
*
|
||||
* TODO [alex] -- Are we still using any of this?
|
||||
*/
|
||||
public class EmojiKeyboardProvider implements MediaKeyboardProvider,
|
||||
MediaKeyboardProvider.TabIconProvider,
|
||||
MediaKeyboardProvider.BackspaceObserver,
|
||||
VariationSelectorListener
|
||||
{
|
||||
private static final KeyEvent DELETE_KEY_EVENT = new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DEL);
|
||||
|
||||
// TODO [alex] -- We are using this.
|
||||
public static final String RECENT_STORAGE_KEY = "pref_recent_emoji2";
|
||||
|
||||
private final Context context;
|
||||
private final List<EmojiPageModel> models;
|
||||
private final RecentEmojiPageModel recentModel;
|
||||
private final EmojiPagerAdapter emojiPagerAdapter;
|
||||
private final EmojiEventListener emojiEventListener;
|
||||
|
||||
private Controller controller;
|
||||
private int currentPosition;
|
||||
|
||||
public EmojiKeyboardProvider(@NonNull Context context, @Nullable EmojiEventListener emojiEventListener) {
|
||||
this.context = context;
|
||||
this.emojiEventListener = emojiEventListener;
|
||||
this.models = new LinkedList<>();
|
||||
this.recentModel = new RecentEmojiPageModel(context, RECENT_STORAGE_KEY);
|
||||
this.emojiPagerAdapter = new EmojiPagerAdapter(context, models, new EmojiEventListener() {
|
||||
@Override
|
||||
public void onEmojiSelected(String emoji) {
|
||||
recentModel.onCodePointSelected(emoji);
|
||||
SignalStore.emojiValues().setPreferredVariation(emoji);
|
||||
|
||||
if (emojiEventListener != null) {
|
||||
emojiEventListener.onEmojiSelected(emoji);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onKeyEvent(KeyEvent keyEvent) {
|
||||
if (emojiEventListener != null) {
|
||||
emojiEventListener.onKeyEvent(keyEvent);
|
||||
}
|
||||
}
|
||||
}, this);
|
||||
|
||||
models.add(recentModel);
|
||||
models.addAll(EmojiSource.getLatest().getDisplayPages());
|
||||
|
||||
currentPosition = recentModel.getEmoji().size() > 0 ? 0 : 1;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void requestPresentation(@NonNull Presenter presenter, boolean isSoloProvider) {
|
||||
presenter.present(this, emojiPagerAdapter, this, this, null, null, currentPosition);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setCurrentPosition(int currentPosition) {
|
||||
this.currentPosition = currentPosition;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setController(@Nullable Controller controller) {
|
||||
this.controller = controller;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getProviderIconView(boolean selected) {
|
||||
if (selected) {
|
||||
return R.layout.emoji_keyboard_icon_selected;
|
||||
} else {
|
||||
return R.layout.emoji_keyboard_icon;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void loadCategoryTabIcon(@NonNull GlideRequests glideRequests, @NonNull ImageView imageView, int index) {
|
||||
Drawable drawable = ResUtil.getDrawable(context, models.get(index).getIconAttr());
|
||||
imageView.setImageDrawable(drawable);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBackspaceClicked() {
|
||||
if (emojiEventListener != null) {
|
||||
emojiEventListener.onKeyEvent(DELETE_KEY_EVENT);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onVariationSelectorStateChanged(boolean open) {
|
||||
if (controller != null) {
|
||||
controller.setViewPagerEnabled(!open);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(@Nullable Object obj) {
|
||||
return obj instanceof EmojiKeyboardProvider;
|
||||
}
|
||||
|
||||
private static class EmojiPagerAdapter extends PagerAdapter {
|
||||
private Context context;
|
||||
private List<EmojiPageModel> pages;
|
||||
private EmojiEventListener emojiSelectionListener;
|
||||
private VariationSelectorListener variationSelectorListener;
|
||||
|
||||
public EmojiPagerAdapter(@NonNull Context context,
|
||||
@NonNull List<EmojiPageModel> pages,
|
||||
@NonNull EmojiEventListener emojiSelectionListener,
|
||||
@NonNull VariationSelectorListener variationSelectorListener)
|
||||
{
|
||||
super();
|
||||
this.context = context;
|
||||
this.pages = pages;
|
||||
this.emojiSelectionListener = emojiSelectionListener;
|
||||
this.variationSelectorListener = variationSelectorListener;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getCount() {
|
||||
return pages.size();
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull Object instantiateItem(@NonNull ViewGroup container, int position) {
|
||||
EmojiPageView page = new EmojiPageView(context, emojiSelectionListener, variationSelectorListener, true);
|
||||
page.setModel(pages.get(position));
|
||||
container.addView(page);
|
||||
return page;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void destroyItem(ViewGroup container, int position, Object object) {
|
||||
container.removeView((View)object);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setPrimaryItem(ViewGroup container, int position, Object object) {
|
||||
EmojiPageView current = (EmojiPageView) object;
|
||||
current.onSelected();
|
||||
super.setPrimaryItem(container, position, object);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isViewFromObject(View view, Object object) {
|
||||
return view == object;
|
||||
}
|
||||
}
|
||||
|
||||
public interface EmojiEventListener {
|
||||
void onEmojiSelected(String emoji);
|
||||
void onKeyEvent(KeyEvent keyEvent);
|
||||
}
|
||||
}
|
||||
@@ -16,22 +16,19 @@ import androidx.recyclerview.widget.LinearSmoothScroller;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiKeyboardProvider.EmojiEventListener;
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiPageViewGridAdapter.EmojiHeader;
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiPageViewGridAdapter.EmojiNoResultsModel;
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiPageViewGridAdapter.VariationSelectorListener;
|
||||
import org.thoughtcrime.securesms.emoji.EmojiCategory;
|
||||
import org.thoughtcrime.securesms.util.ContextUtil;
|
||||
import org.thoughtcrime.securesms.util.DrawableUtil;
|
||||
import org.thoughtcrime.securesms.util.MappingModel;
|
||||
import org.thoughtcrime.securesms.util.MappingModelList;
|
||||
import org.thoughtcrime.securesms.util.ViewUtil;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public class EmojiPageView extends RecyclerView implements VariationSelectorListener {
|
||||
|
||||
private EmojiPageModel model;
|
||||
private AdapterFactory adapterFactory;
|
||||
private LinearLayoutManager layoutManager;
|
||||
private RecyclerView.OnItemTouchListener scrollDisabler;
|
||||
@@ -60,26 +57,26 @@ public class EmojiPageView extends RecyclerView implements VariationSelectorList
|
||||
@NonNull VariationSelectorListener variationSelectorListener,
|
||||
boolean allowVariations,
|
||||
@NonNull LinearLayoutManager layoutManager,
|
||||
@LayoutRes int displayItemLayoutResId)
|
||||
@LayoutRes int displayEmojiLayoutResId,
|
||||
@LayoutRes int displayEmoticonLayoutResId)
|
||||
{
|
||||
super(context);
|
||||
initialize(emojiSelectionListener, variationSelectorListener, allowVariations, layoutManager, displayItemLayoutResId);
|
||||
initialize(emojiSelectionListener, variationSelectorListener, allowVariations, layoutManager, displayEmojiLayoutResId, displayEmoticonLayoutResId);
|
||||
}
|
||||
|
||||
public void initialize(@NonNull EmojiEventListener emojiSelectionListener,
|
||||
@NonNull VariationSelectorListener variationSelectorListener,
|
||||
boolean allowVariations)
|
||||
{
|
||||
initialize(emojiSelectionListener, variationSelectorListener, allowVariations, new GridLayoutManager(getContext(), 8), R.layout.emoji_display_item);
|
||||
Drawable drawable = DrawableUtil.tint(ContextUtil.requireDrawable(getContext(), R.drawable.triangle_bottom_right_corner), ContextCompat.getColor(getContext(), R.color.signal_button_secondary_text_disabled));
|
||||
addItemDecoration(new EmojiItemDecoration(allowVariations, drawable));
|
||||
initialize(emojiSelectionListener, variationSelectorListener, allowVariations, new GridLayoutManager(getContext(), 8), R.layout.emoji_display_item_grid, R.layout.emoji_text_display_item_grid);
|
||||
}
|
||||
|
||||
public void initialize(@NonNull EmojiEventListener emojiSelectionListener,
|
||||
@NonNull VariationSelectorListener variationSelectorListener,
|
||||
boolean allowVariations,
|
||||
@NonNull LinearLayoutManager layoutManager,
|
||||
@LayoutRes int displayItemLayoutResId)
|
||||
@LayoutRes int displayEmojiLayoutResId,
|
||||
@LayoutRes int displayEmoticonLayoutResId)
|
||||
{
|
||||
this.variationSelectorListener = variationSelectorListener;
|
||||
|
||||
@@ -90,7 +87,8 @@ public class EmojiPageView extends RecyclerView implements VariationSelectorList
|
||||
emojiSelectionListener,
|
||||
this,
|
||||
allowVariations,
|
||||
displayItemLayoutResId);
|
||||
displayEmojiLayoutResId,
|
||||
displayEmoticonLayoutResId);
|
||||
|
||||
if (this.layoutManager instanceof GridLayoutManager) {
|
||||
GridLayoutManager gridLayout = (GridLayoutManager) this.layoutManager;
|
||||
@@ -109,6 +107,9 @@ public class EmojiPageView extends RecyclerView implements VariationSelectorList
|
||||
}
|
||||
|
||||
setLayoutManager(layoutManager);
|
||||
|
||||
Drawable drawable = DrawableUtil.tint(ContextUtil.requireDrawable(getContext(), R.drawable.triangle_bottom_right_corner), ContextCompat.getColor(getContext(), R.color.signal_button_secondary_text_disabled));
|
||||
addItemDecoration(new EmojiItemDecoration(allowVariations, drawable));
|
||||
}
|
||||
|
||||
public void presentForEmojiKeyboard() {
|
||||
@@ -121,45 +122,15 @@ public class EmojiPageView extends RecyclerView implements VariationSelectorList
|
||||
}
|
||||
|
||||
public void onSelected() {
|
||||
if (getAdapter() != null && (model == null || model.isDynamic())) {
|
||||
if (getAdapter() != null) {
|
||||
getAdapter().notifyDataSetChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public void setList(@NonNull MappingModelList list) {
|
||||
this.model = null;
|
||||
public void setList(@NonNull List<MappingModel<?>> list, @Nullable Runnable commitCallback) {
|
||||
EmojiPageViewGridAdapter adapter = adapterFactory.create();
|
||||
setAdapter(adapter);
|
||||
adapter.submitList(list);
|
||||
}
|
||||
|
||||
public void setModel(@Nullable EmojiPageModel model) {
|
||||
this.model = model;
|
||||
|
||||
EmojiPageViewGridAdapter adapter = adapterFactory.create();
|
||||
setAdapter(adapter);
|
||||
adapter.submitList(getMappingModelList());
|
||||
}
|
||||
|
||||
public void bindSearchableAdapter(@Nullable EmojiPageModel model) {
|
||||
this.model = model;
|
||||
|
||||
EmojiPageViewGridAdapter adapter = adapterFactory.create();
|
||||
setAdapter(adapter);
|
||||
adapter.submitList(getMappingModelList());
|
||||
}
|
||||
|
||||
private @NonNull MappingModelList getMappingModelList() {
|
||||
if (model != null) {
|
||||
boolean emoticonPage = EmojiCategory.EMOTICONS.getKey().equals(model.getKey());
|
||||
return model.getDisplayEmoji()
|
||||
.stream()
|
||||
.map(e -> emoticonPage ? new EmojiPageViewGridAdapter.EmojiTextModel(model.getKey(), e)
|
||||
: new EmojiPageViewGridAdapter.EmojiModel(model.getKey(), e))
|
||||
.collect(MappingModelList.collect());
|
||||
}
|
||||
|
||||
return new MappingModelList();
|
||||
adapter.submitList(list, commitCallback);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -10,7 +10,6 @@ import androidx.annotation.LayoutRes;
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiKeyboardProvider.EmojiEventListener;
|
||||
import org.thoughtcrime.securesms.util.MappingAdapter;
|
||||
import org.thoughtcrime.securesms.util.MappingModel;
|
||||
import org.thoughtcrime.securesms.util.MappingViewHolder;
|
||||
@@ -23,15 +22,16 @@ public class EmojiPageViewGridAdapter extends MappingAdapter implements PopupWin
|
||||
@NonNull EmojiEventListener emojiEventListener,
|
||||
@NonNull VariationSelectorListener variationSelectorListener,
|
||||
boolean allowVariations,
|
||||
@LayoutRes int displayItemLayoutResId)
|
||||
@LayoutRes int displayEmojiLayoutResId,
|
||||
@LayoutRes int displayEmoticonLayoutResId)
|
||||
{
|
||||
this.variationSelectorListener = variationSelectorListener;
|
||||
|
||||
popup.setOnDismissListener(this);
|
||||
|
||||
registerFactory(EmojiHeader.class, new LayoutFactory<>(EmojiHeaderViewHolder::new, R.layout.emoji_grid_header));
|
||||
registerFactory(EmojiModel.class, new LayoutFactory<>(v -> new EmojiViewHolder(v, emojiEventListener, variationSelectorListener, popup, allowVariations), displayItemLayoutResId));
|
||||
registerFactory(EmojiTextModel.class, new LayoutFactory<>(v -> new EmojiTextViewHolder(v, emojiEventListener), R.layout.emoji_text_display_item));
|
||||
registerFactory(EmojiModel.class, new LayoutFactory<>(v -> new EmojiViewHolder(v, emojiEventListener, variationSelectorListener, popup, allowVariations), displayEmojiLayoutResId));
|
||||
registerFactory(EmojiTextModel.class, new LayoutFactory<>(v -> new EmojiTextViewHolder(v, emojiEventListener), displayEmoticonLayoutResId));
|
||||
registerFactory(EmojiNoResultsModel.class, new LayoutFactory<>(MappingViewHolder.SimpleViewHolder::new, R.layout.emoji_grid_no_results));
|
||||
}
|
||||
|
||||
@@ -119,7 +119,6 @@ public class EmojiPageViewGridAdapter extends MappingAdapter implements PopupWin
|
||||
private final boolean allowVariations;
|
||||
|
||||
private final ImageView imageView;
|
||||
private final ImageView hintCorner;
|
||||
|
||||
public EmojiViewHolder(@NonNull View itemView,
|
||||
@NonNull EmojiEventListener emojiEventListener,
|
||||
@@ -135,7 +134,6 @@ public class EmojiPageViewGridAdapter extends MappingAdapter implements PopupWin
|
||||
this.allowVariations = allowVariations;
|
||||
|
||||
this.imageView = itemView.findViewById(R.id.emoji_image);
|
||||
this.hintCorner = itemView.findViewById(R.id.emoji_variation_hint);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -152,9 +150,6 @@ public class EmojiPageViewGridAdapter extends MappingAdapter implements PopupWin
|
||||
});
|
||||
|
||||
if (allowVariations && model.emoji.hasMultipleVariations()) {
|
||||
if (hintCorner != null) {
|
||||
hintCorner.setVisibility(View.VISIBLE);
|
||||
}
|
||||
itemView.setOnLongClickListener(v -> {
|
||||
popup.dismiss();
|
||||
popup.setVariations(model.emoji.getVariations());
|
||||
@@ -163,9 +158,6 @@ public class EmojiPageViewGridAdapter extends MappingAdapter implements PopupWin
|
||||
return true;
|
||||
});
|
||||
} else {
|
||||
if (hintCorner != null) {
|
||||
hintCorner.setVisibility(View.GONE);
|
||||
}
|
||||
itemView.setOnLongClickListener(null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,11 +6,9 @@ import android.util.AttributeSet;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.appcompat.widget.AppCompatImageButton;
|
||||
import androidx.core.content.ContextCompat;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.keyboard.KeyboardPage;
|
||||
import org.thoughtcrime.securesms.stickers.StickerKeyboardProvider;
|
||||
import org.thoughtcrime.securesms.util.ContextUtil;
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ import androidx.annotation.NonNull;
|
||||
import androidx.core.content.ContextCompat;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiKeyboardProvider.EmojiEventListener;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ package org.thoughtcrime.securesms.components.emoji;
|
||||
import android.content.Context;
|
||||
import android.util.AttributeSet;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.FrameLayout;
|
||||
|
||||
@@ -13,7 +12,6 @@ import androidx.fragment.app.Fragment;
|
||||
import androidx.fragment.app.FragmentActivity;
|
||||
import androidx.fragment.app.FragmentManager;
|
||||
import androidx.fragment.app.FragmentTransaction;
|
||||
import androidx.viewpager.widget.PagerAdapter;
|
||||
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
@@ -22,13 +20,7 @@ import org.thoughtcrime.securesms.keyboard.KeyboardPage;
|
||||
import org.thoughtcrime.securesms.keyboard.KeyboardPagerFragment;
|
||||
import org.thoughtcrime.securesms.keyboard.emoji.search.EmojiSearchFragment;
|
||||
|
||||
import java.security.Key;
|
||||
|
||||
public class MediaKeyboard extends FrameLayout implements InputView,
|
||||
MediaKeyboardProvider.Presenter,
|
||||
MediaKeyboardProvider.Controller,
|
||||
MediaKeyboardBottomTabAdapter.EventListener
|
||||
{
|
||||
public class MediaKeyboard extends FrameLayout implements InputView {
|
||||
|
||||
private static final String TAG = Log.tag(MediaKeyboard.class);
|
||||
private static final String EMOJI_SEARCH = "emoji_search_fragment";
|
||||
@@ -88,60 +80,6 @@ public class MediaKeyboard extends FrameLayout implements InputView,
|
||||
keyboardPagerFragment.hide();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void present(@NonNull MediaKeyboardProvider provider,
|
||||
@NonNull PagerAdapter pagerAdapter,
|
||||
@NonNull MediaKeyboardProvider.TabIconProvider tabIconProvider,
|
||||
@Nullable MediaKeyboardProvider.BackspaceObserver backspaceObserver,
|
||||
@Nullable MediaKeyboardProvider.AddObserver addObserver,
|
||||
@Nullable MediaKeyboardProvider.SearchObserver searchObserver,
|
||||
int startingIndex)
|
||||
{
|
||||
// if (categoryPager == null) return;
|
||||
// if (!provider.equals(providers[providerIndex])) return;
|
||||
// if (keyboardListener != null) keyboardListener.onKeyboardChanged(provider);
|
||||
//
|
||||
// boolean isSolo = providers.length == 1;
|
||||
//
|
||||
// presentProviderStrip(isSolo);
|
||||
// presentCategoryPager(pagerAdapter, tabIconProvider, startingIndex);
|
||||
// presentProviderTabs(providers, providerIndex);
|
||||
// presentSearchButton(searchObserver);
|
||||
// presentBackspaceButton(backspaceObserver, isSolo);
|
||||
// presentAddButton(addObserver);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getCurrentPosition() {
|
||||
// return categoryPager != null ? categoryPager.getCurrentItem() : 0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void requestDismissal() {
|
||||
hide(true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isVisible() {
|
||||
return getVisibility() == View.VISIBLE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onTabSelected(int index) {
|
||||
// if (categoryPager != null) {
|
||||
// categoryPager.setCurrentItem(index);
|
||||
// categoryTabs.smoothScrollToPosition(index);
|
||||
// }
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setViewPagerEnabled(boolean enabled) {
|
||||
// if (categoryPager != null) {
|
||||
// categoryPager.setEnabled(enabled);
|
||||
// }
|
||||
}
|
||||
|
||||
public void onCloseEmojiSearch() {
|
||||
onCloseEmojiSearchInternal(true);
|
||||
}
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
package org.thoughtcrime.securesms.components.emoji;
|
||||
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.ImageView;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.components.emoji.MediaKeyboardProvider.TabIconProvider;
|
||||
import org.thoughtcrime.securesms.mms.GlideRequests;
|
||||
|
||||
public class MediaKeyboardBottomTabAdapter extends RecyclerView.Adapter<MediaKeyboardBottomTabAdapter.MediaKeyboardBottomTabViewHolder> {
|
||||
|
||||
private final GlideRequests glideRequests;
|
||||
private final EventListener eventListener;
|
||||
|
||||
private TabIconProvider tabIconProvider;
|
||||
private int activePosition;
|
||||
private int count;
|
||||
|
||||
public MediaKeyboardBottomTabAdapter(@NonNull GlideRequests glideRequests, @NonNull EventListener eventListener) {
|
||||
this.glideRequests = glideRequests;
|
||||
this.eventListener = eventListener;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull MediaKeyboardBottomTabViewHolder onCreateViewHolder(@NonNull ViewGroup viewGroup, int i) {
|
||||
return new MediaKeyboardBottomTabViewHolder(LayoutInflater.from(viewGroup.getContext()).inflate(R.layout.media_keyboard_bottom_tab_item, viewGroup, false));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBindViewHolder(@NonNull MediaKeyboardBottomTabViewHolder viewHolder, int i) {
|
||||
viewHolder.bind(glideRequests, eventListener, tabIconProvider, i, i == activePosition);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onViewRecycled(@NonNull MediaKeyboardBottomTabViewHolder holder) {
|
||||
holder.recycle();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getItemCount() {
|
||||
return count;
|
||||
}
|
||||
|
||||
public void setTabIconProvider(@NonNull TabIconProvider iconProvider, int count) {
|
||||
this.tabIconProvider = iconProvider;
|
||||
this.count = count;
|
||||
|
||||
notifyDataSetChanged();
|
||||
}
|
||||
|
||||
public void setActivePosition(int position) {
|
||||
this.activePosition = position;
|
||||
notifyDataSetChanged();
|
||||
}
|
||||
|
||||
static class MediaKeyboardBottomTabViewHolder extends RecyclerView.ViewHolder {
|
||||
|
||||
private final ImageView image;
|
||||
private final View imageSelected;
|
||||
|
||||
public MediaKeyboardBottomTabViewHolder(@NonNull View itemView) {
|
||||
super(itemView);
|
||||
this.image = itemView.findViewById(R.id.category_icon);
|
||||
this.imageSelected = itemView.findViewById(R.id.category_icon_selected);
|
||||
}
|
||||
|
||||
void bind(@NonNull GlideRequests glideRequests,
|
||||
@NonNull EventListener eventListener,
|
||||
@NonNull TabIconProvider tabIconProvider,
|
||||
int index,
|
||||
boolean selected)
|
||||
{
|
||||
tabIconProvider.loadCategoryTabIcon(glideRequests, image, index);
|
||||
image.setAlpha(selected ? 1 : 0.5f);
|
||||
imageSelected.setSelected(selected);
|
||||
|
||||
itemView.setOnClickListener(v -> eventListener.onTabSelected(index));
|
||||
}
|
||||
|
||||
void recycle() {
|
||||
itemView.setOnClickListener(null);
|
||||
}
|
||||
}
|
||||
|
||||
public interface EventListener {
|
||||
void onTabSelected(int index);
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
package org.thoughtcrime.securesms.components.emoji;
|
||||
|
||||
import android.widget.ImageView;
|
||||
|
||||
import androidx.annotation.LayoutRes;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.viewpager.widget.PagerAdapter;
|
||||
|
||||
import org.thoughtcrime.securesms.mms.GlideRequests;
|
||||
|
||||
public interface MediaKeyboardProvider {
|
||||
@LayoutRes int getProviderIconView(boolean selected);
|
||||
/** @return True if the click was handled with provider-specific logic, otherwise false */
|
||||
void requestPresentation(@NonNull Presenter presenter, boolean isSoloProvider);
|
||||
void setController(@Nullable Controller controller);
|
||||
void setCurrentPosition(int currentPosition);
|
||||
|
||||
interface BackspaceObserver {
|
||||
void onBackspaceClicked();
|
||||
}
|
||||
|
||||
interface AddObserver {
|
||||
void onAddClicked();
|
||||
}
|
||||
|
||||
interface SearchObserver {
|
||||
void onSearchOpened();
|
||||
void onSearchClosed();
|
||||
void onSearchChanged(@NonNull String query);
|
||||
}
|
||||
|
||||
interface Controller {
|
||||
void setViewPagerEnabled(boolean enabled);
|
||||
}
|
||||
|
||||
interface Presenter {
|
||||
void present(@NonNull MediaKeyboardProvider provider,
|
||||
@NonNull PagerAdapter pagerAdapter,
|
||||
@NonNull TabIconProvider iconProvider,
|
||||
@Nullable BackspaceObserver backspaceObserver,
|
||||
@Nullable AddObserver addObserver,
|
||||
@Nullable SearchObserver searchObserver,
|
||||
int startingIndex);
|
||||
int getCurrentPosition();
|
||||
void requestDismissal();
|
||||
boolean isVisible();
|
||||
}
|
||||
|
||||
interface TabIconProvider {
|
||||
void loadCategoryTabIcon(@NonNull GlideRequests glideRequests, @NonNull ImageView imageView, int index);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package org.thoughtcrime.securesms.components.recyclerview
|
||||
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
|
||||
/**
|
||||
* Allows implementor to trigger an animation when the attached recyclerview is
|
||||
* scrolled.
|
||||
*/
|
||||
abstract class OnScrollAnimationHelper : RecyclerView.OnScrollListener() {
|
||||
private var lastAnimationState = AnimationState.NONE
|
||||
|
||||
protected open val duration: Long = 250L
|
||||
|
||||
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
|
||||
val newAnimationState = getAnimationState(recyclerView)
|
||||
|
||||
if (newAnimationState == lastAnimationState) {
|
||||
return
|
||||
}
|
||||
|
||||
if (lastAnimationState == AnimationState.NONE) {
|
||||
setImmediateState(recyclerView)
|
||||
return
|
||||
}
|
||||
|
||||
when (newAnimationState) {
|
||||
AnimationState.NONE -> throw AssertionError()
|
||||
AnimationState.HIDE -> hide(duration)
|
||||
AnimationState.SHOW -> show(duration)
|
||||
}
|
||||
|
||||
lastAnimationState = newAnimationState
|
||||
}
|
||||
|
||||
fun setImmediateState(recyclerView: RecyclerView) {
|
||||
val newAnimationState = getAnimationState(recyclerView)
|
||||
|
||||
when (newAnimationState) {
|
||||
AnimationState.NONE -> throw AssertionError()
|
||||
AnimationState.HIDE -> hide(0L)
|
||||
AnimationState.SHOW -> show(0L)
|
||||
}
|
||||
|
||||
lastAnimationState = newAnimationState
|
||||
}
|
||||
|
||||
protected open fun getAnimationState(recyclerView: RecyclerView): AnimationState {
|
||||
return if (recyclerView.canScrollVertically(-1)) AnimationState.SHOW else AnimationState.HIDE
|
||||
}
|
||||
|
||||
/**
|
||||
* Fired when the RecyclerView is able to be scrolled up
|
||||
*/
|
||||
protected abstract fun show(duration: Long)
|
||||
|
||||
/**
|
||||
* Fired when the RecyclerView is not able to be scrolled up
|
||||
*/
|
||||
protected abstract fun hide(duration: Long)
|
||||
|
||||
enum class AnimationState {
|
||||
NONE,
|
||||
HIDE,
|
||||
SHOW
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package org.thoughtcrime.securesms.components.recyclerview
|
||||
|
||||
import android.view.View
|
||||
|
||||
/**
|
||||
* Animates in and out a given view. This is intended to be used to show and hide a toolbar shadow,
|
||||
* but makes no restrictions in this manner.
|
||||
*/
|
||||
open class ToolbarShadowAnimationHelper(private val toolbarShadow: View) : OnScrollAnimationHelper() {
|
||||
|
||||
override fun show(duration: Long) {
|
||||
toolbarShadow.animate()
|
||||
.setDuration(duration)
|
||||
.alpha(1f)
|
||||
}
|
||||
|
||||
override fun hide(duration: Long) {
|
||||
toolbarShadow.animate()
|
||||
.setDuration(duration)
|
||||
.alpha(0f)
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,7 @@ public class OutdatedBuildReminder extends Reminder {
|
||||
}
|
||||
|
||||
private static CharSequence getPluralsText(final Context context) {
|
||||
int days = getDaysUntilExpiry() - 1;
|
||||
int days = getDaysUntilExpiry();
|
||||
|
||||
if (days == 0) {
|
||||
return context.getString(R.string.OutdatedBuildReminder_your_version_of_signal_will_expire_today);
|
||||
|
||||
@@ -12,6 +12,8 @@ import androidx.core.content.ContextCompat
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.recyclerview.OnScrollAnimationHelper
|
||||
import org.thoughtcrime.securesms.components.recyclerview.ToolbarShadowAnimationHelper
|
||||
|
||||
abstract class DSLSettingsFragment(
|
||||
@StringRes private val titleId: Int = -1,
|
||||
@@ -66,72 +68,4 @@ abstract class DSLSettingsFragment(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
abstract class OnScrollAnimationHelper : RecyclerView.OnScrollListener() {
|
||||
private var lastAnimationState = AnimationState.NONE
|
||||
|
||||
protected open val duration: Long = 250L
|
||||
|
||||
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
|
||||
val newAnimationState = getAnimationState(recyclerView)
|
||||
|
||||
if (newAnimationState == lastAnimationState) {
|
||||
return
|
||||
}
|
||||
|
||||
if (lastAnimationState == AnimationState.NONE) {
|
||||
setImmediateState(recyclerView)
|
||||
return
|
||||
}
|
||||
|
||||
when (newAnimationState) {
|
||||
AnimationState.NONE -> throw AssertionError()
|
||||
AnimationState.HIDE -> hide(duration)
|
||||
AnimationState.SHOW -> show(duration)
|
||||
}
|
||||
|
||||
lastAnimationState = newAnimationState
|
||||
}
|
||||
|
||||
fun setImmediateState(recyclerView: RecyclerView) {
|
||||
val newAnimationState = getAnimationState(recyclerView)
|
||||
|
||||
when (newAnimationState) {
|
||||
AnimationState.NONE -> throw AssertionError()
|
||||
AnimationState.HIDE -> hide(0L)
|
||||
AnimationState.SHOW -> show(0L)
|
||||
}
|
||||
|
||||
lastAnimationState = newAnimationState
|
||||
}
|
||||
|
||||
protected open fun getAnimationState(recyclerView: RecyclerView): AnimationState {
|
||||
return if (recyclerView.canScrollVertically(-1)) AnimationState.SHOW else AnimationState.HIDE
|
||||
}
|
||||
|
||||
protected abstract fun show(duration: Long)
|
||||
|
||||
protected abstract fun hide(duration: Long)
|
||||
|
||||
enum class AnimationState {
|
||||
NONE,
|
||||
HIDE,
|
||||
SHOW
|
||||
}
|
||||
}
|
||||
|
||||
open class ToolbarShadowAnimationHelper(private val toolbarShadow: View) : OnScrollAnimationHelper() {
|
||||
|
||||
override fun show(duration: Long) {
|
||||
toolbarShadow.animate()
|
||||
.setDuration(duration)
|
||||
.alpha(1f)
|
||||
}
|
||||
|
||||
override fun hide(duration: Long) {
|
||||
toolbarShadow.animate()
|
||||
.setDuration(duration)
|
||||
.alpha(0f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,7 +88,7 @@ class AppSettingsActivity : DSLSettingsActivity() {
|
||||
|
||||
@JvmStatic
|
||||
fun help(context: Context, startCategoryIndex: Int = 0): Intent {
|
||||
return getIntentForStartLocation(context, StartLocation.HOME)
|
||||
return getIntentForStartLocation(context, StartLocation.HELP)
|
||||
.putExtra(HelpFragment.START_CATEGORY_INDEX, startCategoryIndex)
|
||||
}
|
||||
|
||||
|
||||
@@ -244,6 +244,15 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
|
||||
}
|
||||
)
|
||||
|
||||
switchPref(
|
||||
title = DSLSettingsText.from(R.string.preferences__internal_delay_resends),
|
||||
summary = DSLSettingsText.from(R.string.preferences__internal_delay_resending_messages_in_response_to_retry_receipts),
|
||||
isChecked = state.delayResends,
|
||||
onClick = {
|
||||
viewModel.setDelayResends(!state.delayResends)
|
||||
}
|
||||
)
|
||||
|
||||
dividerPref()
|
||||
|
||||
sectionHeaderPref(R.string.preferences__internal_calling)
|
||||
|
||||
@@ -15,4 +15,5 @@ data class InternalSettingsState(
|
||||
val useBuiltInEmojiSet: Boolean,
|
||||
val emojiVersion: EmojiFiles.Version?,
|
||||
val removeSenderKeyMinimium: Boolean,
|
||||
val delayResends: Boolean,
|
||||
)
|
||||
|
||||
@@ -70,6 +70,11 @@ class InternalSettingsViewModel(private val repository: InternalSettingsReposito
|
||||
refresh()
|
||||
}
|
||||
|
||||
fun setDelayResends(enabled: Boolean) {
|
||||
preferenceDataStore.putBoolean(InternalValues.DELAY_RESENDS, enabled)
|
||||
refresh()
|
||||
}
|
||||
|
||||
fun setInternalGroupCallingServer(server: String?) {
|
||||
preferenceDataStore.putString(InternalValues.CALLING_SERVER, server)
|
||||
refresh()
|
||||
@@ -91,7 +96,8 @@ class InternalSettingsViewModel(private val repository: InternalSettingsReposito
|
||||
callingServer = SignalStore.internalValues().groupCallingServer(),
|
||||
useBuiltInEmojiSet = SignalStore.internalValues().forceBuiltInEmoji(),
|
||||
emojiVersion = null,
|
||||
removeSenderKeyMinimium = SignalStore.internalValues().removeSenderKeyMinimum()
|
||||
removeSenderKeyMinimium = SignalStore.internalValues().removeSenderKeyMinimum(),
|
||||
delayResends = SignalStore.internalValues().delayResends()
|
||||
)
|
||||
|
||||
class Factory(private val repository: InternalSettingsRepository) : ViewModelProvider.Factory {
|
||||
|
||||
@@ -31,7 +31,7 @@ class ConversationSettingsActivity : DSLSettingsActivity(), ConversationSettings
|
||||
|
||||
override fun finish() {
|
||||
super.finish()
|
||||
overridePendingTransition(0, R.anim.fade_out)
|
||||
overridePendingTransition(0, R.anim.slide_fade_to_bottom)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -31,6 +31,8 @@ import org.thoughtcrime.securesms.PushContactSelectionActivity
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.VerifyIdentityActivity
|
||||
import org.thoughtcrime.securesms.components.AvatarImageView
|
||||
import org.thoughtcrime.securesms.components.recyclerview.OnScrollAnimationHelper
|
||||
import org.thoughtcrime.securesms.components.recyclerview.ToolbarShadowAnimationHelper
|
||||
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
|
||||
@@ -204,7 +206,7 @@ class ConversationSettingsFragment : DSLSettingsFragment(
|
||||
|
||||
adapter.submitList(getConfiguration(state).toMappingModelList()) {
|
||||
if (state.isLoaded) {
|
||||
(requireView().parent as? ViewGroup)?.doOnPreDraw {
|
||||
(view?.parent as? ViewGroup)?.doOnPreDraw {
|
||||
callback.onContentWillRender()
|
||||
}
|
||||
}
|
||||
@@ -300,6 +302,9 @@ class ConversationSettingsFragment : DSLSettingsFragment(
|
||||
recipient = state.recipient,
|
||||
onDisableProfileSharingClick = {
|
||||
viewModel.disableProfileSharing()
|
||||
},
|
||||
onDeleteSessionClick = {
|
||||
viewModel.deleteSession()
|
||||
}
|
||||
)
|
||||
)
|
||||
@@ -432,6 +437,7 @@ class ConversationSettingsFragment : DSLSettingsFragment(
|
||||
customPref(
|
||||
SharedMediaPreference.Model(
|
||||
mediaCursor = state.sharedMedia,
|
||||
mediaIds = state.sharedMediaIds,
|
||||
onMediaRecordClick = { mediaRecord, isLtr ->
|
||||
startActivityForResult(
|
||||
MediaPreviewActivity.intentFromMediaRecord(requireContext(), mediaRecord, isLtr),
|
||||
|
||||
@@ -24,6 +24,7 @@ import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.recipients.RecipientUtil
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags
|
||||
import org.whispersystems.libsignal.util.guava.Optional
|
||||
import org.whispersystems.libsignal.util.guava.Preconditions
|
||||
import java.io.IOException
|
||||
|
||||
private val TAG = Log.tag(ConversationSettingsRepository::class.java)
|
||||
@@ -185,12 +186,22 @@ class ConversationSettingsRepository(
|
||||
}
|
||||
}
|
||||
|
||||
fun disableProfileSharing(recipientId: RecipientId) {
|
||||
fun disableProfileSharingForInternalUser(recipientId: RecipientId) {
|
||||
Preconditions.checkArgument(FeatureFlags.internalUser(), "Internal users only!")
|
||||
|
||||
SignalExecutors.BOUNDED.execute {
|
||||
DatabaseFactory.getRecipientDatabase(context).setProfileSharing(recipientId, false)
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteSessionForInternalUser(recipientId: RecipientId) {
|
||||
Preconditions.checkArgument(FeatureFlags.internalUser(), "Internal users only!")
|
||||
|
||||
SignalExecutors.BOUNDED.execute {
|
||||
DatabaseFactory.getSessionDatabase(context).deleteAllFor(recipientId)
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
fun isMessageRequestAccepted(recipient: Recipient): Boolean {
|
||||
return RecipientUtil.isMessageRequestAccepted(context, recipient)
|
||||
|
||||
@@ -15,6 +15,7 @@ data class ConversationSettingsState(
|
||||
val disappearingMessagesLifespan: Int = 0,
|
||||
val canModifyBlockedState: Boolean = false,
|
||||
val sharedMedia: Cursor? = null,
|
||||
val sharedMediaIds: List<Long> = listOf(),
|
||||
private val sharedMediaLoaded: Boolean = false,
|
||||
private val specificSettingsState: SpecificSettingsState,
|
||||
) {
|
||||
|
||||
@@ -10,12 +10,14 @@ import org.signal.core.util.ThreadUtil
|
||||
import org.signal.core.util.concurrent.SignalExecutors
|
||||
import org.thoughtcrime.securesms.components.settings.conversation.preferences.ButtonStripPreference
|
||||
import org.thoughtcrime.securesms.components.settings.conversation.preferences.LegacyGroupPreference
|
||||
import org.thoughtcrime.securesms.database.AttachmentDatabase
|
||||
import org.thoughtcrime.securesms.database.RecipientDatabase
|
||||
import org.thoughtcrime.securesms.groups.GroupId
|
||||
import org.thoughtcrime.securesms.groups.LiveGroup
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.recipients.RecipientUtil
|
||||
import org.thoughtcrime.securesms.util.CursorUtil
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags
|
||||
import org.thoughtcrime.securesms.util.SingleLiveEvent
|
||||
import org.thoughtcrime.securesms.util.livedata.LiveDataUtil
|
||||
@@ -58,7 +60,19 @@ sealed class ConversationSettingsViewModel(
|
||||
openedMediaCursors.add(cursor.get())
|
||||
}
|
||||
|
||||
state.copy(sharedMedia = cursor.orNull(), sharedMediaLoaded = true)
|
||||
val ids: List<Long> = cursor.transform<List<Long>> {
|
||||
val result = mutableListOf<Long>()
|
||||
while (it.moveToNext()) {
|
||||
result.add(CursorUtil.requireLong(it, AttachmentDatabase.ROW_ID))
|
||||
}
|
||||
result
|
||||
}.or(listOf())
|
||||
|
||||
state.copy(
|
||||
sharedMedia = cursor.orNull(),
|
||||
sharedMediaIds = ids,
|
||||
sharedMediaLoaded = true
|
||||
)
|
||||
} else {
|
||||
cursor.orNull().ensureClosed()
|
||||
state.copy(sharedMedia = null)
|
||||
@@ -102,6 +116,8 @@ sealed class ConversationSettingsViewModel(
|
||||
|
||||
open fun disableProfileSharing(): Unit = error("This ViewModel does not support this interaction")
|
||||
|
||||
open fun deleteSession(): Unit = error("This ViewModel does not support this interaction")
|
||||
|
||||
open fun initiateGroupUpgrade(): Unit = error("This ViewModel does not support this interaction")
|
||||
|
||||
private class RecipientSettingsViewModel(
|
||||
@@ -125,7 +141,7 @@ sealed class ConversationSettingsViewModel(
|
||||
isAudioAvailable = !recipient.isGroup && !recipient.isSelf,
|
||||
isAudioSecure = recipient.registered == RecipientDatabase.RegisteredState.REGISTERED,
|
||||
isMuted = recipient.isMuted,
|
||||
isMuteAvailable = true,
|
||||
isMuteAvailable = !recipient.isSelf,
|
||||
isSearchAvailable = true
|
||||
),
|
||||
disappearingMessagesLifespan = recipient.expireMessages,
|
||||
@@ -150,12 +166,13 @@ sealed class ConversationSettingsViewModel(
|
||||
repository.getGroupsInCommon(recipientId) { groupsInCommon ->
|
||||
store.update { state ->
|
||||
val recipientSettings = state.requireRecipientSettingsState()
|
||||
val expanded = recipientSettings.groupsInCommonExpanded
|
||||
val canShowMore = !recipientSettings.groupsInCommonExpanded && groupsInCommon.size > 6
|
||||
|
||||
state.copy(
|
||||
specificSettingsState = recipientSettings.copy(
|
||||
allGroupsInCommon = groupsInCommon,
|
||||
groupsInCommon = if (expanded) groupsInCommon else groupsInCommon.take(5),
|
||||
canShowMoreGroupsInCommon = !expanded && groupsInCommon.size > 5
|
||||
groupsInCommon = if (!canShowMore) groupsInCommon else groupsInCommon.take(5),
|
||||
canShowMoreGroupsInCommon = canShowMore
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -222,7 +239,11 @@ sealed class ConversationSettingsViewModel(
|
||||
}
|
||||
|
||||
override fun disableProfileSharing() {
|
||||
repository.disableProfileSharing(recipientId)
|
||||
repository.disableProfileSharingForInternalUser(recipientId)
|
||||
}
|
||||
|
||||
override fun deleteSession() {
|
||||
repository.deleteSessionForInternalUser(recipientId)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -290,12 +311,13 @@ sealed class ConversationSettingsViewModel(
|
||||
|
||||
store.update(liveGroup.fullMembers) { fullMembers, state ->
|
||||
val groupState = state.requireGroupSettingsState()
|
||||
val canShowMore = !groupState.groupMembersExpanded && fullMembers.size > 6
|
||||
|
||||
state.copy(
|
||||
specificSettingsState = groupState.copy(
|
||||
allMembers = fullMembers,
|
||||
members = if (groupState.groupMembersExpanded) fullMembers else fullMembers.take(5),
|
||||
canShowMoreGroupMembers = !groupState.groupMembersExpanded && fullMembers.size > 5
|
||||
members = if (!canShowMore) fullMembers else fullMembers.take(5),
|
||||
canShowMoreGroupMembers = canShowMore
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
package org.thoughtcrime.securesms.components.settings.conversation.preferences
|
||||
|
||||
import android.content.ClipData
|
||||
import android.content.Context
|
||||
import android.view.View
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.PreferenceModel
|
||||
import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.util.MappingAdapter
|
||||
import org.thoughtcrime.securesms.util.MappingViewHolder
|
||||
import org.thoughtcrime.securesms.util.ServiceUtil
|
||||
|
||||
/**
|
||||
* Renders name, description, about, etc. for a given group or recipient.
|
||||
@@ -70,7 +73,7 @@ object BioTextPreference {
|
||||
|
||||
private val headline: TextView = itemView.findViewById(R.id.bio_preference_headline)
|
||||
private val subhead1: TextView = itemView.findViewById(R.id.bio_preference_subhead_1)
|
||||
private val subhead2: TextView = itemView.findViewById(R.id.bio_preference_subhead_2)
|
||||
protected val subhead2: TextView = itemView.findViewById(R.id.bio_preference_subhead_2)
|
||||
|
||||
override fun bind(model: T) {
|
||||
headline.text = model.getHeadlineText(context)
|
||||
@@ -87,6 +90,23 @@ object BioTextPreference {
|
||||
}
|
||||
}
|
||||
|
||||
private class RecipientViewHolder(itemView: View) : BioTextViewHolder<RecipientModel>(itemView)
|
||||
private class RecipientViewHolder(itemView: View) : BioTextViewHolder<RecipientModel>(itemView) {
|
||||
override fun bind(model: RecipientModel) {
|
||||
super.bind(model)
|
||||
|
||||
val phoneNumber = model.getSubhead2Text()
|
||||
if (!phoneNumber.isNullOrEmpty()) {
|
||||
subhead2.setOnLongClickListener {
|
||||
val clipboardManager = ServiceUtil.getClipboardManager(context)
|
||||
clipboardManager.setPrimaryClip(ClipData.newPlainText(context.getString(R.string.ConversationSettingsFragment__phone_number), subhead2.text.toString()))
|
||||
Toast.makeText(context, R.string.ConversationSettingsFragment__copied_phone_number_to_clipboard, Toast.LENGTH_SHORT).show()
|
||||
true
|
||||
}
|
||||
} else {
|
||||
subhead2.setOnLongClickListener(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class GroupViewHolder(itemView: View) : BioTextViewHolder<GroupModel>(itemView)
|
||||
}
|
||||
|
||||
@@ -19,7 +19,8 @@ object InternalPreference {
|
||||
|
||||
class Model(
|
||||
private val recipient: Recipient,
|
||||
val onDisableProfileSharingClick: () -> Unit
|
||||
val onDisableProfileSharingClick: () -> Unit,
|
||||
val onDeleteSessionClick: () -> Unit
|
||||
) : PreferenceModel<Model>() {
|
||||
|
||||
val body: String get() {
|
||||
@@ -58,10 +59,12 @@ object InternalPreference {
|
||||
|
||||
private val body: TextView = itemView.findViewById(R.id.internal_preference_body)
|
||||
private val disableProfileSharing: View = itemView.findViewById(R.id.internal_disable_profile_sharing)
|
||||
private val deleteSession: View = itemView.findViewById(R.id.internal_delete_session)
|
||||
|
||||
override fun bind(model: Model) {
|
||||
body.text = model.body
|
||||
disableProfileSharing.setOnClickListener { model.onDisableProfileSharingClick() }
|
||||
deleteSession.setOnClickListener { model.onDeleteSessionClick() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,10 +22,16 @@ object SharedMediaPreference {
|
||||
|
||||
class Model(
|
||||
val mediaCursor: Cursor,
|
||||
val mediaIds: List<Long>,
|
||||
val onMediaRecordClick: (MediaDatabase.MediaRecord, Boolean) -> Unit
|
||||
) : PreferenceModel<Model>() {
|
||||
override fun areItemsTheSame(newItem: Model): Boolean {
|
||||
return newItem.mediaCursor == mediaCursor
|
||||
return true
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(newItem: Model): Boolean {
|
||||
return super.areContentsTheSame(newItem) &&
|
||||
mediaIds == newItem.mediaIds
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import org.thoughtcrime.securesms.database.RecipientDatabase
|
||||
import org.thoughtcrime.securesms.notifications.NotificationChannels
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.util.livedata.Store
|
||||
@@ -21,9 +22,10 @@ class SoundsAndNotificationsSettingsViewModel(
|
||||
store.update(Recipient.live(recipientId).liveData) { recipient, state ->
|
||||
state.copy(
|
||||
recipientId = recipientId,
|
||||
muteUntil = recipient.muteUntil,
|
||||
muteUntil = if (recipient.isMuted) recipient.muteUntil else 0L,
|
||||
mentionSetting = recipient.mentionSetting,
|
||||
hasMentionsSupport = recipient.isPushV2Group
|
||||
hasMentionsSupport = recipient.isPushV2Group,
|
||||
hasCustomNotificationSettings = recipient.notificationChannel != null || !NotificationChannels.supported()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package org.thoughtcrime.securesms.components.voice
|
||||
|
||||
import android.net.Uri
|
||||
import org.thoughtcrime.securesms.database.DraftDatabase
|
||||
import java.lang.IllegalArgumentException
|
||||
|
||||
private const val SIZE = "size"
|
||||
|
||||
class VoiceNoteDraft(
|
||||
val uri: Uri,
|
||||
val size: Long
|
||||
) {
|
||||
companion object {
|
||||
@JvmStatic
|
||||
fun fromDraft(draft: DraftDatabase.Draft): VoiceNoteDraft {
|
||||
if (draft.type != DraftDatabase.Draft.VOICE_NOTE) {
|
||||
throw IllegalArgumentException()
|
||||
}
|
||||
|
||||
val draftUri = Uri.parse(draft.value)
|
||||
|
||||
val uri: Uri = draftUri.buildUpon().clearQuery().build()
|
||||
val size: Long = draftUri.getQueryParameter("size")!!.toLong()
|
||||
|
||||
return VoiceNoteDraft(uri, size)
|
||||
}
|
||||
}
|
||||
|
||||
fun asDraft(): DraftDatabase.Draft {
|
||||
val draftUri = uri.buildUpon().appendQueryParameter(SIZE, size.toString())
|
||||
|
||||
return DraftDatabase.Draft(DraftDatabase.Draft.VOICE_NOTE, draftUri.build().toString())
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.components.voice;
|
||||
|
||||
import android.content.ComponentName;
|
||||
import android.media.AudioManager;
|
||||
import android.media.session.PlaybackState;
|
||||
import android.net.Uri;
|
||||
import android.os.Bundle;
|
||||
import android.os.Handler;
|
||||
@@ -9,19 +10,28 @@ import android.os.Looper;
|
||||
import android.os.Message;
|
||||
import android.os.RemoteException;
|
||||
import android.support.v4.media.MediaBrowserCompat;
|
||||
import android.support.v4.media.MediaDescriptionCompat;
|
||||
import android.support.v4.media.MediaMetadataCompat;
|
||||
import android.support.v4.media.session.MediaControllerCompat;
|
||||
import android.support.v4.media.session.MediaSessionCompat;
|
||||
import android.support.v4.media.session.PlaybackStateCompat;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
import androidx.lifecycle.DefaultLifecycleObserver;
|
||||
import androidx.lifecycle.LifecycleOwner;
|
||||
import androidx.lifecycle.LiveData;
|
||||
import androidx.lifecycle.MutableLiveData;
|
||||
import androidx.lifecycle.Transformations;
|
||||
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.recipients.LiveRecipient;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.util.DefaultValueLiveData;
|
||||
import org.thoughtcrime.securesms.util.livedata.LiveDataUtil;
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
@@ -35,16 +45,18 @@ import java.util.Objects;
|
||||
*/
|
||||
public class VoiceNoteMediaController implements DefaultLifecycleObserver {
|
||||
|
||||
public static final String EXTRA_THREAD_ID = "voice.note.thread_id";
|
||||
public static final String EXTRA_MESSAGE_ID = "voice.note.message_id";
|
||||
public static final String EXTRA_PROGRESS = "voice.note.playhead";
|
||||
public static final String EXTRA_PLAY_SINGLE = "voice.note.play.single";
|
||||
|
||||
private static final String TAG = Log.tag(VoiceNoteMediaController.class);
|
||||
|
||||
private MediaBrowserCompat mediaBrowser;
|
||||
private AppCompatActivity activity;
|
||||
private ProgressEventHandler progressEventHandler;
|
||||
private MutableLiveData<VoiceNotePlaybackState> voiceNotePlaybackState = new MutableLiveData<>(VoiceNotePlaybackState.NONE);
|
||||
private MediaBrowserCompat mediaBrowser;
|
||||
private AppCompatActivity activity;
|
||||
private ProgressEventHandler progressEventHandler;
|
||||
private MutableLiveData<VoiceNotePlaybackState> voiceNotePlaybackState = new MutableLiveData<>(VoiceNotePlaybackState.NONE);
|
||||
private LiveData<Optional<VoiceNotePlayerView.State>> voiceNotePlayerViewState;
|
||||
|
||||
private final MediaControllerCompatCallback mediaControllerCompatCallback = new MediaControllerCompatCallback();
|
||||
|
||||
@@ -56,12 +68,44 @@ public class VoiceNoteMediaController implements DefaultLifecycleObserver {
|
||||
null);
|
||||
|
||||
activity.getLifecycle().addObserver(this);
|
||||
|
||||
voiceNotePlayerViewState = Transformations.switchMap(voiceNotePlaybackState, playbackState -> {
|
||||
if (playbackState.getClipType() instanceof VoiceNotePlaybackState.ClipType.Message) {
|
||||
VoiceNotePlaybackState.ClipType.Message message = (VoiceNotePlaybackState.ClipType.Message) playbackState.getClipType();
|
||||
LiveRecipient sender = Recipient.live(message.getSenderId());
|
||||
LiveRecipient threadRecipient = Recipient.live(message.getThreadRecipientId());
|
||||
LiveData<String> name = LiveDataUtil.combineLatest(sender.getLiveDataResolved(),
|
||||
threadRecipient.getLiveDataResolved(),
|
||||
(s, t) -> VoiceNoteMediaDescriptionCompatFactory.getTitle(activity, s, t, null));
|
||||
|
||||
return Transformations.map(name, displayName -> Optional.of(
|
||||
new VoiceNotePlayerView.State(
|
||||
playbackState.getUri(),
|
||||
message.getMessageId(),
|
||||
message.getThreadId(),
|
||||
!playbackState.isPlaying(),
|
||||
message.getSenderId(),
|
||||
message.getThreadRecipientId(),
|
||||
message.getMessagePosition(),
|
||||
message.getTimestamp(),
|
||||
displayName,
|
||||
playbackState.getPlayheadPositionMillis(),
|
||||
playbackState.getTrackDuration(),
|
||||
playbackState.getSpeed())));
|
||||
} else {
|
||||
return new DefaultValueLiveData<>(Optional.absent());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public LiveData<VoiceNotePlaybackState> getVoiceNotePlaybackState() {
|
||||
return voiceNotePlaybackState;
|
||||
}
|
||||
|
||||
public LiveData<Optional<VoiceNotePlayerView.State>> getVoiceNotePlayerViewState() {
|
||||
return voiceNotePlayerViewState;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onStart(@NonNull LifecycleOwner owner) {
|
||||
mediaBrowser.connect();
|
||||
@@ -93,17 +137,29 @@ public class VoiceNoteMediaController implements DefaultLifecycleObserver {
|
||||
playbackStateCompat.getState() == PlaybackStateCompat.STATE_PLAYING;
|
||||
}
|
||||
|
||||
private static boolean isPlayerPaused(@NonNull PlaybackStateCompat playbackStateCompat) {
|
||||
return playbackStateCompat.getState() == PlaybackStateCompat.STATE_PAUSED;
|
||||
}
|
||||
|
||||
private static boolean isPlayerStopped(@NonNull PlaybackStateCompat playbackStateCompat) {
|
||||
return playbackStateCompat.getState() <= PlaybackStateCompat.STATE_STOPPED;
|
||||
}
|
||||
|
||||
private @NonNull MediaControllerCompat getMediaController() {
|
||||
return MediaControllerCompat.getMediaController(activity);
|
||||
}
|
||||
|
||||
|
||||
public void startConsecutivePlayback(@NonNull Uri audioSlideUri, long messageId, double progress) {
|
||||
startPlayback(audioSlideUri, messageId, progress, false);
|
||||
startPlayback(audioSlideUri, messageId, -1, progress, false);
|
||||
}
|
||||
|
||||
public void startSinglePlayback(@NonNull Uri audioSlideUri, long messageId, double progress) {
|
||||
startPlayback(audioSlideUri, messageId, progress, true);
|
||||
startPlayback(audioSlideUri, messageId, -1, progress, true);
|
||||
}
|
||||
|
||||
public void startSinglePlaybackForDraft(@NonNull Uri draftUri, long threadId, double progress) {
|
||||
startPlayback(draftUri, -1, threadId, progress, true);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -115,7 +171,7 @@ public class VoiceNoteMediaController implements DefaultLifecycleObserver {
|
||||
* @param progress The desired progress % to seek to.
|
||||
* @param singlePlayback The player will only play back the specified Uri, and not build a playlist.
|
||||
*/
|
||||
private void startPlayback(@NonNull Uri audioSlideUri, long messageId, double progress, boolean singlePlayback) {
|
||||
private void startPlayback(@NonNull Uri audioSlideUri, long messageId, long threadId, double progress, boolean singlePlayback) {
|
||||
if (isCurrentTrack(audioSlideUri)) {
|
||||
long duration = getMediaController().getMetadata().getLong(MediaMetadataCompat.METADATA_KEY_DURATION);
|
||||
|
||||
@@ -124,6 +180,7 @@ public class VoiceNoteMediaController implements DefaultLifecycleObserver {
|
||||
} else {
|
||||
Bundle extras = new Bundle();
|
||||
extras.putLong(EXTRA_MESSAGE_ID, messageId);
|
||||
extras.putLong(EXTRA_THREAD_ID, threadId);
|
||||
extras.putDouble(EXTRA_PROGRESS, progress);
|
||||
extras.putBoolean(EXTRA_PLAY_SINGLE, singlePlayback);
|
||||
|
||||
@@ -170,6 +227,15 @@ public class VoiceNoteMediaController implements DefaultLifecycleObserver {
|
||||
}
|
||||
}
|
||||
|
||||
public void setPlaybackSpeed(@NonNull Uri audioSlideUri, float playbackSpeed) {
|
||||
if (isCurrentTrack(audioSlideUri)) {
|
||||
Bundle bundle = new Bundle();
|
||||
bundle.putFloat(VoiceNotePlaybackService.ACTION_NEXT_PLAYBACK_SPEED, playbackSpeed);
|
||||
|
||||
getMediaController().sendCommand(VoiceNotePlaybackService.ACTION_NEXT_PLAYBACK_SPEED, bundle, null);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isCurrentTrack(@NonNull Uri uri) {
|
||||
MediaMetadataCompat metadataCompat = getMediaController().getMetadata();
|
||||
|
||||
@@ -198,6 +264,17 @@ public class VoiceNoteMediaController implements DefaultLifecycleObserver {
|
||||
|
||||
MediaControllerCompat.setMediaController(activity, mediaController);
|
||||
|
||||
MediaMetadataCompat mediaMetadataCompat = mediaController.getMetadata();
|
||||
if (canExtractPlaybackInformationFromMetadata(mediaMetadataCompat)) {
|
||||
VoiceNotePlaybackState newState = extractStateFromMetadata(mediaController, mediaMetadataCompat, null);
|
||||
|
||||
if (newState != null) {
|
||||
voiceNotePlaybackState.postValue(newState);
|
||||
} else {
|
||||
voiceNotePlaybackState.postValue(VoiceNotePlaybackState.NONE);
|
||||
}
|
||||
}
|
||||
|
||||
mediaController.registerCallback(mediaControllerCompatCallback);
|
||||
|
||||
mediaControllerCompatCallback.onPlaybackStateChanged(mediaController.getPlaybackState());
|
||||
@@ -207,6 +284,107 @@ public class VoiceNoteMediaController implements DefaultLifecycleObserver {
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean canExtractPlaybackInformationFromMetadata(@Nullable MediaMetadataCompat mediaMetadataCompat) {
|
||||
return mediaMetadataCompat != null &&
|
||||
mediaMetadataCompat.getDescription() != null &&
|
||||
mediaMetadataCompat.getDescription().getMediaUri() != null;
|
||||
}
|
||||
|
||||
private static @Nullable VoiceNotePlaybackState extractStateFromMetadata(@NonNull MediaControllerCompat mediaController,
|
||||
@NonNull MediaMetadataCompat mediaMetadataCompat,
|
||||
@Nullable VoiceNotePlaybackState previousState)
|
||||
{
|
||||
Uri mediaUri = Objects.requireNonNull(mediaMetadataCompat.getDescription().getMediaUri());
|
||||
boolean autoReset = Objects.equals(mediaUri, VoiceNotePlaybackPreparer.NEXT_URI) || Objects.equals(mediaUri, VoiceNotePlaybackPreparer.END_URI);
|
||||
long position = mediaController.getPlaybackState().getPosition();
|
||||
long duration = mediaMetadataCompat.getLong(MediaMetadataCompat.METADATA_KEY_DURATION);
|
||||
Bundle extras = mediaController.getExtras();
|
||||
float speed = extras != null ? extras.getFloat(VoiceNotePlaybackService.ACTION_NEXT_PLAYBACK_SPEED, 1f) : 1f;
|
||||
|
||||
if (previousState != null && Objects.equals(mediaUri, previousState.getUri())) {
|
||||
if (position < 0 && previousState.getPlayheadPositionMillis() >= 0) {
|
||||
position = previousState.getPlayheadPositionMillis();
|
||||
}
|
||||
|
||||
if (duration <= 0 && previousState.getTrackDuration() > 0) {
|
||||
duration = previousState.getTrackDuration();
|
||||
}
|
||||
}
|
||||
|
||||
if (duration > 0 && position >= 0 && position <= duration) {
|
||||
return new VoiceNotePlaybackState(mediaUri,
|
||||
position,
|
||||
duration,
|
||||
autoReset,
|
||||
speed,
|
||||
isPlayerActive(mediaController.getPlaybackState()),
|
||||
getClipType(mediaMetadataCompat.getBundle()));
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static @Nullable VoiceNotePlaybackState constructPlaybackState(@NonNull MediaControllerCompat mediaController,
|
||||
@Nullable VoiceNotePlaybackState previousState)
|
||||
{
|
||||
MediaMetadataCompat mediaMetadataCompat = mediaController.getMetadata();
|
||||
if (isPlayerActive(mediaController.getPlaybackState()) &&
|
||||
canExtractPlaybackInformationFromMetadata(mediaMetadataCompat))
|
||||
{
|
||||
return extractStateFromMetadata(mediaController, mediaMetadataCompat, previousState);
|
||||
} else if (isPlayerPaused(mediaController.getPlaybackState()) &&
|
||||
mediaMetadataCompat != null)
|
||||
{
|
||||
long position = mediaController.getPlaybackState().getPosition();
|
||||
long duration = mediaMetadataCompat.getLong(MediaMetadataCompat.METADATA_KEY_DURATION);
|
||||
|
||||
if (previousState != null && position < duration) {
|
||||
return previousState.asPaused();
|
||||
} else {
|
||||
return VoiceNotePlaybackState.NONE;
|
||||
}
|
||||
} else {
|
||||
return VoiceNotePlaybackState.NONE;
|
||||
}
|
||||
}
|
||||
|
||||
private static @NonNull VoiceNotePlaybackState.ClipType getClipType(@Nullable Bundle mediaExtras) {
|
||||
long messageId = -1L;
|
||||
RecipientId senderId = RecipientId.UNKNOWN;
|
||||
long messagePosition = -1L;
|
||||
long threadId = -1L;
|
||||
RecipientId threadRecipientId = RecipientId.UNKNOWN;
|
||||
long timestamp = -1L;
|
||||
|
||||
if (mediaExtras != null) {
|
||||
messageId = mediaExtras.getLong(VoiceNoteMediaDescriptionCompatFactory.EXTRA_MESSAGE_ID, -1L);
|
||||
messagePosition = mediaExtras.getLong(VoiceNoteMediaDescriptionCompatFactory.EXTRA_MESSAGE_POSITION, -1L);
|
||||
threadId = mediaExtras.getLong(VoiceNoteMediaDescriptionCompatFactory.EXTRA_THREAD_ID, -1L);
|
||||
timestamp = mediaExtras.getLong(VoiceNoteMediaDescriptionCompatFactory.EXTRA_MESSAGE_TIMESTAMP, -1L);
|
||||
|
||||
String serializedSenderId = mediaExtras.getString(VoiceNoteMediaDescriptionCompatFactory.EXTRA_INDIVIDUAL_RECIPIENT_ID);
|
||||
if (serializedSenderId != null) {
|
||||
senderId = RecipientId.from(serializedSenderId);
|
||||
}
|
||||
|
||||
String serializedThreadRecipientId = mediaExtras.getString(VoiceNoteMediaDescriptionCompatFactory.EXTRA_THREAD_RECIPIENT_ID);
|
||||
if (serializedThreadRecipientId != null) {
|
||||
threadRecipientId = RecipientId.from(serializedThreadRecipientId);
|
||||
}
|
||||
}
|
||||
|
||||
if (messageId != -1L) {
|
||||
return new VoiceNotePlaybackState.ClipType.Message(messageId,
|
||||
senderId,
|
||||
threadRecipientId,
|
||||
messagePosition,
|
||||
threadId,
|
||||
timestamp);
|
||||
} else {
|
||||
return VoiceNotePlaybackState.ClipType.Draft.INSTANCE;
|
||||
}
|
||||
}
|
||||
|
||||
private static class ProgressEventHandler extends Handler {
|
||||
|
||||
private final MediaControllerCompat mediaController;
|
||||
@@ -223,36 +401,14 @@ public class VoiceNoteMediaController implements DefaultLifecycleObserver {
|
||||
|
||||
@Override
|
||||
public void handleMessage(@NonNull Message msg) {
|
||||
MediaMetadataCompat mediaMetadataCompat = mediaController.getMetadata();
|
||||
if (isPlayerActive(mediaController.getPlaybackState()) &&
|
||||
mediaMetadataCompat != null &&
|
||||
mediaMetadataCompat.getDescription() != null &&
|
||||
mediaMetadataCompat.getDescription().getMediaUri() != null)
|
||||
{
|
||||
VoiceNotePlaybackState newPlaybackState = constructPlaybackState(mediaController, voiceNotePlaybackState.getValue());
|
||||
|
||||
Uri mediaUri = Objects.requireNonNull(mediaMetadataCompat.getDescription().getMediaUri());
|
||||
boolean autoReset = Objects.equals(mediaUri, VoiceNotePlaybackPreparer.NEXT_URI) || Objects.equals(mediaUri, VoiceNotePlaybackPreparer.END_URI);
|
||||
VoiceNotePlaybackState previousState = voiceNotePlaybackState.getValue();
|
||||
long position = mediaController.getPlaybackState().getPosition();
|
||||
long duration = mediaMetadataCompat.getLong(MediaMetadataCompat.METADATA_KEY_DURATION);
|
||||
|
||||
if (previousState != null && Objects.equals(mediaUri, previousState.getUri())) {
|
||||
if (position < 0 && previousState.getPlayheadPositionMillis() >= 0) {
|
||||
position = previousState.getPlayheadPositionMillis();
|
||||
}
|
||||
|
||||
if (duration <= 0 && previousState.getTrackDuration() > 0) {
|
||||
duration = previousState.getTrackDuration();
|
||||
}
|
||||
}
|
||||
|
||||
if (duration > 0 && position >= 0 && position <= duration) {
|
||||
voiceNotePlaybackState.postValue(new VoiceNotePlaybackState(mediaUri, position, duration, autoReset));
|
||||
}
|
||||
if (newPlaybackState != null) {
|
||||
voiceNotePlaybackState.postValue(newPlaybackState);
|
||||
}
|
||||
|
||||
if (isPlayerActive(mediaController.getPlaybackState())) {
|
||||
sendEmptyMessageDelayed(0, 50);
|
||||
} else {
|
||||
voiceNotePlaybackState.postValue(VoiceNotePlaybackState.NONE);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -264,6 +420,10 @@ public class VoiceNoteMediaController implements DefaultLifecycleObserver {
|
||||
notifyProgressEventHandler();
|
||||
} else {
|
||||
clearProgressEventHandler();
|
||||
|
||||
if (isPlayerStopped(state)) {
|
||||
voiceNotePlaybackState.postValue(VoiceNotePlaybackState.NONE);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
package org.thoughtcrime.securesms.components.voice
|
||||
|
||||
interface VoiceNoteMediaControllerOwner {
|
||||
val voiceNoteMediaController: VoiceNoteMediaController
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import android.os.Bundle;
|
||||
import android.support.v4.media.MediaDescriptionCompat;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.WorkerThread;
|
||||
|
||||
import org.signal.core.util.logging.Log;
|
||||
@@ -14,10 +15,10 @@ import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord;
|
||||
import org.thoughtcrime.securesms.database.model.MmsMessageRecord;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.mms.AudioSlide;
|
||||
import org.thoughtcrime.securesms.preferences.widgets.NotificationPrivacyPreference;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.util.DateUtils;
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
@@ -34,23 +35,44 @@ class VoiceNoteMediaDescriptionCompatFactory {
|
||||
public static final String EXTRA_THREAD_ID = "voice.note.extra.THREAD_ID";
|
||||
public static final String EXTRA_COLOR = "voice.note.extra.COLOR";
|
||||
public static final String EXTRA_MESSAGE_ID = "voice.note.extra.MESSAGE_ID";
|
||||
public static final String EXTRA_MESSAGE_TIMESTAMP = "voice.note.extra.MESSAGE_TIMESTAMP";
|
||||
|
||||
private static final String TAG = Log.tag(VoiceNoteMediaDescriptionCompatFactory.class);
|
||||
|
||||
private VoiceNoteMediaDescriptionCompatFactory() {}
|
||||
|
||||
static MediaDescriptionCompat buildMediaDescription(@NonNull Context context,
|
||||
long threadId,
|
||||
@NonNull Uri draftUri)
|
||||
{
|
||||
|
||||
Recipient threadRecipient = DatabaseFactory.getThreadDatabase(context).getRecipientForThreadId(threadId);
|
||||
if (threadRecipient == null) {
|
||||
threadRecipient = Recipient.UNKNOWN;
|
||||
}
|
||||
|
||||
return buildMediaDescription(context,
|
||||
threadRecipient,
|
||||
Recipient.self(),
|
||||
Recipient.self(),
|
||||
0,
|
||||
threadId,
|
||||
-1,
|
||||
System.currentTimeMillis(),
|
||||
draftUri);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build out a MediaDescriptionCompat for a given voice note. Expects to be run
|
||||
* on a background thread.
|
||||
*
|
||||
* @param context Context.
|
||||
* @param messageRecord The MessageRecord of the given voice note.
|
||||
*
|
||||
* @return A MediaDescriptionCompat with all the details the service expects.
|
||||
*/
|
||||
@WorkerThread
|
||||
static MediaDescriptionCompat buildMediaDescription(@NonNull Context context,
|
||||
@NonNull MessageRecord messageRecord)
|
||||
@Nullable static MediaDescriptionCompat buildMediaDescription(@NonNull Context context,
|
||||
@NonNull MessageRecord messageRecord)
|
||||
{
|
||||
int startingPosition = DatabaseFactory.getMmsSmsDatabase(context)
|
||||
.getMessagePositionInConversation(messageRecord.getThreadId(),
|
||||
@@ -58,46 +80,87 @@ class VoiceNoteMediaDescriptionCompatFactory {
|
||||
|
||||
Recipient threadRecipient = Objects.requireNonNull(DatabaseFactory.getThreadDatabase(context)
|
||||
.getRecipientForThreadId(messageRecord.getThreadId()));
|
||||
Recipient sender = messageRecord.isOutgoing() ? Recipient.self() : messageRecord.getIndividualRecipient();
|
||||
Recipient avatarRecipient = threadRecipient.isGroup() ? threadRecipient : sender;
|
||||
Recipient sender = messageRecord.isOutgoing() ? Recipient.self() : messageRecord.getIndividualRecipient();
|
||||
Recipient avatarRecipient = threadRecipient.isGroup() ? threadRecipient : sender;
|
||||
AudioSlide audioSlide = ((MmsMessageRecord) messageRecord).getSlideDeck().getAudioSlide();
|
||||
|
||||
if (audioSlide == null) {
|
||||
Log.w(TAG, "Message does not have an audio slide. Can't play this voice note.");
|
||||
return null;
|
||||
}
|
||||
|
||||
Uri uri = audioSlide.getUri();
|
||||
if (uri == null) {
|
||||
Log.w(TAG, "Audio slide does not have a URI. Can't play this voice note.");
|
||||
return null;
|
||||
}
|
||||
|
||||
return buildMediaDescription(context,
|
||||
threadRecipient,
|
||||
avatarRecipient,
|
||||
sender,
|
||||
startingPosition,
|
||||
messageRecord.getThreadId(),
|
||||
messageRecord.getId(),
|
||||
messageRecord.getDateReceived(),
|
||||
uri);
|
||||
}
|
||||
|
||||
private static MediaDescriptionCompat buildMediaDescription(@NonNull Context context,
|
||||
@NonNull Recipient threadRecipient,
|
||||
@NonNull Recipient avatarRecipient,
|
||||
@NonNull Recipient sender,
|
||||
int startingPosition,
|
||||
long threadId,
|
||||
long messageId,
|
||||
long dateReceived,
|
||||
@NonNull Uri audioUri)
|
||||
{
|
||||
Bundle extras = new Bundle();
|
||||
extras.putString(EXTRA_THREAD_RECIPIENT_ID, threadRecipient.getId().serialize());
|
||||
extras.putString(EXTRA_AVATAR_RECIPIENT_ID, avatarRecipient.getId().serialize());
|
||||
extras.putString(EXTRA_INDIVIDUAL_RECIPIENT_ID, sender.getId().serialize());
|
||||
extras.putLong(EXTRA_MESSAGE_POSITION, startingPosition);
|
||||
extras.putLong(EXTRA_THREAD_ID, messageRecord.getThreadId());
|
||||
extras.putLong(EXTRA_THREAD_ID, threadId);
|
||||
extras.putLong(EXTRA_COLOR, threadRecipient.getChatColors().asSingleColor());
|
||||
extras.putLong(EXTRA_MESSAGE_ID, messageRecord.getId());
|
||||
extras.putLong(EXTRA_MESSAGE_ID, messageId);
|
||||
extras.putLong(EXTRA_MESSAGE_TIMESTAMP, dateReceived);
|
||||
|
||||
NotificationPrivacyPreference preference = SignalStore.settings().getMessageNotificationsPrivacy();
|
||||
|
||||
String title;
|
||||
if (preference.isDisplayContact() && threadRecipient.isGroup()) {
|
||||
title = context.getString(R.string.VoiceNoteMediaDescriptionCompatFactory__s_to_s,
|
||||
sender.getDisplayName(context),
|
||||
threadRecipient.getDisplayName(context));
|
||||
} else if (preference.isDisplayContact()) {
|
||||
title = sender.getDisplayName(context);
|
||||
} else {
|
||||
title = context.getString(R.string.MessageNotifier_signal_message);
|
||||
}
|
||||
String title = getTitle(context, sender, threadRecipient, preference);
|
||||
|
||||
String subtitle = null;
|
||||
if (preference.isDisplayContact()) {
|
||||
subtitle = context.getString(R.string.VoiceNoteMediaDescriptionCompatFactory__voice_message,
|
||||
DateUtils.formatDateWithoutDayOfWeek(Locale.getDefault(),
|
||||
messageRecord.getDateReceived()));
|
||||
dateReceived));
|
||||
}
|
||||
|
||||
Uri uri = ((MmsMessageRecord) messageRecord).getSlideDeck().getAudioSlide().getUri();
|
||||
|
||||
return new MediaDescriptionCompat.Builder()
|
||||
.setMediaUri(uri)
|
||||
.setMediaUri(audioUri)
|
||||
.setTitle(title)
|
||||
.setSubtitle(subtitle)
|
||||
.setExtras(extras)
|
||||
.build();
|
||||
}
|
||||
|
||||
public static @NonNull String getTitle(@NonNull Context context, @NonNull Recipient sender, @NonNull Recipient threadRecipient, @Nullable NotificationPrivacyPreference notificationPrivacyPreference) {
|
||||
NotificationPrivacyPreference preference;
|
||||
if (notificationPrivacyPreference == null) {
|
||||
preference = new NotificationPrivacyPreference("all");
|
||||
} else {
|
||||
preference = notificationPrivacyPreference;
|
||||
}
|
||||
|
||||
if (preference.isDisplayContact() && threadRecipient.isGroup()) {
|
||||
return context.getString(R.string.VoiceNoteMediaDescriptionCompatFactory__s_to_s,
|
||||
sender.getDisplayName(context),
|
||||
threadRecipient.getDisplayName(context));
|
||||
} else if (preference.isDisplayContact()) {
|
||||
return sender.getDisplayName(context);
|
||||
} else {
|
||||
return context.getString(R.string.MessageNotifier_signal_message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package org.thoughtcrime.securesms.components.voice
|
||||
|
||||
import android.os.Bundle
|
||||
import android.os.ResultReceiver
|
||||
import com.google.android.exoplayer2.PlaybackParameters
|
||||
import com.google.android.exoplayer2.Player
|
||||
import com.google.android.exoplayer2.ext.mediasession.DefaultPlaybackController
|
||||
|
||||
class VoiceNotePlaybackController(private val voiceNotePlaybackParameters: VoiceNotePlaybackParameters) : DefaultPlaybackController() {
|
||||
|
||||
override fun getCommands(): Array<String> {
|
||||
return arrayOf(VoiceNotePlaybackService.ACTION_NEXT_PLAYBACK_SPEED)
|
||||
}
|
||||
|
||||
override fun onCommand(player: Player, command: String, extras: Bundle?, cb: ResultReceiver?) {
|
||||
if (command == VoiceNotePlaybackService.ACTION_NEXT_PLAYBACK_SPEED) {
|
||||
val speed = extras?.getFloat(VoiceNotePlaybackService.ACTION_NEXT_PLAYBACK_SPEED, 1f) ?: 1f
|
||||
|
||||
player.playbackParameters = PlaybackParameters(speed)
|
||||
voiceNotePlaybackParameters.setSpeed(speed)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package org.thoughtcrime.securesms.components.voice;
|
||||
|
||||
import android.os.Bundle;
|
||||
import android.support.v4.media.session.MediaSessionCompat;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import com.google.android.exoplayer2.PlaybackParameters;
|
||||
|
||||
import org.signal.core.util.logging.Log;
|
||||
|
||||
public final class VoiceNotePlaybackParameters {
|
||||
|
||||
private final MediaSessionCompat mediaSessionCompat;
|
||||
|
||||
VoiceNotePlaybackParameters(@NonNull MediaSessionCompat mediaSessionCompat) {
|
||||
this.mediaSessionCompat = mediaSessionCompat;
|
||||
}
|
||||
|
||||
@NonNull PlaybackParameters getParameters() {
|
||||
float speed = getSpeed();
|
||||
return new PlaybackParameters(speed);
|
||||
}
|
||||
|
||||
void setSpeed(float speed) {
|
||||
Bundle extras = new Bundle();
|
||||
extras.putFloat(VoiceNotePlaybackService.ACTION_NEXT_PLAYBACK_SPEED, speed);
|
||||
|
||||
mediaSessionCompat.setExtras(extras);
|
||||
}
|
||||
|
||||
private float getSpeed() {
|
||||
Bundle extras = mediaSessionCompat.getController().getExtras();
|
||||
|
||||
if (extras == null) {
|
||||
return 1f;
|
||||
} else {
|
||||
return extras.getFloat(VoiceNotePlaybackService.ACTION_NEXT_PLAYBACK_SPEED, 1f);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import android.os.Bundle;
|
||||
import android.os.ResultReceiver;
|
||||
import android.support.v4.media.MediaDescriptionCompat;
|
||||
import android.support.v4.media.session.PlaybackStateCompat;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.annotation.MainThread;
|
||||
import androidx.annotation.NonNull;
|
||||
@@ -19,10 +20,14 @@ import com.google.android.exoplayer2.Timeline;
|
||||
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector;
|
||||
import com.google.android.exoplayer2.source.ConcatenatingMediaSource;
|
||||
|
||||
import org.signal.core.util.ThreadUtil;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||
import org.thoughtcrime.securesms.database.NoSuchMessageException;
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.util.MessageRecordUtil;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
import org.thoughtcrime.securesms.util.concurrent.SimpleTask;
|
||||
@@ -33,6 +38,7 @@ import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.Executor;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* ExoPlayer Preparer for Voice Notes. This only supports ACTION_PLAY_FROM_URI
|
||||
@@ -46,11 +52,12 @@ final class VoiceNotePlaybackPreparer implements MediaSessionConnector.PlaybackP
|
||||
public static final Uri NEXT_URI = Uri.parse("file:///android_asset/sounds/state-change_confirm-down.ogg");
|
||||
public static final Uri END_URI = Uri.parse("file:///android_asset/sounds/state-change_confirm-up.ogg");
|
||||
|
||||
private final Context context;
|
||||
private final SimpleExoPlayer player;
|
||||
private final Context context;
|
||||
private final SimpleExoPlayer player;
|
||||
private final VoiceNoteQueueDataAdapter queueDataAdapter;
|
||||
private final AttachmentMediaSourceFactory mediaSourceFactory;
|
||||
private final ConcatenatingMediaSource dataSource;
|
||||
private final ConcatenatingMediaSource dataSource;
|
||||
private final VoiceNotePlaybackParameters voiceNotePlaybackParameters;
|
||||
|
||||
private boolean canLoadMore;
|
||||
private Uri latestUri = Uri.EMPTY;
|
||||
@@ -58,13 +65,15 @@ final class VoiceNotePlaybackPreparer implements MediaSessionConnector.PlaybackP
|
||||
VoiceNotePlaybackPreparer(@NonNull Context context,
|
||||
@NonNull SimpleExoPlayer player,
|
||||
@NonNull VoiceNoteQueueDataAdapter queueDataAdapter,
|
||||
@NonNull AttachmentMediaSourceFactory mediaSourceFactory)
|
||||
@NonNull AttachmentMediaSourceFactory mediaSourceFactory,
|
||||
@NonNull VoiceNotePlaybackParameters voiceNotePlaybackParameters)
|
||||
{
|
||||
this.context = context;
|
||||
this.player = player;
|
||||
this.queueDataAdapter = queueDataAdapter;
|
||||
this.mediaSourceFactory = mediaSourceFactory;
|
||||
this.dataSource = new ConcatenatingMediaSource();
|
||||
this.dataSource = new ConcatenatingMediaSource();
|
||||
this.voiceNotePlaybackParameters = voiceNotePlaybackParameters;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -92,6 +101,7 @@ final class VoiceNotePlaybackPreparer implements MediaSessionConnector.PlaybackP
|
||||
Log.d(TAG, "onPrepareFromUri: " + uri);
|
||||
|
||||
long messageId = extras.getLong(VoiceNoteMediaController.EXTRA_MESSAGE_ID);
|
||||
long threadId = extras.getLong(VoiceNoteMediaController.EXTRA_THREAD_ID);
|
||||
double progress = extras.getDouble(VoiceNoteMediaController.EXTRA_PROGRESS, 0);
|
||||
boolean singlePlayback = extras.getBoolean(VoiceNoteMediaController.EXTRA_PLAY_SINGLE, false);
|
||||
|
||||
@@ -101,7 +111,11 @@ final class VoiceNotePlaybackPreparer implements MediaSessionConnector.PlaybackP
|
||||
SimpleTask.run(EXECUTOR,
|
||||
() -> {
|
||||
if (singlePlayback) {
|
||||
return loadMediaDescriptionForSinglePlayback(messageId);
|
||||
if (messageId != -1) {
|
||||
return loadMediaDescriptionForSinglePlayback(messageId);
|
||||
} else {
|
||||
return loadMediaDescriptionForDraftPlayback(threadId, uri);
|
||||
}
|
||||
} else {
|
||||
return loadMediaDescriptionsForConsecutivePlayback(messageId);
|
||||
}
|
||||
@@ -119,7 +133,10 @@ final class VoiceNotePlaybackPreparer implements MediaSessionConnector.PlaybackP
|
||||
@Override
|
||||
public void onTimelineChanged(Timeline timeline, @Nullable Object manifest, int reason) {
|
||||
if (timeline.getWindowCount() >= window) {
|
||||
player.setPlayWhenReady(false);
|
||||
player.setPlaybackParameters(voiceNotePlaybackParameters.getParameters());
|
||||
player.seekTo(window, (long) (player.getDuration() * progress));
|
||||
player.setPlayWhenReady(true);
|
||||
player.removeListener(this);
|
||||
}
|
||||
}
|
||||
@@ -127,6 +144,10 @@ final class VoiceNotePlaybackPreparer implements MediaSessionConnector.PlaybackP
|
||||
|
||||
player.prepare(dataSource);
|
||||
canLoadMore = !singlePlayback;
|
||||
} else if (Objects.equals(latestUri, uri)) {
|
||||
Log.w(TAG, "Requested playback but no voice notes could be found.");
|
||||
ThreadUtil.postToMain(() -> Toast.makeText(context, R.string.VoiceNotePlaybackPreparer__failed_to_play_voice_message, Toast.LENGTH_SHORT)
|
||||
.show());
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -249,21 +270,31 @@ final class VoiceNotePlaybackPreparer implements MediaSessionConnector.PlaybackP
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
return Collections.singletonList(VoiceNoteMediaDescriptionCompatFactory.buildMediaDescription(context ,messageRecord));
|
||||
MediaDescriptionCompat mediaDescriptionCompat = VoiceNoteMediaDescriptionCompatFactory.buildMediaDescription(context ,messageRecord);
|
||||
if (mediaDescriptionCompat == null) {
|
||||
return Collections.emptyList();
|
||||
} else {
|
||||
return Collections.singletonList(mediaDescriptionCompat);
|
||||
}
|
||||
} catch (NoSuchMessageException e) {
|
||||
Log.w(TAG, "Could not find message.", e);
|
||||
return Collections.emptyList();
|
||||
}
|
||||
}
|
||||
|
||||
private @NonNull List<MediaDescriptionCompat> loadMediaDescriptionForDraftPlayback(long threadId, @NonNull Uri draftUri) {
|
||||
return Collections.singletonList(VoiceNoteMediaDescriptionCompatFactory.buildMediaDescription(context, threadId, draftUri));
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private @NonNull List<MediaDescriptionCompat> loadMediaDescriptionsForConsecutivePlayback(long messageId) {
|
||||
try {
|
||||
List<MessageRecord> recordsAfter = DatabaseFactory.getMmsSmsDatabase(context).getMessagesAfterVoiceNoteInclusive(messageId, LIMIT);
|
||||
|
||||
return Stream.of(buildFilteredMessageRecordList(recordsAfter))
|
||||
.map(record -> VoiceNoteMediaDescriptionCompatFactory.buildMediaDescription(context, record))
|
||||
.toList();
|
||||
return buildFilteredMessageRecordList(recordsAfter).stream()
|
||||
.map(record -> VoiceNoteMediaDescriptionCompatFactory.buildMediaDescription(context, record))
|
||||
.filter(Objects::nonNull)
|
||||
.collect(Collectors.toList());
|
||||
} catch (NoSuchMessageException e) {
|
||||
Log.w(TAG, "Could not find message.", e);
|
||||
return Collections.emptyList();
|
||||
|
||||
@@ -26,6 +26,7 @@ import com.google.android.exoplayer2.DefaultRenderersFactory;
|
||||
import com.google.android.exoplayer2.ExoPlaybackException;
|
||||
import com.google.android.exoplayer2.ExoPlayerFactory;
|
||||
import com.google.android.exoplayer2.LoadControl;
|
||||
import com.google.android.exoplayer2.PlaybackParameters;
|
||||
import com.google.android.exoplayer2.Player;
|
||||
import com.google.android.exoplayer2.SimpleExoPlayer;
|
||||
import com.google.android.exoplayer2.audio.AudioAttributes;
|
||||
@@ -35,15 +36,15 @@ import com.google.android.exoplayer2.ui.PlayerNotificationManager;
|
||||
|
||||
import org.signal.core.util.concurrent.SignalExecutors;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||
import org.thoughtcrime.securesms.database.MessageDatabase;
|
||||
import org.thoughtcrime.securesms.database.MmsSmsDatabase;
|
||||
import org.thoughtcrime.securesms.database.model.MmsMessageRecord;
|
||||
import org.thoughtcrime.securesms.database.model.MessageId;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.jobs.MultiDeviceViewedUpdateJob;
|
||||
import org.thoughtcrime.securesms.jobs.SendViewedReceiptJob;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags;
|
||||
import org.thoughtcrime.securesms.service.KeyCachingService;
|
||||
import org.thoughtcrime.securesms.video.exo.AttachmentMediaSourceFactory;
|
||||
|
||||
import java.util.Collections;
|
||||
@@ -54,6 +55,8 @@ import java.util.List;
|
||||
*/
|
||||
public class VoiceNotePlaybackService extends MediaBrowserServiceCompat {
|
||||
|
||||
public static final String ACTION_NEXT_PLAYBACK_SPEED = "org.thoughtcrime.securesms.components.voice.VoiceNotePlaybackService.action.next_playback_speed";
|
||||
|
||||
private static final String TAG = Log.tag(VoiceNotePlaybackService.class);
|
||||
private static final String EMPTY_ROOT_ID = "empty-root-id";
|
||||
private static final int LOAD_MORE_THRESHOLD = 2;
|
||||
@@ -69,11 +72,13 @@ public class VoiceNotePlaybackService extends MediaBrowserServiceCompat {
|
||||
private PlaybackStateCompat.Builder stateBuilder;
|
||||
private SimpleExoPlayer player;
|
||||
private BecomingNoisyReceiver becomingNoisyReceiver;
|
||||
private KeyClearedReceiver keyClearedReceiver;
|
||||
private VoiceNoteNotificationManager voiceNoteNotificationManager;
|
||||
private VoiceNoteQueueDataAdapter queueDataAdapter;
|
||||
private VoiceNotePlaybackPreparer voiceNotePlaybackPreparer;
|
||||
private VoiceNoteProximityManager voiceNoteProximityManager;
|
||||
private boolean isForegroundService;
|
||||
private VoiceNotePlaybackParameters voiceNotePlaybackParameters;
|
||||
|
||||
private final LoadControl loadControl = new DefaultLoadControl.Builder()
|
||||
.setBufferDurationsMs(Integer.MAX_VALUE,
|
||||
@@ -87,10 +92,13 @@ public class VoiceNotePlaybackService extends MediaBrowserServiceCompat {
|
||||
super.onCreate();
|
||||
|
||||
mediaSession = new MediaSessionCompat(this, TAG);
|
||||
voiceNotePlaybackParameters = new VoiceNotePlaybackParameters(mediaSession);
|
||||
stateBuilder = new PlaybackStateCompat.Builder()
|
||||
.setActions(SUPPORTED_ACTIONS);
|
||||
mediaSessionConnector = new MediaSessionConnector(mediaSession, null);
|
||||
.setActions(SUPPORTED_ACTIONS)
|
||||
.addCustomAction(ACTION_NEXT_PLAYBACK_SPEED, "speed", R.drawable.ic_toggle_24);
|
||||
mediaSessionConnector = new MediaSessionConnector(mediaSession, new VoiceNotePlaybackController(voiceNotePlaybackParameters));
|
||||
becomingNoisyReceiver = new BecomingNoisyReceiver(this, mediaSession.getSessionToken());
|
||||
keyClearedReceiver = new KeyClearedReceiver(this, mediaSession.getSessionToken());
|
||||
player = ExoPlayerFactory.newSimpleInstance(this, new DefaultRenderersFactory(this), new DefaultTrackSelector(), loadControl);
|
||||
queueDataAdapter = new VoiceNoteQueueDataAdapter();
|
||||
voiceNoteNotificationManager = new VoiceNoteNotificationManager(this,
|
||||
@@ -100,7 +108,7 @@ public class VoiceNotePlaybackService extends MediaBrowserServiceCompat {
|
||||
|
||||
AttachmentMediaSourceFactory mediaSourceFactory = new AttachmentMediaSourceFactory(this);
|
||||
|
||||
voiceNotePlaybackPreparer = new VoiceNotePlaybackPreparer(this, player, queueDataAdapter, mediaSourceFactory);
|
||||
voiceNotePlaybackPreparer = new VoiceNotePlaybackPreparer(this, player, queueDataAdapter, mediaSourceFactory, voiceNotePlaybackParameters);
|
||||
voiceNoteProximityManager = new VoiceNoteProximityManager(this, player, queueDataAdapter);
|
||||
|
||||
mediaSession.setPlaybackState(stateBuilder.build());
|
||||
@@ -117,6 +125,7 @@ public class VoiceNotePlaybackService extends MediaBrowserServiceCompat {
|
||||
setSessionToken(mediaSession.getSessionToken());
|
||||
|
||||
mediaSession.setActive(true);
|
||||
keyClearedReceiver.register();
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -132,6 +141,7 @@ public class VoiceNotePlaybackService extends MediaBrowserServiceCompat {
|
||||
mediaSession.setActive(false);
|
||||
mediaSession.release();
|
||||
becomingNoisyReceiver.unregister();
|
||||
keyClearedReceiver.unregister();
|
||||
player.release();
|
||||
}
|
||||
|
||||
@@ -150,6 +160,7 @@ public class VoiceNotePlaybackService extends MediaBrowserServiceCompat {
|
||||
}
|
||||
|
||||
private class VoiceNotePlayerEventListener implements Player.EventListener {
|
||||
|
||||
@Override
|
||||
public void onPlayerStateChanged(boolean playWhenReady, int playbackState) {
|
||||
switch (playbackState) {
|
||||
@@ -182,9 +193,19 @@ public class VoiceNotePlaybackService extends MediaBrowserServiceCompat {
|
||||
}
|
||||
|
||||
if (reason == Player.DISCONTINUITY_REASON_PERIOD_TRANSITION) {
|
||||
MediaDescriptionCompat mediaDescriptionCompat = queueDataAdapter.getMediaDescription(currentWindowIndex);
|
||||
sendViewedReceiptForCurrentWindowIndex();
|
||||
MediaDescriptionCompat mediaDescriptionCompat = queueDataAdapter.getMediaDescription(currentWindowIndex);
|
||||
Log.d(TAG, "onPositionDiscontinuity: current window uri: " + mediaDescriptionCompat.getMediaUri());
|
||||
|
||||
PlaybackParameters playbackParameters = getPlaybackParametersForWindowPosition(currentWindowIndex);
|
||||
|
||||
final float speed = playbackParameters != null ? playbackParameters.speed : 1f;
|
||||
if (speed != player.getPlaybackParameters().speed) {
|
||||
player.setPlayWhenReady(false);
|
||||
player.setPlaybackParameters(playbackParameters);
|
||||
player.seekTo(currentWindowIndex, 1);
|
||||
player.setPlayWhenReady(true);
|
||||
}
|
||||
}
|
||||
|
||||
boolean isWithinThreshold = currentWindowIndex < LOAD_MORE_THRESHOLD ||
|
||||
@@ -202,6 +223,18 @@ public class VoiceNotePlaybackService extends MediaBrowserServiceCompat {
|
||||
}
|
||||
}
|
||||
|
||||
private @Nullable PlaybackParameters getPlaybackParametersForWindowPosition(int currentWindowIndex) {
|
||||
if (isAudioMessage(currentWindowIndex)) {
|
||||
return voiceNotePlaybackParameters.getParameters();
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isAudioMessage(int currentWindowIndex) {
|
||||
return currentWindowIndex % 2 == 0;
|
||||
}
|
||||
|
||||
private void sendViewedReceiptForCurrentWindowIndex() {
|
||||
if (player.getPlaybackState() == Player.STATE_READY &&
|
||||
player.getPlayWhenReady() &&
|
||||
@@ -225,7 +258,8 @@ public class VoiceNotePlaybackService extends MediaBrowserServiceCompat {
|
||||
if (markedMessageInfo != null) {
|
||||
ApplicationDependencies.getJobManager().add(new SendViewedReceiptJob(markedMessageInfo.getThreadId(),
|
||||
recipientId,
|
||||
markedMessageInfo.getSyncMessageId().getTimetamp()));
|
||||
markedMessageInfo.getSyncMessageId().getTimetamp(),
|
||||
new MessageId(messageId, true)));
|
||||
MultiDeviceViewedUpdateJob.enqueue(Collections.singletonList(markedMessageInfo.getSyncMessageId()));
|
||||
}
|
||||
});
|
||||
@@ -251,6 +285,46 @@ public class VoiceNotePlaybackService extends MediaBrowserServiceCompat {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Receiver to stop playback and kill the notification if user locks signal via screen lock.
|
||||
*/
|
||||
private static class KeyClearedReceiver extends BroadcastReceiver {
|
||||
private static final IntentFilter KEY_CLEARED_FILTER = new IntentFilter(KeyCachingService.CLEAR_KEY_EVENT);
|
||||
|
||||
private final Context context;
|
||||
private final MediaControllerCompat controller;
|
||||
|
||||
private boolean registered;
|
||||
|
||||
private KeyClearedReceiver(@NonNull Context context, @NonNull MediaSessionCompat.Token token) {
|
||||
this.context = context;
|
||||
try {
|
||||
this.controller = new MediaControllerCompat(context, token);
|
||||
} catch (RemoteException e) {
|
||||
throw new IllegalArgumentException("Failed to create controller from token", e);
|
||||
}
|
||||
}
|
||||
|
||||
void register() {
|
||||
if (!registered) {
|
||||
context.registerReceiver(this, KEY_CLEARED_FILTER);
|
||||
registered = true;
|
||||
}
|
||||
}
|
||||
|
||||
void unregister() {
|
||||
if (registered) {
|
||||
context.unregisterReceiver(this);
|
||||
registered = false;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onReceive(Context context, Intent intent) {
|
||||
controller.getTransportControls().stop();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Receiver to pause playback when things become noisy.
|
||||
*/
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
package org.thoughtcrime.securesms.components.voice;
|
||||
|
||||
import android.net.Uri;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
/**
|
||||
* Domain-level state object representing the state of the currently playing voice note.
|
||||
*/
|
||||
public class VoiceNotePlaybackState {
|
||||
|
||||
public static final VoiceNotePlaybackState NONE = new VoiceNotePlaybackState(Uri.EMPTY, 0, 0, false);
|
||||
|
||||
private final Uri uri;
|
||||
private final long playheadPositionMillis;
|
||||
private final long trackDuration;
|
||||
private final boolean autoReset;
|
||||
|
||||
public VoiceNotePlaybackState(@NonNull Uri uri, long playheadPositionMillis, long trackDuration, boolean autoReset) {
|
||||
this.uri = uri;
|
||||
this.playheadPositionMillis = playheadPositionMillis;
|
||||
this.trackDuration = trackDuration;
|
||||
this.autoReset = autoReset;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Uri of the currently playing AudioSlide
|
||||
*/
|
||||
public Uri getUri() {
|
||||
return uri;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return The last known playhead position
|
||||
*/
|
||||
public long getPlayheadPositionMillis() {
|
||||
return playheadPositionMillis;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return The track duration in ms
|
||||
*/
|
||||
public long getTrackDuration() {
|
||||
return trackDuration;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return true if we should reset the currently playing clip.
|
||||
*/
|
||||
public boolean isAutoReset() {
|
||||
return autoReset;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package org.thoughtcrime.securesms.components.voice
|
||||
|
||||
import android.net.Uri
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
|
||||
/**
|
||||
* Domain-level state object representing the state of the currently playing voice note.
|
||||
*/
|
||||
data class VoiceNotePlaybackState(
|
||||
/**
|
||||
* @return Uri of the currently playing AudioSlide
|
||||
*/
|
||||
val uri: Uri,
|
||||
|
||||
/**
|
||||
* @return The last known playhead position
|
||||
*/
|
||||
val playheadPositionMillis: Long,
|
||||
|
||||
/**
|
||||
* @return The track duration in ms
|
||||
*/
|
||||
val trackDuration: Long,
|
||||
|
||||
/**
|
||||
* @return true if we should reset the currently playing clip.
|
||||
*/
|
||||
val isAutoReset: Boolean,
|
||||
|
||||
/**
|
||||
* @return The current playback speed factor
|
||||
*/
|
||||
val speed: Float,
|
||||
|
||||
/**
|
||||
* @return Whether we are playing or paused
|
||||
*/
|
||||
val isPlaying: Boolean,
|
||||
|
||||
/**
|
||||
* @return Information about the type this clip represents.
|
||||
*/
|
||||
val clipType: ClipType
|
||||
) {
|
||||
companion object {
|
||||
@JvmField
|
||||
val NONE = VoiceNotePlaybackState(Uri.EMPTY, 0, 0, false, 1f, false, ClipType.Idle)
|
||||
}
|
||||
|
||||
fun asPaused(): VoiceNotePlaybackState {
|
||||
return copy(isPlaying = false)
|
||||
}
|
||||
|
||||
sealed class ClipType {
|
||||
data class Message(
|
||||
val messageId: Long,
|
||||
val senderId: RecipientId,
|
||||
val threadRecipientId: RecipientId,
|
||||
val messagePosition: Long,
|
||||
val threadId: Long,
|
||||
val timestamp: Long
|
||||
) : ClipType()
|
||||
object Draft : ClipType()
|
||||
object Idle : ClipType()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
package org.thoughtcrime.securesms.components.voice
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.util.AttributeSet
|
||||
import android.view.View
|
||||
import android.view.animation.Animation
|
||||
import android.view.animation.AnimationUtils
|
||||
import android.widget.TextView
|
||||
import androidx.constraintlayout.widget.ConstraintLayout
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.airbnb.lottie.LottieAnimationView
|
||||
import com.airbnb.lottie.LottieProperty
|
||||
import com.airbnb.lottie.SimpleColorFilter
|
||||
import com.airbnb.lottie.model.KeyPath
|
||||
import com.airbnb.lottie.value.LottieValueCallback
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.PlaybackSpeedToggleTextView
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
private const val ANIMATE_DURATION: Long = 150L
|
||||
private const val TO_PAUSE = 1
|
||||
private const val TO_PLAY = -1
|
||||
|
||||
/**
|
||||
* Renders a bar at the top of Conversation list and in a conversation to allow
|
||||
* playback manipulation of voice notes.
|
||||
*/
|
||||
class VoiceNotePlayerView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyleAttr: Int = 0
|
||||
) : ConstraintLayout(context, attrs, defStyleAttr) {
|
||||
|
||||
private val playPauseToggleView: LottieAnimationView
|
||||
private val infoView: TextView
|
||||
private val speedView: PlaybackSpeedToggleTextView
|
||||
private val closeButton: View
|
||||
|
||||
private var lastState: State? = null
|
||||
private var playerVisible: Boolean = false
|
||||
private var lottieDirection: Int = 0
|
||||
|
||||
var listener: Listener? = null
|
||||
|
||||
init {
|
||||
inflate(context, R.layout.voice_note_player_view, this)
|
||||
|
||||
playPauseToggleView = findViewById(R.id.voice_note_player_play_pause_toggle)
|
||||
infoView = findViewById(R.id.voice_note_player_info)
|
||||
speedView = findViewById(R.id.voice_note_player_speed)
|
||||
closeButton = findViewById(R.id.voice_note_player_close)
|
||||
|
||||
infoView.isSelected = true
|
||||
|
||||
val speedTouchTarget: View = findViewById(R.id.voice_note_player_speed_touch_target)
|
||||
speedTouchTarget.setOnClickListener {
|
||||
speedView.performClick()
|
||||
}
|
||||
|
||||
speedView.playbackSpeedListener = object : PlaybackSpeedToggleTextView.PlaybackSpeedListener {
|
||||
override fun onPlaybackSpeedChanged(speed: Float) {
|
||||
lastState?.let {
|
||||
listener?.onSpeedChangeRequested(it.uri, speed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
closeButton.setOnClickListener {
|
||||
lastState?.let {
|
||||
listener?.onCloseRequested(it.uri)
|
||||
}
|
||||
}
|
||||
|
||||
playPauseToggleView.setOnClickListener {
|
||||
lastState?.let {
|
||||
if (it.isPaused) {
|
||||
if (it.playbackPosition >= it.playbackDuration) {
|
||||
listener?.onPlay(it.uri, it.messageId, 0.0)
|
||||
} else {
|
||||
listener?.onPlay(it.uri, it.messageId, it.playbackPosition.toDouble() / it.playbackDuration)
|
||||
}
|
||||
} else {
|
||||
listener?.onPause(it.uri)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
post {
|
||||
playPauseToggleView.addValueCallback(
|
||||
KeyPath("**"),
|
||||
LottieProperty.COLOR_FILTER,
|
||||
LottieValueCallback(SimpleColorFilter(ContextCompat.getColor(context, R.color.signal_icon_tint_primary)))
|
||||
)
|
||||
}
|
||||
|
||||
if (background != null) {
|
||||
background.colorFilter = SimpleColorFilter(ContextCompat.getColor(context, R.color.voice_note_player_view_background))
|
||||
}
|
||||
|
||||
setOnClickListener {
|
||||
lastState?.let {
|
||||
listener?.onNavigateToMessage(it.threadId, it.threadRecipientId, it.senderId, it.messageTimestamp, it.messagePositionInThread)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setState(state: State) {
|
||||
this.lastState = state
|
||||
|
||||
if (state.isPaused) {
|
||||
animateToggleToPlay()
|
||||
} else {
|
||||
animateToggleToPause()
|
||||
}
|
||||
|
||||
infoView.text = context.getString(R.string.VoiceNotePlayerView__s_dot_s, state.name, formatDuration(state.playbackDuration - state.playbackPosition))
|
||||
speedView.setCurrentSpeed(state.playbackSpeed)
|
||||
}
|
||||
|
||||
fun show() {
|
||||
if (!playerVisible) {
|
||||
visibility = VISIBLE
|
||||
|
||||
val animation = AnimationUtils.loadAnimation(context, R.anim.slide_from_top)
|
||||
animation.duration = ANIMATE_DURATION
|
||||
|
||||
startAnimation(animation)
|
||||
}
|
||||
|
||||
playerVisible = true
|
||||
}
|
||||
|
||||
fun hide() {
|
||||
if (playerVisible) {
|
||||
val animation = AnimationUtils.loadAnimation(context, R.anim.slide_to_top)
|
||||
animation.duration = ANIMATE_DURATION
|
||||
animation.setAnimationListener(object : Animation.AnimationListener {
|
||||
override fun onAnimationStart(animation: Animation?) = Unit
|
||||
override fun onAnimationRepeat(animation: Animation?) = Unit
|
||||
|
||||
override fun onAnimationEnd(animation: Animation?) {
|
||||
visibility = GONE
|
||||
}
|
||||
})
|
||||
|
||||
startAnimation(animation)
|
||||
}
|
||||
|
||||
playerVisible = false
|
||||
}
|
||||
|
||||
private fun formatDuration(duration: Long): String {
|
||||
val secs = TimeUnit.MILLISECONDS.toSeconds(duration)
|
||||
|
||||
return resources.getString(R.string.AudioView_duration, secs / 60, secs % 60)
|
||||
}
|
||||
|
||||
private fun animateToggleToPlay() {
|
||||
startLottieAnimation(TO_PLAY)
|
||||
}
|
||||
|
||||
private fun animateToggleToPause() {
|
||||
startLottieAnimation(TO_PAUSE)
|
||||
}
|
||||
|
||||
private fun startLottieAnimation(direction: Int) {
|
||||
if (lottieDirection == direction) {
|
||||
return
|
||||
}
|
||||
|
||||
lottieDirection = direction
|
||||
playPauseToggleView.pauseAnimation()
|
||||
playPauseToggleView.speed = (direction * 2).toFloat()
|
||||
playPauseToggleView.resumeAnimation()
|
||||
}
|
||||
|
||||
data class State(
|
||||
val uri: Uri,
|
||||
val messageId: Long,
|
||||
val threadId: Long,
|
||||
val isPaused: Boolean,
|
||||
val senderId: RecipientId,
|
||||
val threadRecipientId: RecipientId,
|
||||
val messagePositionInThread: Long,
|
||||
val messageTimestamp: Long,
|
||||
val name: String,
|
||||
val playbackPosition: Long,
|
||||
val playbackDuration: Long,
|
||||
val playbackSpeed: Float
|
||||
)
|
||||
|
||||
interface Listener {
|
||||
fun onPlay(uri: Uri, messageId: Long, position: Double)
|
||||
fun onPause(uri: Uri)
|
||||
fun onCloseRequested(uri: Uri)
|
||||
fun onSpeedChangeRequested(uri: Uri, speed: Float)
|
||||
fun onNavigateToMessage(threadId: Long, threadRecipientId: RecipientId, senderId: RecipientId, messageSentAt: Long, messagePositionInThread: Long)
|
||||
}
|
||||
}
|
||||
@@ -108,15 +108,31 @@ public class ContactRepository {
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
public Cursor querySignalContacts(@NonNull String query) {
|
||||
public @NonNull Cursor querySignalContacts(@NonNull String query) {
|
||||
return querySignalContacts(query, true);
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
public Cursor querySignalContacts(@NonNull String query, boolean includeSelf) {
|
||||
Cursor cursor = TextUtils.isEmpty(query) ? recipientDatabase.getSignalContacts(includeSelf)
|
||||
: recipientDatabase.querySignalContacts(query, includeSelf);
|
||||
public @NonNull Cursor querySignalContacts(@NonNull String query, boolean includeSelf) {
|
||||
Cursor cursor = TextUtils.isEmpty(query) ? recipientDatabase.getSignalContacts(includeSelf)
|
||||
: recipientDatabase.querySignalContacts(query, includeSelf);
|
||||
|
||||
cursor = handleNoteToSelfQuery(query, includeSelf, cursor);
|
||||
|
||||
return new SearchCursorWrapper(cursor, SEARCH_CURSOR_MAPPERS);
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
public @NonNull Cursor queryNonGroupContacts(@NonNull String query, boolean includeSelf) {
|
||||
Cursor cursor = TextUtils.isEmpty(query) ? recipientDatabase.getNonGroupContacts(includeSelf)
|
||||
: recipientDatabase.queryNonGroupContacts(query, includeSelf);
|
||||
|
||||
cursor = handleNoteToSelfQuery(query, includeSelf, cursor);
|
||||
|
||||
return new SearchCursorWrapper(cursor, SEARCH_CURSOR_MAPPERS);
|
||||
}
|
||||
|
||||
private @NonNull Cursor handleNoteToSelfQuery(@NonNull String query, boolean includeSelf, Cursor cursor) {
|
||||
if (includeSelf && noteToSelfTitle.toLowerCase().contains(query.toLowerCase())) {
|
||||
Recipient self = Recipient.self();
|
||||
boolean nameMatch = self.getDisplayName(context).toLowerCase().contains(query.toLowerCase());
|
||||
@@ -130,8 +146,7 @@ public class ContactRepository {
|
||||
cursor = cursor == null ? selfCursor : new MergeCursor(new Cursor[]{ cursor, selfCursor });
|
||||
}
|
||||
}
|
||||
|
||||
return new SearchCursorWrapper(cursor, SEARCH_CURSOR_MAPPERS);
|
||||
return cursor;
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
/**
|
||||
* Copyright (C) 2014 Open Whisper Systems
|
||||
*
|
||||
* <p>
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* <p>
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* <p>
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
@@ -21,6 +21,7 @@ import android.database.Cursor;
|
||||
import android.provider.ContactsContract;
|
||||
import android.text.SpannableString;
|
||||
import android.text.Spanned;
|
||||
import android.text.TextUtils;
|
||||
import android.text.style.ForegroundColorSpan;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
@@ -29,7 +30,6 @@ import android.widget.TextView;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.core.content.ContextCompat;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import org.signal.core.util.logging.Log;
|
||||
@@ -40,12 +40,15 @@ import org.thoughtcrime.securesms.contacts.ContactSelectionListAdapter.ViewHolde
|
||||
import org.thoughtcrime.securesms.database.CursorRecyclerViewAdapter;
|
||||
import org.thoughtcrime.securesms.mms.GlideRequests;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.util.CharacterIterable;
|
||||
import org.thoughtcrime.securesms.util.CursorUtil;
|
||||
import org.thoughtcrime.securesms.util.StickyHeaderDecoration.StickyHeaderAdapter;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
|
||||
import java.util.Iterator;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
@@ -54,8 +57,8 @@ import java.util.Set;
|
||||
* @author Jake McGinty
|
||||
*/
|
||||
public class ContactSelectionListAdapter extends CursorRecyclerViewAdapter<ViewHolder>
|
||||
implements FastScrollAdapter,
|
||||
StickyHeaderAdapter<HeaderViewHolder>
|
||||
implements FastScrollAdapter,
|
||||
StickyHeaderAdapter<HeaderViewHolder>
|
||||
{
|
||||
@SuppressWarnings("unused")
|
||||
private final static String TAG = Log.tag(ContactSelectionListAdapter.class);
|
||||
@@ -98,14 +101,28 @@ public class ContactSelectionListAdapter extends CursorRecyclerViewAdapter<ViewH
|
||||
super(itemView);
|
||||
}
|
||||
|
||||
public abstract void bind(@NonNull GlideRequests glideRequests, @Nullable RecipientId recipientId, int type, String name, String number, String label, String about, int color, boolean checkboxVisible);
|
||||
public abstract void bind(@NonNull GlideRequests glideRequests, @Nullable RecipientId recipientId, int type, String name, String number, String label, String about, boolean checkboxVisible);
|
||||
|
||||
public abstract void unbind(@NonNull GlideRequests glideRequests);
|
||||
|
||||
public abstract void setChecked(boolean checked);
|
||||
|
||||
public void animateChecked(boolean checked) {
|
||||
// Intentionally empty.
|
||||
}
|
||||
|
||||
public abstract void setEnabled(boolean enabled);
|
||||
|
||||
public void setLetterHeaderCharacter(@Nullable String letterHeaderCharacter) {
|
||||
// Intentionally empty.
|
||||
}
|
||||
}
|
||||
|
||||
public static class ContactViewHolder extends ViewHolder {
|
||||
ContactViewHolder(@NonNull final View itemView,
|
||||
public static class ContactViewHolder extends ViewHolder implements LetterHeaderDecoration.LetterHeaderItem {
|
||||
|
||||
private String letterHeader;
|
||||
|
||||
ContactViewHolder(@NonNull final View itemView,
|
||||
@Nullable final ItemClickListener clickListener)
|
||||
{
|
||||
super(itemView);
|
||||
@@ -118,8 +135,8 @@ public class ContactSelectionListAdapter extends CursorRecyclerViewAdapter<ViewH
|
||||
return (ContactSelectionListItem) itemView;
|
||||
}
|
||||
|
||||
public void bind(@NonNull GlideRequests glideRequests, @Nullable RecipientId recipientId, int type, String name, String number, String label, String about, int color, boolean checkBoxVisible) {
|
||||
getView().set(glideRequests, recipientId, type, name, number, label, about, color, checkBoxVisible);
|
||||
public void bind(@NonNull GlideRequests glideRequests, @Nullable RecipientId recipientId, int type, String name, String number, String label, String about, boolean checkBoxVisible) {
|
||||
getView().set(glideRequests, recipientId, type, name, number, label, about, checkBoxVisible);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -129,13 +146,28 @@ public class ContactSelectionListAdapter extends CursorRecyclerViewAdapter<ViewH
|
||||
|
||||
@Override
|
||||
public void setChecked(boolean checked) {
|
||||
getView().setChecked(checked);
|
||||
getView().setChecked(checked, false);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void animateChecked(boolean checked) {
|
||||
getView().setChecked(checked, true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setEnabled(boolean enabled) {
|
||||
getView().setEnabled(enabled);
|
||||
}
|
||||
|
||||
@Override
|
||||
public @Nullable String getHeaderLetter() {
|
||||
return letterHeader;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setLetterHeaderCharacter(@Nullable String letterHeaderCharacter) {
|
||||
this.letterHeader = letterHeaderCharacter;
|
||||
}
|
||||
}
|
||||
|
||||
public static class DividerViewHolder extends ViewHolder {
|
||||
@@ -148,7 +180,7 @@ public class ContactSelectionListAdapter extends CursorRecyclerViewAdapter<ViewH
|
||||
}
|
||||
|
||||
@Override
|
||||
public void bind(@NonNull GlideRequests glideRequests, @Nullable RecipientId recipientId, int type, String name, String number, String label, String about, int color, boolean checkboxVisible) {
|
||||
public void bind(@NonNull GlideRequests glideRequests, @Nullable RecipientId recipientId, int type, String name, String number, String label, String about, boolean checkboxVisible) {
|
||||
this.label.setText(name);
|
||||
}
|
||||
|
||||
@@ -168,15 +200,15 @@ public class ContactSelectionListAdapter extends CursorRecyclerViewAdapter<ViewH
|
||||
}
|
||||
}
|
||||
|
||||
public ContactSelectionListAdapter(@NonNull Context context,
|
||||
@NonNull GlideRequests glideRequests,
|
||||
public ContactSelectionListAdapter(@NonNull Context context,
|
||||
@NonNull GlideRequests glideRequests,
|
||||
@Nullable Cursor cursor,
|
||||
@Nullable ItemClickListener clickListener,
|
||||
boolean multiSelect,
|
||||
@NonNull Set<RecipientId> currentContacts)
|
||||
{
|
||||
super(context, cursor);
|
||||
this.layoutInflater = LayoutInflater.from(context);
|
||||
this.layoutInflater = LayoutInflater.from(context);
|
||||
this.glideRequests = glideRequests;
|
||||
this.multiSelect = multiSelect;
|
||||
this.clickListener = clickListener;
|
||||
@@ -186,7 +218,7 @@ public class ContactSelectionListAdapter extends CursorRecyclerViewAdapter<ViewH
|
||||
@Override
|
||||
public long getHeaderId(int i) {
|
||||
if (!isActiveCursor()) return -1;
|
||||
else if (i == -1) return -1;
|
||||
else if (i == -1) return -1;
|
||||
|
||||
int contactType = getContactType(i);
|
||||
|
||||
@@ -215,15 +247,10 @@ public class ContactSelectionListAdapter extends CursorRecyclerViewAdapter<ViewH
|
||||
String label = CursorUtil.requireString(cursor, ContactRepository.LABEL_COLUMN);
|
||||
String labelText = ContactsContract.CommonDataKinds.Phone.getTypeLabel(getContext().getResources(),
|
||||
numberType, label).toString();
|
||||
boolean isPush = (contactType & ContactRepository.PUSH_TYPE) > 0;
|
||||
|
||||
int color = isPush ? ContextCompat.getColor(getContext(), R.color.signal_text_primary)
|
||||
: ContextCompat.getColor(getContext(), R.color.signal_inverse_transparent_60);
|
||||
|
||||
boolean currentContact = currentContacts.contains(id);
|
||||
|
||||
viewHolder.unbind(glideRequests);
|
||||
viewHolder.bind(glideRequests, id, contactType, name, number, labelText, about, color, multiSelect || currentContact);
|
||||
viewHolder.bind(glideRequests, id, contactType, name, number, labelText, about, multiSelect || currentContact);
|
||||
viewHolder.setEnabled(true);
|
||||
|
||||
if (currentContact) {
|
||||
@@ -234,6 +261,54 @@ public class ContactSelectionListAdapter extends CursorRecyclerViewAdapter<ViewH
|
||||
} else {
|
||||
viewHolder.setChecked(selectedContacts.contains(SelectedContact.forPhone(id, number)));
|
||||
}
|
||||
|
||||
if (isContactRow(contactType)) {
|
||||
int position = cursor.getPosition();
|
||||
if (position == 0) {
|
||||
viewHolder.setLetterHeaderCharacter(getHeaderLetterForDisplayName(cursor));
|
||||
} else {
|
||||
cursor.moveToPrevious();
|
||||
|
||||
int previousRowContactType = CursorUtil.requireInt(cursor, ContactRepository.CONTACT_TYPE_COLUMN);
|
||||
|
||||
if (!isContactRow(previousRowContactType)) {
|
||||
cursor.moveToNext();
|
||||
viewHolder.setLetterHeaderCharacter(getHeaderLetterForDisplayName(cursor));
|
||||
} else {
|
||||
String previousHeaderLetter = getHeaderLetterForDisplayName(cursor);
|
||||
cursor.moveToNext();
|
||||
String newHeaderLetter = getHeaderLetterForDisplayName(cursor);
|
||||
|
||||
if (Objects.equals(previousHeaderLetter, newHeaderLetter)) {
|
||||
viewHolder.setLetterHeaderCharacter(null);
|
||||
} else {
|
||||
viewHolder.setLetterHeaderCharacter(newHeaderLetter);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isContactRow(int contactType) {
|
||||
return (contactType & (ContactRepository.NEW_PHONE_TYPE | ContactRepository.NEW_USERNAME_TYPE | ContactRepository.DIVIDER_TYPE)) == 0;
|
||||
}
|
||||
|
||||
private @Nullable String getHeaderLetterForDisplayName(@NonNull Cursor cursor) {
|
||||
String name = CursorUtil.requireString(cursor, ContactRepository.NAME_COLUMN);
|
||||
Iterator<String> characterIterator = new CharacterIterable(name).iterator();
|
||||
|
||||
if (!TextUtils.isEmpty(name) && characterIterator.hasNext()) {
|
||||
String next = characterIterator.next();
|
||||
|
||||
if (Character.isLetter(next.codePointAt(0))) {
|
||||
return next.toUpperCase();
|
||||
} else {
|
||||
return "#";
|
||||
}
|
||||
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -250,12 +325,12 @@ public class ContactSelectionListAdapter extends CursorRecyclerViewAdapter<ViewH
|
||||
viewHolder.setEnabled(true);
|
||||
|
||||
if (currentContacts.contains(id)) {
|
||||
viewHolder.setChecked(true);
|
||||
viewHolder.animateChecked(true);
|
||||
viewHolder.setEnabled(false);
|
||||
} else if (numberType == ContactRepository.NEW_USERNAME_TYPE) {
|
||||
viewHolder.setChecked(selectedContacts.contains(SelectedContact.forUsername(id, number)));
|
||||
viewHolder.animateChecked(selectedContacts.contains(SelectedContact.forUsername(id, number)));
|
||||
} else {
|
||||
viewHolder.setChecked(selectedContacts.contains(SelectedContact.forPhone(id, number)));
|
||||
viewHolder.animateChecked(selectedContacts.contains(SelectedContact.forPhone(id, number)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -275,7 +350,7 @@ public class ContactSelectionListAdapter extends CursorRecyclerViewAdapter<ViewH
|
||||
|
||||
@Override
|
||||
public void onBindHeaderViewHolder(HeaderViewHolder viewHolder, int position, int type) {
|
||||
((TextView)viewHolder.itemView).setText(getSpannedHeaderString(position));
|
||||
((TextView) viewHolder.itemView).setText(getSpannedHeaderString(position));
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -301,6 +376,10 @@ public class ContactSelectionListAdapter extends CursorRecyclerViewAdapter<ViewH
|
||||
return selectedContacts.size();
|
||||
}
|
||||
|
||||
public int getCurrentContactsCount() {
|
||||
return currentContacts.size();
|
||||
}
|
||||
|
||||
private CharSequence getSpannedHeaderString(int position) {
|
||||
final String headerString = getHeaderString(position);
|
||||
if (isPush(position)) {
|
||||
|
||||
@@ -34,6 +34,7 @@ public class ContactSelectionListItem extends LinearLayout implements RecipientF
|
||||
private FromTextView nameView;
|
||||
private TextView labelView;
|
||||
private CheckBox checkBox;
|
||||
private View smsTag;
|
||||
|
||||
private String number;
|
||||
private String chipName;
|
||||
@@ -61,6 +62,7 @@ public class ContactSelectionListItem extends LinearLayout implements RecipientF
|
||||
this.labelView = findViewById(R.id.label);
|
||||
this.nameView = findViewById(R.id.name);
|
||||
this.checkBox = findViewById(R.id.check_box);
|
||||
this.smsTag = findViewById(R.id.sms_tag);
|
||||
|
||||
ViewUtil.setTextViewGravityStart(this.nameView, getContext());
|
||||
}
|
||||
@@ -72,7 +74,6 @@ public class ContactSelectionListItem extends LinearLayout implements RecipientF
|
||||
String number,
|
||||
String label,
|
||||
String about,
|
||||
int color,
|
||||
boolean checkboxVisible)
|
||||
{
|
||||
this.glideRequests = glideRequests;
|
||||
@@ -92,10 +93,14 @@ public class ContactSelectionListItem extends LinearLayout implements RecipientF
|
||||
name = this.recipient.get().getDisplayName(getContext());
|
||||
}
|
||||
|
||||
if (recipient == null || recipient.get().isRegistered()) {
|
||||
smsTag.setVisibility(GONE);
|
||||
} else {
|
||||
smsTag.setVisibility(VISIBLE);
|
||||
}
|
||||
|
||||
Recipient recipientSnapshot = recipient != null ? recipient.get() : null;
|
||||
|
||||
this.nameView.setTextColor(color);
|
||||
this.numberView.setTextColor(color);
|
||||
if (recipientSnapshot == null || recipientSnapshot.isResolving()) {
|
||||
this.contactPhotoImage.setAvatar(glideRequests, null, false);
|
||||
setText(null, type, name, number, label, about);
|
||||
@@ -107,8 +112,20 @@ public class ContactSelectionListItem extends LinearLayout implements RecipientF
|
||||
this.checkBox.setVisibility(checkboxVisible ? View.VISIBLE : View.GONE);
|
||||
}
|
||||
|
||||
public void setChecked(boolean selected) {
|
||||
this.checkBox.setChecked(selected);
|
||||
public void setChecked(boolean selected, boolean animate) {
|
||||
boolean wasSelected = checkBox.isChecked();
|
||||
|
||||
if (wasSelected != selected) {
|
||||
checkBox.setChecked(selected);
|
||||
|
||||
float alpha = selected ? 1f : 0f;
|
||||
if (animate) {
|
||||
checkBox.animate().setDuration(250L).alpha(alpha);
|
||||
} else {
|
||||
checkBox.animate().cancel();
|
||||
checkBox.setAlpha(alpha);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -146,7 +163,7 @@ public class ContactSelectionListItem extends LinearLayout implements RecipientF
|
||||
} else {
|
||||
this.numberView.setText(!Util.isEmpty(about) ? about : number);
|
||||
this.nameView.setEnabled(true);
|
||||
this.labelView.setText(label != null && !label.equals("null") ? label : "");
|
||||
this.labelView.setText(label != null && !label.equals("null") ? getResources().getString(R.string.ContactSelectionListItem__dot_s, label) : "");
|
||||
this.labelView.setVisibility(View.VISIBLE);
|
||||
}
|
||||
|
||||
@@ -189,7 +206,11 @@ public class ContactSelectionListItem extends LinearLayout implements RecipientF
|
||||
|
||||
@Override
|
||||
public void onRecipientChanged(@NonNull Recipient recipient) {
|
||||
contactPhotoImage.setAvatar(glideRequests, recipient, false);
|
||||
setText(recipient, contactType, contactName, contactNumber, contactLabel, contactAbout);
|
||||
if (this.recipient != null && this.recipient.getId().equals(recipient.getId())) {
|
||||
contactPhotoImage.setAvatar(glideRequests, recipient, false);
|
||||
setText(recipient, contactType, contactName, contactNumber, contactLabel, contactAbout);
|
||||
} else {
|
||||
Log.w(TAG, "Bad change! Local recipient doesn't match. Ignoring. Local: " + (this.recipient == null ? "null" : this.recipient.getId()) + ", Changed: " + recipient.getId());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
*/
|
||||
package org.thoughtcrime.securesms.contacts;
|
||||
|
||||
import android.Manifest;
|
||||
import android.content.Context;
|
||||
import android.database.Cursor;
|
||||
import android.database.MatrixCursor;
|
||||
@@ -30,7 +29,6 @@ import org.thoughtcrime.securesms.database.GroupDatabase;
|
||||
import org.thoughtcrime.securesms.database.RecipientDatabase;
|
||||
import org.thoughtcrime.securesms.database.ThreadDatabase;
|
||||
import org.thoughtcrime.securesms.database.model.ThreadRecord;
|
||||
import org.thoughtcrime.securesms.permissions.Permissions;
|
||||
import org.thoughtcrime.securesms.phonenumbers.NumberUtil;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
@@ -126,7 +124,9 @@ public class ContactsCursorLoader extends AbstractContactsCursorLoader {
|
||||
List<Cursor> contacts = getContactsCursors();
|
||||
|
||||
if (!isCursorListEmpty(contacts)) {
|
||||
cursorList.add(ContactsCursorRows.forContactsHeader(getContext()));
|
||||
if (!getFilter().isEmpty() || recents) {
|
||||
cursorList.add(ContactsCursorRows.forContactsHeader(getContext()));
|
||||
}
|
||||
cursorList.addAll(contacts);
|
||||
}
|
||||
}
|
||||
@@ -195,19 +195,14 @@ public class ContactsCursorLoader extends AbstractContactsCursorLoader {
|
||||
private List<Cursor> getContactsCursors() {
|
||||
List<Cursor> cursorList = new ArrayList<>(2);
|
||||
|
||||
if (!Permissions.hasAny(getContext(), Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS)) {
|
||||
return cursorList;
|
||||
}
|
||||
|
||||
if (pushEnabled(mode)) {
|
||||
cursorList.add(contactRepository.querySignalContacts(getFilter(), selfEnabled(mode)));
|
||||
}
|
||||
|
||||
if (pushEnabled(mode) && smsEnabled(mode)) {
|
||||
cursorList.add(contactRepository.queryNonSignalContacts(getFilter()));
|
||||
cursorList.add(contactRepository.queryNonGroupContacts(getFilter(), selfEnabled(mode)));
|
||||
} else if (pushEnabled(mode)) {
|
||||
cursorList.add(contactRepository.querySignalContacts(getFilter(), selfEnabled(mode)));
|
||||
} else if (smsEnabled(mode)) {
|
||||
cursorList.add(filterNonPushContacts(contactRepository.queryNonSignalContacts(getFilter())));
|
||||
cursorList.add(contactRepository.queryNonSignalContacts(getFilter()));
|
||||
}
|
||||
|
||||
return cursorList;
|
||||
}
|
||||
|
||||
@@ -240,25 +235,6 @@ public class ContactsCursorLoader extends AbstractContactsCursorLoader {
|
||||
}
|
||||
}
|
||||
|
||||
private @NonNull Cursor filterNonPushContacts(@NonNull Cursor cursor) {
|
||||
try {
|
||||
final long startMillis = System.currentTimeMillis();
|
||||
final MatrixCursor matrix = ContactsCursorRows.createMatrixCursor();
|
||||
while (cursor.moveToNext()) {
|
||||
final RecipientId id = RecipientId.from(cursor.getLong(cursor.getColumnIndexOrThrow(ContactRepository.ID_COLUMN)));
|
||||
final Recipient recipient = Recipient.resolved(id);
|
||||
|
||||
if (recipient.resolve().getRegistered() != RecipientDatabase.RegisteredState.REGISTERED) {
|
||||
matrix.addRow(ContactsCursorRows.forNonPushContact(cursor));
|
||||
}
|
||||
}
|
||||
Log.i(TAG, "filterNonPushContacts() -> " + (System.currentTimeMillis() - startMillis) + "ms");
|
||||
return matrix;
|
||||
} finally {
|
||||
cursor.close();
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isCursorListEmpty(List<Cursor> list) {
|
||||
int sum = 0;
|
||||
for (Cursor cursor : list) {
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
package org.thoughtcrime.securesms.contacts
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Rect
|
||||
import android.graphics.Typeface
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.util.ViewUtil
|
||||
|
||||
/**
|
||||
* ItemDecoration which paints a letter header at the appropriate location above a LetterHeaderItem.
|
||||
*/
|
||||
class LetterHeaderDecoration(private val context: Context, private val hideDecoration: () -> Boolean) : RecyclerView.ItemDecoration() {
|
||||
|
||||
private val textBounds = Rect()
|
||||
private val bounds = Rect()
|
||||
private val padTop = ViewUtil.dpToPx(16)
|
||||
private val padStart = context.resources.getDimensionPixelSize(R.dimen.dsl_settings_gutter)
|
||||
|
||||
private var dividerHeight = -1
|
||||
|
||||
private val textPaint = Paint().apply {
|
||||
color = ContextCompat.getColor(context, R.color.signal_text_primary)
|
||||
isAntiAlias = true
|
||||
style = Paint.Style.FILL
|
||||
typeface = Typeface.create("sans-serif-medium", Typeface.BOLD)
|
||||
textAlign = Paint.Align.LEFT
|
||||
textSize = ViewUtil.spToPx(16f).toFloat()
|
||||
}
|
||||
|
||||
override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) {
|
||||
val viewHolder = parent.getChildViewHolder(view)
|
||||
if (hideDecoration() || viewHolder !is LetterHeaderItem || viewHolder.getHeaderLetter() == null) {
|
||||
outRect.set(0, 0, 0, 0)
|
||||
return
|
||||
}
|
||||
|
||||
if (dividerHeight == -1) {
|
||||
val v = LayoutInflater.from(context).inflate(R.layout.dsl_section_header, parent, false)
|
||||
v.measure(0, 0)
|
||||
dividerHeight = v.measuredHeight
|
||||
}
|
||||
outRect.set(0, dividerHeight, 0, 0)
|
||||
}
|
||||
|
||||
override fun onDrawOver(canvas: Canvas, parent: RecyclerView, state: RecyclerView.State) {
|
||||
if (hideDecoration()) {
|
||||
return
|
||||
}
|
||||
|
||||
val childCount = parent.childCount
|
||||
val isRtl = parent.layoutDirection == View.LAYOUT_DIRECTION_RTL
|
||||
|
||||
for (i in 0 until childCount) {
|
||||
val child = parent.getChildAt(i)
|
||||
val holder = parent.getChildViewHolder(child)
|
||||
val headerLetter = if (holder is LetterHeaderItem) holder.getHeaderLetter() else null
|
||||
|
||||
if (headerLetter != null) {
|
||||
parent.getDecoratedBoundsWithMargins(child, bounds)
|
||||
|
||||
textPaint.getTextBounds(headerLetter, 0, headerLetter.length, textBounds)
|
||||
|
||||
val x = if (isRtl) getLayoutBoundsRTL() else getLayoutBoundsLTR()
|
||||
val y = bounds.top + padTop - textBounds.top
|
||||
|
||||
canvas.save()
|
||||
canvas.drawText(headerLetter, x.toFloat(), y.toFloat(), textPaint)
|
||||
canvas.restore()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getLayoutBoundsLTR() = bounds.left + padStart
|
||||
|
||||
private fun getLayoutBoundsRTL() = bounds.right - padStart - textBounds.width()
|
||||
|
||||
interface LetterHeaderItem {
|
||||
fun getHeaderLetter(): String?
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,16 @@
|
||||
package org.thoughtcrime.securesms.contacts.avatars;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.Color;
|
||||
import android.graphics.drawable.ColorDrawable;
|
||||
import android.graphics.drawable.Drawable;
|
||||
import android.graphics.drawable.GradientDrawable;
|
||||
import android.graphics.drawable.LayerDrawable;
|
||||
|
||||
import androidx.annotation.DrawableRes;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.appcompat.content.res.AppCompatResources;
|
||||
import androidx.core.content.ContextCompat;
|
||||
import androidx.core.graphics.drawable.DrawableCompat;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
@@ -41,7 +45,16 @@ public final class FallbackPhoto80dp implements FallbackContactPhoto {
|
||||
|
||||
@Override
|
||||
public Drawable asCallCard(Context context) {
|
||||
throw new UnsupportedOperationException();
|
||||
Drawable background = new ColorDrawable(backgroundColor);
|
||||
Drawable foreground = AppCompatResources.getDrawable(context, drawable80dp);
|
||||
int transparent20 = ContextCompat.getColor(context, R.color.signal_transparent_20);
|
||||
Drawable gradient = new GradientDrawable(GradientDrawable.Orientation.TOP_BOTTOM, new int[]{ Color.TRANSPARENT, transparent20 });
|
||||
LayerDrawable drawable = new LayerDrawable(new Drawable[]{background, foreground, gradient});
|
||||
int foregroundInset = ViewUtil.dpToPx(24);
|
||||
|
||||
drawable.setLayerInset(1, foregroundInset, foregroundInset, foregroundInset, foregroundInset);
|
||||
|
||||
return drawable;
|
||||
}
|
||||
|
||||
private @NonNull Drawable buildDrawable(@NonNull Context context) {
|
||||
|
||||
@@ -53,8 +53,9 @@ import org.whispersystems.libsignal.util.guava.Optional;
|
||||
import org.whispersystems.signalservice.api.profiles.ProfileAndCredential;
|
||||
import org.whispersystems.signalservice.api.profiles.SignalServiceProfile;
|
||||
import org.whispersystems.signalservice.api.push.exceptions.NotFoundException;
|
||||
import org.whispersystems.signalservice.api.services.ProfileService;
|
||||
import org.whispersystems.signalservice.api.util.UuidUtil;
|
||||
import org.whispersystems.signalservice.internal.util.concurrent.ListenableFuture;
|
||||
import org.whispersystems.signalservice.internal.ServiceResponse;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Calendar;
|
||||
@@ -66,9 +67,10 @@ import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
|
||||
import io.reactivex.rxjava3.core.Observable;
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers;
|
||||
|
||||
/**
|
||||
* Manages all the stuff around determining if a user is registered or not.
|
||||
@@ -131,7 +133,7 @@ public class DirectoryHelper {
|
||||
Stopwatch stopwatch = new Stopwatch("single");
|
||||
RecipientDatabase recipientDatabase = DatabaseFactory.getRecipientDatabase(context);
|
||||
RegisteredState originalRegisteredState = recipient.resolve().getRegistered();
|
||||
RegisteredState newRegisteredState = null;
|
||||
RegisteredState newRegisteredState;
|
||||
|
||||
if (recipient.hasUuid() && !recipient.hasE164()) {
|
||||
boolean isRegistered = isUuidRegistered(context, recipient);
|
||||
@@ -510,29 +512,34 @@ public class DirectoryHelper {
|
||||
.filter(r -> hasCommunicatedWith(context, r))
|
||||
.toList();
|
||||
|
||||
List<Pair<Recipient, ListenableFuture<ProfileAndCredential>>> futures = Stream.of(possiblyUnlisted)
|
||||
.map(r -> new Pair<>(r, ProfileUtil.retrieveProfile(context, r, SignalServiceProfile.RequestType.PROFILE)))
|
||||
.toList();
|
||||
Set<RecipientId> potentiallyActiveIds = new HashSet<>();
|
||||
Set<RecipientId> retries = new HashSet<>();
|
||||
ProfileService profileService = new ProfileService(ApplicationDependencies.getGroupsV2Operations().getProfileOperations(),
|
||||
ApplicationDependencies.getSignalServiceMessageReceiver(),
|
||||
ApplicationDependencies.getSignalWebSocket());
|
||||
|
||||
Stream.of(futures)
|
||||
.forEach(pair -> {
|
||||
try {
|
||||
pair.second().get(5, TimeUnit.SECONDS);
|
||||
potentiallyActiveIds.add(pair.first().getId());
|
||||
} catch (InterruptedException | TimeoutException e) {
|
||||
retries.add(pair.first().getId());
|
||||
potentiallyActiveIds.add(pair.first().getId());
|
||||
} catch (ExecutionException e) {
|
||||
if (!(e.getCause() instanceof NotFoundException)) {
|
||||
retries.add(pair.first().getId());
|
||||
potentiallyActiveIds.add(pair.first().getId());
|
||||
}
|
||||
}
|
||||
});
|
||||
List<Observable<Pair<Recipient, ServiceResponse<ProfileAndCredential>>>> requests = Stream.of(possiblyUnlisted)
|
||||
.map(r -> ProfileUtil.retrieveProfile(context, r, SignalServiceProfile.RequestType.PROFILE, profileService)
|
||||
.toObservable()
|
||||
.timeout(5, TimeUnit.SECONDS)
|
||||
.onErrorReturn(t -> new Pair<>(r, ServiceResponse.forUnknownError(t))))
|
||||
.toList();
|
||||
|
||||
return new UnlistedResult(potentiallyActiveIds, retries);
|
||||
return Observable.mergeDelayError(requests)
|
||||
.observeOn(Schedulers.io(), true)
|
||||
.scan(new UnlistedResult.Builder(), (builder, pair) -> {
|
||||
Recipient recipient = pair.first();
|
||||
ProfileService.ProfileResponseProcessor processor = new ProfileService.ProfileResponseProcessor(pair.second());
|
||||
if (processor.hasResult()) {
|
||||
builder.potentiallyActiveIds.add(recipient.getId());
|
||||
} else if (processor.genericIoError() || !processor.notFound()) {
|
||||
builder.retries.add(recipient.getId());
|
||||
builder.potentiallyActiveIds.add(recipient.getId());
|
||||
}
|
||||
|
||||
return builder;
|
||||
})
|
||||
.lastOrError()
|
||||
.map(UnlistedResult.Builder::build)
|
||||
.blockingGet();
|
||||
}
|
||||
|
||||
private static boolean hasCommunicatedWith(@NonNull Context context, @NonNull Recipient recipient) {
|
||||
@@ -584,6 +591,15 @@ public class DirectoryHelper {
|
||||
@NonNull Set<RecipientId> getRetries() {
|
||||
return retries;
|
||||
}
|
||||
|
||||
private static class Builder {
|
||||
final Set<RecipientId> potentiallyActiveIds = new HashSet<>();
|
||||
final Set<RecipientId> retries = new HashSet<>();
|
||||
|
||||
@NonNull UnlistedResult build() {
|
||||
return new UnlistedResult(potentiallyActiveIds, retries);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static class AccountHolder {
|
||||
|
||||
@@ -28,7 +28,6 @@ public class AttachmentKeyboard extends FrameLayout implements InputAwareLayout.
|
||||
|
||||
private static final List<AttachmentKeyboardButton> DEFAULT_BUTTONS = Arrays.asList(
|
||||
AttachmentKeyboardButton.GALLERY,
|
||||
AttachmentKeyboardButton.GIF,
|
||||
AttachmentKeyboardButton.FILE,
|
||||
AttachmentKeyboardButton.PAYMENT,
|
||||
AttachmentKeyboardButton.CONTACT,
|
||||
|
||||
@@ -8,7 +8,6 @@ import org.thoughtcrime.securesms.R;
|
||||
public enum AttachmentKeyboardButton {
|
||||
|
||||
GALLERY(R.string.AttachmentKeyboard_gallery, R.drawable.ic_photo_album_outline_32),
|
||||
GIF(R.string.AttachmentKeyboard_gif, R.drawable.ic_gif_outline_32),
|
||||
FILE(R.string.AttachmentKeyboard_file, R.drawable.ic_file_outline_32),
|
||||
PAYMENT(R.string.AttachmentKeyboard_payment, R.drawable.ic_payments_32),
|
||||
CONTACT(R.string.AttachmentKeyboard_contact, R.drawable.ic_contact_circle_outline_32),
|
||||
|
||||
@@ -83,7 +83,7 @@ class AttachmentKeyboardButtonAdapter extends RecyclerView.Adapter<AttachmentKey
|
||||
this.title = itemView.findViewById(R.id.attachment_button_title);
|
||||
}
|
||||
|
||||
void bind(@NonNull AttachmentKeyboardButton button,boolean wallpaperEnabled, @NonNull Listener listener) {
|
||||
void bind(@NonNull AttachmentKeyboardButton button, boolean wallpaperEnabled, @NonNull Listener listener) {
|
||||
image.setImageResource(button.getIconRes());
|
||||
title.setText(button.getTitleRes());
|
||||
|
||||
|
||||
@@ -76,6 +76,8 @@ import androidx.core.content.pm.ShortcutInfoCompat;
|
||||
import androidx.core.content.pm.ShortcutManagerCompat;
|
||||
import androidx.core.graphics.drawable.DrawableCompat;
|
||||
import androidx.core.graphics.drawable.IconCompat;
|
||||
import androidx.lifecycle.Lifecycle;
|
||||
import androidx.lifecycle.Observer;
|
||||
import androidx.lifecycle.ViewModelProviders;
|
||||
|
||||
import com.annimon.stream.Collectors;
|
||||
@@ -116,7 +118,7 @@ import org.thoughtcrime.securesms.components.MaskView;
|
||||
import org.thoughtcrime.securesms.components.SendButton;
|
||||
import org.thoughtcrime.securesms.components.TooltipPopup;
|
||||
import org.thoughtcrime.securesms.components.TypingStatusSender;
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiKeyboardProvider;
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiEventListener;
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiStrings;
|
||||
import org.thoughtcrime.securesms.components.emoji.MediaKeyboard;
|
||||
import org.thoughtcrime.securesms.components.identity.UnverifiedBannerView;
|
||||
@@ -130,6 +132,10 @@ import org.thoughtcrime.securesms.components.reminder.ReminderView;
|
||||
import org.thoughtcrime.securesms.components.reminder.ServiceOutageReminder;
|
||||
import org.thoughtcrime.securesms.components.reminder.UnauthorizedReminder;
|
||||
import org.thoughtcrime.securesms.components.settings.conversation.ConversationSettingsActivity;
|
||||
import org.thoughtcrime.securesms.components.voice.VoiceNoteDraft;
|
||||
import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaController;
|
||||
import org.thoughtcrime.securesms.components.voice.VoiceNotePlaybackState;
|
||||
import org.thoughtcrime.securesms.components.voice.VoiceNotePlayerView;
|
||||
import org.thoughtcrime.securesms.contacts.ContactAccessor;
|
||||
import org.thoughtcrime.securesms.contacts.ContactAccessor.ContactData;
|
||||
import org.thoughtcrime.securesms.contacts.sync.DirectoryHelper;
|
||||
@@ -139,6 +145,8 @@ import org.thoughtcrime.securesms.contactshare.ContactUtil;
|
||||
import org.thoughtcrime.securesms.contactshare.SimpleTextWatcher;
|
||||
import org.thoughtcrime.securesms.conversation.ConversationGroupViewModel.GroupActiveState;
|
||||
import org.thoughtcrime.securesms.conversation.ConversationMessage.ConversationMessageFactory;
|
||||
import org.thoughtcrime.securesms.conversation.drafts.DraftRepository;
|
||||
import org.thoughtcrime.securesms.conversation.drafts.DraftViewModel;
|
||||
import org.thoughtcrime.securesms.conversation.ui.error.SafetyNumberChangeDialog;
|
||||
import org.thoughtcrime.securesms.conversation.ui.groupcall.GroupCallViewModel;
|
||||
import org.thoughtcrime.securesms.conversation.ui.mentions.MentionsPickerViewModel;
|
||||
@@ -244,7 +252,7 @@ import org.thoughtcrime.securesms.sms.MessageSender;
|
||||
import org.thoughtcrime.securesms.sms.OutgoingEncryptedMessage;
|
||||
import org.thoughtcrime.securesms.sms.OutgoingEndSessionMessage;
|
||||
import org.thoughtcrime.securesms.sms.OutgoingTextMessage;
|
||||
import org.thoughtcrime.securesms.stickers.StickerKeyboardProvider;
|
||||
import org.thoughtcrime.securesms.stickers.StickerEventListener;
|
||||
import org.thoughtcrime.securesms.stickers.StickerLocator;
|
||||
import org.thoughtcrime.securesms.stickers.StickerManagementActivity;
|
||||
import org.thoughtcrime.securesms.stickers.StickerPackInstallEvent;
|
||||
@@ -318,14 +326,14 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
||||
InputPanel.MediaListener,
|
||||
ComposeText.CursorPositionChangedListener,
|
||||
ConversationSearchBottomBar.EventListener,
|
||||
StickerKeyboardProvider.StickerEventListener,
|
||||
StickerEventListener,
|
||||
AttachmentKeyboard.Callback,
|
||||
ConversationReactionOverlay.OnReactionSelectedListener,
|
||||
ReactWithAnyEmojiBottomSheetDialogFragment.Callback,
|
||||
SafetyNumberChangeDialog.Callback,
|
||||
ReactionsBottomSheetDialogFragment.Callback,
|
||||
MediaKeyboard.MediaKeyboardListener,
|
||||
EmojiKeyboardProvider.EmojiEventListener,
|
||||
EmojiEventListener,
|
||||
GifKeyboardPageFragment.Host,
|
||||
EmojiKeyboardPageFragment.Callback,
|
||||
EmojiSearchFragment.Callback
|
||||
@@ -373,6 +381,7 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
||||
private MenuItem searchViewItem;
|
||||
private MessageRequestsBottomView messageRequestBottomView;
|
||||
private ConversationReactionDelegate reactionDelegate;
|
||||
private Stub<VoiceNotePlayerView> voiceNotePlayerViewStub;
|
||||
|
||||
private AttachmentManager attachmentManager;
|
||||
private AudioRecorder audioRecorder;
|
||||
@@ -402,6 +411,9 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
||||
private MentionsPickerViewModel mentionsViewModel;
|
||||
private GroupCallViewModel groupCallViewModel;
|
||||
private VoiceRecorderWakeLock voiceRecorderWakeLock;
|
||||
private DraftViewModel draftViewModel;
|
||||
private VoiceNoteMediaController voiceNoteMediaController;
|
||||
|
||||
|
||||
private LiveRecipient recipient;
|
||||
private long threadId;
|
||||
@@ -435,7 +447,8 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
||||
return;
|
||||
}
|
||||
|
||||
voiceRecorderWakeLock = new VoiceRecorderWakeLock(this);
|
||||
voiceNoteMediaController = new VoiceNoteMediaController(this);
|
||||
voiceRecorderWakeLock = new VoiceRecorderWakeLock(this);
|
||||
|
||||
new FullscreenHelper(this).showSystemUI();
|
||||
|
||||
@@ -462,6 +475,7 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
||||
initializeGroupViewModel();
|
||||
initializeMentionsViewModel();
|
||||
initializeGroupCallViewModel();
|
||||
initializeDraftViewModel();
|
||||
initializeEnabledCheck();
|
||||
initializePendingRequestsBanner();
|
||||
initializeGroupV1MigrationsBanners();
|
||||
@@ -520,7 +534,7 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
||||
}
|
||||
|
||||
reactWithAnyEmojiStartPage = -1;
|
||||
if (!Util.isEmpty(composeText) || attachmentManager.isAttachmentPresent() || inputPanel.getQuote().isPresent()) {
|
||||
if (!Util.isEmpty(composeText) || attachmentManager.isAttachmentPresent() || inputPanel.hasSaveableContent()) {
|
||||
saveDraft();
|
||||
attachmentManager.clear(glideRequests, false);
|
||||
inputPanel.clearQuote();
|
||||
@@ -627,6 +641,7 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
||||
@Override
|
||||
protected void onStop() {
|
||||
super.onStop();
|
||||
saveDraft();
|
||||
EventBus.getDefault().unregister(this);
|
||||
}
|
||||
|
||||
@@ -647,7 +662,6 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
||||
|
||||
@Override
|
||||
protected void onDestroy() {
|
||||
saveDraft();
|
||||
if (securityUpdateReceiver != null) unregisterReceiver(securityUpdateReceiver);
|
||||
super.onDestroy();
|
||||
}
|
||||
@@ -810,14 +824,7 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
||||
}
|
||||
|
||||
private void reportShortcutLaunch(@NonNull RecipientId recipientId) {
|
||||
if (Build.VERSION.SDK_INT < ConversationUtil.CONVERSATION_SUPPORT_VERSION) {
|
||||
return;
|
||||
}
|
||||
|
||||
ShortcutManager shortcutManager = ServiceUtil.getShortcutManager(this);
|
||||
if (shortcutManager != null) {
|
||||
shortcutManager.reportShortcutUsed(ConversationUtil.getShortcutId(recipientId));
|
||||
}
|
||||
ShortcutManagerCompat.reportShortcutUsed(this, ConversationUtil.getShortcutId(recipientId));
|
||||
}
|
||||
|
||||
private void handleImageFromDeviceCameraApp() {
|
||||
@@ -975,6 +982,10 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
||||
}
|
||||
});
|
||||
|
||||
if (threadId == -1L) {
|
||||
hideMenuItem(menu, R.id.menu_view_media);
|
||||
}
|
||||
|
||||
searchViewItem = menu.findItem(R.id.menu_search);
|
||||
|
||||
SearchView searchView = (SearchView) searchViewItem.getActionView();
|
||||
@@ -1136,14 +1147,6 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
||||
case GALLERY:
|
||||
AttachmentManager.selectGallery(this, MEDIA_SENDER, recipient.get(), composeText.getTextTrimmed(), sendButton.getSelectedTransport());
|
||||
break;
|
||||
case GIF:
|
||||
new MaterialAlertDialogBuilder(this)
|
||||
.setTitle(R.string.ConversationActivity_gifs_have_moved)
|
||||
.setMessage(R.string.ConversationActivity_look_for_gifs_next_to_emoji_and_stickers)
|
||||
.setPositiveButton(android.R.string.ok, null)
|
||||
.setOnDismissListener(unused -> inputPanel.showGifMovedTooltip())
|
||||
.show();
|
||||
break;
|
||||
case FILE:
|
||||
AttachmentManager.selectDocument(this, PICK_DOCUMENT);
|
||||
break;
|
||||
@@ -1762,6 +1765,9 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
||||
new QuoteRestorationTask(draft.getValue(), quoteResult).execute();
|
||||
quoteResult.addListener(listener);
|
||||
break;
|
||||
case Draft.VOICE_NOTE:
|
||||
draftViewModel.setVoiceNoteDraft(recipient.getId(), draft);
|
||||
break;
|
||||
}
|
||||
} catch (IOException e) {
|
||||
Log.w(TAG, e);
|
||||
@@ -1996,6 +2002,7 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
||||
mentionsSuggestions = ViewUtil.findStubById(this, R.id.conversation_mention_suggestions_stub);
|
||||
wallpaper = findViewById(R.id.conversation_wallpaper);
|
||||
wallpaperDim = findViewById(R.id.conversation_wallpaper_dim);
|
||||
voiceNotePlayerViewStub = ViewUtil.findStubById(this, R.id.voice_note_player_stub);
|
||||
|
||||
ImageButton quickCameraToggle = findViewById(R.id.quick_camera_toggle);
|
||||
ImageButton inlineAttachmentButton = findViewById(R.id.inline_attachment_button);
|
||||
@@ -2065,6 +2072,20 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
||||
reactionDelegate.setOnReactionSelectedListener(this);
|
||||
|
||||
joinGroupCallButton.setOnClickListener(v -> handleVideo(getRecipient()));
|
||||
|
||||
voiceNoteMediaController.getVoiceNotePlayerViewState().observe(this, state -> {
|
||||
if (state.isPresent()) {
|
||||
if (!voiceNotePlayerViewStub.resolved()) {
|
||||
voiceNotePlayerViewStub.get().setListener(new VoiceNotePlayerViewListener());
|
||||
}
|
||||
voiceNotePlayerViewStub.get().show();
|
||||
voiceNotePlayerViewStub.get().setState(state.get());
|
||||
} else if (voiceNotePlayerViewStub.resolved()) {
|
||||
voiceNotePlayerViewStub.get().hide();
|
||||
}
|
||||
});
|
||||
|
||||
voiceNoteMediaController.getVoiceNotePlaybackState().observe(ConversationActivity.this, inputPanel.getPlaybackStateObserver());
|
||||
}
|
||||
|
||||
private void updateWallpaper(@Nullable ChatWallpaper chatWallpaper) {
|
||||
@@ -2277,6 +2298,20 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
||||
groupCallViewModel.groupCallHasCapacity().observe(this, hasCapacity -> joinGroupCallButton.setText(hasCapacity ? R.string.ConversationActivity_join : R.string.ConversationActivity_full));
|
||||
}
|
||||
|
||||
public void initializeDraftViewModel() {
|
||||
draftViewModel = ViewModelProviders.of(this, new DraftViewModel.Factory(new DraftRepository(getApplicationContext()))).get(DraftViewModel.class);
|
||||
|
||||
recipient.observe(this, r -> {
|
||||
draftViewModel.onRecipientChanged(r);
|
||||
});
|
||||
|
||||
draftViewModel.getState().observe(this,
|
||||
state -> {
|
||||
inputPanel.setVoiceNoteDraft(state.getVoiceNoteDraft());
|
||||
updateToggleButtonState();
|
||||
});
|
||||
}
|
||||
|
||||
private void showGroupCallingTooltip() {
|
||||
if (Build.VERSION.SDK_INT == 19 || !SignalStore.tooltips().shouldShowGroupCallingTooltip() || callingTooltipShown) {
|
||||
return;
|
||||
@@ -2416,6 +2451,10 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
||||
groupCallViewModel.onRecipientChange(recipient);
|
||||
}
|
||||
|
||||
if (draftViewModel != null) {
|
||||
draftViewModel.onRecipientChanged(recipient);
|
||||
}
|
||||
|
||||
if (this.threadId == -1) {
|
||||
SimpleTask.run(() -> DatabaseFactory.getThreadDatabase(this).getThreadIdIfExistsFor(recipient.getId()), threadId -> {
|
||||
if (this.threadId != threadId) {
|
||||
@@ -2562,6 +2601,11 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
||||
drafts.add(new Draft(Draft.QUOTE, new QuoteId(quote.get().getId(), quote.get().getAuthor()).serialize()));
|
||||
}
|
||||
|
||||
DraftDatabase.Draft voiceNoteDraft = draftViewModel.getVoiceNoteDraft();
|
||||
if (voiceNoteDraft != null) {
|
||||
drafts.add(voiceNoteDraft);
|
||||
}
|
||||
|
||||
return drafts;
|
||||
}
|
||||
|
||||
@@ -2573,13 +2617,25 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
||||
return future;
|
||||
}
|
||||
|
||||
final Drafts drafts = getDraftsForCurrentState();
|
||||
final long thisThreadId = this.threadId;
|
||||
final int thisDistributionType = this.distributionType;
|
||||
final Drafts drafts = getDraftsForCurrentState();
|
||||
final long thisThreadId = this.threadId;
|
||||
final RecipientId recipientId = this.recipient.getId();
|
||||
final int thisDistributionType = this.distributionType;
|
||||
final ListenableFuture<VoiceNoteDraft> voiceNoteDraftFuture = draftViewModel.consumeVoiceNoteDraftFuture();
|
||||
|
||||
new AsyncTask<Long, Void, Long>() {
|
||||
@Override
|
||||
protected Long doInBackground(Long... params) {
|
||||
if (voiceNoteDraftFuture != null) {
|
||||
try {
|
||||
Draft voiceNoteDraft = voiceNoteDraftFuture.get().asDraft();
|
||||
draftViewModel.setVoiceNoteDraft(recipientId, voiceNoteDraft);
|
||||
drafts.add(voiceNoteDraft);
|
||||
} catch (ExecutionException | InterruptedException e) {
|
||||
Log.w(TAG, "Could not extract voice note draft data.", e);
|
||||
}
|
||||
}
|
||||
|
||||
ThreadDatabase threadDatabase = DatabaseFactory.getThreadDatabase(ConversationActivity.this);
|
||||
DraftDatabase draftDatabase = DatabaseFactory.getDraftDatabase(ConversationActivity.this);
|
||||
long threadId = params[0];
|
||||
@@ -2587,7 +2643,7 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
||||
if (drafts.size() > 0) {
|
||||
if (threadId == -1) threadId = threadDatabase.getThreadIdFor(getRecipient(), thisDistributionType);
|
||||
|
||||
draftDatabase.insertDrafts(threadId, drafts);
|
||||
draftDatabase.replaceDrafts(threadId, drafts);
|
||||
threadDatabase.updateSnippet(threadId, drafts.getSnippet(ConversationActivity.this),
|
||||
drafts.getUriSnippet(),
|
||||
System.currentTimeMillis(), Types.BASE_DRAFT_TYPE, true);
|
||||
@@ -2595,6 +2651,10 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
||||
threadDatabase.update(threadId, false);
|
||||
}
|
||||
|
||||
if (drafts.isEmpty()) {
|
||||
draftDatabase.clearDrafts(threadId);
|
||||
}
|
||||
|
||||
return threadId;
|
||||
}
|
||||
|
||||
@@ -2761,6 +2821,15 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
||||
return;
|
||||
}
|
||||
|
||||
Draft voiceNote = draftViewModel.getVoiceNoteDraft();
|
||||
if (voiceNote != null) {
|
||||
AudioSlide audioSlide = AudioSlide.createFromVoiceNoteDraft(this, voiceNote);
|
||||
|
||||
sendVoiceNote(Objects.requireNonNull(audioSlide.getUri()), audioSlide.getFileSize());
|
||||
draftViewModel.clearVoiceNoteDraft();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
Recipient recipient = getRecipient();
|
||||
|
||||
@@ -2975,6 +3044,13 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
||||
return;
|
||||
}
|
||||
|
||||
if (draftViewModel.hasVoiceNoteDraft()) {
|
||||
buttonToggle.display(sendButton);
|
||||
quickAttachmentToggle.hide();
|
||||
inlineAttachmentToggle.hide();
|
||||
return;
|
||||
}
|
||||
|
||||
if (composeText.getText().length() == 0 && !attachmentManager.isAttachmentPresent()) {
|
||||
buttonToggle.display(attachButton);
|
||||
quickAttachmentToggle.show();
|
||||
@@ -3063,44 +3139,11 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
||||
getWindow().clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
|
||||
setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED);
|
||||
|
||||
ListenableFuture<Pair<Uri, Long>> future = audioRecorder.stopRecording();
|
||||
future.addListener(new ListenableFuture.Listener<Pair<Uri, Long>>() {
|
||||
ListenableFuture<VoiceNoteDraft> future = audioRecorder.stopRecording();
|
||||
future.addListener(new ListenableFuture.Listener<VoiceNoteDraft>() {
|
||||
@Override
|
||||
public void onSuccess(final @NonNull Pair<Uri, Long> result) {
|
||||
boolean forceSms = sendButton.isManualSelection() && sendButton.getSelectedTransport().isSms();
|
||||
boolean initiating = threadId == -1;
|
||||
int subscriptionId = sendButton.getSelectedTransport().getSimSubscriptionId().or(-1);
|
||||
long expiresIn = recipient.get().getExpireMessages() * 1000L;
|
||||
AudioSlide audioSlide = new AudioSlide(ConversationActivity.this, result.first(), result.second(), MediaUtil.AUDIO_AAC, true);
|
||||
SlideDeck slideDeck = new SlideDeck();
|
||||
slideDeck.addSlide(audioSlide);
|
||||
|
||||
ListenableFuture<Void> sendResult = sendMediaMessage(recipient.getId(),
|
||||
forceSms,
|
||||
"",
|
||||
slideDeck,
|
||||
inputPanel.getQuote().orNull(),
|
||||
Collections.emptyList(),
|
||||
Collections.emptyList(),
|
||||
composeText.getMentions(),
|
||||
expiresIn,
|
||||
false,
|
||||
subscriptionId,
|
||||
initiating,
|
||||
true);
|
||||
|
||||
sendResult.addListener(new AssertedSuccessListener<Void>() {
|
||||
@Override
|
||||
public void onSuccess(Void nothing) {
|
||||
new AsyncTask<Void, Void, Void>() {
|
||||
@Override
|
||||
protected Void doInBackground(Void... params) {
|
||||
BlobProvider.getInstance().delete(ConversationActivity.this, result.first());
|
||||
return null;
|
||||
}
|
||||
}.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
|
||||
}
|
||||
});
|
||||
public void onSuccess(final @NonNull VoiceNoteDraft result) {
|
||||
sendVoiceNote(result.getUri(), result.getSize());
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -3120,22 +3163,12 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
||||
getWindow().clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
|
||||
setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED);
|
||||
|
||||
ListenableFuture<Pair<Uri, Long>> future = audioRecorder.stopRecording();
|
||||
future.addListener(new ListenableFuture.Listener<Pair<Uri, Long>>() {
|
||||
@Override
|
||||
public void onSuccess(final Pair<Uri, Long> result) {
|
||||
new AsyncTask<Void, Void, Void>() {
|
||||
@Override
|
||||
protected Void doInBackground(Void... params) {
|
||||
BlobProvider.getInstance().delete(ConversationActivity.this, result.first());
|
||||
return null;
|
||||
}
|
||||
}.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(ExecutionException e) {}
|
||||
});
|
||||
ListenableFuture<VoiceNoteDraft> future = audioRecorder.stopRecording();
|
||||
if (getLifecycle().getCurrentState().isAtLeast(Lifecycle.State.RESUMED)) {
|
||||
future.addListener(new DeleteCanceledVoiceNoteListener());
|
||||
} else {
|
||||
draftViewModel.setVoiceNoteDraftFuture(future);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -3194,6 +3227,37 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
||||
container.hideAttachedInput(true);
|
||||
}
|
||||
|
||||
private void sendVoiceNote(@NonNull Uri uri, long size) {
|
||||
boolean forceSms = sendButton.isManualSelection() && sendButton.getSelectedTransport().isSms();
|
||||
boolean initiating = threadId == -1;
|
||||
int subscriptionId = sendButton.getSelectedTransport().getSimSubscriptionId().or(-1);
|
||||
long expiresIn = recipient.get().getExpireMessages() * 1000L;
|
||||
AudioSlide audioSlide = new AudioSlide(ConversationActivity.this, uri, size, MediaUtil.AUDIO_AAC, true);
|
||||
SlideDeck slideDeck = new SlideDeck();
|
||||
slideDeck.addSlide(audioSlide);
|
||||
|
||||
ListenableFuture<Void> sendResult = sendMediaMessage(recipient.getId(),
|
||||
forceSms,
|
||||
"",
|
||||
slideDeck,
|
||||
inputPanel.getQuote().orNull(),
|
||||
Collections.emptyList(),
|
||||
Collections.emptyList(),
|
||||
composeText.getMentions(),
|
||||
expiresIn,
|
||||
false,
|
||||
subscriptionId,
|
||||
initiating,
|
||||
true);
|
||||
|
||||
sendResult.addListener(new AssertedSuccessListener<Void>() {
|
||||
@Override
|
||||
public void onSuccess(Void nothing) {
|
||||
draftViewModel.deleteBlob(uri);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void sendSticker(@NonNull StickerRecord stickerRecord, boolean clearCompose) {
|
||||
sendSticker(new StickerLocator(stickerRecord.getPackId(), stickerRecord.getPackKey(), stickerRecord.getStickerId(), stickerRecord.getEmoji()), stickerRecord.getContentType(), stickerRecord.getUri(), stickerRecord.getSize(), clearCompose);
|
||||
|
||||
@@ -3297,8 +3361,44 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onVoiceNoteDraftPlay(@NonNull Uri audioUri, double progress) {
|
||||
voiceNoteMediaController.startSinglePlaybackForDraft(audioUri, threadId, progress);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onVoiceNoteDraftPause(@NonNull Uri audioUri) {
|
||||
voiceNoteMediaController.pausePlayback(audioUri);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onVoiceNoteDraftSeekTo(@NonNull Uri audioUri, double progress) {
|
||||
voiceNoteMediaController.seekToPosition(audioUri, progress);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onVoiceNoteDraftDelete(@NonNull Uri audioUri) {
|
||||
voiceNoteMediaController.stopPlaybackAndReset(audioUri);
|
||||
draftViewModel.deleteVoiceNoteDraft();
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull VoiceNoteMediaController getVoiceNoteMediaController() {
|
||||
return voiceNoteMediaController;
|
||||
}
|
||||
|
||||
// Listeners
|
||||
|
||||
private final class DeleteCanceledVoiceNoteListener implements ListenableFuture.Listener<VoiceNoteDraft> {
|
||||
@Override
|
||||
public void onSuccess(final VoiceNoteDraft result) {
|
||||
draftViewModel.deleteBlob(result.getUri());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(ExecutionException e) {}
|
||||
}
|
||||
|
||||
private class QuickCameraToggleListener implements OnClickListener {
|
||||
@Override
|
||||
public void onClick(View v) {
|
||||
@@ -3563,6 +3663,36 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
||||
reactionDelegate.showMask(maskTarget, titleView.getMeasuredHeight(), inputAreaHeight());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onVoiceNotePause(@NonNull Uri uri) {
|
||||
voiceNoteMediaController.pausePlayback(uri);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onVoiceNotePlay(@NonNull Uri uri, long messageId, double progress) {
|
||||
voiceNoteMediaController.startConsecutivePlayback(uri, messageId, progress);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onVoiceNoteSeekTo(@NonNull Uri uri, double progress) {
|
||||
voiceNoteMediaController.seekToPosition(uri, progress);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onVoiceNotePlaybackSpeedChanged(@NonNull Uri uri, float speed) {
|
||||
voiceNoteMediaController.setPlaybackSpeed(uri, speed);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRegisterVoiceNoteCallbacks(@NonNull Observer<VoiceNotePlaybackState> onPlaybackStartObserver) {
|
||||
voiceNoteMediaController.getVoiceNotePlaybackState().observe(this, onPlaybackStartObserver);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onUnregisterVoiceNoteCallbacks(@NonNull Observer<VoiceNotePlaybackState> onPlaybackStartObserver) {
|
||||
voiceNoteMediaController.getVoiceNotePlaybackState().removeObserver(onPlaybackStartObserver);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCursorChanged() {
|
||||
if (!reactionDelegate.isShowing()) {
|
||||
@@ -3869,6 +3999,39 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
||||
}
|
||||
}
|
||||
|
||||
private final class VoiceNotePlayerViewListener implements VoiceNotePlayerView.Listener {
|
||||
@Override
|
||||
public void onCloseRequested(@NonNull Uri uri) {
|
||||
voiceNoteMediaController.stopPlaybackAndReset(uri);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onSpeedChangeRequested(@NonNull Uri uri, float speed) {
|
||||
voiceNoteMediaController.setPlaybackSpeed(uri, speed);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPlay(@NonNull Uri uri, long messageId, double position) {
|
||||
voiceNoteMediaController.startSinglePlayback(uri, messageId, position);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPause(@NonNull Uri uri) {
|
||||
voiceNoteMediaController.pausePlayback(uri);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onNavigateToMessage(long threadId, @NonNull RecipientId threadRecipientId, @NonNull RecipientId senderId, long messageTimestamp, long messagePositionInThread) {
|
||||
if (threadId != ConversationActivity.this.threadId) {
|
||||
startActivity(ConversationIntents.createBuilder(ConversationActivity.this, threadRecipientId, threadId)
|
||||
.withStartingPosition((int) messagePositionInThread)
|
||||
.build());
|
||||
} else {
|
||||
fragment.jumpToMessage(senderId, messageTimestamp, () -> { });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void presentMessageRequestState(@Nullable MessageRequestViewModel.MessageData messageData) {
|
||||
if (!Util.isEmpty(viewModel.getArgs().getDraftText()) ||
|
||||
viewModel.getArgs().getMedia() != null ||
|
||||
|
||||
@@ -387,7 +387,13 @@ public class ConversationAdapter
|
||||
if (pagingController != null) {
|
||||
pagingController.onDataNeededAroundIndex(correctedPosition);
|
||||
}
|
||||
return super.getItem(correctedPosition);
|
||||
|
||||
if (correctedPosition < getItemCount()) {
|
||||
return super.getItem(correctedPosition);
|
||||
} else {
|
||||
Log.d(TAG, "Could not access corrected position " + correctedPosition + " as it is out of bounds.");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -81,7 +81,7 @@ import org.thoughtcrime.securesms.components.TooltipPopup;
|
||||
import org.thoughtcrime.securesms.components.TypingStatusRepository;
|
||||
import org.thoughtcrime.securesms.components.recyclerview.SmoothScrollingLinearLayoutManager;
|
||||
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity;
|
||||
import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaController;
|
||||
import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaControllerOwner;
|
||||
import org.thoughtcrime.securesms.components.voice.VoiceNotePlaybackState;
|
||||
import org.thoughtcrime.securesms.contactshare.Contact;
|
||||
import org.thoughtcrime.securesms.contactshare.ContactUtil;
|
||||
@@ -92,6 +92,7 @@ import org.thoughtcrime.securesms.conversation.ConversationMessage.ConversationM
|
||||
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.conversation.ui.error.SafetyNumberChangeDialog;
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||
import org.thoughtcrime.securesms.database.MessageDatabase;
|
||||
import org.thoughtcrime.securesms.database.MmsDatabase;
|
||||
@@ -214,7 +215,6 @@ public class ConversationFragment extends LoggingFragment {
|
||||
private Animation mentionButtonOutAnimation;
|
||||
private OnScrollListener conversationScrollListener;
|
||||
private int pulsePosition = -1;
|
||||
private VoiceNoteMediaController voiceNoteMediaController;
|
||||
private View toolbarShadow;
|
||||
private ColorizerView colorizerView;
|
||||
private Stopwatch startupStopwatch;
|
||||
@@ -336,6 +336,9 @@ public class ConversationFragment extends LoggingFragment {
|
||||
conversationUpdateTick = new ConversationUpdateTick(this::updateConversationItemTimestamps);
|
||||
getViewLifecycleOwner().getLifecycle().addObserver(conversationUpdateTick);
|
||||
|
||||
listener.getVoiceNoteMediaController().getVoiceNotePlayerViewState().observe(getViewLifecycleOwner(), state -> conversationViewModel.setInlinePlayerVisible(state.isPresent()));
|
||||
conversationViewModel.getScrollDateTopMargin().observe(getViewLifecycleOwner(), topMargin -> ViewUtil.setTopMargin(scrollDateHeader, topMargin));
|
||||
|
||||
return view;
|
||||
}
|
||||
|
||||
@@ -408,7 +411,6 @@ public class ConversationFragment extends LoggingFragment {
|
||||
initializeResources();
|
||||
initializeMessageRequestViewModel();
|
||||
initializeListAdapter();
|
||||
voiceNoteMediaController = new VoiceNoteMediaController((AppCompatActivity) requireActivity());
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -1283,15 +1285,14 @@ public class ConversationFragment extends LoggingFragment {
|
||||
public void onGlobalLayout() {
|
||||
Rect rect = new Rect();
|
||||
toolbar.getGlobalVisibleRect(rect);
|
||||
ViewUtil.setTopMargin(scrollDateHeader, rect.bottom + ViewUtil.dpToPx(8));
|
||||
conversationViewModel.setToolbarBottom(rect.bottom + ViewUtil.dpToPx(8));
|
||||
ViewUtil.setTopMargin(conversationBanner, rect.bottom + ViewUtil.dpToPx(16));
|
||||
toolbar.getViewTreeObserver().removeOnGlobalLayoutListener(this);
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
public interface ConversationFragmentListener {
|
||||
public interface ConversationFragmentListener extends VoiceNoteMediaControllerOwner {
|
||||
void setThreadId(long threadId);
|
||||
void handleReplyMessage(ConversationMessage conversationMessage);
|
||||
void onMessageActionToolbarOpened();
|
||||
@@ -1305,6 +1306,12 @@ public class ConversationFragment extends LoggingFragment {
|
||||
void onListVerticalTranslationChanged(float translationY);
|
||||
void onMessageWithErrorClicked(@NonNull MessageRecord messageRecord);
|
||||
void handleReactionDetails(@NonNull MaskView.MaskTarget maskTarget);
|
||||
void onVoiceNotePause(@NonNull Uri uri);
|
||||
void onVoiceNotePlay(@NonNull Uri uri, long messageId, double progress);
|
||||
void onVoiceNoteSeekTo(@NonNull Uri uri, double progress);
|
||||
void onVoiceNotePlaybackSpeedChanged(@NonNull Uri uri, float speed);
|
||||
void onRegisterVoiceNoteCallbacks(@NonNull Observer<VoiceNotePlaybackState> onPlaybackStartObserver);
|
||||
void onUnregisterVoiceNoteCallbacks(@NonNull Observer<VoiceNotePlaybackState> onPlaybackStartObserver);
|
||||
}
|
||||
|
||||
private class ConversationScrollListener extends OnScrollListener {
|
||||
@@ -1579,29 +1586,39 @@ public class ConversationFragment extends LoggingFragment {
|
||||
RecaptchaProofBottomSheetFragment.show(getChildFragmentManager());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onIncomingIdentityMismatchClicked(@NonNull RecipientId recipientId) {
|
||||
SafetyNumberChangeDialog.show(getParentFragmentManager(), recipientId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onVoiceNotePause(@NonNull Uri uri) {
|
||||
voiceNoteMediaController.pausePlayback(uri);
|
||||
listener.onVoiceNotePause(uri);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onVoiceNotePlay(@NonNull Uri uri, long messageId, double progress) {
|
||||
voiceNoteMediaController.startConsecutivePlayback(uri, messageId, progress);
|
||||
listener.onVoiceNotePlay(uri, messageId, progress);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onVoiceNoteSeekTo(@NonNull Uri uri, double progress) {
|
||||
voiceNoteMediaController.seekToPosition(uri, progress);
|
||||
listener.onVoiceNoteSeekTo(uri, progress);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onVoiceNotePlaybackSpeedChanged(@NonNull Uri uri, float speed) {
|
||||
listener.onVoiceNotePlaybackSpeedChanged(uri, speed);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRegisterVoiceNoteCallbacks(@NonNull Observer<VoiceNotePlaybackState> onPlaybackStartObserver) {
|
||||
voiceNoteMediaController.getVoiceNotePlaybackState().observe(getViewLifecycleOwner(), onPlaybackStartObserver);
|
||||
listener.onRegisterVoiceNoteCallbacks(onPlaybackStartObserver);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onUnregisterVoiceNoteCallbacks(@NonNull Observer<VoiceNotePlaybackState> onPlaybackStartObserver) {
|
||||
voiceNoteMediaController.getVoiceNotePlaybackState().removeObserver(onPlaybackStartObserver);
|
||||
listener.onUnregisterVoiceNoteCallbacks(onPlaybackStartObserver);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -42,6 +42,7 @@ import android.text.style.URLSpan;
|
||||
import android.text.util.Linkify;
|
||||
import android.util.AttributeSet;
|
||||
import android.util.TypedValue;
|
||||
import android.view.TouchDelegate;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.RelativeLayout;
|
||||
@@ -54,15 +55,16 @@ import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
import androidx.core.content.ContextCompat;
|
||||
import androidx.core.text.util.LinkifyCompat;
|
||||
import androidx.lifecycle.LifecycleOwner;
|
||||
|
||||
import com.annimon.stream.Collectors;
|
||||
import com.annimon.stream.Stream;
|
||||
import com.google.android.exoplayer2.source.MediaSource;
|
||||
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.BindableConversationItem;
|
||||
import org.thoughtcrime.securesms.ConfirmIdentityDialog;
|
||||
import org.thoughtcrime.securesms.MediaPreviewActivity;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.attachments.DatabaseAttachment;
|
||||
@@ -75,6 +77,7 @@ import org.thoughtcrime.securesms.components.ConversationItemThumbnail;
|
||||
import org.thoughtcrime.securesms.components.DocumentView;
|
||||
import org.thoughtcrime.securesms.components.LinkPreviewView;
|
||||
import org.thoughtcrime.securesms.components.Outliner;
|
||||
import org.thoughtcrime.securesms.components.PlaybackSpeedToggleTextView;
|
||||
import org.thoughtcrime.securesms.components.QuoteView;
|
||||
import org.thoughtcrime.securesms.components.SharedContactView;
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiTextView;
|
||||
@@ -117,8 +120,6 @@ import org.thoughtcrime.securesms.revealable.ViewOnceMessageView;
|
||||
import org.thoughtcrime.securesms.revealable.ViewOnceUtil;
|
||||
import org.thoughtcrime.securesms.stickers.StickerUrl;
|
||||
import org.thoughtcrime.securesms.util.DateUtils;
|
||||
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;
|
||||
@@ -202,15 +203,16 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
private int defaultBubbleColorForWallpaper;
|
||||
private int measureCalls;
|
||||
|
||||
private final PassthroughClickListener passthroughClickListener = new PassthroughClickListener();
|
||||
private final AttachmentDownloadClickListener downloadClickListener = new AttachmentDownloadClickListener();
|
||||
private final SlideClickPassthroughListener singleDownloadClickListener = new SlideClickPassthroughListener(downloadClickListener);
|
||||
private final SharedContactEventListener sharedContactEventListener = new SharedContactEventListener();
|
||||
private final SharedContactClickListener sharedContactClickListener = new SharedContactClickListener();
|
||||
private final LinkPreviewClickListener linkPreviewClickListener = new LinkPreviewClickListener();
|
||||
private final ViewOnceMessageClickListener revealableClickListener = new ViewOnceMessageClickListener();
|
||||
private final UrlClickListener urlClickListener = new UrlClickListener();
|
||||
private final Rect thumbnailMaskingRect = new Rect();
|
||||
private final PassthroughClickListener passthroughClickListener = new PassthroughClickListener();
|
||||
private final AttachmentDownloadClickListener downloadClickListener = new AttachmentDownloadClickListener();
|
||||
private final SlideClickPassthroughListener singleDownloadClickListener = new SlideClickPassthroughListener(downloadClickListener);
|
||||
private final SharedContactEventListener sharedContactEventListener = new SharedContactEventListener();
|
||||
private final SharedContactClickListener sharedContactClickListener = new SharedContactClickListener();
|
||||
private final LinkPreviewClickListener linkPreviewClickListener = new LinkPreviewClickListener();
|
||||
private final ViewOnceMessageClickListener revealableClickListener = new ViewOnceMessageClickListener();
|
||||
private final UrlClickListener urlClickListener = new UrlClickListener();
|
||||
private final Rect thumbnailMaskingRect = new Rect();
|
||||
private final TouchDelegateChangedListener touchDelegateChangedListener = new TouchDelegateChangedListener();
|
||||
|
||||
private final Context context;
|
||||
|
||||
@@ -267,6 +269,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
|
||||
bodyText.setOnLongClickListener(passthroughClickListener);
|
||||
bodyText.setOnClickListener(passthroughClickListener);
|
||||
footer.setOnTouchDelegateChangedListener(touchDelegateChangedListener);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -406,6 +409,10 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
public void onRecipientChanged(@NonNull Recipient modified) {
|
||||
if (conversationRecipient.getId().equals(modified.getId())) {
|
||||
setBubbleState(messageRecord, modified, modified.hasWallpaper(), colorizer);
|
||||
|
||||
if (audioViewStub.resolved()) {
|
||||
setAudioViewTint(messageRecord);
|
||||
}
|
||||
}
|
||||
|
||||
if (recipient.getId().equals(modified.getId())) {
|
||||
@@ -518,13 +525,15 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
private void setAudioViewTint(MessageRecord messageRecord) {
|
||||
if (hasAudio(messageRecord)) {
|
||||
if (!messageRecord.isOutgoing()) {
|
||||
if (DynamicTheme.isDarkTheme(context)) {
|
||||
audioViewStub.get().setTint(Color.WHITE);
|
||||
audioViewStub.get().setTint(getContext().getResources().getColor(R.color.conversation_item_incoming_audio_foreground_tint));
|
||||
if (hasWallpaper) {
|
||||
audioViewStub.get().setProgressAndPlayBackgroundTint(getContext().getResources().getColor(R.color.conversation_item_incoming_audio_play_pause_background_tint_wallpaper));
|
||||
} else {
|
||||
audioViewStub.get().setTint(getContext().getResources().getColor(R.color.core_grey_60));
|
||||
audioViewStub.get().setProgressAndPlayBackgroundTint(getContext().getResources().getColor(R.color.conversation_item_incoming_audio_play_pause_background_tint_normal));
|
||||
}
|
||||
} else {
|
||||
audioViewStub.get().setTint(Color.WHITE);
|
||||
audioViewStub.get().setProgressAndPlayBackgroundTint(getContext().getResources().getColor(R.color.transparent_white_20));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -740,6 +749,8 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
eventListener.onUnregisterVoiceNoteCallbacks(audioViewStub.get().getPlaybackStateObserver());
|
||||
}
|
||||
|
||||
footer.setPlaybackSpeedListener(null);
|
||||
|
||||
if (isViewOnceMessage(messageRecord) && !messageRecord.isRemoteDelete()) {
|
||||
revealableStub.get().setVisibility(VISIBLE);
|
||||
if (mediaThumbnailStub.resolved()) mediaThumbnailStub.get().setVisibility(View.GONE);
|
||||
@@ -756,7 +767,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
footer.setVisibility(VISIBLE);
|
||||
} else if (hasSharedContact(messageRecord)) {
|
||||
sharedContactStub.get().setVisibility(VISIBLE);
|
||||
if (audioViewStub.resolved()) mediaThumbnailStub.get().setVisibility(View.GONE);
|
||||
if (audioViewStub.resolved()) audioViewStub.get().setVisibility(View.GONE);
|
||||
if (mediaThumbnailStub.resolved()) mediaThumbnailStub.get().setVisibility(View.GONE);
|
||||
if (documentViewStub.resolved()) documentViewStub.get().setVisibility(View.GONE);
|
||||
if (linkPreviewStub.resolved()) linkPreviewStub.get().setVisibility(GONE);
|
||||
@@ -822,7 +833,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
if (stickerStub.resolved()) stickerStub.get().setVisibility(View.GONE);
|
||||
if (revealableStub.resolved()) revealableStub.get().setVisibility(View.GONE);
|
||||
|
||||
audioViewStub.get().setAudio(Objects.requireNonNull(((MediaMmsMessageRecord) messageRecord).getSlideDeck().getAudioSlide()), new AudioViewCallbacks(), showControls, false);
|
||||
audioViewStub.get().setAudio(Objects.requireNonNull(((MediaMmsMessageRecord) messageRecord).getSlideDeck().getAudioSlide()), new AudioViewCallbacks(), showControls, true);
|
||||
audioViewStub.get().setDownloadClickListener(singleDownloadClickListener);
|
||||
audioViewStub.get().setOnLongClickListener(passthroughClickListener);
|
||||
|
||||
@@ -836,6 +847,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
ViewUtil.updateLayoutParams(bodyText, ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
|
||||
ViewUtil.updateLayoutParamsIfNonNull(groupSenderHolder, ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
|
||||
|
||||
footer.setPlaybackSpeedListener(new AudioPlaybackSpeedToggleListener());
|
||||
footer.setVisibility(VISIBLE);
|
||||
} else if (hasDocument(messageRecord)) {
|
||||
documentViewStub.get().setVisibility(View.VISIBLE);
|
||||
@@ -1067,7 +1079,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
boolean shouldLinkifyAllLinks)
|
||||
{
|
||||
int linkPattern = Linkify.WEB_URLS | Linkify.EMAIL_ADDRESSES | Linkify.PHONE_NUMBERS;
|
||||
boolean hasLinks = Linkify.addLinks(messageBody, shouldLinkifyAllLinks ? linkPattern : 0);
|
||||
boolean hasLinks = LinkifyCompat.addLinks(messageBody, shouldLinkifyAllLinks ? linkPattern : 0);
|
||||
|
||||
if (hasLinks) {
|
||||
Stream.of(messageBody.getSpans(0, messageBody.length(), URLSpan.class))
|
||||
@@ -1436,16 +1448,6 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
|
||||
/// Event handlers
|
||||
|
||||
private void handleApproveIdentity() {
|
||||
List<IdentityKeyMismatch> mismatches = messageRecord.getIdentityKeyMismatches();
|
||||
|
||||
if (mismatches.size() != 1) {
|
||||
throw new AssertionError("Identity mismatch count: " + mismatches.size());
|
||||
}
|
||||
|
||||
new ConfirmIdentityDialog(context, messageRecord, mismatches.get(0)).show();
|
||||
}
|
||||
|
||||
private Spannable getLongMessageSpan(@NonNull MessageRecord messageRecord) {
|
||||
String message;
|
||||
Runnable action;
|
||||
@@ -1796,13 +1798,23 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
eventListener.onMessageWithRecaptchaNeededClicked(messageRecord);
|
||||
}
|
||||
} else if (!messageRecord.isOutgoing() && messageRecord.isIdentityMismatchFailure()) {
|
||||
handleApproveIdentity();
|
||||
if (eventListener != null) {
|
||||
eventListener.onIncomingIdentityMismatchClicked(messageRecord.getIndividualRecipient().getId());
|
||||
}
|
||||
} else if (messageRecord.isPendingInsecureSmsFallback()) {
|
||||
handleMessageApproval();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private final class TouchDelegateChangedListener implements ConversationItemFooter.OnTouchDelegateChangedListener {
|
||||
@Override
|
||||
public void onTouchDelegateChanged(@NonNull @NotNull Rect delegateRect, @NonNull @NotNull View delegateView) {
|
||||
offsetDescendantRectToMyCoords(footer, delegateRect);
|
||||
setTouchDelegate(new TouchDelegate(delegateRect, delegateView));
|
||||
}
|
||||
}
|
||||
|
||||
private final class UrlClickListener implements UrlClickHandler {
|
||||
|
||||
@Override
|
||||
@@ -1830,6 +1842,22 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
public void updateDrawState(@NonNull TextPaint ds) { }
|
||||
}
|
||||
|
||||
private final class AudioPlaybackSpeedToggleListener implements PlaybackSpeedToggleTextView.PlaybackSpeedListener {
|
||||
@Override
|
||||
public void onPlaybackSpeedChanged(float speed) {
|
||||
if (eventListener == null || !audioViewStub.resolved()) {
|
||||
return;
|
||||
}
|
||||
|
||||
Uri uri = audioViewStub.get().getAudioSlideUri();
|
||||
if (uri == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
eventListener.onVoiceNotePlaybackSpeedChanged(uri, speed);
|
||||
}
|
||||
}
|
||||
|
||||
private final class AudioViewCallbacks implements AudioView.Callbacks {
|
||||
|
||||
@Override
|
||||
@@ -1858,6 +1886,11 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onSpeedChanged(float speed, boolean isPlaying) {
|
||||
footer.setAudioPlaybackSpeed(speed, isPlaying);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onProgressUpdated(long durationMillis, long playheadMillis) {
|
||||
footer.setAudioDuration(durationMillis, playheadMillis);
|
||||
|
||||
@@ -36,6 +36,7 @@ import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.util.DefaultValueLiveData;
|
||||
import org.thoughtcrime.securesms.util.SingleLiveEvent;
|
||||
import org.thoughtcrime.securesms.util.ViewUtil;
|
||||
import org.thoughtcrime.securesms.util.livedata.LiveDataUtil;
|
||||
import org.thoughtcrime.securesms.wallpaper.ChatWallpaper;
|
||||
import org.whispersystems.libsignal.util.Pair;
|
||||
@@ -69,6 +70,9 @@ public class ConversationViewModel extends ViewModel {
|
||||
private final LiveData<ChatWallpaper> wallpaper;
|
||||
private final SingleLiveEvent<Event> events;
|
||||
private final LiveData<ChatColors> chatColors;
|
||||
private final MutableLiveData<Integer> toolbarBottom;
|
||||
private final MutableLiveData<Integer> inlinePlayerHeight;
|
||||
private final LiveData<Integer> scrollDateTopMargin;
|
||||
|
||||
private final Map<GroupId, Set<Recipient>> sessionMemberCache = new HashMap<>();
|
||||
|
||||
@@ -87,6 +91,9 @@ public class ConversationViewModel extends ViewModel {
|
||||
this.events = new SingleLiveEvent<>();
|
||||
this.pagingController = new ProxyPagingController();
|
||||
this.messageObserver = pagingController::onDataInvalidated;
|
||||
this.toolbarBottom = new MutableLiveData<>();
|
||||
this.inlinePlayerHeight = new MutableLiveData<>();
|
||||
this.scrollDateTopMargin = Transformations.distinctUntilChanged(LiveDataUtil.combineLatest(toolbarBottom, inlinePlayerHeight, Integer::sum));
|
||||
|
||||
LiveData<Recipient> recipientLiveData = LiveDataUtil.mapAsync(recipientId, Recipient::resolved);
|
||||
LiveData<ThreadAndRecipient> threadAndRecipient = LiveDataUtil.combineLatest(threadId, recipientLiveData, ThreadAndRecipient::new);
|
||||
@@ -144,6 +151,14 @@ public class ConversationViewModel extends ViewModel {
|
||||
Recipient::getChatColors);
|
||||
}
|
||||
|
||||
void setToolbarBottom(int bottom) {
|
||||
toolbarBottom.postValue(bottom);
|
||||
}
|
||||
|
||||
void setInlinePlayerVisible(boolean isVisible) {
|
||||
inlinePlayerHeight.postValue(isVisible ? ViewUtil.dpToPx(36) : 0);
|
||||
}
|
||||
|
||||
void onAttachmentKeyboardOpen() {
|
||||
mediaRepository.getMediaInBucket(context, Media.ALL_MEDIA_BUCKET_ID, recentMedia::postValue);
|
||||
}
|
||||
@@ -162,6 +177,10 @@ public class ConversationViewModel extends ViewModel {
|
||||
this.threadId.postValue(-1L);
|
||||
}
|
||||
|
||||
@NonNull LiveData<Integer> getScrollDateTopMargin() {
|
||||
return scrollDateTopMargin;
|
||||
}
|
||||
|
||||
@NonNull LiveData<Boolean> canShowAsBubble() {
|
||||
return canShowAsBubble;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
package org.thoughtcrime.securesms.conversation
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.util.AttributeSet
|
||||
import android.view.View
|
||||
import androidx.appcompat.widget.LinearLayoutCompat
|
||||
import androidx.lifecycle.Observer
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.AudioView
|
||||
import org.thoughtcrime.securesms.components.voice.VoiceNotePlaybackState
|
||||
import org.thoughtcrime.securesms.database.DraftDatabase
|
||||
import org.thoughtcrime.securesms.mms.AudioSlide
|
||||
|
||||
class VoiceNoteDraftView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyleAttr: Int = 0
|
||||
) : LinearLayoutCompat(context, attrs, defStyleAttr) {
|
||||
|
||||
var listener: Listener? = null
|
||||
|
||||
var draft: DraftDatabase.Draft? = null
|
||||
private set
|
||||
|
||||
private lateinit var audioView: AudioView
|
||||
|
||||
val playbackStateObserver: Observer<VoiceNotePlaybackState>
|
||||
get() = audioView.playbackStateObserver
|
||||
|
||||
init {
|
||||
inflate(context, R.layout.voice_note_draft_view, this)
|
||||
|
||||
val delete: View = findViewById(R.id.voice_note_draft_delete)
|
||||
|
||||
delete.setOnClickListener {
|
||||
if (draft != null) {
|
||||
val uri = audioView.audioSlideUri
|
||||
if (uri != null) {
|
||||
listener?.onVoiceNoteDraftDelete(uri)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
audioView = findViewById(R.id.voice_note_audio_view)
|
||||
}
|
||||
|
||||
fun clearDraft() {
|
||||
this.draft = null
|
||||
}
|
||||
|
||||
fun setDraft(draft: DraftDatabase.Draft) {
|
||||
audioView.setAudio(
|
||||
AudioSlide.createFromVoiceNoteDraft(context, draft),
|
||||
AudioViewCallbacksAdapter(),
|
||||
true,
|
||||
false
|
||||
)
|
||||
|
||||
this.draft = draft
|
||||
}
|
||||
|
||||
private inner class AudioViewCallbacksAdapter : AudioView.Callbacks {
|
||||
override fun onPlay(audioUri: Uri, progress: Double) {
|
||||
listener?.onVoiceNoteDraftPlay(audioUri, progress)
|
||||
}
|
||||
|
||||
override fun onPause(audioUri: Uri) {
|
||||
listener?.onVoiceNoteDraftPause(audioUri)
|
||||
}
|
||||
|
||||
override fun onSeekTo(audioUri: Uri, progress: Double) {
|
||||
listener?.onVoiceNoteDraftSeekTo(audioUri, progress)
|
||||
}
|
||||
|
||||
override fun onStopAndReset(audioUri: Uri) {
|
||||
throw UnsupportedOperationException()
|
||||
}
|
||||
|
||||
override fun onProgressUpdated(durationMillis: Long, playheadMillis: Long) = Unit
|
||||
|
||||
override fun onSpeedChanged(speed: Float, isPlaying: Boolean) = Unit
|
||||
}
|
||||
|
||||
interface Listener {
|
||||
fun onVoiceNoteDraftPlay(audioUri: Uri, progress: Double)
|
||||
fun onVoiceNoteDraftPause(audioUri: Uri)
|
||||
fun onVoiceNoteDraftSeekTo(audioUri: Uri, progress: Double)
|
||||
fun onVoiceNoteDraftDelete(audioUri: Uri)
|
||||
}
|
||||
}
|
||||
@@ -46,7 +46,11 @@ class ChatColorSelectionFragment : Fragment(R.layout.chat_color_selection_fragme
|
||||
|
||||
viewModel.events.observe(viewLifecycleOwner) { event ->
|
||||
if (event is ChatColorSelectionViewModel.Event.ConfirmDeletion) {
|
||||
showWarningDialog(event)
|
||||
if (event.usageCount > 0) {
|
||||
showWarningDialogForMultipleUses(event)
|
||||
} else {
|
||||
showWarningDialogForNoUses(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -56,7 +60,20 @@ class ChatColorSelectionFragment : Fragment(R.layout.chat_color_selection_fragme
|
||||
viewModel.refresh()
|
||||
}
|
||||
|
||||
private fun showWarningDialog(confirmDeletion: ChatColorSelectionViewModel.Event.ConfirmDeletion) {
|
||||
private fun showWarningDialogForNoUses(confirmDeletion: ChatColorSelectionViewModel.Event.ConfirmDeletion) {
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setMessage(R.string.ChatColorSelectionFragment__delete_chat_color)
|
||||
.setPositiveButton(R.string.ChatColorSelectionFragment__delete) { dialog, _ ->
|
||||
viewModel.deleteNow(confirmDeletion.chatColors)
|
||||
dialog.dismiss()
|
||||
}
|
||||
.setNegativeButton(android.R.string.cancel) { dialog, _ ->
|
||||
dialog.dismiss()
|
||||
}
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun showWarningDialogForMultipleUses(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))
|
||||
|
||||
@@ -43,11 +43,7 @@ class ChatColorSelectionViewModel(private val repository: ChatColorSelectionRepo
|
||||
|
||||
fun startDeletion(chatColors: ChatColors) {
|
||||
repository.getUsageCount(chatColors.id) {
|
||||
if (it > 0) {
|
||||
internalEvents.postValue(Event.ConfirmDeletion(it, chatColors))
|
||||
} else {
|
||||
deleteNow(chatColors)
|
||||
}
|
||||
internalEvents.postValue(Event.ConfirmDeletion(it, chatColors))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package org.thoughtcrime.securesms.conversation.drafts
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import org.signal.core.util.concurrent.SignalExecutors
|
||||
import org.thoughtcrime.securesms.database.DraftDatabase
|
||||
import org.thoughtcrime.securesms.providers.BlobProvider
|
||||
|
||||
class DraftRepository(private val context: Context) {
|
||||
fun deleteVoiceNoteDraft(draft: DraftDatabase.Draft) {
|
||||
deleteBlob(Uri.parse(draft.value).buildUpon().clearQuery().build())
|
||||
}
|
||||
|
||||
fun deleteBlob(uri: Uri) {
|
||||
SignalExecutors.BOUNDED.execute {
|
||||
BlobProvider.getInstance().delete(context, uri)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package org.thoughtcrime.securesms.conversation.drafts
|
||||
|
||||
import org.thoughtcrime.securesms.database.DraftDatabase
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
|
||||
/**
|
||||
* State object responsible for holding Voice Note draft state. The intention is to allow
|
||||
* other pieces of draft state to be held here as well in the future, and to serve as a
|
||||
* management pattern going forward for drafts.
|
||||
*/
|
||||
data class DraftState(
|
||||
val recipientId: RecipientId = Recipient.UNKNOWN.id,
|
||||
val voiceNoteDraft: DraftDatabase.Draft? = null
|
||||
)
|
||||
@@ -0,0 +1,87 @@
|
||||
package org.thoughtcrime.securesms.conversation.drafts
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import org.thoughtcrime.securesms.components.voice.VoiceNoteDraft
|
||||
import org.thoughtcrime.securesms.database.DraftDatabase
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.util.concurrent.ListenableFuture
|
||||
import org.thoughtcrime.securesms.util.livedata.Store
|
||||
|
||||
/**
|
||||
* ViewModel responsible for holding Voice Note draft state. The intention is to allow
|
||||
* other pieces of draft state to be held here as well in the future, and to serve as a
|
||||
* management pattern going forward for drafts.
|
||||
*/
|
||||
class DraftViewModel(
|
||||
private val repository: DraftRepository
|
||||
) : ViewModel() {
|
||||
|
||||
private val store = Store<DraftState>(DraftState())
|
||||
|
||||
val state: LiveData<DraftState> = store.stateLiveData
|
||||
|
||||
private var voiceNoteDraftFuture: ListenableFuture<VoiceNoteDraft>? = null
|
||||
|
||||
val voiceNoteDraft: DraftDatabase.Draft?
|
||||
get() = store.state.voiceNoteDraft
|
||||
|
||||
fun consumeVoiceNoteDraftFuture(): ListenableFuture<VoiceNoteDraft>? {
|
||||
val future = voiceNoteDraftFuture
|
||||
voiceNoteDraftFuture = null
|
||||
|
||||
return future
|
||||
}
|
||||
|
||||
fun setVoiceNoteDraftFuture(voiceNoteDraftFuture: ListenableFuture<VoiceNoteDraft>) {
|
||||
this.voiceNoteDraftFuture = voiceNoteDraftFuture
|
||||
}
|
||||
|
||||
fun setVoiceNoteDraft(recipientId: RecipientId, draft: DraftDatabase.Draft) {
|
||||
store.update {
|
||||
it.copy(recipientId = recipientId, voiceNoteDraft = draft)
|
||||
}
|
||||
}
|
||||
|
||||
@get:JvmName("hasVoiceNoteDraft")
|
||||
val hasVoiceNoteDraft: Boolean
|
||||
get() = store.state.voiceNoteDraft != null
|
||||
|
||||
fun clearVoiceNoteDraft() {
|
||||
store.update {
|
||||
it.copy(voiceNoteDraft = null)
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteVoiceNoteDraft() {
|
||||
val draft = store.state.voiceNoteDraft
|
||||
if (draft != null) {
|
||||
clearVoiceNoteDraft()
|
||||
repository.deleteVoiceNoteDraft(draft)
|
||||
}
|
||||
}
|
||||
|
||||
fun onRecipientChanged(recipient: Recipient) {
|
||||
store.update {
|
||||
if (recipient.id != it.recipientId) {
|
||||
it.copy(recipientId = recipient.id, voiceNoteDraft = null)
|
||||
} else {
|
||||
it
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteBlob(uri: Uri) {
|
||||
repository.deleteBlob(uri)
|
||||
}
|
||||
|
||||
class Factory(private val repository: DraftRepository) : ViewModelProvider.Factory {
|
||||
|
||||
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
|
||||
return requireNotNull(modelClass.cast(DraftViewModel(repository)))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -46,11 +46,22 @@ public final class SafetyNumberChangeDialog extends DialogFragment implements Sa
|
||||
private static final String MESSAGE_TYPE_EXTRA = "message_type";
|
||||
private static final String CONTINUE_TEXT_RESOURCE_EXTRA = "continue_text_resource";
|
||||
private static final String CANCEL_TEXT_RESOURCE_EXTRA = "cancel_text_resource";
|
||||
private static final String SKIP_CALLBACKS_EXTRA = "skip_callbacks_extra";
|
||||
|
||||
private SafetyNumberChangeViewModel viewModel;
|
||||
private SafetyNumberChangeAdapter adapter;
|
||||
private View dialogView;
|
||||
|
||||
public static void show(@NonNull FragmentManager fragmentManager, @NonNull RecipientId recipientId) {
|
||||
Bundle arguments = new Bundle();
|
||||
arguments.putStringArray(RECIPIENT_IDS_EXTRA, new String[] { recipientId.serialize() });
|
||||
arguments.putInt(CONTINUE_TEXT_RESOURCE_EXTRA, R.string.safety_number_change_dialog__accept);
|
||||
arguments.putBoolean(SKIP_CALLBACKS_EXTRA, true);
|
||||
SafetyNumberChangeDialog fragment = new SafetyNumberChangeDialog();
|
||||
fragment.setArguments(arguments);
|
||||
fragment.show(fragmentManager, SAFETY_NUMBER_DIALOG);
|
||||
}
|
||||
|
||||
public static void show(@NonNull FragmentManager fragmentManager, @NonNull List<IdentityDatabase.IdentityRecord> identityRecords) {
|
||||
List<String> ids = Stream.of(identityRecords)
|
||||
.filterNot(IdentityDatabase.IdentityRecord::isFirstUse)
|
||||
@@ -196,9 +207,11 @@ public final class SafetyNumberChangeDialog extends DialogFragment implements Sa
|
||||
private void handleSendAnyway(DialogInterface dialogInterface, int which) {
|
||||
Log.d(TAG, "handleSendAnyway");
|
||||
|
||||
boolean skipCallbacks = requireArguments().getBoolean(SKIP_CALLBACKS_EXTRA, false);
|
||||
|
||||
Activity activity = getActivity();
|
||||
Callback callback;
|
||||
if (activity instanceof Callback) {
|
||||
if (activity instanceof Callback && !skipCallbacks) {
|
||||
callback = (Callback) activity;
|
||||
} else {
|
||||
callback = null;
|
||||
@@ -241,7 +254,9 @@ public final class SafetyNumberChangeDialog extends DialogFragment implements Sa
|
||||
|
||||
public interface Callback {
|
||||
void onSendAnywayAfterSafetyNumberChange(@NonNull List<RecipientId> changedRecipients);
|
||||
|
||||
void onMessageResentAfterSafetyNumberChange();
|
||||
|
||||
void onCanceled();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ import org.thoughtcrime.securesms.util.Util;
|
||||
import org.whispersystems.libsignal.IdentityKey;
|
||||
import org.whispersystems.libsignal.SignalProtocolAddress;
|
||||
import org.whispersystems.signalservice.api.SignalSessionLock;
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
@@ -124,13 +125,14 @@ final class SafetyNumberChangeRepository {
|
||||
|
||||
try(SignalSessionLock.Lock unused = ReentrantSessionLock.INSTANCE.acquire()) {
|
||||
for (ChangedRecipient changedRecipient : changedRecipients) {
|
||||
SignalProtocolAddress mismatchAddress = new SignalProtocolAddress(changedRecipient.getRecipient().requireServiceId(), 1);
|
||||
SignalProtocolAddress mismatchAddress = new SignalProtocolAddress(changedRecipient.getRecipient().requireServiceId(), SignalServiceAddress.DEFAULT_DEVICE_ID);
|
||||
TextSecureIdentityKeyStore identityKeyStore = new TextSecureIdentityKeyStore(context);
|
||||
Log.d(TAG, "Saving identity for: " + changedRecipient.getRecipient().getId() + " " + changedRecipient.getIdentityRecord().getIdentityKey().hashCode());
|
||||
TextSecureIdentityKeyStore.SaveResult result = identityKeyStore.saveIdentity(mismatchAddress, changedRecipient.getIdentityRecord().getIdentityKey(), true);
|
||||
Log.d(TAG, "Saving identity result: " + result);
|
||||
if (result == TextSecureIdentityKeyStore.SaveResult.NO_CHANGE) {
|
||||
Log.i(TAG, "Archiving sessions explicitly as they appear to be out of sync.");
|
||||
SessionUtil.archiveSession(context, changedRecipient.getRecipient().getId(), SignalServiceAddress.DEFAULT_DEVICE_ID);
|
||||
SessionUtil.archiveSiblingSessions(context, mismatchAddress);
|
||||
DatabaseFactory.getSenderKeySharedDatabase(context).deleteAllFor(changedRecipient.getRecipient().getId());
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ import android.content.res.TypedArray;
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.Color;
|
||||
import android.graphics.drawable.Drawable;
|
||||
import android.net.Uri;
|
||||
import android.os.AsyncTask;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
@@ -57,6 +58,8 @@ import androidx.constraintlayout.widget.ConstraintLayout;
|
||||
import androidx.constraintlayout.widget.ConstraintSet;
|
||||
import androidx.core.content.res.ResourcesCompat;
|
||||
import androidx.fragment.app.DialogFragment;
|
||||
import androidx.lifecycle.LifecycleOwner;
|
||||
import androidx.lifecycle.Observer;
|
||||
import androidx.lifecycle.ViewModelProviders;
|
||||
import androidx.recyclerview.widget.ItemTouchHelper;
|
||||
import androidx.recyclerview.widget.LinearLayoutManager;
|
||||
@@ -89,11 +92,11 @@ import org.thoughtcrime.securesms.components.reminder.ReminderView;
|
||||
import org.thoughtcrime.securesms.components.reminder.ServiceOutageReminder;
|
||||
import org.thoughtcrime.securesms.components.reminder.UnauthorizedReminder;
|
||||
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity;
|
||||
import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaController;
|
||||
import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaControllerOwner;
|
||||
import org.thoughtcrime.securesms.components.voice.VoiceNotePlayerView;
|
||||
import org.thoughtcrime.securesms.conversation.ConversationFragment;
|
||||
import org.thoughtcrime.securesms.conversationlist.model.Conversation;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.search.MessageResult;
|
||||
import org.thoughtcrime.securesms.search.SearchResult;
|
||||
import org.thoughtcrime.securesms.conversationlist.model.UnreadPayments;
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||
import org.thoughtcrime.securesms.database.MessageDatabase.MarkedMessageInfo;
|
||||
@@ -119,6 +122,9 @@ import org.thoughtcrime.securesms.payments.preferences.details.PaymentDetailsPar
|
||||
import org.thoughtcrime.securesms.permissions.Permissions;
|
||||
import org.thoughtcrime.securesms.ratelimit.RecaptchaProofBottomSheetFragment;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.search.MessageResult;
|
||||
import org.thoughtcrime.securesms.search.SearchResult;
|
||||
import org.thoughtcrime.securesms.service.KeyCachingService;
|
||||
import org.thoughtcrime.securesms.sms.MessageSender;
|
||||
import org.thoughtcrime.securesms.storage.StorageSyncHelper;
|
||||
@@ -188,6 +194,8 @@ public class ConversationListFragment extends MainFragment implements ActionMode
|
||||
private SnapToTopDataObserver snapToTopDataObserver;
|
||||
private Drawable archiveDrawable;
|
||||
private AppForegroundObserver.Listener appForegroundObserver;
|
||||
private VoiceNoteMediaControllerOwner mediaControllerOwner;
|
||||
private Stub<VoiceNotePlayerView> voiceNotePlayerViewStub;
|
||||
|
||||
private Stopwatch startupStopwatch;
|
||||
|
||||
@@ -195,6 +203,17 @@ public class ConversationListFragment extends MainFragment implements ActionMode
|
||||
return new ConversationListFragment();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onAttach(@NonNull Context context) {
|
||||
super.onAttach(context);
|
||||
|
||||
if (context instanceof VoiceNoteMediaControllerOwner) {
|
||||
mediaControllerOwner = (VoiceNoteMediaControllerOwner) context;
|
||||
} else {
|
||||
throw new ClassCastException("Expected context to be a Listener");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCreate(Bundle icicle) {
|
||||
super.onCreate(icicle);
|
||||
@@ -223,6 +242,7 @@ public class ConversationListFragment extends MainFragment implements ActionMode
|
||||
searchToolbar = new Stub<>(view.findViewById(R.id.search_toolbar));
|
||||
megaphoneContainer = new Stub<>(view.findViewById(R.id.megaphone_container));
|
||||
paymentNotificationView = new Stub<>(view.findViewById(R.id.payments_notification));
|
||||
voiceNotePlayerViewStub = new Stub<>(view.findViewById(R.id.voice_note_player));
|
||||
|
||||
Toolbar toolbar = getToolbar(view);
|
||||
toolbar.setVisibility(View.VISIBLE);
|
||||
@@ -257,6 +277,7 @@ public class ConversationListFragment extends MainFragment implements ActionMode
|
||||
initializeListAdapters();
|
||||
initializeTypingObserver();
|
||||
initializeSearchListener();
|
||||
initializeVoiceNotePlayer();
|
||||
|
||||
RatingManager.showRatingDialogIfNecessary(requireContext());
|
||||
|
||||
@@ -507,6 +528,21 @@ public class ConversationListFragment extends MainFragment implements ActionMode
|
||||
});
|
||||
}
|
||||
|
||||
private void initializeVoiceNotePlayer() {
|
||||
mediaControllerOwner.getVoiceNoteMediaController().getVoiceNotePlayerViewState().observe(getViewLifecycleOwner(), state -> {
|
||||
if (state.isPresent()) {
|
||||
if (!voiceNotePlayerViewStub.resolved()) {
|
||||
voiceNotePlayerViewStub.get().setListener(new VoiceNotePlayerViewListener());
|
||||
}
|
||||
|
||||
voiceNotePlayerViewStub.get().setState(state.get());
|
||||
voiceNotePlayerViewStub.get().show();
|
||||
} else if (voiceNotePlayerViewStub.resolved()) {
|
||||
voiceNotePlayerViewStub.get().hide();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void initializeListAdapters() {
|
||||
defaultAdapter = new ConversationListAdapter(GlideApp.with(this), this);
|
||||
searchAdapter = new ConversationListSearchAdapter(GlideApp.with(this), this, Locale.getDefault());
|
||||
@@ -1282,6 +1318,36 @@ public class ConversationListFragment extends MainFragment implements ActionMode
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private final class VoiceNotePlayerViewListener implements VoiceNotePlayerView.Listener {
|
||||
|
||||
@Override
|
||||
public void onCloseRequested(@NonNull Uri uri) {
|
||||
if (voiceNotePlayerViewStub.resolved()) {
|
||||
mediaControllerOwner.getVoiceNoteMediaController().stopPlaybackAndReset(uri);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onSpeedChangeRequested(@NonNull Uri uri, float speed) {
|
||||
mediaControllerOwner.getVoiceNoteMediaController().setPlaybackSpeed(uri, speed);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPlay(@NonNull Uri uri, long messageId, double position) {
|
||||
mediaControllerOwner.getVoiceNoteMediaController().startSinglePlayback(uri, messageId, position);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPause(@NonNull Uri uri) {
|
||||
mediaControllerOwner.getVoiceNoteMediaController().pausePlayback(uri);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onNavigateToMessage(long threadId, @NonNull RecipientId threadRecipientId, @NonNull RecipientId senderId, long messageSentAt, long messagePositionInThread) {
|
||||
MainNavigator.get(requireActivity()).goToConversation(threadRecipientId, threadId, ThreadDatabase.DistributionTypes.DEFAULT, (int) messagePositionInThread);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -466,13 +466,12 @@ public class AttachmentDatabase extends Database {
|
||||
public void trimAllAbandonedAttachments() {
|
||||
SQLiteDatabase db = databaseHelper.getWritableDatabase();
|
||||
String selectAllMmsIds = "SELECT " + MmsDatabase.ID + " FROM " + MmsDatabase.TABLE_NAME;
|
||||
String selectDataInUse = "SELECT DISTINCT " + DATA + " FROM " + TABLE_NAME + " WHERE " + QUOTE + " = 0 AND (" + MMS_ID + " IN (" + selectAllMmsIds + ") OR " + MMS_ID + " = " + PREUPLOAD_MESSAGE_ID + ")";
|
||||
String where = MMS_ID + " NOT IN (" + selectAllMmsIds + ") AND " + DATA + " NOT IN (" + selectDataInUse + ")";
|
||||
String where = MMS_ID + " != " + PREUPLOAD_MESSAGE_ID + " AND " + MMS_ID + " NOT IN (" + selectAllMmsIds + ")";
|
||||
|
||||
db.delete(TABLE_NAME, where, null);
|
||||
}
|
||||
|
||||
public void deleteAbandonedAttachmentFiles() {
|
||||
public int deleteAbandonedAttachmentFiles() {
|
||||
Set<String> filesOnDisk = new HashSet<>();
|
||||
Set<String> filesInDb = new HashSet<>();
|
||||
|
||||
@@ -495,6 +494,8 @@ public class AttachmentDatabase extends Database {
|
||||
//noinspection ResultOfMethodCallIgnored
|
||||
new File(filePath).delete();
|
||||
}
|
||||
|
||||
return onDiskButNotInDatabase.size();
|
||||
}
|
||||
|
||||
@SuppressWarnings("ResultOfMethodCallIgnored")
|
||||
|
||||
@@ -76,6 +76,7 @@ public abstract class Database {
|
||||
}
|
||||
|
||||
protected void notifyStickerPackListeners() {
|
||||
ApplicationDependencies.getDatabaseObserver().notifyStickerPackObservers();
|
||||
context.getContentResolver().notifyChange(DatabaseContentProviders.StickerPack.CONTENT_URI, null);
|
||||
}
|
||||
|
||||
|
||||
@@ -208,6 +208,8 @@ public class DatabaseFactory {
|
||||
synchronized (lock) {
|
||||
getInstance(context).databaseHelper.onUpgrade(database, database.getVersion(), -1);
|
||||
getInstance(context).databaseHelper.markCurrent(database);
|
||||
getInstance(context).sms.deleteAbandonedMessages();
|
||||
getInstance(context).mms.deleteAbandonedMessages();
|
||||
getInstance(context).mms.trimEntriesForExpiredMessages();
|
||||
getInstance(context).getRawDatabase().rawExecSQL("DROP TABLE IF EXISTS key_value");
|
||||
getInstance(context).getRawDatabase().rawExecSQL("DROP TABLE IF EXISTS megaphone");
|
||||
@@ -225,7 +227,7 @@ public class DatabaseFactory {
|
||||
}
|
||||
|
||||
private DatabaseFactory(@NonNull Context context) {
|
||||
SQLiteDatabase.loadLibs(context);
|
||||
SqlCipherLibraryLoader.load(context);
|
||||
|
||||
DatabaseSecret databaseSecret = DatabaseSecretProvider.getOrCreateDatabaseSecret(context);
|
||||
AttachmentSecret attachmentSecret = AttachmentSecretProvider.getInstance(context).getOrCreateAttachmentSecret();
|
||||
|
||||
@@ -30,6 +30,7 @@ public final class DatabaseObserver {
|
||||
private final Map<UUID, Set<Observer>> paymentObservers;
|
||||
private final Set<Observer> allPaymentsObservers;
|
||||
private final Set<Observer> chatColorsObservers;
|
||||
private final Set<Observer> stickerPackObservers;
|
||||
|
||||
public DatabaseObserver(Application application) {
|
||||
this.application = application;
|
||||
@@ -40,6 +41,7 @@ public final class DatabaseObserver {
|
||||
this.paymentObservers = new HashMap<>();
|
||||
this.allPaymentsObservers = new HashSet<>();
|
||||
this.chatColorsObservers = new HashSet<>();
|
||||
this.stickerPackObservers = new HashSet<>();
|
||||
}
|
||||
|
||||
public void registerConversationListObserver(@NonNull Observer listener) {
|
||||
@@ -78,6 +80,12 @@ public final class DatabaseObserver {
|
||||
});
|
||||
}
|
||||
|
||||
public void registerStickerPackObserver(@NonNull Observer listener) {
|
||||
executor.execute(() -> {
|
||||
stickerPackObservers.add(listener);
|
||||
});
|
||||
}
|
||||
|
||||
public void unregisterObserver(@NonNull Observer listener) {
|
||||
executor.execute(() -> {
|
||||
conversationListObservers.remove(listener);
|
||||
@@ -85,6 +93,7 @@ public final class DatabaseObserver {
|
||||
unregisterMapped(verboseConversationObservers, listener);
|
||||
unregisterMapped(paymentObservers, listener);
|
||||
chatColorsObservers.remove(listener);
|
||||
stickerPackObservers.remove(listener);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -160,6 +169,12 @@ public final class DatabaseObserver {
|
||||
});
|
||||
}
|
||||
|
||||
public void notifyStickerPackObservers() {
|
||||
executor.execute(() -> {
|
||||
notifySet(stickerPackObservers);
|
||||
});
|
||||
}
|
||||
|
||||
private <K> void registerMapped(@NonNull Map<K, Set<Observer>> map, @NonNull K key, @NonNull Observer listener) {
|
||||
Set<Observer> listeners = map.get(key);
|
||||
|
||||
|
||||
@@ -8,8 +8,11 @@ import android.net.Uri;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper;
|
||||
import org.thoughtcrime.securesms.util.CursorUtil;
|
||||
import org.thoughtcrime.securesms.util.SqlUtil;
|
||||
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
@@ -17,6 +20,8 @@ import java.util.Set;
|
||||
|
||||
public class DraftDatabase extends Database {
|
||||
|
||||
private static final String TAG = Log.tag(DraftDatabase.class);
|
||||
|
||||
static final String TABLE_NAME = "drafts";
|
||||
public static final String ID = "_id";
|
||||
public static final String THREAD_ID = "thread_id";
|
||||
@@ -34,22 +39,34 @@ public class DraftDatabase extends Database {
|
||||
super(context, databaseHelper);
|
||||
}
|
||||
|
||||
public void insertDrafts(long threadId, List<Draft> drafts) {
|
||||
public void replaceDrafts(long threadId, List<Draft> drafts) {
|
||||
SQLiteDatabase db = databaseHelper.getWritableDatabase();
|
||||
|
||||
for (Draft draft : drafts) {
|
||||
ContentValues values = new ContentValues(3);
|
||||
values.put(THREAD_ID, threadId);
|
||||
values.put(DRAFT_TYPE, draft.getType());
|
||||
values.put(DRAFT_VALUE, draft.getValue());
|
||||
try {
|
||||
db.beginTransaction();
|
||||
|
||||
db.insert(TABLE_NAME, null, values);
|
||||
int deletedRowCount = db.delete(TABLE_NAME, THREAD_ID + " = ?", SqlUtil.buildArgs(threadId));
|
||||
Log.d(TAG, "[replaceDrafts] Deleted " + deletedRowCount + " rows for thread " + threadId);
|
||||
|
||||
for (Draft draft : drafts) {
|
||||
ContentValues values = new ContentValues(3);
|
||||
values.put(THREAD_ID, threadId);
|
||||
values.put(DRAFT_TYPE, draft.getType());
|
||||
values.put(DRAFT_VALUE, draft.getValue());
|
||||
|
||||
db.insert(TABLE_NAME, null, values);
|
||||
}
|
||||
|
||||
db.setTransactionSuccessful();
|
||||
} finally {
|
||||
db.endTransaction();
|
||||
}
|
||||
}
|
||||
|
||||
public void clearDrafts(long threadId) {
|
||||
SQLiteDatabase db = databaseHelper.getWritableDatabase();
|
||||
db.delete(TABLE_NAME, THREAD_ID + " = ?", new String[] {threadId+""});
|
||||
int deletedRowCount = db.delete(TABLE_NAME, THREAD_ID + " = ?", SqlUtil.buildArgs(threadId));
|
||||
Log.d(TAG, "[clearDrafts] Deleted " + deletedRowCount + " rows for thread " + threadId);
|
||||
}
|
||||
|
||||
void clearDrafts(Set<Long> threadIds) {
|
||||
@@ -89,14 +106,33 @@ public class DraftDatabase extends Database {
|
||||
}
|
||||
}
|
||||
|
||||
public @NonNull Drafts getAllVoiceNoteDrafts() {
|
||||
SQLiteDatabase db = databaseHelper.getReadableDatabase();
|
||||
Drafts results = new Drafts();
|
||||
String where = DRAFT_TYPE + " = ?";
|
||||
String[] args = SqlUtil.buildArgs(Draft.VOICE_NOTE);
|
||||
|
||||
try (Cursor cursor = db.query(TABLE_NAME, null, where, args, null, null, null)) {
|
||||
while (cursor != null && cursor.moveToNext()) {
|
||||
String type = CursorUtil.requireString(cursor, DRAFT_TYPE);
|
||||
String value = CursorUtil.requireString(cursor, DRAFT_VALUE);
|
||||
|
||||
results.add(new Draft(type, value));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
}
|
||||
|
||||
public static class Draft {
|
||||
public static final String TEXT = "text";
|
||||
public static final String IMAGE = "image";
|
||||
public static final String VIDEO = "video";
|
||||
public static final String AUDIO = "audio";
|
||||
public static final String LOCATION = "location";
|
||||
public static final String QUOTE = "quote";
|
||||
public static final String MENTION = "mention";
|
||||
public static final String TEXT = "text";
|
||||
public static final String IMAGE = "image";
|
||||
public static final String VIDEO = "video";
|
||||
public static final String AUDIO = "audio";
|
||||
public static final String LOCATION = "location";
|
||||
public static final String QUOTE = "quote";
|
||||
public static final String MENTION = "mention";
|
||||
public static final String VOICE_NOTE = "voice_note";
|
||||
|
||||
private final String type;
|
||||
private final String value;
|
||||
@@ -116,13 +152,14 @@ public class DraftDatabase extends Database {
|
||||
|
||||
String getSnippet(Context context) {
|
||||
switch (type) {
|
||||
case TEXT: return value;
|
||||
case IMAGE: return context.getString(R.string.DraftDatabase_Draft_image_snippet);
|
||||
case VIDEO: return context.getString(R.string.DraftDatabase_Draft_video_snippet);
|
||||
case AUDIO: return context.getString(R.string.DraftDatabase_Draft_audio_snippet);
|
||||
case LOCATION: return context.getString(R.string.DraftDatabase_Draft_location_snippet);
|
||||
case QUOTE: return context.getString(R.string.DraftDatabase_Draft_quote_snippet);
|
||||
default: return null;
|
||||
case TEXT: return value;
|
||||
case IMAGE: return context.getString(R.string.DraftDatabase_Draft_image_snippet);
|
||||
case VIDEO: return context.getString(R.string.DraftDatabase_Draft_video_snippet);
|
||||
case AUDIO: return context.getString(R.string.DraftDatabase_Draft_audio_snippet);
|
||||
case LOCATION: return context.getString(R.string.DraftDatabase_Draft_location_snippet);
|
||||
case QUOTE: return context.getString(R.string.DraftDatabase_Draft_quote_snippet);
|
||||
case VOICE_NOTE: return context.getString(R.string.DraftDatabase_Draft_voice_note);
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,6 +172,20 @@ private static final String[] GROUP_PROJECTION = {
|
||||
}
|
||||
}
|
||||
|
||||
public Optional<GroupRecord> getGroupByDistributionId(@NonNull DistributionId distributionId) {
|
||||
SQLiteDatabase db = databaseHelper.getReadableDatabase();
|
||||
String query = DISTRIBUTION_ID + " = ?";
|
||||
String[] args = SqlUtil.buildArgs(distributionId);
|
||||
|
||||
try (Cursor cursor = db.query(TABLE_NAME, null, query, args, null, null, null)) {
|
||||
if (cursor.moveToFirst()) {
|
||||
return getGroup(cursor);
|
||||
} else {
|
||||
return Optional.absent();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the specified members from the list of 'unmigrated V1 members' -- the list of members
|
||||
* that were either dropped or had to be invited when migrating the group from V1->V2.
|
||||
@@ -452,7 +466,7 @@ private static final String[] GROUP_PROJECTION = {
|
||||
GroupId.V2 groupId = GroupId.v2(groupMasterKey);
|
||||
|
||||
if (getGroupV1ByExpectedV2(groupId).isPresent()) {
|
||||
throw new MissedGroupMigrationInsertException(groupId);
|
||||
Log.w(TAG, "There already exists a V1 group that should be migrated into this group. But if the recipient already exists, there's not much we can do here.");
|
||||
}
|
||||
|
||||
SQLiteDatabase db = databaseHelper.getWritableDatabase();
|
||||
|
||||
@@ -11,6 +11,7 @@ import com.annimon.stream.Stream;
|
||||
import net.sqlcipher.database.SQLiteOpenHelper;
|
||||
import net.sqlcipher.database.SQLiteDatabase;
|
||||
|
||||
import org.signal.core.util.concurrent.SignalExecutors;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.crypto.DatabaseSecret;
|
||||
import org.thoughtcrime.securesms.crypto.DatabaseSecretProvider;
|
||||
@@ -140,9 +141,11 @@ public class JobDatabase extends SQLiteOpenHelper implements SignalDatabase {
|
||||
public void onOpen(SQLiteDatabase db) {
|
||||
Log.i(TAG, "onOpen()");
|
||||
|
||||
dropTableIfPresent("job_spec");
|
||||
dropTableIfPresent("constraint_spec");
|
||||
dropTableIfPresent("dependency_spec");
|
||||
SignalExecutors.BOUNDED.execute(() -> {
|
||||
dropTableIfPresent("job_spec");
|
||||
dropTableIfPresent("constraint_spec");
|
||||
dropTableIfPresent("dependency_spec");
|
||||
});
|
||||
}
|
||||
|
||||
public synchronized void insertJobs(@NonNull List<FullSpec> fullSpecs) {
|
||||
|
||||
@@ -10,6 +10,7 @@ import net.sqlcipher.database.SQLiteDatabase;
|
||||
import net.sqlcipher.database.SQLiteDatabaseHook;
|
||||
import net.sqlcipher.database.SQLiteOpenHelper;
|
||||
|
||||
import org.signal.core.util.concurrent.SignalExecutors;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.crypto.DatabaseSecret;
|
||||
import org.thoughtcrime.securesms.crypto.DatabaseSecretProvider;
|
||||
@@ -88,10 +89,12 @@ public class KeyValueDatabase extends SQLiteOpenHelper implements SignalDatabase
|
||||
public void onOpen(SQLiteDatabase db) {
|
||||
Log.i(TAG, "onOpen()");
|
||||
|
||||
if (DatabaseFactory.getInstance(application).hasTable("key_value")) {
|
||||
Log.i(TAG, "Dropping original key_value table from the main database.");
|
||||
DatabaseFactory.getInstance(application).getRawDatabase().rawExecSQL("DROP TABLE key_value");
|
||||
}
|
||||
SignalExecutors.BOUNDED.execute(() -> {
|
||||
if (DatabaseFactory.getInstance(application).hasTable("key_value")) {
|
||||
Log.i(TAG, "Dropping original key_value table from the main database.");
|
||||
DatabaseFactory.getInstance(application).getRawDatabase().rawExecSQL("DROP TABLE key_value");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public @NonNull KeyValueDataSet getDataSet() {
|
||||
|
||||
@@ -9,6 +9,7 @@ import androidx.annotation.NonNull;
|
||||
import net.sqlcipher.database.SQLiteDatabase;
|
||||
import net.sqlcipher.database.SQLiteOpenHelper;
|
||||
|
||||
import org.signal.core.util.concurrent.SignalExecutors;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.crypto.DatabaseSecret;
|
||||
import org.thoughtcrime.securesms.crypto.DatabaseSecretProvider;
|
||||
@@ -91,10 +92,12 @@ public class MegaphoneDatabase extends SQLiteOpenHelper implements SignalDatabas
|
||||
public void onOpen(SQLiteDatabase db) {
|
||||
Log.i(TAG, "onOpen()");
|
||||
|
||||
if (DatabaseFactory.getInstance(application).hasTable("megaphone")) {
|
||||
Log.i(TAG, "Dropping original megaphone table from the main database.");
|
||||
DatabaseFactory.getInstance(application).getRawDatabase().rawExecSQL("DROP TABLE megaphone");
|
||||
}
|
||||
SignalExecutors.BOUNDED.execute(() -> {
|
||||
if (DatabaseFactory.getInstance(application).hasTable("megaphone")) {
|
||||
Log.i(TAG, "Dropping original megaphone table from the main database.");
|
||||
DatabaseFactory.getInstance(application).getRawDatabase().rawExecSQL("DROP TABLE megaphone");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public void insert(@NonNull Collection<Event> events) {
|
||||
|
||||
@@ -20,6 +20,7 @@ import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatch;
|
||||
import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatchList;
|
||||
import org.thoughtcrime.securesms.database.documents.NetworkFailure;
|
||||
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper;
|
||||
import org.thoughtcrime.securesms.database.model.MessageId;
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord;
|
||||
import org.thoughtcrime.securesms.database.model.ReactionRecord;
|
||||
import org.thoughtcrime.securesms.database.model.SmsMessageRecord;
|
||||
@@ -131,7 +132,7 @@ public abstract class MessageDatabase extends Database implements MmsSmsColumns
|
||||
public abstract List<MarkedMessageInfo> setEntireThreadRead(long threadId);
|
||||
public abstract List<MarkedMessageInfo> setMessagesReadSince(long threadId, long timestamp);
|
||||
public abstract List<MarkedMessageInfo> setAllMessagesRead();
|
||||
public abstract Pair<Long, Long> updateBundleMessageBody(long messageId, String body);
|
||||
public abstract InsertResult updateBundleMessageBody(long messageId, String body);
|
||||
public abstract @NonNull List<MarkedMessageInfo> getViewedIncomingMessages(long threadId);
|
||||
public abstract @Nullable MarkedMessageInfo setIncomingMessageViewed(long messageId);
|
||||
public abstract @NonNull List<MarkedMessageInfo> setIncomingMessagesViewed(@NonNull List<Long> messageIds);
|
||||
@@ -682,11 +683,13 @@ public abstract class MessageDatabase extends Database implements MmsSmsColumns
|
||||
|
||||
private final long threadId;
|
||||
private final SyncMessageId syncMessageId;
|
||||
private final MessageId messageId;
|
||||
private final ExpirationInfo expirationInfo;
|
||||
|
||||
public MarkedMessageInfo(long threadId, SyncMessageId syncMessageId, ExpirationInfo expirationInfo) {
|
||||
public MarkedMessageInfo(long threadId, @NonNull SyncMessageId syncMessageId, @NonNull MessageId messageId, @Nullable ExpirationInfo expirationInfo) {
|
||||
this.threadId = threadId;
|
||||
this.syncMessageId = syncMessageId;
|
||||
this.messageId = messageId;
|
||||
this.expirationInfo = expirationInfo;
|
||||
}
|
||||
|
||||
@@ -694,11 +697,15 @@ public abstract class MessageDatabase extends Database implements MmsSmsColumns
|
||||
return threadId;
|
||||
}
|
||||
|
||||
public SyncMessageId getSyncMessageId() {
|
||||
public @NonNull SyncMessageId getSyncMessageId() {
|
||||
return syncMessageId;
|
||||
}
|
||||
|
||||
public ExpirationInfo getExpirationInfo() {
|
||||
public @NonNull MessageId getMessageId() {
|
||||
return messageId;
|
||||
}
|
||||
|
||||
public @Nullable ExpirationInfo getExpirationInfo() {
|
||||
return expirationInfo;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,55 +3,66 @@ package org.thoughtcrime.securesms.database
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper
|
||||
import org.thoughtcrime.securesms.database.model.MessageId
|
||||
import org.thoughtcrime.securesms.database.model.MessageLogEntry
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.util.CursorUtil
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags
|
||||
import org.thoughtcrime.securesms.util.RecipientAccessList
|
||||
import org.thoughtcrime.securesms.util.SqlUtil
|
||||
import org.whispersystems.signalservice.api.crypto.ContentHint
|
||||
import org.whispersystems.signalservice.api.messages.SendMessageResult
|
||||
import org.whispersystems.signalservice.internal.push.SignalServiceProtos
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Stores a 24-hr buffer of all outgoing messages. Used for the retry logic required for sender key.
|
||||
*
|
||||
* General note: This class is actually two tables -- one to store the entry, and another to store all the devices that were sent it.
|
||||
* General note: This class is actually three tables:
|
||||
* - one to store the entry
|
||||
* - one to store all the devices that were sent it, and
|
||||
* - one to store the set of related messages.
|
||||
*
|
||||
* The general lifecycle of entries in the store goes something like this:
|
||||
* - Upon sending a message, throw an entry in the 'message table' and throw an entry for each recipient you sent it to in the 'recipient table'
|
||||
* - Upon sending a message, put an entry in the 'payload table', an entry for each recipient you sent it to in the 'recipient table', and an entry for each
|
||||
* related message in the 'message table'
|
||||
* - Whenever you get a delivery receipt, delete the entries in the 'recipient table'
|
||||
* - Whenever there's no more records in the 'recipient table' for a given message, delete the entry in the 'message table'
|
||||
* - Whenever you delete a message, delete the entry in the 'message table'
|
||||
* - Whenever you delete a message, delete the relevant entries from the 'payload table'
|
||||
* - Whenever you read an entry from the table, first trim off all the entries that are too old
|
||||
*
|
||||
* Because of all of this, you can be sure that if an entry is in this store, it's safe to resend to someone upon request
|
||||
*
|
||||
* Worth noting that we use triggers + foreign keys to make sure entries in this table are properly cleaned up. Triggers for when you delete a message, and
|
||||
* a cascading delete foreign key between these two tables.
|
||||
* cascading delete foreign keys between these three tables.
|
||||
*
|
||||
* Performance considerations:
|
||||
* - The most common operations by far are:
|
||||
* - Inserting into the table
|
||||
* - Deleting a recipient (in response to a delivery receipt)
|
||||
* - We should also optimize for when we delete messages from the sms/mms tables, since you can delete a bunch at once
|
||||
* - We *don't* really need to optimize for retrieval, since that happens very infrequently. In particular, we don't want to slow down inserts in order to
|
||||
* improve retrieval time. That means we shouldn't be adding indexes that optimize for retrieval.
|
||||
*/
|
||||
class MessageSendLogDatabase constructor(context: Context?, databaseHelper: SQLCipherOpenHelper?) : Database(context, databaseHelper) {
|
||||
|
||||
companion object {
|
||||
@JvmField
|
||||
val CREATE_TABLE: Array<String> = arrayOf(MessageTable.CREATE_TABLE, RecipientTable.CREATE_TABLE)
|
||||
val CREATE_TABLE: Array<String> = arrayOf(PayloadTable.CREATE_TABLE, RecipientTable.CREATE_TABLE, MessageTable.CREATE_TABLE)
|
||||
|
||||
@JvmField
|
||||
val CREATE_INDEXES: Array<String> = MessageTable.CREATE_INDEXES + RecipientTable.CREATE_INDEXES
|
||||
val CREATE_INDEXES: Array<String> = PayloadTable.CREATE_INDEXES + RecipientTable.CREATE_INDEXES + MessageTable.CREATE_INDEXES
|
||||
|
||||
@JvmField
|
||||
val CREATE_TRIGGERS: Array<String> = MessageTable.CREATE_TRIGGERS
|
||||
val CREATE_TRIGGERS: Array<String> = PayloadTable.CREATE_TRIGGERS
|
||||
}
|
||||
|
||||
private object MessageTable {
|
||||
const val TABLE_NAME = "message_send_log"
|
||||
private object PayloadTable {
|
||||
const val TABLE_NAME = "msl_payload"
|
||||
|
||||
const val ID = "_id"
|
||||
const val DATE_SENT = "date_sent"
|
||||
const val CONTENT = "content"
|
||||
const val RELATED_MESSAGE_ID = "related_message_id"
|
||||
const val IS_RELATED_MESSAGE_MMS = "is_related_message_mms"
|
||||
const val CONTENT_HINT = "content_hint"
|
||||
|
||||
const val CREATE_TABLE = """
|
||||
@@ -59,117 +70,192 @@ class MessageSendLogDatabase constructor(context: Context?, databaseHelper: SQLC
|
||||
$ID INTEGER PRIMARY KEY,
|
||||
$DATE_SENT INTEGER NOT NULL,
|
||||
$CONTENT BLOB NOT NULL,
|
||||
$RELATED_MESSAGE_ID INTEGER DEFAULT -1,
|
||||
$IS_RELATED_MESSAGE_MMS INTEGER DEFAULT 0,
|
||||
$CONTENT_HINT INTEGER NOT NULL
|
||||
)
|
||||
"""
|
||||
|
||||
@JvmField
|
||||
/** Created for [deleteEntriesForRecipient] */
|
||||
val CREATE_INDEXES = arrayOf(
|
||||
"CREATE INDEX message_log_date_sent_index ON $TABLE_NAME ($DATE_SENT)",
|
||||
"CREATE INDEX message_log_related_message_index ON $TABLE_NAME ($RELATED_MESSAGE_ID, $IS_RELATED_MESSAGE_MMS)"
|
||||
"CREATE INDEX msl_payload_date_sent_index ON $TABLE_NAME ($DATE_SENT)",
|
||||
)
|
||||
|
||||
@JvmField
|
||||
val CREATE_TRIGGERS = arrayOf(
|
||||
"""
|
||||
CREATE TRIGGER msl_sms_delete AFTER DELETE ON ${SmsDatabase.TABLE_NAME}
|
||||
BEGIN
|
||||
DELETE FROM $TABLE_NAME WHERE $RELATED_MESSAGE_ID = old.${SmsDatabase.ID} AND $IS_RELATED_MESSAGE_MMS = 0;
|
||||
DELETE FROM $TABLE_NAME WHERE $ID IN (SELECT ${MessageTable.PAYLOAD_ID} FROM ${MessageTable.TABLE_NAME} WHERE ${MessageTable.MESSAGE_ID} = old.${SmsDatabase.ID} AND ${MessageTable.IS_MMS} = 0);
|
||||
END
|
||||
""",
|
||||
"""
|
||||
CREATE TRIGGER msl_mms_delete AFTER DELETE ON ${MmsDatabase.TABLE_NAME}
|
||||
BEGIN
|
||||
DELETE FROM $TABLE_NAME WHERE $RELATED_MESSAGE_ID = old.${MmsDatabase.ID} AND $IS_RELATED_MESSAGE_MMS = 1;
|
||||
DELETE FROM $TABLE_NAME WHERE $ID IN (SELECT ${MessageTable.PAYLOAD_ID} FROM ${MessageTable.TABLE_NAME} WHERE ${MessageTable.MESSAGE_ID} = old.${MmsDatabase.ID} AND ${MessageTable.IS_MMS} = 1);
|
||||
END
|
||||
""",
|
||||
"""
|
||||
CREATE TRIGGER msl_attachment_delete AFTER DELETE ON ${AttachmentDatabase.TABLE_NAME}
|
||||
BEGIN
|
||||
DELETE FROM $TABLE_NAME WHERE $ID IN (SELECT ${MessageTable.PAYLOAD_ID} FROM ${MessageTable.TABLE_NAME} WHERE ${MessageTable.MESSAGE_ID} = old.${AttachmentDatabase.MMS_ID} AND ${MessageTable.IS_MMS} = 1);
|
||||
END
|
||||
"""
|
||||
)
|
||||
}
|
||||
|
||||
private object RecipientTable {
|
||||
const val TABLE_NAME = "message_send_log_recipients"
|
||||
const val TABLE_NAME = "msl_recipient"
|
||||
|
||||
const val ID = "_id"
|
||||
const val MESSAGE_LOG_ID = "message_send_log_id"
|
||||
const val PAYLOAD_ID = "payload_id"
|
||||
const val RECIPIENT_ID = "recipient_id"
|
||||
const val DEVICE = "device"
|
||||
|
||||
const val CREATE_TABLE = """
|
||||
CREATE TABLE $TABLE_NAME (
|
||||
$ID INTEGER PRIMARY KEY,
|
||||
$MESSAGE_LOG_ID INTEGER NOT NULL REFERENCES ${MessageTable.TABLE_NAME} (${MessageTable.ID}) ON DELETE CASCADE,
|
||||
$PAYLOAD_ID INTEGER NOT NULL REFERENCES ${PayloadTable.TABLE_NAME} (${PayloadTable.ID}) ON DELETE CASCADE,
|
||||
$RECIPIENT_ID INTEGER NOT NULL,
|
||||
$DEVICE INTEGER NOT NULL
|
||||
)
|
||||
"""
|
||||
|
||||
/** Created for [deleteEntriesForRecipient] */
|
||||
val CREATE_INDEXES = arrayOf(
|
||||
"CREATE INDEX message_send_log_recipients_recipient_index ON $TABLE_NAME ($RECIPIENT_ID, $DEVICE)"
|
||||
"CREATE INDEX msl_recipient_recipient_index ON $TABLE_NAME ($RECIPIENT_ID, $DEVICE, $PAYLOAD_ID)",
|
||||
"CREATE INDEX msl_recipient_payload_index ON $TABLE_NAME ($PAYLOAD_ID)"
|
||||
)
|
||||
}
|
||||
|
||||
fun insertIfPossible(recipientId: RecipientId, sentTimestamp: Long, sendMessageResult: SendMessageResult, contentHint: ContentHint, relatedMessageId: Long, isRelatedMessageMms: Boolean) {
|
||||
if (!FeatureFlags.senderKey()) return
|
||||
private object MessageTable {
|
||||
const val TABLE_NAME = "msl_message"
|
||||
|
||||
const val ID = "_id"
|
||||
const val PAYLOAD_ID = "payload_id"
|
||||
const val MESSAGE_ID = "message_id"
|
||||
const val IS_MMS = "is_mms"
|
||||
|
||||
const val CREATE_TABLE = """
|
||||
CREATE TABLE $TABLE_NAME (
|
||||
$ID INTEGER PRIMARY KEY,
|
||||
$PAYLOAD_ID INTEGER NOT NULL REFERENCES ${PayloadTable.TABLE_NAME} (${PayloadTable.ID}) ON DELETE CASCADE,
|
||||
$MESSAGE_ID INTEGER NOT NULL,
|
||||
$IS_MMS INTEGER NOT NULL
|
||||
)
|
||||
"""
|
||||
|
||||
/** Created for [PayloadTable.CREATE_TRIGGERS] and [deleteAllRelatedToMessage] */
|
||||
val CREATE_INDEXES = arrayOf(
|
||||
"CREATE INDEX msl_message_message_index ON $TABLE_NAME ($MESSAGE_ID, $IS_MMS, $PAYLOAD_ID)"
|
||||
)
|
||||
}
|
||||
|
||||
/** @return The ID of the inserted entry, or -1 if none was inserted. Can be used with [addRecipientToExistingEntryIfPossible] */
|
||||
fun insertIfPossible(recipientId: RecipientId, sentTimestamp: Long, sendMessageResult: SendMessageResult, contentHint: ContentHint, messageId: MessageId): Long {
|
||||
if (!FeatureFlags.senderKey()) return -1
|
||||
|
||||
if (sendMessageResult.isSuccess && sendMessageResult.success.content.isPresent) {
|
||||
val recipientDevice = listOf(RecipientDevice(recipientId, sendMessageResult.success.devices))
|
||||
insert(recipientDevice, sentTimestamp, sendMessageResult.success.content.get(), contentHint, relatedMessageId, isRelatedMessageMms)
|
||||
return insert(recipientDevice, sentTimestamp, sendMessageResult.success.content.get(), contentHint, listOf(messageId))
|
||||
}
|
||||
|
||||
return -1
|
||||
}
|
||||
|
||||
fun insertIfPossible(sentTimestamp: Long, possibleRecipients: List<Recipient>, results: List<SendMessageResult>, contentHint: ContentHint, relatedMessageId: Long, isRelatedMessageMms: Boolean) {
|
||||
if (!FeatureFlags.senderKey()) return
|
||||
/** @return The ID of the inserted entry, or -1 if none was inserted. Can be used with [addRecipientToExistingEntryIfPossible] */
|
||||
fun insertIfPossible(recipientId: RecipientId, sentTimestamp: Long, sendMessageResult: SendMessageResult, contentHint: ContentHint, messageIds: List<MessageId>): Long {
|
||||
if (!FeatureFlags.senderKey()) return -1
|
||||
|
||||
val recipientsByUuid: Map<UUID, Recipient> = possibleRecipients.filter(Recipient::hasUuid).associateBy(Recipient::requireUuid, { it })
|
||||
val recipientsByE164: Map<String, Recipient> = possibleRecipients.filter(Recipient::hasE164).associateBy(Recipient::requireE164, { it })
|
||||
if (sendMessageResult.isSuccess && sendMessageResult.success.content.isPresent) {
|
||||
val recipientDevice = listOf(RecipientDevice(recipientId, sendMessageResult.success.devices))
|
||||
return insert(recipientDevice, sentTimestamp, sendMessageResult.success.content.get(), contentHint, messageIds)
|
||||
}
|
||||
|
||||
return -1
|
||||
}
|
||||
|
||||
/** @return The ID of the inserted entry, or -1 if none was inserted. Can be used with [addRecipientToExistingEntryIfPossible] */
|
||||
fun insertIfPossible(sentTimestamp: Long, possibleRecipients: List<Recipient>, results: List<SendMessageResult>, contentHint: ContentHint, messageId: MessageId): Long {
|
||||
if (!FeatureFlags.senderKey()) return -1
|
||||
|
||||
val accessList = RecipientAccessList(possibleRecipients)
|
||||
|
||||
val recipientDevices: List<RecipientDevice> = results
|
||||
.filter { it.isSuccess && it.success.content.isPresent }
|
||||
.map { result ->
|
||||
val recipient: Recipient =
|
||||
if (result.address.uuid.isPresent) {
|
||||
recipientsByUuid[result.address.uuid.get()]!!
|
||||
} else {
|
||||
recipientsByE164[result.address.number.get()]!!
|
||||
}
|
||||
|
||||
val recipient: Recipient = accessList.requireByAddress(result.address)
|
||||
RecipientDevice(recipient.id, result.success.devices)
|
||||
}
|
||||
|
||||
if (recipientDevices.isEmpty()) {
|
||||
return -1
|
||||
}
|
||||
|
||||
val content: SignalServiceProtos.Content = results.first { it.isSuccess && it.success.content.isPresent }.success.content.get()
|
||||
|
||||
insert(recipientDevices, sentTimestamp, content, contentHint, relatedMessageId, isRelatedMessageMms)
|
||||
return insert(recipientDevices, sentTimestamp, content, contentHint, listOf(messageId))
|
||||
}
|
||||
|
||||
private fun insert(recipients: List<RecipientDevice>, dateSent: Long, content: SignalServiceProtos.Content, contentHint: ContentHint, relatedMessageId: Long, isRelatedMessageMms: Boolean) {
|
||||
fun addRecipientToExistingEntryIfPossible(payloadId: Long, recipientId: RecipientId, sendMessageResult: SendMessageResult) {
|
||||
if (!FeatureFlags.senderKey()) return
|
||||
|
||||
if (sendMessageResult.isSuccess && sendMessageResult.success.content.isPresent) {
|
||||
val db = databaseHelper.writableDatabase
|
||||
|
||||
db.beginTransaction()
|
||||
try {
|
||||
sendMessageResult.success.devices.forEach { device ->
|
||||
val recipientValues = ContentValues().apply {
|
||||
put(RecipientTable.PAYLOAD_ID, payloadId)
|
||||
put(RecipientTable.RECIPIENT_ID, recipientId.serialize())
|
||||
put(RecipientTable.DEVICE, device)
|
||||
}
|
||||
|
||||
db.insert(RecipientTable.TABLE_NAME, null, recipientValues)
|
||||
}
|
||||
db.setTransactionSuccessful()
|
||||
} finally {
|
||||
db.endTransaction()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun insert(recipients: List<RecipientDevice>, dateSent: Long, content: SignalServiceProtos.Content, contentHint: ContentHint, messageIds: List<MessageId>): Long {
|
||||
val db = databaseHelper.writableDatabase
|
||||
|
||||
db.beginTransaction()
|
||||
try {
|
||||
val logValues = ContentValues().apply {
|
||||
put(MessageTable.DATE_SENT, dateSent)
|
||||
put(MessageTable.CONTENT, content.toByteArray())
|
||||
put(MessageTable.CONTENT_HINT, contentHint.type)
|
||||
put(MessageTable.RELATED_MESSAGE_ID, relatedMessageId)
|
||||
put(MessageTable.IS_RELATED_MESSAGE_MMS, if (isRelatedMessageMms) 1 else 0)
|
||||
val payloadValues = ContentValues().apply {
|
||||
put(PayloadTable.DATE_SENT, dateSent)
|
||||
put(PayloadTable.CONTENT, content.toByteArray())
|
||||
put(PayloadTable.CONTENT_HINT, contentHint.type)
|
||||
}
|
||||
|
||||
val messageLogId: Long = db.insert(MessageTable.TABLE_NAME, null, logValues)
|
||||
val payloadId: Long = db.insert(PayloadTable.TABLE_NAME, null, payloadValues)
|
||||
|
||||
recipients.forEach { recipientDevice ->
|
||||
recipientDevice.devices.forEach { device ->
|
||||
val recipientValues = ContentValues()
|
||||
recipientValues.put(RecipientTable.MESSAGE_LOG_ID, messageLogId)
|
||||
recipientValues.put(RecipientTable.RECIPIENT_ID, recipientDevice.recipientId.serialize())
|
||||
recipientValues.put(RecipientTable.DEVICE, device)
|
||||
val recipientValues = ContentValues().apply {
|
||||
put(RecipientTable.PAYLOAD_ID, payloadId)
|
||||
put(RecipientTable.RECIPIENT_ID, recipientDevice.recipientId.serialize())
|
||||
put(RecipientTable.DEVICE, device)
|
||||
}
|
||||
|
||||
db.insert(RecipientTable.TABLE_NAME, null, recipientValues)
|
||||
}
|
||||
}
|
||||
|
||||
messageIds.forEach { messageId ->
|
||||
val messageValues = ContentValues().apply {
|
||||
put(MessageTable.PAYLOAD_ID, payloadId)
|
||||
put(MessageTable.MESSAGE_ID, messageId.id)
|
||||
put(MessageTable.IS_MMS, if (messageId.mms) 1 else 0)
|
||||
}
|
||||
|
||||
db.insert(MessageTable.TABLE_NAME, null, messageValues)
|
||||
}
|
||||
|
||||
db.setTransactionSuccessful()
|
||||
|
||||
return payloadId
|
||||
} finally {
|
||||
db.endTransaction()
|
||||
}
|
||||
@@ -181,20 +267,34 @@ class MessageSendLogDatabase constructor(context: Context?, databaseHelper: SQLC
|
||||
trimOldMessages(System.currentTimeMillis(), FeatureFlags.retryRespondMaxAge())
|
||||
|
||||
val db = databaseHelper.readableDatabase
|
||||
val table = "${MessageTable.TABLE_NAME} LEFT JOIN ${RecipientTable.TABLE_NAME} ON ${MessageTable.TABLE_NAME}.${MessageTable.ID} = ${RecipientTable.TABLE_NAME}.${RecipientTable.MESSAGE_LOG_ID}"
|
||||
val query = "${MessageTable.DATE_SENT} = ? AND ${RecipientTable.RECIPIENT_ID} = ? AND ${RecipientTable.DEVICE} = ?"
|
||||
val table = "${PayloadTable.TABLE_NAME} LEFT JOIN ${RecipientTable.TABLE_NAME} ON ${PayloadTable.TABLE_NAME}.${PayloadTable.ID} = ${RecipientTable.TABLE_NAME}.${RecipientTable.PAYLOAD_ID}"
|
||||
val query = "${PayloadTable.DATE_SENT} = ? AND ${RecipientTable.RECIPIENT_ID} = ? AND ${RecipientTable.DEVICE} = ?"
|
||||
val args = SqlUtil.buildArgs(dateSent, recipientId, device)
|
||||
|
||||
db.query(table, null, query, args, null, null, null).use { cursor ->
|
||||
if (cursor.moveToFirst()) {
|
||||
return MessageLogEntry(
|
||||
recipientId = RecipientId.from(CursorUtil.requireLong(cursor, RecipientTable.RECIPIENT_ID)),
|
||||
dateSent = CursorUtil.requireLong(cursor, MessageTable.DATE_SENT),
|
||||
content = SignalServiceProtos.Content.parseFrom(CursorUtil.requireBlob(cursor, MessageTable.CONTENT)),
|
||||
contentHint = ContentHint.fromType(CursorUtil.requireInt(cursor, MessageTable.CONTENT_HINT)),
|
||||
relatedMessageId = CursorUtil.requireLong(cursor, MessageTable.RELATED_MESSAGE_ID),
|
||||
isRelatedMessageMms = CursorUtil.requireBoolean(cursor, MessageTable.IS_RELATED_MESSAGE_MMS)
|
||||
)
|
||||
db.query(table, null, query, args, null, null, null).use { entryCursor ->
|
||||
if (entryCursor.moveToFirst()) {
|
||||
val payloadId = CursorUtil.requireLong(entryCursor, RecipientTable.PAYLOAD_ID)
|
||||
|
||||
db.query(MessageTable.TABLE_NAME, null, "${MessageTable.PAYLOAD_ID} = ?", SqlUtil.buildArgs(payloadId), null, null, null).use { messageCursor ->
|
||||
val messageIds: MutableList<MessageId> = mutableListOf()
|
||||
|
||||
while (messageCursor.moveToNext()) {
|
||||
messageIds.add(
|
||||
MessageId(
|
||||
id = CursorUtil.requireLong(messageCursor, MessageTable.MESSAGE_ID),
|
||||
mms = CursorUtil.requireBoolean(messageCursor, MessageTable.IS_MMS)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return MessageLogEntry(
|
||||
recipientId = RecipientId.from(CursorUtil.requireLong(entryCursor, RecipientTable.RECIPIENT_ID)),
|
||||
dateSent = CursorUtil.requireLong(entryCursor, PayloadTable.DATE_SENT),
|
||||
content = SignalServiceProtos.Content.parseFrom(CursorUtil.requireBlob(entryCursor, PayloadTable.CONTENT)),
|
||||
contentHint = ContentHint.fromType(CursorUtil.requireInt(entryCursor, PayloadTable.CONTENT_HINT)),
|
||||
relatedMessages = messageIds
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -205,10 +305,10 @@ class MessageSendLogDatabase constructor(context: Context?, databaseHelper: SQLC
|
||||
if (!FeatureFlags.senderKey()) return
|
||||
|
||||
val db = databaseHelper.writableDatabase
|
||||
val query = "${MessageTable.RELATED_MESSAGE_ID} = ? AND ${MessageTable.IS_RELATED_MESSAGE_MMS} = ?"
|
||||
val query = "${PayloadTable.ID} IN (SELECT ${MessageTable.PAYLOAD_ID} FROM ${MessageTable.TABLE_NAME} WHERE ${MessageTable.MESSAGE_ID} = ? AND ${MessageTable.IS_MMS} = ?)"
|
||||
val args = SqlUtil.buildArgs(messageId, if (mms) 1 else 0)
|
||||
|
||||
db.delete(MessageTable.TABLE_NAME, query, args)
|
||||
db.delete(PayloadTable.TABLE_NAME, query, args)
|
||||
}
|
||||
|
||||
fun deleteEntryForRecipient(dateSent: Long, recipientId: RecipientId, device: Int) {
|
||||
@@ -227,17 +327,17 @@ class MessageSendLogDatabase constructor(context: Context?, databaseHelper: SQLC
|
||||
val query = """
|
||||
${RecipientTable.RECIPIENT_ID} = ? AND
|
||||
${RecipientTable.DEVICE} = ? AND
|
||||
${RecipientTable.MESSAGE_LOG_ID} IN (
|
||||
SELECT ${MessageTable.ID}
|
||||
FROM ${MessageTable.TABLE_NAME}
|
||||
WHERE ${MessageTable.DATE_SENT} IN (${dateSent.joinToString(",")})
|
||||
${RecipientTable.PAYLOAD_ID} IN (
|
||||
SELECT ${PayloadTable.ID}
|
||||
FROM ${PayloadTable.TABLE_NAME}
|
||||
WHERE ${PayloadTable.DATE_SENT} IN (${dateSent.joinToString(",")})
|
||||
)"""
|
||||
val args = SqlUtil.buildArgs(recipientId, device)
|
||||
|
||||
db.delete(RecipientTable.TABLE_NAME, query, args)
|
||||
|
||||
val cleanQuery = "${MessageTable.ID} NOT IN (SELECT ${RecipientTable.MESSAGE_LOG_ID} FROM ${RecipientTable.TABLE_NAME})"
|
||||
db.delete(MessageTable.TABLE_NAME, cleanQuery, null)
|
||||
val cleanQuery = "${PayloadTable.ID} NOT IN (SELECT ${RecipientTable.PAYLOAD_ID} FROM ${RecipientTable.TABLE_NAME})"
|
||||
db.delete(PayloadTable.TABLE_NAME, cleanQuery, null)
|
||||
|
||||
db.setTransactionSuccessful()
|
||||
} finally {
|
||||
@@ -248,17 +348,17 @@ class MessageSendLogDatabase constructor(context: Context?, databaseHelper: SQLC
|
||||
fun deleteAll() {
|
||||
if (!FeatureFlags.senderKey()) return
|
||||
|
||||
databaseHelper.writableDatabase.delete(MessageTable.TABLE_NAME, null, null)
|
||||
databaseHelper.writableDatabase.delete(PayloadTable.TABLE_NAME, null, null)
|
||||
}
|
||||
|
||||
fun trimOldMessages(currentTime: Long, maxAge: Long) {
|
||||
if (!FeatureFlags.senderKey()) return
|
||||
|
||||
val db = databaseHelper.writableDatabase
|
||||
val query = "${MessageTable.DATE_SENT} < ?"
|
||||
val query = "${PayloadTable.DATE_SENT} < ?"
|
||||
val args = SqlUtil.buildArgs(currentTime - maxAge)
|
||||
|
||||
db.delete(MessageTable.TABLE_NAME, query, args)
|
||||
db.delete(PayloadTable.TABLE_NAME, query, args)
|
||||
}
|
||||
|
||||
private data class RecipientDevice(val recipientId: RecipientId, val devices: List<Int>)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user