Compare commits

..

107 Commits
v5.15.3 ... v

Author SHA1 Message Date
Greyson Parrelli
8004565c84 Bump version to 5.17.1 2021-07-16 15:55:54 -04:00
Greyson Parrelli
a101dc4fd1 Updated language translations. 2021-07-16 15:53:42 -04:00
Alex Hart
57f730d8ee Fix cursor issue for non-signal-contact searches. 2021-07-16 16:34:38 -03:00
Alex Hart
3543cc80ba Don't show SMS label for push groups. 2021-07-16 16:34:02 -03:00
Greyson Parrelli
71613d9db1 Ensure SQLCipher libraries are loaded. 2021-07-16 14:12:00 -04:00
Greyson Parrelli
4a0e6a3eb2 Improve logging around message sends. 2021-07-16 12:53:29 -04:00
Alex Hart
f1a87518e1 Fix contact search query returning outdated or bad recipients. 2021-07-16 13:53:17 -03:00
Alex Hart
61f880fd78 Hide all media for new conversations. 2021-07-16 13:20:30 -03:00
Greyson Parrelli
09904e7a16 Remove GIF button from attachment keyboard.
We've had it there for ~45 days for education purposes, but we can
remove it now.
2021-07-16 11:01:10 -04:00
Alex Hart
94658e9090 Fix bug where marquee text stopped scrolling. 2021-07-16 09:23:47 -03:00
Greyson Parrelli
a47448b6c6 Bump version to 5.17.0 2021-07-15 16:41:11 -04:00
Greyson Parrelli
7e4b9b685a Updated language translations. 2021-07-15 16:40:28 -04:00
lucio-signal
64922a8e51 Fix custom vibration settings. 2021-07-15 16:29:43 -04:00
Cody Henthorne
f65f4704c9 Improve routine around bulk attachment deletion. 2021-07-15 16:29:11 -04:00
Greyson Parrelli
b04ca202f6 Fix ApplicationMigrations UI. 2021-07-15 16:28:13 -04:00
Greyson Parrelli
83086a5a2b Make Sms/MmsDatabase ID's autoincrement. 2021-07-15 16:28:13 -04:00
Cody Henthorne
51a521594f Fix crash when deleting threads directly after backup restore. 2021-07-15 16:28:13 -04:00
Greyson Parrelli
0a7a7cf5a9 Fix envelope type conversion. 2021-07-15 16:28:13 -04:00
Greyson Parrelli
6bd689504c Make internal recipient details selectable. 2021-07-15 16:28:13 -04:00
Greyson Parrelli
efec40ff57 Fix crash with GV2 group repair during storage sync. 2021-07-15 16:28:13 -04:00
Greyson Parrelli
69716dde4a Fix navigation directly to the help screen. 2021-07-15 16:28:13 -04:00
Greyson Parrelli
e90fa05d60 Update recipient merging. 2021-07-15 16:28:13 -04:00
Greyson Parrelli
580c000bda Move distribution message processing into the decryption phase. 2021-07-15 16:28:13 -04:00
lucio-signal
2f3d04d3e8 Add EmojiFilter to SearchView input field. 2021-07-15 16:28:13 -04:00
Greyson Parrelli
bf37d412e9 Add message trimming info to the debug log. 2021-07-15 16:28:13 -04:00
Alex Hart
fd115ebb72 Drop voice notes that do not have a URI. 2021-07-15 16:28:13 -04:00
Greyson Parrelli
b9657208fe Make ThreadDatabase ID's autoincrement. 2021-07-15 16:28:13 -04:00
Cody Henthorne
5d6d78a51e Initial WebSocket refactor. 2021-07-15 16:28:13 -04:00
Cody Henthorne
916006e664 Tweak sizes and padding of various keyboard elements. 2021-07-15 16:28:13 -04:00
Cody Henthorne
55c69cd50a Add additional fallback logic for change dialog. 2021-07-15 16:28:13 -04:00
Cody Henthorne
14565b0864 Fix crash when building notification state for messages without threads. 2021-07-15 16:28:13 -04:00
Alex Hart
a157c1ae1d Refresh contact search views. 2021-07-15 16:28:13 -04:00
Greyson Parrelli
a4d458f969 Use current tag for nightly versionName. 2021-07-15 16:28:13 -04:00
Alex Hart
3f53abedab Migrate to new Share APIs. 2021-07-15 16:28:13 -04:00
Jordan Rose
68a2d5ed20 Reimplement ProfileCipherInputStream using libsignal-client.
libsignal-client provides an AES-GCM streaming interface that can
replace the implementation in AES-GCM-Provider. Using it from
ProfileCipherInputStream requires some knowledge about the tag size of
AES-GCM, but frees it from the JCE interface.

Note that it remains a serious error to not read the *entire* stream,
since the authentication tag is at the end!
2021-07-15 16:28:11 -04:00
Jordan Rose
35e9e31a7b Update to libsignal-client 0.8.3
This also fixes a misalignment where signal-client-android was on
0.8.0 but signal-client-java was 0.8.1, which was fortunately harmless
for this particular pair of versions.
2021-07-12 20:29:07 -04:00
Cody Henthorne
444d947743 Add RxJava. 2021-07-12 20:29:07 -04:00
Cody Henthorne
c427dbad08 Bump version to 5.16.3 2021-07-12 20:27:40 -04:00
Cody Henthorne
2cefe813e4 Updated language translations. 2021-07-12 20:17:43 -04:00
Alex Hart
123ffe42c3 Fix crash saving a FLAC file. 2021-07-12 13:37:59 -03:00
Alex Hart
da20e66ecd Fix issue where shared contact render would not hide audio view. 2021-07-12 13:24:04 -03:00
Alex Hart
901440017a Fix audio view width on very narrow screens. 2021-07-12 13:20:56 -03:00
Cody Henthorne
0be76a37fe Bump version to 5.16.2 2021-07-09 15:43:11 -04:00
Cody Henthorne
36dadc8777 Updated language translations. 2021-07-09 15:37:19 -04:00
Cody Henthorne
182749c101 Fix bug where some profile fetches would 400 over the websocket. 2021-07-09 15:30:08 -04:00
Alex Hart
d9228bd911 Fix issue where compose views still display under draft. 2021-07-09 15:30:08 -04:00
Greyson Parrelli
a361fcc8f3 Add additional logging to media send jobs. 2021-07-09 15:30:08 -04:00
Alex Hart
ff4f0b9f42 Stop voice note playback after user locks Signal. 2021-07-09 15:30:07 -04:00
Alex Hart
060dffc9cc Fix crash caused when quote draft left and re-entered. 2021-07-09 15:30:07 -04:00
Alex Hart
172cc302fc Add warning dialog for chat color deletion with no uses. 2021-07-09 15:30:07 -04:00
Alex Hart
416e62112f Refresh shared media screens. 2021-07-09 15:30:07 -04:00
Alex Hart
e584a90f81 Fix several voice note beta bugs.
* Sim label positioning
* Bad player state when navigating to and from conversations
* Scrolling date header placement
2021-07-09 15:29:40 -04:00
Cody Henthorne
9876ffb5e4 Bump version to 5.16.1 2021-07-08 17:52:50 -04:00
Cody Henthorne
53e10f2cad Updated language translations. 2021-07-08 17:43:16 -04:00
Cody Henthorne
cb79f75ac1 Fix bug when calling non-Signal contacts from Settings.
Fixes #11450
2021-07-08 17:36:14 -04:00
Cody Henthorne
5ec9c1cd90 Fix crash when saving media with octet stream content type. 2021-07-08 17:36:14 -04:00
Greyson Parrelli
1f28a30ace Add a nightly build type. 2021-07-08 17:36:14 -04:00
Alex Hart
7715917436 Fix issue where position would not update in draft. 2021-07-08 17:36:14 -04:00
Alex Hart
f79b445fdf Fix issue where drafts might not be properly deleted. 2021-07-08 17:36:14 -04:00
Alex Hart
14484deabe Implement count-down in inline player. 2021-07-08 17:36:14 -04:00
Alex Hart
3ac395d33e Fix row item size issue with huge fonts. 2021-07-08 17:36:14 -04:00
Alex Hart
f83b520ca9 Bump version to 5.16.0 2021-07-07 14:58:51 -03:00
Alex Hart
0123f9aa87 Updated language translations. 2021-07-07 14:58:51 -03:00
Alex Hart
06b64fe619 Add inline voice note player to conversation and conversation list. 2021-07-07 14:58:51 -03:00
Greyson Parrelli
1bb87834d8 Reduce recipient resolves in MessageContentProcessor. 2021-07-07 14:58:51 -03:00
Greyson Parrelli
ae4167ddae Write to RecipientIdCache on cache miss. 2021-07-07 14:58:51 -03:00
Greyson Parrelli
383beafdef Move 'you' to end of unnamed groups. 2021-07-07 14:58:51 -03:00
Greyson Parrelli
062e88b24f Rotate sender key flag. 2021-07-07 14:58:51 -03:00
Greyson Parrelli
8299d49042 Show an error for internal users for decryption failures. 2021-07-07 14:58:51 -03:00
Greyson Parrelli
4677883838 Improve mapping SignalServiceAddresses to Recipients. 2021-07-07 14:58:51 -03:00
Greyson Parrelli
7f0a0bef5a Incrementally insert MSL entries for legacy group sends. 2021-07-07 14:58:50 -03:00
Greyson Parrelli
acc825971b Handle additional places where MSL entries need to be deleted. 2021-07-07 14:58:50 -03:00
Greyson Parrelli
62040d06b4 Create a write-through cache for PendingRetryReceiptDatabase. 2021-07-07 14:58:50 -03:00
Greyson Parrelli
0921ebe5f1 Add read and viewed receipts to the MSL. 2021-07-07 14:58:50 -03:00
Greyson Parrelli
3d0e15e2b8 Add delivery receipts to the MSL. 2021-07-07 14:58:50 -03:00
Greyson Parrelli
5372f79c40 Allow for MSL entries to be associated with multiple messages. 2021-07-07 14:58:50 -03:00
Christian
92e8f9de0e Do not collapse list to hide only one entry. 2021-07-07 14:58:50 -03:00
Christian
c3cf846a10 Fix OutdatedBuildReminder duration.
Fixes #11438
2021-07-07 14:58:50 -03:00
Alex Hart
5826b0c068 Implement drafts for voice notes. 2021-07-07 14:58:50 -03:00
Alex Hart
2d7c043398 Implement a playback speed toggle for voice notes. 2021-07-07 14:58:50 -03:00
Alex Hart
e20d6b63cf Fix adaptive shortcut icon shapes. 2021-07-07 14:58:50 -03:00
Cody Henthorne
b85c5eb54a Make it more likely 8 emoji fit on a row, fix emoji search emoticons. 2021-07-07 14:58:50 -03:00
Greyson Parrelli
a1c8573fad Insert resent messages at the proper location. 2021-07-07 14:58:50 -03:00
Cody Henthorne
90a27d2227 Fix device transfer test dependent on native library. 2021-07-07 14:58:50 -03:00
Cody Henthorne
c54c6018b2 Remove dead keyboard code after refresh. 2021-06-30 16:13:42 -04:00
Rainer Matischek
7419570f94 Fix rotation not updated on phones using 'Legacy API'.
Fixes #10940
2021-06-30 16:13:42 -04:00
Jim Gustafson
8860f792c4 Update to RingRTC v2.10.6 2021-06-30 16:13:42 -04:00
Greyson Parrelli
e47db0d532 Ensure recipients added to the cache have an identifier. 2021-06-30 16:13:42 -04:00
Greyson Parrelli
ab5d3badc2 Enable WAL mode. 2021-06-30 16:13:42 -04:00
Greyson Parrelli
fce362960f Switch to LinkifyCompat.
We've seen some inconsistencies across OEMs with Linkify. Hopefully
LinkifyCompat will resolve them.
2021-06-30 16:13:42 -04:00
Greyson Parrelli
5bf23dcfb3 Bump version to 5.15.6 2021-06-30 16:12:58 -04:00
Greyson Parrelli
65c7dc6ca2 Updated language translations. 2021-06-30 16:12:36 -04:00
Greyson Parrelli
e30a8b6954 Use proper EmojiTextView in conversation settings toolbar. 2021-06-30 15:43:21 -04:00
Alex Hart
838e318200 Fix edit profile theming issue and mute until issue. 2021-06-30 11:11:35 -03:00
Greyson Parrelli
62ee411901 Bump version to 5.15.5 2021-06-29 14:16:06 -04:00
Greyson Parrelli
ceefd2d92f Updated language translations. 2021-06-29 14:15:28 -04:00
Cody Henthorne
e3870f5656 Fix Customize Reactions shadow. 2021-06-29 12:38:40 -04:00
Alex Hart
6e7022ab70 Fix custom notifications toggle and enable copy phone number on long press. 2021-06-29 11:19:51 -03:00
Alex Hart
031d1551e7 Prevent crash by ignoring call if view is null. 2021-06-29 11:02:57 -03:00
Greyson Parrelli
6755b25361 Bump version to 5.15.4 2021-06-28 18:07:36 -04:00
Greyson Parrelli
11d0a73675 Updated language translations. 2021-06-28 18:07:36 -04:00
Alex Hart
44119b6437 Do not crash if we try to access an item outside of the bounds of the conversation. 2021-06-28 18:07:36 -04:00
Cody Henthorne
d4a3b442f4 Add vertical scrolling to Sticker Keyboard. 2021-06-28 18:07:36 -04:00
Cody Henthorne
aba5774446 Fix share contact list updating improperly on selection change. 2021-06-28 18:07:36 -04:00
Alex Hart
911dd9efb1 Fix conversation media overview underline flicker. 2021-06-28 11:38:14 -03:00
Alex Hart
f2a490b07e Fix several conversation settings feedback issues.
* Mute icon in wrong location in RTL
* No exit animation when dismissing conversation settings
* Thumbnails flicker when you come back to conversation settings
* Rounded corners for mute dialog don't match other dialogs
* Mute button in note-to-self conversation settings
* Explore adding contact details to the contact bottom sheet
2021-06-28 11:11:57 -03:00
Cody Henthorne
5675f080f2 Fix text emoticons not showing up in recents. 2021-06-28 10:11:01 -04:00
442 changed files with 11351 additions and 6041 deletions

View File

@@ -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" />

View File

@@ -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
}

View File

@@ -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" />

View File

@@ -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();
}

View File

@@ -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)

View File

@@ -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);

View File

@@ -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);
}
}
}

View File

@@ -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();
}
}

View File

@@ -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 {

View File

@@ -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,

View File

@@ -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;
}
}

View File

@@ -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

View File

@@ -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)

View File

@@ -65,4 +65,8 @@ public class PushContactSelectionActivity extends ContactSelectionActivity {
setResult(RESULT_OK, resultIntent);
finish();
}
@Override
public void onSelectionChanged() {
}
}

View File

@@ -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
}
)
}
}
}

View File

@@ -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);

View File

@@ -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);
}
}
});
}

View File

@@ -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();
}
}

View File

@@ -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);
}
}

View File

@@ -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();

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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));
}
}

View File

@@ -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);
}

View File

@@ -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();

View File

@@ -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)
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}

View File

@@ -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
}
}
}

View File

@@ -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);
}
}

View File

@@ -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

View File

@@ -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);
}
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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);
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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
}
}

View File

@@ -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)
}
}

View File

@@ -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);

View File

@@ -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)
}
}
}

View File

@@ -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)
}

View File

@@ -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)

View File

@@ -15,4 +15,5 @@ data class InternalSettingsState(
val useBuiltInEmojiSet: Boolean,
val emojiVersion: EmojiFiles.Version?,
val removeSenderKeyMinimium: Boolean,
val delayResends: Boolean,
)

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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),

View File

@@ -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)

View File

@@ -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,
) {

View File

@@ -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
)
)
}

View File

@@ -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)
}

View File

@@ -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() }
}
}
}

View File

@@ -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
}
}

View File

@@ -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()
)
}
}

View File

@@ -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())
}
}

View File

@@ -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);
}
}
}
}

View File

@@ -0,0 +1,5 @@
package org.thoughtcrime.securesms.components.voice
interface VoiceNoteMediaControllerOwner {
val voiceNoteMediaController: VoiceNoteMediaController
}

View File

@@ -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);
}
}
}

View File

@@ -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)
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -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();

View File

@@ -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.
*/

View File

@@ -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;
}
}

View File

@@ -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()
}
}

View File

@@ -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)
}
}

View File

@@ -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

View File

@@ -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)) {

View File

@@ -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());
}
}
}

View File

@@ -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) {

View File

@@ -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?
}
}

View File

@@ -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) {

View File

@@ -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 {

View File

@@ -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,

View File

@@ -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),

View File

@@ -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());

View File

@@ -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 ||

View File

@@ -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;
}
}
}

View File

@@ -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

View File

@@ -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);

View File

@@ -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;
}

View File

@@ -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)
}
}

View File

@@ -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))

View File

@@ -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))
}
}

View File

@@ -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)
}
}
}

View File

@@ -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
)

View File

@@ -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)))
}
}
}

View File

@@ -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();
}
}

View File

@@ -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());
}

View File

@@ -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);
}
}
}

View File

@@ -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")

View File

@@ -76,6 +76,7 @@ public abstract class Database {
}
protected void notifyStickerPackListeners() {
ApplicationDependencies.getDatabaseObserver().notifyStickerPackObservers();
context.getContentResolver().notifyChange(DatabaseContentProviders.StickerPack.CONTENT_URI, null);
}

View File

@@ -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();

View File

@@ -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);

View File

@@ -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;
}
}
}

View File

@@ -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();

View File

@@ -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) {

View File

@@ -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() {

View File

@@ -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) {

View File

@@ -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;
}
}

View File

@@ -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