Compare commits

..

181 Commits

Author SHA1 Message Date
Alex Hart ae8e050891 Bump version to 8.13.2 2026-06-03 15:35:01 -03:00
Michelle Tang 7e6ad92ca8 Turn off KT. 2026-06-02 23:58:41 -04:00
Michelle Tang ae2477356b Bump version to 8.13.1 2026-05-29 10:58:26 -04:00
Michelle Tang 8d992fa7db Update baseline profile. 2026-05-29 10:55:07 -04:00
Michelle Tang 71c33fc579 Update translations and other static files. 2026-05-29 10:37:38 -04:00
Greyson Parrelli bdefc274b9 Start using updated character set for AEP. 2026-05-29 09:54:58 -04:00
Greyson Parrelli f926d9c893 Update SVR2 enclave. 2026-05-28 00:23:29 -04:00
Michelle Tang ec02d802f2 Bump version to 8.13.0 2026-05-27 14:52:19 -04:00
Michelle Tang 7925041982 Update translations and other static files. 2026-05-27 14:44:57 -04:00
Michelle Tang de42d748dc Ignore tests. 2026-05-27 14:37:51 -04:00
Michelle Tang 3e1df5b8ac Turn on KT. 2026-05-27 14:08:54 -04:00
Greyson Parrelli f1d722c5cd Fix test dependency import. 2026-05-27 14:08:54 -04:00
Cody Henthorne abcd65603c Fix block/unblock causing ANR in recipient bottom sheet. 2026-05-27 14:08:54 -04:00
Greyson Parrelli 7b23110cac Improve instrumentation setup. 2026-05-27 14:08:54 -04:00
Greyson Parrelli baa4dd3c86 Improve handling of devices without Play Services. 2026-05-27 14:08:54 -04:00
Greyson Parrelli 0beda1e615 Cancel delayed notifications when notification state is empty. 2026-05-27 14:01:29 -04:00
Michelle Tang b3acefdb08 Fix instrumentation tests. 2026-05-27 14:01:29 -04:00
Greyson Parrelli 9600366422 Fix instrumentation tests. 2026-05-27 14:01:29 -04:00
jeffrey-signal d85a57adce Add adaptive layout support to regV5 PIN creation screen. 2026-05-27 14:01:29 -04:00
jeffrey-signal cb1b878198 Add string resources for the PIN entry screen. 2026-05-27 14:01:29 -04:00
jeffrey-signal 4a2e2e0137 Add string resources for the PIN creation screen. 2026-05-27 14:01:29 -04:00
Greyson Parrelli b2f8445e1d Upgrade CameraX to 1.6.1 2026-05-27 14:01:29 -04:00
Alex Hart f5f686fece Add call-link specific network error handling.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-05-27 14:01:29 -04:00
fm-sys 1864534174 Add long press to copy app version code.
Co-authored-by: jeffrey-signal <jeffrey@signal.org>

Closes signalapp/Signal-Android#14795
2026-05-27 14:01:29 -04:00
Alex Hart 54c4bda4f2 Allow local backup restore to proceed regardless of remote restore failure state.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-05-27 14:01:29 -04:00
Alex Hart 3cb61e3e8a Remove inaccurate recovery key mention from local backup restore dialog.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-05-27 14:01:29 -04:00
andrew-signal 83a83f65ef Prune some unused resources. 2026-05-27 14:01:29 -04:00
Greyson Parrelli 122e1770b7 Hide keyboard on contact selection when add-message is disabled. 2026-05-27 14:01:29 -04:00
Greyson Parrelli 68eb4d3c82 Propagate force flag to story attachment downloads. 2026-05-27 14:01:29 -04:00
Greyson Parrelli ce4d68a20f Filter shortcode numbers in externalContact to prevent E164 re-formatting crash. 2026-05-27 14:01:29 -04:00
Greyson Parrelli 5d45914a08 Improved send performance. 2026-05-27 14:01:29 -04:00
Greyson Parrelli b2f450d849 Add index to make marking unread faster. 2026-05-27 14:01:29 -04:00
Cody Henthorne ed73dc2c24 Remove system call triggering location request. 2026-05-27 14:01:28 -04:00
andrew-signal 71967b278e Remove cluster of orphaned code around CameraContactSelectionViewController. 2026-05-27 14:01:28 -04:00
andrew-signal faadc9854f Re-encode call ringing/busy tones from MP3 to opus. 2026-05-27 14:01:28 -04:00
Greyson Parrelli f206487ede Add MessageService and IndividualSendJobV2. 2026-05-27 14:01:28 -04:00
Greyson Parrelli 0284da2d0f Move a lot of utils into core. 2026-05-27 14:01:28 -04:00
Alex Hart 15a3a8efde Add debug log pop (8 clicks) for reg v5.
Co-authored-by: Michelle Tang <mtang@signal.org>
2026-05-27 14:01:28 -04:00
andrew-signal b429d08c5e Exclude libsignal-testing.md acknowledgment from non-test builds. 2026-05-27 14:01:28 -04:00
Jim Gustafson 3892113a0e Update to RingRTC v2.69.2 2026-05-27 14:01:28 -04:00
Cody Henthorne 41f52ed886 Fix thrashing storage service fields. 2026-05-27 14:01:28 -04:00
Cody Henthorne ec07b7805e Fix incorrect sync message being sent when network drops group send response. 2026-05-27 14:01:28 -04:00
Alexandria 333e514a2f Fix media review confirm button color.
Closes signalapp/Signal-Android#14683
2026-05-27 14:01:28 -04:00
Michelle Tang 15a17adf1a Bump version to 8.12.4 2026-05-27 13:53:20 -04:00
Michelle Tang 13888bab0a Update translations and other static files. 2026-05-27 13:24:56 -04:00
Alex Hart 16fc81f715 Use bitmap avatar icons on API 36+ to avoid BadForegroundServiceNotificationException.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-05-27 13:03:24 -04:00
Alex Hart b4f2d8682f Always load avatar in the background. 2026-05-27 12:54:11 -03:00
Michelle Tang a05ce88bc0 Bump version to 8.12.3 2026-05-26 16:17:51 -04:00
Michelle Tang 59c38147c7 Update translations and other static files. 2026-05-26 16:07:46 -04:00
Michelle Tang da85d0b4eb Turn off KT. 2026-05-26 15:34:19 -04:00
Cody Henthorne 5aa7e3a7c1 Only take remote release note sync data if present. 2026-05-26 12:33:42 -04:00
Alex Hart c92b0505df Move ContactSearchPagedDataSource.size() off the main thread to fix ANR.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-05-26 12:55:50 -03:00
Michelle Tang ca8c494fc4 Reset KT for username changes in storage service. 2026-05-26 11:43:39 -04:00
Cody Henthorne fbbcadf09b Bump version to 8.12.2 2026-05-24 14:05:47 -04:00
Cody Henthorne 3fc6ac3871 Update translations and other static files. 2026-05-24 13:56:15 -04:00
Cody Henthorne 6a2ec01c52 Fix duplicate key crash in contact search lists. 2026-05-24 13:29:41 -04:00
Cody Henthorne c1b3fb6d1b Fix profile fetch crash when skipping debounce. 2026-05-24 10:32:58 -04:00
jeffrey-signal 792d86f4d8 Bump version to 8.12.1 2026-05-21 17:06:28 -04:00
jeffrey-signal 849856cde8 Update baseline profile. 2026-05-21 16:58:59 -04:00
jeffrey-signal 0646418d4d Update translations and other static files. 2026-05-21 16:36:58 -04:00
Alex Hart ed540a2f9e Update model key entry. 2026-05-21 16:57:14 -03:00
Cody Henthorne 00d86101f5 Fix crash when no recent recipients for profile refresh.
Fixes #14791
2026-05-21 15:38:37 -04:00
jeffrey-signal 86e49cd564 Bump version to 8.12.0 2026-05-21 11:12:07 -04:00
jeffrey-signal 21b7d64fcd Update baseline profile. 2026-05-21 10:50:28 -04:00
jeffrey-signal 42c0044096 Update translations and other static files. 2026-05-21 10:43:07 -04:00
Michelle Tang f2e8b83604 Update reg v5 UI for quick restore. 2026-05-21 10:38:23 -04:00
Michelle Tang 46b8ac6561 Update reg v5 UI for local backup v1 account. 2026-05-21 10:38:22 -04:00
Michelle Tang 9089cc393e Update reg v5 UI for locked account. 2026-05-21 10:38:22 -04:00
Alex Hart 2ea59bef68 Handle PniChangeNumber sync on linked devices. 2026-05-21 10:38:22 -04:00
Alex Hart 698fc38aed Migrate ContactSearch RV to MappingLazyColumn. 2026-05-21 10:38:22 -04:00
Michelle Tang 1d74b00b91 Update local backups reg v5 UI. 2026-05-21 10:38:21 -04:00
Greyson Parrelli ea861fff49 Remove unnecessary link test. 2026-05-21 10:38:21 -04:00
Cody Henthorne 3b93edcdaf Add verification code requested alert handling. 2026-05-21 10:38:21 -04:00
Cody Henthorne 6722a28f98 Fix broken linkify unit tests. 2026-05-21 10:38:21 -04:00
Cody Henthorne 16de2efa9e Fix non-instrumentation variants not being able to run unit tests. 2026-05-21 10:38:20 -04:00
Michelle Tang 4d0919c9a8 Fix minor safety number UI issues. 2026-05-21 10:38:20 -04:00
Greyson Parrelli 01e1cb4d67 Hide keyboard before navigating to chat settings. 2026-05-21 10:38:20 -04:00
Michelle Tang 9d1d5142da Turn on key transparency. 2026-05-21 10:38:20 -04:00
Alex Hart 49f0c2502b Add IncomingMessageObserver integration test infrastructure. 2026-05-21 10:38:19 -04:00
Michelle Tang 71ffc36e7f Update key transparency string. 2026-05-21 10:38:19 -04:00
Greyson Parrelli 5941ff814d Improve perf for backup info in debuglogs. 2026-05-21 10:38:19 -04:00
Cody Henthorne 1661f3b5f7 Improve profile fetch performance for large groups. 2026-05-21 10:38:18 -04:00
Cody Henthorne d682de08d2 Sort pinned chats like chat list for shortcut list. 2026-05-21 10:38:18 -04:00
Cody Henthorne 5321f8124a Add additional download checks to long message text attachments. 2026-05-21 10:38:18 -04:00
Michelle Tang 5052f22d44 Fix local restore crash from file selection. 2026-05-21 10:38:18 -04:00
Michelle Tang 9a8cb1785b Update reg v5 to use new scaffold. 2026-05-21 10:38:17 -04:00
Cody Henthorne a2065becdd fixup! Fix chat bubbles not rendering due to ConstraintLayout bug. 2026-05-21 10:38:17 -04:00
Greyson Parrelli e85637a58d Inline useBinaryId remote config. 2026-05-21 10:38:17 -04:00
Greyson Parrelli 73c3d141e3 Remove USE_STRING_ID build config. 2026-05-21 10:38:17 -04:00
jeffrey-signal eafba156ba RegistrationScaffold will automatically add top padding when there is no header. 2026-05-21 10:38:17 -04:00
Greyson Parrelli e6beafd612 Update video demo project to support batch transcoding. 2026-05-21 10:38:16 -04:00
Cody Henthorne a9649fd017 Enforce change number post registration delay. 2026-05-21 10:38:16 -04:00
adel-signal 4decae274b Update to RingRTC v2.69.1 2026-05-21 10:38:16 -04:00
gram-signal dbb83d86e3 Add remote config for requirePqRatio. 2026-05-21 10:38:15 -04:00
Greyson Parrelli 2aa27df95b Add SignalRestClient. 2026-05-21 10:38:15 -04:00
Cody Henthorne ec47b83f76 Add sync message encrypt local metric to send flows. 2026-05-21 10:38:15 -04:00
Cody Henthorne 6eea4ba937 Sync release note channel settings with storage service. 2026-05-21 10:38:15 -04:00
Cody Henthorne 9f608337f1 Add friendly toasts for forced remote config refresh. 2026-05-21 10:38:14 -04:00
jeffrey-signal 28edcdf62d Update regV5 permissions screen to use RegistrationScaffold. 2026-05-21 10:38:14 -04:00
jeffrey-signal 10d969ea35 Add two pane registration scaffold. 2026-05-21 10:38:14 -04:00
jeffrey-signal 38bac16640 Add separate window breakpoints for windows with large widths vs large heights. 2026-05-21 10:36:50 -04:00
jeffrey-signal 93077ac457 Bump version to 8.11.4 2026-05-21 10:24:18 -04:00
jeffrey-signal c069eb1b88 Update baseline profile. 2026-05-21 10:08:50 -04:00
jeffrey-signal e5cd18bf1e Update translations and other static files. 2026-05-21 10:02:16 -04:00
Alex Hart 9e8ae7e26a Only update desktop activity timestamp for user-initiated sync messages.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-05-20 12:42:52 -03:00
Cody Henthorne 00042b9579 Stop screen sharing when disabled from system UI. 2026-05-20 10:06:18 -04:00
Greyson Parrelli e750b81a31 Disable dnsjava hosts file parsing to fix NPE race condition. 2026-05-20 10:05:39 -04:00
Greyson Parrelli daec317f52 Don't auto-snooze donation megaphones. 2026-05-20 10:02:35 -04:00
Greyson Parrelli 112514c221 Remove persistent play services error notification.
Fixes #14786
2026-05-19 18:05:05 -04:00
Cody Henthorne f43db8ace0 Fix chat bubbles not rendering due to ConstraintLayout bug.
Resolves signalapp/Signal-Android#14774
2026-05-19 18:05:04 -04:00
jeffrey-signal 54df95727b Bump version to 8.11.3 2026-05-19 12:12:47 -04:00
jeffrey-signal 022b4d9508 Update baseline profile. 2026-05-19 11:58:37 -04:00
jeffrey-signal 7411e725ec Update translations and other static files. 2026-05-19 11:51:57 -04:00
Alex Hart 83a279f422 Fix display bug with donation type. 2026-05-19 11:42:12 -04:00
Michelle Tang 523066d093 Turn off key transparency. 2026-05-19 10:39:06 -04:00
Michelle Tang de27343c24 Update key transparency api. 2026-05-19 10:38:08 -04:00
Michelle Tang c36179293e Fix missing safety number dialog. 2026-05-18 14:13:59 -04:00
andrew-signal a79a91bafb Bump to libsignal v0.94.1 2026-05-18 14:09:27 -04:00
Michelle Tang 13de1ede90 Bump version to 8.11.2 2026-05-15 16:32:54 -04:00
Michelle Tang b94f420393 Update translations and other static files. 2026-05-15 16:29:45 -04:00
Michelle Tang 4909f130cc Animate safety number once. 2026-05-15 16:21:45 -04:00
Michelle Tang 0010386b9e Bump version to 8.11.1 2026-05-14 15:20:56 -04:00
Michelle Tang 02c760945d Update translations and other static files. 2026-05-14 14:58:23 -04:00
Greyson Parrelli a0247bb8cc Add R8 keep rule for org.signal.network to fix Jackson deserialization. 2026-05-14 14:43:36 -04:00
Greyson Parrelli bcfec5de50 Fix benchmark build. 2026-05-14 14:36:10 -04:00
Cody Henthorne b2215915ef Switch to speaker and disable proximity when screen sharing. 2026-05-14 14:36:10 -04:00
Michelle Tang a0577cd8a2 Bump version to 8.11.0 2026-05-14 13:41:16 -04:00
Michelle Tang 9438646814 Update translations and other static files. 2026-05-14 13:28:56 -04:00
Cody Henthorne 9dcf68581d Improve screen share capture dimension calculation and use remote config. 2026-05-14 13:23:16 -04:00
Greyson Parrelli 4dd57460de Move a bunch of files into the network modules. 2026-05-14 13:23:16 -04:00
Greyson Parrelli 6339b38dee Move RemoteConfigResponse. 2026-05-14 13:23:16 -04:00
Greyson Parrelli 1ce41edc7f Move CertificateApi and RateLimitChallengeApi deps. 2026-05-14 13:23:16 -04:00
Michelle Tang 3087116618 Move hardcoded reg strings to strings.xml. 2026-05-14 13:23:16 -04:00
Michelle Tang 8fd2065253 Revert "Use adaptive bitmap for dynamic shortcut icons to remove white border."
This reverts commit b98452f7b3c99bdf5d4e02ba5129a63846ed563a.
2026-05-14 13:23:16 -04:00
Greyson Parrelli 09822d3ae9 More changes to fix reproducible build issues. 2026-05-14 13:23:16 -04:00
Greyson Parrelli 10b0221e98 Add support for a better AEP character set. 2026-05-14 13:23:16 -04:00
Cody Henthorne db4def45f9 Reduce blocking calls in happy network call path. 2026-05-14 13:23:16 -04:00
Greyson Parrelli 7dd6829bfa Automatically snooze megaphones after 3 days. 2026-05-14 13:23:16 -04:00
jeffrey-signal 0e40acfdaa Fix permissions screen button background elevation for medium/large layout. 2026-05-14 13:23:16 -04:00
Greyson Parrelli c2e8cec042 Upgrade to SQLCipher 4.16.0 2026-05-14 13:23:16 -04:00
Cody Henthorne 04d2b3b0fe Improve prekey fetch performance. 2026-05-14 13:23:16 -04:00
Greyson Parrelli 64cdff4638 Remove support for END_SESSION flag. 2026-05-14 13:23:16 -04:00
Greyson Parrelli 59b42ac546 Prompt permission for scheduled sends in media flow. 2026-05-14 13:23:16 -04:00
Cody Henthorne 2e9fd87b06 Reduce thrashing on multiple identity change events. 2026-05-14 13:23:16 -04:00
Greyson Parrelli 0cf7705d4f Convert megaphone database and repository to kotlin. 2026-05-14 13:23:16 -04:00
Greyson Parrelli 5bcbbdf339 React to availability of Play Services at app start. 2026-05-14 13:23:16 -04:00
jeffrey-signal a796316ad6 Add click handling for regV5 welcome screen terms button. 2026-05-14 13:23:16 -04:00
Greyson Parrelli 5655fcf973 Create new linkifier.
Co-authored-by: Cody Henthorne <cody@signal.org>
2026-05-12 10:05:38 -04:00
Alex Hart f4bd5fbe8b Add log line for ignored payment transcript. 2026-05-12 10:53:43 -03:00
Alex Hart fc448ecb59 Fix call screen crash when participant count drops during speaker view. 2026-05-12 10:47:28 -03:00
Michelle Tang c4e7841ea3 Fix missing chevron. 2026-05-12 09:46:19 -04:00
Cody Henthorne e248aee25c Improve quote query performance for conversation open. 2026-05-11 16:32:26 -04:00
Michelle Tang 7c9268e326 Turn on key transparency. 2026-05-11 16:32:26 -04:00
Alex Hart 8ffc2e7ab8 Handle SUBSCRIPTION_STATUS FetchLatest sync message. 2026-05-11 16:32:26 -04:00
andrew-signal b4404bb5b4 Bump to libsignal v0.94.0 2026-05-11 16:32:26 -04:00
Michelle Tang 155bba2f81 Expand touch area for key transparency bottom sheet. 2026-05-11 16:32:26 -04:00
adel-signal 639438b863 Update RingRTC version to v2.69.0 2026-05-11 16:32:26 -04:00
Alex Hart b374a90ffe Allow donations on linked devices. 2026-05-11 16:32:26 -04:00
Cody Henthorne d333503838 Migrate to new staging SVR2 enclave. 2026-05-11 16:32:26 -04:00
Alex Hart 2efc115410 Add intrumented testing and a small fix for nav from shortcuts. 2026-05-11 16:32:26 -04:00
Cody Henthorne 43a1c93961 Make network calling infrastructure more coroutine friendly. 2026-05-11 16:32:26 -04:00
Cody Henthorne 39529af4e9 Add screen share to 1:1 and group calling. 2026-05-11 16:32:24 -04:00
Cody Henthorne d1e2fc0423 Use trimmed video start for thumbnail generation. 2026-05-11 16:31:32 -04:00
Alex Hart 5e4865be73 Add a sync after rotating a subscriber id. 2026-05-11 16:31:32 -04:00
Cody Henthorne 5903c1bbf5 Update reproducible build dependencies. 2026-05-11 16:31:32 -04:00
Alex Hart 45da9fbfc0 Skip slow deviceheuristics for linked devices. 2026-05-11 16:31:32 -04:00
Greyson Parrelli 0dacc4e8dc Ensure we use the correct index for starred messages. 2026-05-11 16:31:32 -04:00
Cody Henthorne 74935c963a Refresh thread snippet after view-once attachment is viewed.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-05-11 16:31:32 -04:00
Greyson Parrelli 02d245ac0c Manually draw location on google map. 2026-05-11 16:31:32 -04:00
Cody Henthorne e100ffbc14 Fix crash during group call peek with unknown members. 2026-05-11 16:31:32 -04:00
Cody Henthorne d4b3328151 Fix IllegalArgumentException crash when dismissing progress dialog. 2026-05-11 16:31:32 -04:00
Cody Henthorne 0e82a43be7 Cycle use REST fallback remote config to try without it again. 2026-05-11 16:31:32 -04:00
Alex Hart 0960c0dfea Skip processing backfill requests on linked devices. 2026-05-11 16:31:32 -04:00
jeffrey-signal 8503c49db0 Prevent input panel quote author name from overlapping the dismiss button. 2026-05-11 16:31:32 -04:00
Greyson Parrelli 88b95ce6a5 Ignore lint error in keyboard layout. 2026-05-11 16:31:32 -04:00
Greyson Parrelli 6a1d06486c Treat unparseable shortcut recipient ids as invalid shortcuts. 2026-05-11 16:31:32 -04:00
Greyson Parrelli 824f0af00b Reject unknown share-target recipient ids. 2026-05-11 16:31:32 -04:00
Greyson Parrelli 03a6d8c12f Fix crash when handling malformed URI's. 2026-05-11 16:31:32 -04:00
Alex Hart f1b231ca38 Wire in inactive-primary websocket alert. 2026-05-11 16:31:31 -04:00
Greyson Parrelli dca4351b8b Update some gradle properties that are giving warnings. 2026-05-11 16:31:31 -04:00
andrew-signal b2a18f7202 Make chat list tap state slightly darker. 2026-05-11 16:31:31 -04:00
1130 changed files with 34890 additions and 15261 deletions
+34 -26
View File
@@ -27,9 +27,9 @@ plugins {
val staticIps = Properties().apply { file("static-ips.properties").reader().use { load(it) } }
staticIps.stringPropertyNames().forEach { rootProject.extra[it] = staticIps.getProperty(it) }
val canonicalVersionCode = 1687
val canonicalVersionName = "8.10.2"
val currentHotfixVersion = 0
val canonicalVersionCode = 1699
val canonicalVersionName = "8.13.2"
val currentHotfixVersion = 1
val maxHotfixVersions = 100
// We don't want versions to ever end in 0 so that they don't conflict with nightly versions
@@ -56,6 +56,11 @@ val localProperties: Properties? = if (localPropertiesFile.exists()) {
val quickstartCredentialsDir: String? = localProperties?.getProperty("quickstart.credentials.dir")
val benchmarkBackupFile: String? = localProperties?.getProperty("benchmark.backup.file")
val isInstrumentationTestRun = gradle.startParameter.taskNames.any { taskName ->
val lower = taskName.lowercase()
lower.contains("androidtest") || lower.contains("connectedcheck")
}
val selectableVariants = listOf(
"nightlyProdSpinner",
"nightlyProdPerf",
@@ -68,13 +73,11 @@ val selectableVariants = listOf(
"playProdMocked",
"playProdNonMinifiedMocked",
"playProdBenchmark",
"playProdInstrumentation",
"playProdRelease",
"playStagingDebug",
"playStagingCanary",
"playStagingSpinner",
"playStagingPerf",
"playStagingInstrumentation",
"playStagingRelease",
"playProdQuickstart",
"playStagingQuickstart",
@@ -100,6 +103,9 @@ tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile::class.java).conf
if (!isTestTask && (name.contains("Mocked") || name.contains("Benchmark"))) {
source("$projectDir/src/benchmarkShared/java")
}
if (isTestTask && name.contains("AndroidTest")) {
source("$projectDir/src/benchmarkShared/java")
}
}
wire {
@@ -129,7 +135,6 @@ android {
ndkVersion = libs.versions.ndk.get()
flavorDimensions += listOf("distribution", "environment")
testBuildType = "instrumentation"
android.bundle.language.enableSplit = false
@@ -168,6 +173,7 @@ android {
getByName("androidTest") {
java.srcDir("$projectDir/src/testShared")
java.srcDir("$projectDir/src/benchmarkShared/java")
}
}
@@ -215,6 +221,10 @@ android {
versionCode = (canonicalVersionCode * maxHotfixVersions) + possibleHotfixVersions[currentHotfixVersion]
versionName = canonicalVersionName
if (isInstrumentationTestRun) {
applicationIdSuffix = ".test_run"
}
minSdk = libs.versions.minSdk.get().toInt()
targetSdk = libs.versions.targetSdk.get().toInt()
@@ -249,8 +259,8 @@ android {
buildConfigField("String[]", "SIGNAL_CDSI_IPS", rootProject.extra["cdsi_ips"] as String)
buildConfigField("String[]", "SIGNAL_SVR2_IPS", rootProject.extra["svr2_ips"] as String)
buildConfigField("String", "SIGNAL_AGENT", "\"OWA\"")
buildConfigField("String", "SVR2_MRENCLAVE_LEGACY", "\"29cd63c87bea751e3bfd0fbd401279192e2e5c99948b4ee9437eafc4968355fb\"")
buildConfigField("String", "SVR2_MRENCLAVE", "\"1240acbd4aa26974184844c8a46b1022d3957ac8a76c1fd8f5b1a15141ee0708\"")
buildConfigField("String", "SVR2_MRENCLAVE_LEGACY", "\"1240acbd4aa26974184844c8a46b1022d3957ac8a76c1fd8f5b1a15141ee0708\"")
buildConfigField("String", "SVR2_MRENCLAVE", "\"ced8217b26228e4b210c985786999d095c4958a94faf37b14acaf25c4cbb02a4\"")
buildConfigField("String[]", "UNIDENTIFIED_SENDER_TRUST_ROOTS", "new String[]{ \"BXu6QIKVz5MA8gstzfOgRQGqyLqOwNKHL6INkv3IHWMF\", \"BUkY0I+9+oPgDCn4+Ac6Iu813yvqkDr/ga8DzLxFxuk6\"}")
buildConfigField("String", "ZKGROUP_SERVER_PUBLIC_PARAMS", "\"AMhf5ywVwITZMsff/eCyudZx9JDmkkkbV6PInzG4p8x3VqVJSFiMvnvlEKWuRob/1eaIetR31IYeAbm0NdOuHH8Qi+Rexi1wLlpzIo1gstHWBfZzy1+qHRV5A4TqPp15YzBPm0WSggW6PbSn+F4lf57VCnHF7p8SvzAA2ZZJPYJURt8X7bbg+H3i+PEjH9DXItNEqs2sNcug37xZQDLm7X36nOoGPs54XsEGzPdEV+itQNGUFEjY6X9Uv+Acuks7NpyGvCoKxGwgKgE5XyJ+nNKlyHHOLb6N1NuHyBrZrgtY/JYJHRooo5CEqYKBqdFnmbTVGEkCvJKxLnjwKWf+fEPoWeQFj5ObDjcKMZf2Jm2Ae69x+ikU5gBXsRmoF94GXTLfN0/vLt98KDPnxwAQL9j5V1jGOY8jQl6MLxEs56cwXN0dqCnImzVH3TZT1cJ8SW1BRX6qIVxEzjsSGx3yxF3suAilPMqGRp4ffyopjMD1JXiKR2RwLKzizUe5e8XyGOy9fplzhw3jVzTRyUZTRSZKkMLWcQ/gv0E4aONNqs4P+NameAZYOD12qRkxosQQP5uux6B2nRyZ7sAV54DgFyLiRcq1FvwKw2EPQdk4HDoePrO/RNUbyNddnM/mMgj4FW65xCoT1LmjrIjsv/Ggdlx46ueczhMgtBunx1/w8k8V+l8LVZ8gAT6wkU5J+DPQalQguMg12Jzug3q4TbdHiGCmD9EunCwOmsLuLJkz6EcSYXtrlDEnAM+hicw7iergYLLlMXpfTdGxJCWJmP4zqUFeTTmsmhsjGBt7NiEB/9pFFEB3pSbf4iiUukw63Eo8Aqnf4iwob6X1QviCWuc8t0LUlT9vALgh/f2DPVOOmR0RW6bgRvc7DSF20V/omg+YBw==\"")
buildConfigField("String", "GENERIC_SERVER_PUBLIC_PARAMS", "\"AByD873dTilmOSG0TjKrvpeaKEsUmIO8Vx9BeMmftwUs9v7ikPwM8P3OHyT0+X3EUMZrSe9VUp26Wai51Q9I8mdk0hX/yo7CeFGJyzoOqn8e/i4Ygbn5HoAyXJx5eXfIbqpc0bIxzju4H/HOQeOpt6h742qii5u/cbwOhFZCsMIbElZTaeU+BWMBQiZHIGHT5IE0qCordQKZ5iPZom0HeFa8Yq0ShuEyAl0WINBiY6xE3H/9WnvzXBbMuuk//eRxXgzO8ieCeK8FwQNxbfXqZm6Ro1cMhCOF3u7xoX83QhpN\"")
@@ -272,7 +282,6 @@ android {
buildConfigField("String", "STRIPE_PUBLISHABLE_KEY", "\"pk_live_6cmGZopuTsV8novGgJJW9JpC00vLIgtQ1D\"")
buildConfigField("boolean", "TRACING_ENABLED", "false")
buildConfigField("boolean", "LINK_DEVICE_UX_ENABLED", "false")
buildConfigField("boolean", "USE_STRING_ID", "false")
ndk {
abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64")
@@ -288,7 +297,11 @@ android {
}
}
testInstrumentationRunner = "org.thoughtcrime.securesms.testing.SignalTestRunner"
testInstrumentationRunner = if (project.hasProperty("imoTests")) {
"org.thoughtcrime.securesms.testing.incomingmessageobserver.IncomingMessageObserverTestRunner"
} else {
"org.thoughtcrime.securesms.testing.SignalTestRunner"
}
testInstrumentationRunnerArguments["clearPackageData"] = "true"
}
@@ -337,17 +350,6 @@ android {
buildConfigField("String", "BUILD_VARIANT_TYPE", "\"Release\"")
}
create("instrumentation") {
initWith(getByName("debug"))
isDefault = false
isMinifyEnabled = false
matchingFallbacks += "debug"
applicationIdSuffix = ".instrumentation"
buildConfigField("String", "BUILD_VARIANT_TYPE", "\"Instrumentation\"")
buildConfigField("String", "STRIPE_BASE_URL", "\"http://127.0.0.1:8080/stripe\"")
}
create("spinner") {
initWith(getByName("debug"))
isDefault = false
@@ -463,8 +465,8 @@ android {
buildConfigField("String", "SIGNAL_CDN3_URL", "\"https://cdn3-staging.signal.org\"")
buildConfigField("String", "SIGNAL_CDSI_URL", "\"https://cdsi.staging.signal.org\"")
buildConfigField("String", "SIGNAL_SVR2_URL", "\"https://svr2.staging.signal.org\"")
buildConfigField("String", "SVR2_MRENCLAVE_LEGACY", "\"a75542d82da9f6914a1e31f8a7407053b99cc99a0e7291d8fbd394253e19b036\"")
buildConfigField("String", "SVR2_MRENCLAVE", "\"97f151f6ed078edbbfd72fa9cae694dcc08353f1f5e8d9ccd79a971b10ffc535\"")
buildConfigField("String", "SVR2_MRENCLAVE_LEGACY", "\"97f151f6ed078edbbfd72fa9cae694dcc08353f1f5e8d9ccd79a971b10ffc535\"")
buildConfigField("String", "SVR2_MRENCLAVE", "\"3c699f4975aaa3d172c0aad042f94f031b2b03e10b9c19a45116a01693d83302\"")
buildConfigField("String[]", "UNIDENTIFIED_SENDER_TRUST_ROOTS", "new String[]{\"BbqY1DzohE4NUZoVF+L18oUPrK3kILllLEJh2UnPSsEx\", \"BYhU6tPjqP46KGZEzRs1OL4U39V5dlPJ/X09ha4rErkm\"}")
buildConfigField("String", "ZKGROUP_SERVER_PUBLIC_PARAMS", "\"ABSY21VckQcbSXVNCGRYJcfWHiAMZmpTtTELcDmxgdFbtp/bWsSxZdMKzfCp8rvIs8ocCU3B37fT3r4Mi5qAemeGeR2X+/YmOGR5ofui7tD5mDQfstAI9i+4WpMtIe8KC3wU5w3Inq3uNWVmoGtpKndsNfwJrCg0Hd9zmObhypUnSkfYn2ooMOOnBpfdanRtrvetZUayDMSC5iSRcXKpdlukrpzzsCIvEwjwQlJYVPOQPj4V0F4UXXBdHSLK05uoPBCQG8G9rYIGedYsClJXnbrgGYG3eMTG5hnx4X4ntARBgELuMWWUEEfSK0mjXg+/2lPmWcTZWR9nkqgQQP0tbzuiPm74H2wMO4u1Wafe+UwyIlIT9L7KLS19Aw8r4sPrXZSSsOZ6s7M1+rTJN0bI5CKY2PX29y5Ok3jSWufIKcgKOnWoP67d5b2du2ZVJjpjfibNIHbT/cegy/sBLoFwtHogVYUewANUAXIaMPyCLRArsKhfJ5wBtTminG/PAvuBdJ70Z/bXVPf8TVsR292zQ65xwvWTejROW6AZX6aqucUjlENAErBme1YHmOSpU6tr6doJ66dPzVAWIanmO/5mgjNEDeK7DDqQdB1xd03HT2Qs2TxY3kCK8aAb/0iM0HQiXjxZ9HIgYhbtvGEnDKW5ILSUydqH/KBhW4Pb0jZWnqN/YgbWDKeJxnDbYcUob5ZY5Lt5ZCMKuaGUvCJRrCtuugSMaqjowCGRempsDdJEt+cMaalhZ6gczklJB/IbdwENW9KeVFPoFNFzhxWUIS5ML9riVYhAtE6JE5jX0xiHNVIIPthb458cfA8daR0nYfYAUKogQArm0iBezOO+mPk5vCNWI+wwkyFCqNDXz/qxl1gAntuCJtSfq9OC3NkdhQlgYQ==\"")
buildConfigField("String", "GENERIC_SERVER_PUBLIC_PARAMS", "\"AHILOIrFPXX9laLbalbA9+L1CXpSbM/bTJXZGZiuyK1JaI6dK5FHHWL6tWxmHKYAZTSYmElmJ5z2A5YcirjO/yfoemE03FItyaf8W1fE4p14hzb5qnrmfXUSiAIVrhaXVwIwSzH6RL/+EO8jFIjJ/YfExfJ8aBl48CKHgu1+A6kWynhttonvWWx6h7924mIzW0Czj2ROuh4LwQyZypex4GuOPW8sgIT21KNZaafgg+KbV7XM1x1tF3XA17B4uGUaDbDw2O+nR1+U5p6qHPzmJ7ggFjSN6Utu+35dS1sS0P9N\"")
@@ -516,6 +518,9 @@ android {
androidComponents {
beforeVariants { variant ->
variant.enable = variant.name in selectableVariants
if (variant.enable) {
(variant as? com.android.build.api.variant.HasUnitTestBuilder)?.enableUnitTest = true
}
}
onVariants(selector().all()) { variant: com.android.build.api.variant.ApplicationVariant ->
// Rename APK to include version name
@@ -527,9 +532,10 @@ androidComponents {
transformationRequest.set(renameRequest)
}
// Include the test-only library on debug builds.
if (variant.buildType != "instrumentation") {
// Include the test-only library on non-release builds.
if (variant.buildType == "release") {
variant.packaging.jniLibs.excludes.add("**/libsignal_jni_testing.so")
variant.androidResources.ignoreAssetsPatterns.add("libsignal-testing.md")
}
// Starting with minSdk 23, Android leaves native libraries uncompressed, which is fine for the Play Store, but not for our self-distributed APKs.
@@ -686,6 +692,7 @@ dependencies {
implementation(libs.google.play.services.maps)
implementation(libs.google.play.services.auth)
implementation(libs.google.signin)
implementation(libs.androidx.media)
implementation(libs.bundles.media3)
implementation(libs.conscrypt.android)
implementation(libs.signal.aesgcmprovider)
@@ -714,6 +721,7 @@ dependencies {
}
implementation(libs.dnsjava)
implementation(libs.kotlinx.collections.immutable)
implementation(libs.arrow.core)
implementation(libs.accompanist.permissions)
implementation(libs.accompanist.drawablepainter)
implementation(libs.kotlin.stdlib.jdk8)
@@ -735,7 +743,7 @@ dependencies {
"canaryImplementation"(libs.square.leakcanary)
"instrumentationImplementation"(libs.androidx.fragment.testing) {
androidTestImplementation(libs.androidx.fragment.testing) {
exclude(group = "androidx.test", module = "core")
}
+33 -1055
View File
File diff suppressed because it is too large Load Diff
+9
View File
@@ -7,6 +7,7 @@
-keep class org.signal.libsignal.usernames.** { *; }
-keep class org.thoughtcrime.securesms.** { *; }
-keep class org.signal.donations.json.** { *; }
-keep class org.signal.network.** { *; }
-keepclassmembers class ** {
public void onEvent*(**);
}
@@ -16,6 +17,14 @@
-keep class androidx.window.** { *; }
# Workaround for R8 non-determinism in AGP 9.x. R8 inconsistently keeps or strips
# the Signature attribute on this Kotlin lambda subclass of the generic
# LottieValueCallback, causing intermittent dex byte differences. Explicitly
# keeping the class stabilizes R8's attribute decisions.
-keep class com.airbnb.lottie.compose.LottieDynamicPropertiesKt$toValueCallback$1 {
*;
}
-keepclassmembers class * extends androidx.constraintlayout.motion.widget.Key {
public <init>();
}
@@ -0,0 +1,36 @@
package org.thoughtcrime.securesms
import org.signal.core.util.logging.AndroidLogger
import org.signal.core.util.logging.Log
import org.signal.libsignal.protocol.logging.SignalProtocolLoggerProvider
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.dependencies.ApplicationDependencyProvider
import org.thoughtcrime.securesms.logging.CustomSignalProtocolLogger
import org.thoughtcrime.securesms.testing.incomingmessageobserver.IncomingMessageObserverDependencyProvider
import org.thoughtcrime.securesms.testing.incomingmessageobserver.IncomingMessageObserverTestRunner
/**
* Application used when running `IncomingMessageObserver` instrumentation tests. Installs
* [IncomingMessageObserverDependencyProvider] so the websocket and job manager are replaced
* with test-friendly implementations. Selected by [IncomingMessageObserverTestRunner] when
* gradle is invoked with `-PimoTests`.
*/
class IncomingMessageObserverInstrumentationApplicationContext : ApplicationContext() {
override fun initializeAppDependencies() {
val default = ApplicationDependencyProvider(this)
AppDependencies.init(this, IncomingMessageObserverDependencyProvider(this, default))
AppDependencies.deadlockDetector.start()
}
override fun initializeLogging() {
Log.initialize({ true }, AndroidLogger)
SignalProtocolLoggerProvider.setProvider(CustomSignalProtocolLogger())
}
override fun beginJobLoop() = Unit
fun beginJobLoopForTests() {
super.beginJobLoop()
}
}
@@ -1,5 +1,6 @@
package org.thoughtcrime.securesms
import android.content.Context
import org.signal.core.util.concurrent.SignalExecutors
import org.signal.core.util.logging.AndroidLogger
import org.signal.core.util.logging.Log
@@ -11,6 +12,7 @@ import org.thoughtcrime.securesms.dependencies.InstrumentationApplicationDepende
import org.thoughtcrime.securesms.logging.CustomSignalProtocolLogger
import org.thoughtcrime.securesms.logging.PersistentLogger
import org.thoughtcrime.securesms.testing.InMemoryLogger
import org.thoughtcrime.securesms.util.Environment
/**
* Application context for running instrumentation tests (aka androidTests).
@@ -19,6 +21,11 @@ class SignalInstrumentationApplicationContext : ApplicationContext() {
val inMemoryLogger: InMemoryLogger = InMemoryLogger()
override fun attachBaseContext(base: Context?) {
Environment.IS_INSTRUMENTATION = true
super.attachBaseContext(base)
}
override fun initializeAppDependencies() {
val default = ApplicationDependencyProvider(this)
AppDependencies.init(this, InstrumentationApplicationDependencyProvider(this, default))
@@ -16,6 +16,7 @@ import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.signal.donations.InAppPaymentType
import org.signal.network.NetworkResult
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository
import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord
@@ -26,7 +27,6 @@ import org.thoughtcrime.securesms.testing.InAppPaymentsRule
import org.thoughtcrime.securesms.testing.RxTestSchedulerRule
import org.thoughtcrime.securesms.testing.SignalActivityRule
import org.thoughtcrime.securesms.testing.actions.RecyclerViewScrollToBottomAction
import org.whispersystems.signalservice.api.NetworkResult
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription
import org.whispersystems.signalservice.api.subscriptions.SubscriberId
import java.math.BigDecimal
@@ -7,9 +7,9 @@ package org.thoughtcrime.securesms.database
import org.signal.core.util.Base64
import org.signal.core.util.Util
import org.signal.network.api.AttachmentUploadResult
import org.thoughtcrime.securesms.attachments.AttachmentId
import org.thoughtcrime.securesms.attachments.Cdn
import org.whispersystems.signalservice.api.attachment.AttachmentUploadResult
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentRemoteId
import kotlin.random.Random
@@ -10,11 +10,11 @@ import org.thoughtcrime.securesms.recipients.LiveRecipientCache
import org.whispersystems.signalservice.api.SignalServiceDataStore
import org.whispersystems.signalservice.api.SignalServiceMessageSender
import org.whispersystems.signalservice.api.account.AccountApi
import org.whispersystems.signalservice.api.attachment.AttachmentApi
import org.whispersystems.signalservice.api.donations.DonationsApi
import org.whispersystems.signalservice.api.keys.KeysApi
import org.whispersystems.signalservice.api.message.MessageApi
import org.whispersystems.signalservice.api.websocket.SignalWebSocket
import org.whispersystems.signalservice.internal.configuration.SignalServiceConfiguration
import org.whispersystems.signalservice.internal.push.PushServiceSocket
/**
@@ -41,7 +41,7 @@ class InstrumentationApplicationDependencyProvider(val application: Application,
return recipientCache
}
override fun provideArchiveApi(authWebSocket: SignalWebSocket.AuthenticatedWebSocket, unauthWebSocket: SignalWebSocket.UnauthenticatedWebSocket, pushServiceSocket: PushServiceSocket): ArchiveApi {
override fun provideArchiveApi(authWebSocket: SignalWebSocket.AuthenticatedWebSocket, unauthWebSocket: SignalWebSocket.UnauthenticatedWebSocket, pushServiceSocket: PushServiceSocket, signalServiceConfiguration: SignalServiceConfiguration): ArchiveApi {
return mockk()
}
@@ -52,12 +52,11 @@ class InstrumentationApplicationDependencyProvider(val application: Application,
override fun provideSignalServiceMessageSender(
protocolStore: SignalServiceDataStore,
pushServiceSocket: PushServiceSocket,
attachmentApi: AttachmentApi,
messageApi: MessageApi,
keysApi: KeysApi
): SignalServiceMessageSender {
if (signalServiceMessageSender == null) {
signalServiceMessageSender = spyk(objToCopy = default.provideSignalServiceMessageSender(protocolStore, pushServiceSocket, attachmentApi, messageApi, keysApi))
signalServiceMessageSender = spyk(objToCopy = default.provideSignalServiceMessageSender(protocolStore, pushServiceSocket, messageApi, keysApi))
}
return signalServiceMessageSender!!
}
@@ -19,6 +19,8 @@ import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.signal.network.NetworkResult
import org.signal.network.exceptions.NonSuccessfulResponseCodeException
import org.thoughtcrime.securesms.backup.DeletionState
import org.thoughtcrime.securesms.backup.v2.BackupRepository
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
@@ -27,8 +29,6 @@ import org.thoughtcrime.securesms.jobs.protos.BackupDeleteJobData
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.testing.SignalActivityRule
import org.thoughtcrime.securesms.util.RemoteConfig
import org.whispersystems.signalservice.api.NetworkResult
import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException
class BackupDeleteJobTest {
@@ -27,6 +27,8 @@ import org.signal.core.util.billing.BillingPurchaseState
import org.signal.core.util.billing.BillingResponseCode
import org.signal.core.util.money.FiatMoney
import org.signal.donations.InAppPaymentType
import org.signal.network.NetworkResult
import org.signal.network.exceptions.NonSuccessfulResponseCodeException
import org.thoughtcrime.securesms.backup.DeletionState
import org.thoughtcrime.securesms.backup.v2.BackupRepository
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
@@ -42,8 +44,6 @@ import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.net.SignalNetwork
import org.thoughtcrime.securesms.testing.SignalActivityRule
import org.thoughtcrime.securesms.util.RemoteConfig
import org.whispersystems.signalservice.api.NetworkResult
import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException
import org.whispersystems.signalservice.api.storage.IAPSubscriptionId
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription.ChargeFailure
@@ -0,0 +1,843 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.main
import android.app.Activity
import android.app.Application
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.net.Uri
import android.os.Bundle
import android.view.View
import android.widget.TextView
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity
import androidx.fragment.app.FragmentManager
import androidx.lifecycle.ViewModelProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.signal.core.models.media.Media
import org.thoughtcrime.securesms.MainActivity
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.calls.log.CallLogFragment
import org.thoughtcrime.securesms.conversation.ConversationArgs
import org.thoughtcrime.securesms.conversation.ConversationIntents
import org.thoughtcrime.securesms.conversation.v2.ConversationFragment
import org.thoughtcrime.securesms.conversationlist.ConversationListArchiveFragment
import org.thoughtcrime.securesms.conversationlist.ConversationListFragment
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionActivity
import org.thoughtcrime.securesms.providers.BlobProvider
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.stories.landing.StoriesLandingFragment
import org.thoughtcrime.securesms.testing.SignalActivityRule
import java.io.ByteArrayOutputStream
import java.util.Collections
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
/**
* End-to-end launch tests for [MainActivity], covering cold-launch and onNewIntent paths
* through [MainNavigationViewModel].
*/
@RunWith(AndroidJUnit4::class)
class MainNavigationLaunchTest {
@get:Rule
val harness = SignalActivityRule(othersCount = 2)
private val context: Context get() = harness.context
private val recipient: RecipientId get() = harness.others.first()
/**
* Share-target cold-launch regression test. Pre-fix, wrapNavigator() re-routed the
* early-staged Conversation through goTo(), whose async wallpaper-prefetch path emitted
* a SECOND internalDetailLocation with a fresh ConversationArgs — recreating the
* fragment and dropping share data.
*/
@Test
fun coldLaunch_shareIntent_createsFragmentExactlyOnceWithShareData() {
val timestamp = System.currentTimeMillis()
val mimeType = "image/jpeg"
val blob = realBlob(byteArrayOf(0x01, 0x02, 0x03), mimeType)
val intent = shareToConversationIntent(
recipient = recipient,
blob = blob,
mimeType = mimeType,
shareDataTimestamp = timestamp
)
launchSync(intent).use { launched ->
val recorder = launched.recorder
try {
await(timeoutMs = 10_000, description = "ConversationFragment to be added") {
recorder.createdArgs.isNotEmpty()
}
} catch (e: IllegalStateException) {
val vm = runOnMainSync { launched.activity.mainNavigationViewModel() }
val state = runOnMainSync {
buildString {
appendLine("--- diagnostic dump ---")
appendLine("fragments observed: ${recorder.allCreated}")
appendLine("activity fragments: ${launched.activity.supportFragmentManager.fragments.map { it::class.simpleName }}")
appendLine("vm.currentListLocation: ${vm.mainNavigationState.value.currentListLocation}")
appendLine("vm.earlyNavigationDetailLocationRequested: ${vm.earlyNavigationDetailLocationRequested}")
}
}
throw IllegalStateException("${e.message}\n$state", e)
}
val expectedName = runOnMainSync { Recipient.resolved(recipient).getDisplayName(context) }
awaitConversationTitle(launched, expectedName)
// Give the post-navigator wallpaper-prefetch path a chance to emit a (pre-fix)
// duplicate second nav before we count fragments.
Thread.sleep(750)
check(recorder.createdArgs.size == 1) {
"Expected exactly one ConversationFragment, got ${recorder.createdArgs.size}"
}
val args = recorder.createdArgs.single()
check(args.shareDataTimestamp == timestamp) {
"Expected shareDataTimestamp=$timestamp, got ${args.shareDataTimestamp}"
}
check(args.recipientId == recipient) {
"Expected recipient=$recipient, got ${args.recipientId}"
}
check(args.draftMedia == blob) {
"Expected draftMedia=$blob, got ${args.draftMedia}"
}
}
}
/**
* Image-share cold-launch: the dispatch path through `ShareOrDraftData.StartSendMedia`
* that hops the user from the conversation into the media-send screen
* ([MediaSelectionActivity]). Asserts that the secondary activity actually launches and
* that its [MediaReviewFragment] surfaces the recipient's display name in the top
* corner — i.e. it knows who the share is targeted at.
*/
@Test
fun coldLaunch_shareImageIntent_opensMediaSendForRecipient() {
val media = realJpegMedia()
val intent = shareImageIntent(recipient = recipient, media = media)
launchSync(intent).use { launched ->
val mediaSend = launched.awaitActivity(MediaSelectionActivity::class.java, timeoutMs = 20_000)
val expectedName = runOnMainSync { Recipient.resolved(recipient).getDisplayName(context) }
await(timeoutMs = 15_000, description = "recipient label populated in MediaReviewFragment") {
// await() already runs the predicate on the main thread; nesting another
// runOnMainSync here would throw "can not be called from the main application thread".
mediaSend.findViewById<TextView>(R.id.recipient)?.text?.toString() == expectedName
}
// Exactly one ConversationFragment should have been created — the share dispatch
// happens from inside it, then it stays put while the media editor sits on top.
check(launched.recorder.createdArgs.size == 1) {
"Expected exactly one ConversationFragment for image share, got ${launched.recorder.createdArgs.size}"
}
}
}
/**
* Text-share cold-launch: the dispatch path through `ShareOrDraftData.SetText`. Asserts
* the navigation boundary — one ConversationFragment, no secondary activity pushed on
* top — *and* that the draft text actually shows up in the composer the user sees.
*/
@Test
fun coldLaunch_shareTextIntent_opensConversationWithDraftText() {
val draftText = "hello from share"
val intent = shareTextIntent(recipient = recipient, text = draftText)
launchSync(intent).use { launched ->
val recorder = launched.recorder
await(timeoutMs = 10_000, description = "ConversationFragment to be added") {
recorder.createdArgs.isNotEmpty()
}
awaitComposerText(launched, draftText)
// Give a beat for any spurious second navigation to surface.
Thread.sleep(750)
check(recorder.createdArgs.size == 1) {
"Expected exactly one ConversationFragment, got ${recorder.createdArgs.size}"
}
val args = recorder.createdArgs.single()
check(args.recipientId == recipient) {
"Expected recipient=$recipient, got ${args.recipientId}"
}
check(args.draftMedia == null) {
"Expected no draftMedia, got ${args.draftMedia}"
}
check(launched.nonMainActivities().isEmpty()) {
"Text share should not launch a secondary activity, got ${launched.nonMainActivities().map { it::class.simpleName }}"
}
}
}
@Test
fun coldLaunch_notificationIntent_opensConversation() {
val intent = notificationToConversationIntent(recipient)
launchSync(intent).use { launched ->
val recorder = launched.recorder
await(timeoutMs = 10_000, description = "ConversationFragment to be added") {
recorder.createdArgs.isNotEmpty()
}
val expectedName = runOnMainSync { Recipient.resolved(recipient).getDisplayName(context) }
awaitConversationTitle(launched, expectedName)
check(recorder.createdArgs.size == 1) {
"Expected exactly one ConversationFragment, got ${recorder.createdArgs.size}"
}
val args = recorder.createdArgs.single()
check(args.recipientId == recipient) {
"Expected recipient=$recipient, got ${args.recipientId}"
}
check(args.threadId > 0) {
"Expected threadId > 0, got ${args.threadId}"
}
check(args.draftMedia == null) {
"Expected no draftMedia, got ${args.draftMedia}"
}
check(args.shareDataTimestamp == -1L) {
"Expected shareDataTimestamp=-1 for notification path, got ${args.shareDataTimestamp}"
}
val vm = runOnMainSync { launched.activity.mainNavigationViewModel() }
check(vm.mainNavigationState.value.currentListLocation == MainNavigationListLocation.CHATS) {
"Expected currentListLocation=CHATS, got ${vm.mainNavigationState.value.currentListLocation}"
}
}
}
@Test
fun coldLaunch_tabIntent_setsListLocation() {
val intent = tabIntent(MainNavigationListLocation.CALLS)
launchSync(intent).use { launched ->
val recorder = launched.recorder
awaitListFragment(launched, MainNavigationListLocation.CALLS)
val vm = runOnMainSync { launched.activity.mainNavigationViewModel() }
check(vm.mainNavigationState.value.currentListLocation == MainNavigationListLocation.CALLS) {
"Expected VM CALLS, got ${vm.mainNavigationState.value.currentListLocation}"
}
Thread.sleep(750)
check(recorder.createdArgs.isEmpty()) {
"Expected no ConversationFragment for tab launch, got ${recorder.createdArgs.size}"
}
}
}
/**
* Locks down present cold-launch behaviour for KEY_DETAIL_LOCATION: today it is only
* consumed by onNewIntent. If a future change starts handling it on cold launch, this
* test should fail and force a deliberate decision.
*/
@Test
fun coldLaunch_detailLocationIntent_isNoOpToday() {
val intent = detailLocationIntent(MainNavigationDetailLocation.Chats.ConversationSettings(recipient))
launchSync(intent).use { launched ->
val recorder = launched.recorder
Thread.sleep(1500)
check(recorder.createdArgs.isEmpty()) {
"KEY_DETAIL_LOCATION is currently only handled by onNewIntent. If a future change " +
"starts handling it on cold launch, update or delete this test. Got: ${recorder.allCreated}"
}
val vm = runOnMainSync { launched.activity.mainNavigationViewModel() }
check(vm.earlyNavigationDetailLocationRequested == null) {
"Expected no early detail to be staged, got ${vm.earlyNavigationDetailLocationRequested}"
}
}
}
@Test
fun coldLaunch_deepLinkIntent_reachesChatsList() {
val intent = deepLinkIntent(Uri.parse("https://signal.org/test-not-a-real-deeplink"))
launchSync(intent).use { launched ->
val recorder = launched.recorder
awaitListFragment(launched, MainNavigationListLocation.CHATS)
val vm = runOnMainSync { launched.activity.mainNavigationViewModel() }
check(vm.mainNavigationState.value.currentListLocation == MainNavigationListLocation.CHATS) {
"Expected CHATS for deep-link launch, got ${vm.mainNavigationState.value.currentListLocation}"
}
check(recorder.createdArgs.isEmpty()) {
"Expected no ConversationFragment for deep-link launch, got ${recorder.createdArgs.size}"
}
}
}
@Test
fun coldLaunch_noExtras_defaultsToChats() {
val intent = Intent(context, MainActivity::class.java)
launchSync(intent).use { launched ->
val recorder = launched.recorder
awaitListFragment(launched, MainNavigationListLocation.CHATS)
val vm = runOnMainSync { launched.activity.mainNavigationViewModel() }
check(vm.mainNavigationState.value.currentListLocation == MainNavigationListLocation.CHATS) {
"Expected default CHATS, got ${vm.mainNavigationState.value.currentListLocation}"
}
Thread.sleep(750)
check(vm.earlyNavigationDetailLocationRequested == null) {
"Expected no early detail, got ${vm.earlyNavigationDetailLocationRequested}"
}
check(recorder.createdArgs.isEmpty()) {
"Expected no ConversationFragment for bare launch, got ${recorder.createdArgs.size}"
}
}
}
@Test
fun warmStart_onNewIntent_conversationIntent_opensConversation() {
launchSync(Intent(context, MainActivity::class.java)).use { launched ->
val recorder = launched.recorder
// Let the bare list settle so we know any further fragment adds came from onNewIntent.
Thread.sleep(1000)
val baseline = recorder.createdArgs.size
val warmIntent = notificationToConversationIntent(recipient)
runOnMainSync {
InstrumentationRegistry.getInstrumentation().callActivityOnNewIntent(launched.activity, warmIntent)
}
await(timeoutMs = 10_000, description = "ConversationFragment after onNewIntent") {
recorder.createdArgs.size > baseline
}
val expectedName = runOnMainSync { Recipient.resolved(recipient).getDisplayName(context) }
awaitConversationTitle(launched, expectedName)
val newArgs = recorder.createdArgs.drop(baseline)
check(newArgs.size == 1) { "Expected one new ConversationFragment, got ${newArgs.size}" }
check(newArgs.single().recipientId == recipient) {
"Expected recipient=$recipient, got ${newArgs.single().recipientId}"
}
}
}
/**
* Mid-conversation onNewIntent with `KEY_DETAIL_LOCATION = Empty` — the contract used
* by [ConversationSettingsFragment.goToConversationList] to drop back to the chat list
* on phones. No new ConversationFragment should be added.
*/
@Test
fun warmStart_onNewIntent_emptyDetailIntent_returnsToList() {
launchSync(notificationToConversationIntent(recipient)).use { launched ->
val recorder = launched.recorder
await(timeoutMs = 10_000, description = "initial ConversationFragment") {
recorder.createdArgs.isNotEmpty()
}
val baseline = recorder.createdArgs.size
val warmIntent = detailLocationIntent(MainNavigationDetailLocation.Empty)
runOnMainSync {
InstrumentationRegistry.getInstrumentation().callActivityOnNewIntent(launched.activity, warmIntent)
}
await(description = "no new ConversationFragment after Empty detail intent") {
recorder.createdArgs.size == baseline
}
// The user-visible signal that we're "back on the list" is the chat list fragment
// being attached, not just the VM saying CHATS.
awaitListFragment(launched, MainNavigationListLocation.CHATS)
val vm = runOnMainSync { launched.activity.mainNavigationViewModel() }
check(vm.mainNavigationState.value.currentListLocation == MainNavigationListLocation.CHATS) {
"Expected CHATS, got ${vm.mainNavigationState.value.currentListLocation}"
}
}
}
@Test
fun warmStart_onNewIntent_tabIntent_switchesList() {
launchSync(Intent(context, MainActivity::class.java)).use { launched ->
awaitListFragment(launched, MainNavigationListLocation.CHATS)
val warmIntent = tabIntent(MainNavigationListLocation.CALLS)
runOnMainSync {
InstrumentationRegistry.getInstrumentation().callActivityOnNewIntent(launched.activity, warmIntent)
}
awaitListFragment(launched, MainNavigationListLocation.CALLS)
val vm = runOnMainSync { launched.activity.mainNavigationViewModel() }
check(vm.mainNavigationState.value.currentListLocation == MainNavigationListLocation.CALLS) {
"Expected VM CALLS, got ${vm.mainNavigationState.value.currentListLocation}"
}
check(launched.recorder.createdArgs.isEmpty()) {
"Expected no ConversationFragment for tab switch, got ${launched.recorder.createdArgs.size}"
}
}
}
@Test
fun recreate_midConversation_restoresState() {
launchSync(notificationToConversationIntent(recipient)).use { launched ->
val recorder = launched.recorder
await(timeoutMs = 10_000, description = "initial ConversationFragment") {
recorder.createdArgs.isNotEmpty()
}
val expectedName = runOnMainSync { Recipient.resolved(recipient).getDisplayName(context) }
awaitConversationTitle(launched, expectedName)
val initial = recorder.createdArgs.first()
runOnMainSync { launched.activity.recreate() }
await(timeoutMs = 15_000, description = "ConversationFragment after recreate") {
recorder.createdArgs.size >= 2
}
// Verify the user-visible title rebinds after recreate, not just the args.
awaitConversationTitle(launched, expectedName)
val recreated = recorder.createdArgs[1]
check(recreated.recipientId == initial.recipientId) {
"Recipient changed across recreate: ${initial.recipientId} -> ${recreated.recipientId}"
}
check(recreated.threadId == initial.threadId) {
"Thread changed across recreate: ${initial.threadId} -> ${recreated.threadId}"
}
}
}
@Test
fun recreate_midTab_restoresTab() {
launchSync(tabIntent(MainNavigationListLocation.CALLS)).use { launched ->
awaitListFragment(launched, MainNavigationListLocation.CALLS)
runOnMainSync { launched.activity.recreate() }
// Verify the user-visible tab content rebinds after recreate, not just the VM. The
// recorder removes destroyed fragments, so this only passes once the post-recreate
// CallLogFragment instance is attached.
awaitListFragment(launched, MainNavigationListLocation.CALLS)
// launched.activity returns the *latest* MainActivity (the holder updates in
// onActivityCreated), so this reads the post-recreate VM instance.
val location = runOnMainSync {
launched.activity.mainNavigationViewModel().mainNavigationState.value.currentListLocation
}
check(location == MainNavigationListLocation.CALLS) {
"Expected VM CALLS post-recreate, got $location"
}
check(launched.recorder.createdArgs.isEmpty()) {
"Expected no ConversationFragment across tab recreate, got ${launched.recorder.createdArgs.size}"
}
}
}
@Test
fun recreate_midShareConversation_preservesShareData() {
val timestamp = System.currentTimeMillis()
val mimeType = "image/jpeg"
val blob = realBlob(byteArrayOf(0x01, 0x02, 0x03), mimeType)
val intent = shareToConversationIntent(
recipient = recipient,
blob = blob,
mimeType = mimeType,
shareDataTimestamp = timestamp
)
launchSync(intent).use { launched ->
val recorder = launched.recorder
await(timeoutMs = 10_000, description = "initial ConversationFragment") {
recorder.createdArgs.isNotEmpty()
}
val expectedName = runOnMainSync { Recipient.resolved(recipient).getDisplayName(context) }
awaitConversationTitle(launched, expectedName)
val initialCount = recorder.createdArgs.size
runOnMainSync { launched.activity.recreate() }
await(timeoutMs = 15_000, description = "ConversationFragment after recreate") {
recorder.createdArgs.size > initialCount
}
awaitConversationTitle(launched, expectedName)
val recreated = recorder.createdArgs.last()
check(recreated.shareDataTimestamp == timestamp) {
"shareDataTimestamp not preserved across recreate: $timestamp -> ${recreated.shareDataTimestamp}"
}
check(recreated.draftMedia == blob) {
"draftMedia not preserved across recreate: $blob -> ${recreated.draftMedia}"
}
}
}
// region Helpers
/**
* Mirrors [org.thoughtcrime.securesms.sharing.v2.ShareActivity.openConversation]. We
* deliberately drop the producer's `clearTop` flags (NEW_TASK | CLEAR_TOP | SINGLE_TOP)
* — they are launch-routing concerns that are incompatible with our lifecycle monitor.
*/
private fun shareToConversationIntent(
recipient: RecipientId,
blob: Uri,
mimeType: String,
draftText: String? = null,
shareDataTimestamp: Long = System.currentTimeMillis()
): Intent {
val builder = ConversationIntents.createBuilder(context, recipient, -1L).blockingGet()
val conversationIntent = builder
.withDataUri(blob)
.withDataType(mimeType)
.withMedia(emptyList())
.withDraftText(draftText)
.withStickerLocator(null)
.asBorderless(false)
.withShareDataTimestamp(shareDataTimestamp)
.build()
return Intent(context, MainActivity::class.java).apply {
action = ConversationIntents.ACTION
putExtras(conversationIntent)
}
}
/**
* Mirrors the image-share path through [org.thoughtcrime.securesms.sharing.v2.ShareActivity.openConversation]:
* a non-empty `media` list is what flips dispatch to `ShareOrDraftData.StartSendMedia`,
* which is what triggers the hop to the media-send screen.
*/
private fun shareImageIntent(recipient: RecipientId, media: Media): Intent {
val builder = ConversationIntents.createBuilder(context, recipient, -1L).blockingGet()
val conversationIntent = builder
.withDataUri(media.uri)
.withDataType(media.contentType)
.withMedia(listOf(media))
.withStickerLocator(null)
.asBorderless(false)
.withShareDataTimestamp(System.currentTimeMillis())
.build()
return Intent(context, MainActivity::class.java).apply {
action = ConversationIntents.ACTION
putExtras(conversationIntent)
}
}
/**
* Mirrors a text-only share. Empty media list + non-null draft text routes dispatch to
* `ShareOrDraftData.SetText`.
*/
private fun shareTextIntent(recipient: RecipientId, text: String): Intent {
val builder = ConversationIntents.createBuilder(context, recipient, -1L).blockingGet()
val conversationIntent = builder
.withMedia(emptyList())
.withDraftText(text)
.withStickerLocator(null)
.asBorderless(false)
.withShareDataTimestamp(System.currentTimeMillis())
.build()
return Intent(context, MainActivity::class.java).apply {
action = ConversationIntents.ACTION
putExtras(conversationIntent)
}
}
private fun notificationToConversationIntent(recipient: RecipientId): Intent {
val conversationIntent = ConversationIntents.createBuilder(context, recipient, -1L)
.blockingGet()
.build()
return Intent(context, MainActivity::class.java).apply {
action = ConversationIntents.ACTION
putExtras(conversationIntent)
}
}
private fun tabIntent(tab: MainNavigationListLocation): Intent {
return Intent(context, MainActivity::class.java)
.putExtra("STARTING_TAB", tab)
}
private fun detailLocationIntent(location: MainNavigationDetailLocation): Intent {
return Intent(context, MainActivity::class.java)
.putExtra("DETAIL_LOCATION", location)
}
private fun realBlob(bytes: ByteArray, mimeType: String): Uri {
return BlobProvider.getInstance()
.forData(bytes)
.withMimeType(mimeType)
.createForSingleSessionInMemory()
}
/**
* Build a [Media] backed by a real 1×1 JPEG. The media-send screen attempts to decode
* the image during MediaReviewFragment setup, so a fake byte array won't survive — we
* need genuine JPEG bytes for the fragment to reach the state where `R.id.recipient`
* is populated.
*/
private fun realJpegMedia(): Media {
val bitmap = Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888)
val bytes = ByteArrayOutputStream().use { stream ->
bitmap.compress(Bitmap.CompressFormat.JPEG, 90, stream)
stream.toByteArray()
}
bitmap.recycle()
val uri = realBlob(bytes, "image/jpeg")
return Media(
uri = uri,
contentType = "image/jpeg",
date = 0L,
width = 1,
height = 1,
size = bytes.size.toLong(),
duration = 0L,
isBorderless = false,
isVideoGif = false,
bucketId = null,
caption = null,
transformProperties = null,
fileName = null
)
}
/**
* Mirrors [org.thoughtcrime.securesms.deeplinks.DeepLinkEntryActivity]: bare clearTop
* plus a [Uri] in the data field.
*/
private fun deepLinkIntent(data: Uri): Intent {
return Intent(context, MainActivity::class.java).setData(data)
}
/**
* Synchronously launch [MainActivity] and return the running instance plus a fragment
* recorder wired up *before* the activity is created.
*
* We bypass [androidx.test.core.app.ActivityScenario] and
* [android.app.Instrumentation.startActivitySync] because both fail for our case:
* ActivityScenario's lifecycle tracker misses CREATED/STARTED/RESUMED for activities
* launched with a custom-action intent, and `startActivitySync` waits for main-thread
* idle which never arrives while MainActivity's composition + ConversationFragment
* setup keeps the looper busy.
*/
private fun launchSync(intent: Intent): LaunchedActivity {
val recorder = ConversationFragmentRecorder()
val app = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as Application
val resumed = CountDownLatch(1)
val activityHolder = arrayOfNulls<MainActivity>(1)
val allActivities: MutableList<Activity> = Collections.synchronizedList(mutableListOf())
val callbacks = object : Application.ActivityLifecycleCallbacks {
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {
allActivities += activity
if (activity is MainActivity) {
activityHolder[0] = activity
activity.supportFragmentManager.registerFragmentLifecycleCallbacks(recorder, true)
}
}
override fun onActivityStarted(activity: Activity) = Unit
override fun onActivityResumed(activity: Activity) {
if (activity is MainActivity) resumed.countDown()
}
override fun onActivityPaused(activity: Activity) = Unit
override fun onActivityStopped(activity: Activity) = Unit
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) = Unit
override fun onActivityDestroyed(activity: Activity) {
allActivities.remove(activity)
}
}
app.registerActivityLifecycleCallbacks(callbacks)
// Application.startActivity from a non-Activity context requires FLAG_ACTIVITY_NEW_TASK.
val launchIntent = Intent(intent).apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) }
try {
app.startActivity(launchIntent)
} catch (t: Throwable) {
app.unregisterActivityLifecycleCallbacks(callbacks)
throw t
}
if (!resumed.await(15, TimeUnit.SECONDS)) {
app.unregisterActivityLifecycleCallbacks(callbacks)
error("MainActivity did not reach RESUMED within 15s")
}
return LaunchedActivity(activityHolder, recorder, app, callbacks, allActivities)
}
private fun <T> runOnMainSync(block: () -> T): T {
var result: Result<T> = Result.failure(IllegalStateException("runOnMainSync did not produce a result"))
InstrumentationRegistry.getInstrumentation().runOnMainSync {
result = runCatching(block)
}
return result.getOrThrow()
}
private fun await(
timeoutMs: Long = 5_000,
pollMs: Long = 50,
description: String = "condition",
predicate: () -> Boolean
) {
val deadline = System.currentTimeMillis() + timeoutMs
while (System.currentTimeMillis() < deadline) {
if (runOnMainSync(predicate)) return
Thread.sleep(pollMs)
}
error("Timed out after ${timeoutMs}ms waiting for $description")
}
private fun MainActivity.mainNavigationViewModel(): MainNavigationViewModel {
return ViewModelProvider(this as FragmentActivity, MainNavigationViewModel.Factory())[MainNavigationViewModel::class.java]
}
/**
* Wait until the latest [ConversationFragment]'s composer EditText shows [expected].
* setDraftText is invoked off the InputReadyState/ShareOrDraftData reactive chain, so the
* text won't be present at fragment-create time — we have to poll the rendered view.
*/
private fun awaitComposerText(launched: LaunchedActivity, expected: String) {
await(timeoutMs = 15_000, description = "composer shows \"$expected\"") {
val frag = launched.recorder.latestActive() ?: return@await false
val view = frag.view ?: return@await false
view.findViewById<TextView>(R.id.embedded_text_editor)?.text?.toString() == expected
}
}
/**
* Wait until the latest [ConversationFragment]'s toolbar shows [expected]. Scoped through
* R.id.conversation_title_view to avoid colliding with other R.id.title uses.
*/
private fun awaitConversationTitle(launched: LaunchedActivity, expected: String) {
await(timeoutMs = 15_000, description = "conversation title shows \"$expected\"") {
val frag = launched.recorder.latestActive() ?: return@await false
val view = frag.view ?: return@await false
val titleHost = view.findViewById<View>(R.id.conversation_title_view) ?: return@await false
titleHost.findViewById<TextView>(R.id.title)?.text?.toString() == expected
}
}
/**
* MainActivity hosts each tab as a different [Fragment] via Compose's `AndroidFragment`
* (see MainActivity.kt:662-698). The user sees the content of whichever one is currently
* attached, so a tab assertion that reads the FragmentManager is a real user-visible
* signal — strictly stronger than reading the VM's `currentListLocation`.
*/
private fun listFragmentClass(location: MainNavigationListLocation): Class<out Fragment> = when (location) {
MainNavigationListLocation.CHATS -> ConversationListFragment::class.java
MainNavigationListLocation.ARCHIVE -> ConversationListArchiveFragment::class.java
MainNavigationListLocation.CALLS -> CallLogFragment::class.java
MainNavigationListLocation.STORIES -> StoriesLandingFragment::class.java
}
private fun awaitListFragment(launched: LaunchedActivity, location: MainNavigationListLocation) {
val expected = listFragmentClass(location)
try {
await(timeoutMs = 10_000, description = "${expected.simpleName} attached for $location") {
launched.recorder.isAttached(expected)
}
} catch (e: IllegalStateException) {
throw IllegalStateException("${e.message}; currently attached: ${launched.recorder.attachedNames()}", e)
}
}
// endregion
// region Types
/**
* Records every [ConversationFragment] added under an activity's fragment manager,
* capturing each fragment's arguments at create-time.
*/
private class ConversationFragmentRecorder : FragmentManager.FragmentLifecycleCallbacks() {
val createdArgs: MutableList<ConversationArgs> = mutableListOf()
val allCreated: MutableList<String> = mutableListOf()
private val active: MutableList<ConversationFragment> = mutableListOf()
private val attached: MutableList<Fragment> = mutableListOf()
var destroyedCount: Int = 0
private set
/** Most-recently-added still-attached ConversationFragment, or null. Main-thread read. */
fun latestActive(): ConversationFragment? = active.lastOrNull()
/**
* Exact class match (not [Class.isInstance]) — `ConversationListArchiveFragment`
* extends `ConversationListFragment`, so an `isInstance` check for CHATS would falsely
* pass when the archive list is attached.
*/
fun isAttached(clazz: Class<out Fragment>): Boolean = attached.any { it::class.java == clazz }
fun attachedNames(): List<String> = attached.map { it::class.simpleName ?: it::class.java.name }
override fun onFragmentCreated(fm: FragmentManager, f: Fragment, savedInstanceState: android.os.Bundle?) {
allCreated += f::class.simpleName ?: f::class.java.name
attached += f
if (f is ConversationFragment) {
createdArgs += ConversationIntents.readArgsFromBundle(f.requireArguments())
active += f
}
}
override fun onFragmentDestroyed(fm: FragmentManager, f: Fragment) {
attached.remove(f)
if (f is ConversationFragment) {
active.remove(f)
destroyedCount++
}
}
}
private class LaunchedActivity(
private val activityHolder: Array<MainActivity?>,
val recorder: ConversationFragmentRecorder,
private val app: Application,
private val callbacks: Application.ActivityLifecycleCallbacks,
private val allActivities: MutableList<Activity>
) : AutoCloseable {
/**
* Always returns the *latest* MainActivity instance so reads follow `recreate()`.
*/
val activity: MainActivity get() = checkNotNull(activityHolder[0]) { "No active MainActivity" }
/**
* Poll until an activity of [clazz] has been created, then return it. Used to assert
* the share-image flow's hop into MediaSelectionActivity.
*/
fun <T : Activity> awaitActivity(clazz: Class<T>, timeoutMs: Long = 10_000): T {
val deadline = System.currentTimeMillis() + timeoutMs
while (System.currentTimeMillis() < deadline) {
val match = synchronized(allActivities) {
allActivities.firstOrNull { clazz.isInstance(it) }
}
if (match != null) return clazz.cast(match)!!
Thread.sleep(50)
}
val seen = synchronized(allActivities) { allActivities.map { it::class.simpleName } }
error("Timed out after ${timeoutMs}ms waiting for ${clazz.simpleName}; saw $seen")
}
fun nonMainActivities(): List<Activity> = synchronized(allActivities) {
allActivities.filter { it !is MainActivity }.toList()
}
override fun close() {
// Don't wait for looper idle — secondary activities (e.g. MediaSelectionActivity
// opened by share processing) can keep it busy indefinitely. Finish every tracked
// activity so subsequent tests start from a clean slate.
val toFinish = synchronized(allActivities) { allActivities.toList() }
if (toFinish.isNotEmpty()) {
InstrumentationRegistry.getInstrumentation().runOnMainSync {
toFinish.forEach { it.finish() }
}
}
app.unregisterActivityLifecycleCallbacks(callbacks)
}
}
// endregion
}
@@ -75,6 +75,14 @@ class EditMessageSyncProcessorTest {
.timestamp(originalTimestamp)
.expirationStartTimestamp(originalTimestamp)
.message(content.dataMessage)
.unidentifiedStatus(
listOf(
SyncMessage.Sent.UnidentifiedDeliveryStatus.Builder()
.destinationServiceIdBinary(toRecipient.requireServiceId().toByteString())
.unidentified(true)
.build()
)
)
.build()
).build()
).build()
@@ -100,6 +108,14 @@ class EditMessageSyncProcessorTest {
.targetSentTimestamp(originalTimestamp)
.build()
)
.unidentifiedStatus(
listOf(
SyncMessage.Sent.UnidentifiedDeliveryStatus.Builder()
.destinationServiceIdBinary(toRecipient.requireServiceId().toByteString())
.unidentified(true)
.build()
)
)
.build()
).build()
).build()
@@ -13,7 +13,6 @@ import assertk.assertions.isEqualTo
import assertk.assertions.isGreaterThan
import assertk.assertions.isNotEqualTo
import assertk.assertions.isNotNull
import assertk.assertions.isNull
import org.junit.After
import org.junit.Before
import org.junit.Ignore
@@ -25,6 +24,7 @@ import org.signal.core.util.Util
import org.signal.core.util.logging.Log
import org.signal.core.util.update
import org.signal.core.util.withinTransaction
import org.signal.network.api.AttachmentUploadResult
import org.thoughtcrime.securesms.attachments.Attachment
import org.thoughtcrime.securesms.attachments.DatabaseAttachment
import org.thoughtcrime.securesms.database.AttachmentTable
@@ -37,7 +37,6 @@ import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.testing.MessageContentFuzzer.DeleteForMeSync
import org.thoughtcrime.securesms.testing.SignalActivityRule
import org.thoughtcrime.securesms.util.IdentityUtil
import org.whispersystems.signalservice.api.attachment.AttachmentUploadResult
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentRemoteId
import java.util.UUID
@@ -166,7 +165,7 @@ class SyncMessageProcessorTest_synchronizeDeleteForMe {
assertThat(messageCount).isEqualTo(0)
val threadRecord = SignalDatabase.threads.getThreadRecord(threadId)
assertThat(threadRecord).isNull()
assertThat(threadRecord?.active).isEqualTo(false)
}
@Test
@@ -245,7 +244,7 @@ class SyncMessageProcessorTest_synchronizeDeleteForMe {
// THEN
assertThat(SignalDatabase.messages.getMessageCountForThread(threadId)).isEqualTo(0)
assertThat(SignalDatabase.threads.getThreadRecord(threadId)).isNull()
assertThat(SignalDatabase.threads.getThreadRecord(threadId)?.active).isEqualTo(false)
}
@Test
@@ -304,7 +303,7 @@ class SyncMessageProcessorTest_synchronizeDeleteForMe {
// THEN
assertThat(SignalDatabase.messages.getMessageCountForThread(threadId)).isEqualTo(0)
assertThat(SignalDatabase.threads.getThreadRecord(threadId)).isNull()
assertThat(SignalDatabase.threads.getThreadRecord(threadId)?.active).isEqualTo(false)
harness.inMemoryLogger.flush()
assertThat(harness.inMemoryLogger.entries().filter { it.message?.contains("Using backup non-expiring messages") == true }).hasSize(1)
@@ -344,7 +343,7 @@ class SyncMessageProcessorTest_synchronizeDeleteForMe {
// THEN
assertThat(SignalDatabase.messages.getMessageCountForThread(threadId)).isEqualTo(0)
assertThat(SignalDatabase.threads.getThreadRecord(threadId)).isNull()
assertThat(SignalDatabase.threads.getThreadRecord(threadId)?.active).isEqualTo(false)
}
@Test
@@ -376,7 +375,7 @@ class SyncMessageProcessorTest_synchronizeDeleteForMe {
// THEN
assertThat(SignalDatabase.messages.getMessageCountForThread(threadId)).isEqualTo(3)
assertThat(SignalDatabase.threads.getThreadRecord(threadId)).isNotNull()
assertThat(SignalDatabase.threads.getThreadRecord(threadId)?.active).isEqualTo(true)
}
@Test
@@ -405,7 +404,7 @@ class SyncMessageProcessorTest_synchronizeDeleteForMe {
// THEN
assertThat(SignalDatabase.messages.getMessageCountForThread(threadId)).isEqualTo(0)
assertThat(SignalDatabase.threads.getThreadRecord(threadId)).isNull()
assertThat(SignalDatabase.threads.getThreadRecord(threadId)?.active).isEqualTo(false)
}
@Test
@@ -435,7 +434,7 @@ class SyncMessageProcessorTest_synchronizeDeleteForMe {
// THEN
threadIds.forEach {
assertThat(SignalDatabase.messages.getMessageCountForThread(it)).isEqualTo(0)
assertThat(SignalDatabase.threads.getThreadRecord(it)).isNull()
assertThat(SignalDatabase.threads.getThreadRecord(it)?.active).isEqualTo(false)
}
}
@@ -463,7 +462,7 @@ class SyncMessageProcessorTest_synchronizeDeleteForMe {
// THEN
assertThat(SignalDatabase.messages.getMessageCountForThread(aliceThreadId)).isEqualTo(0)
assertThat(SignalDatabase.threads.getThreadRecord(aliceThreadId)).isNull()
assertThat(SignalDatabase.threads.getThreadRecord(aliceThreadId)?.active).isEqualTo(false)
}
@Ignore("counts are consistent for some reason")
@@ -527,10 +526,10 @@ class SyncMessageProcessorTest_synchronizeDeleteForMe {
// THEN
assertThat(SignalDatabase.messages.getMessageCountForThread(aliceThreadId)).isEqualTo(0)
assertThat(SignalDatabase.threads.getThreadRecord(aliceThreadId)).isNull()
assertThat(SignalDatabase.threads.getThreadRecord(aliceThreadId)?.active).isEqualTo(false)
assertThat(SignalDatabase.messages.getMessageCountForThread(groupThreadId)).isEqualTo(0)
assertThat(SignalDatabase.threads.getThreadRecord(groupThreadId)).isNull()
assertThat(SignalDatabase.threads.getThreadRecord(groupThreadId)?.active).isEqualTo(false)
}
@Test
@@ -551,7 +550,7 @@ class SyncMessageProcessorTest_synchronizeDeleteForMe {
// THEN
assertThat(SignalDatabase.messages.getMessageCountForThread(threadId)).isEqualTo(20)
assertThat(SignalDatabase.threads.getThreadRecord(threadId)).isNotNull()
assertThat(SignalDatabase.threads.getThreadRecord(threadId)?.active).isEqualTo(true)
harness.inMemoryLogger.flush()
assertThat(harness.inMemoryLogger.entries().filter { it.message?.contains("Thread is not local only") == true }).hasSize(1)
@@ -665,7 +664,7 @@ class SyncMessageProcessorTest_synchronizeDeleteForMe {
updatedAttachments = SignalDatabase.attachments.getAttachmentsForMessage(message1.messageId)
assertThat(updatedAttachments).isEmpty()
assertThat(SignalDatabase.threads.getThreadRecord(threadId)).isNull()
assertThat(SignalDatabase.threads.getThreadRecord(threadId)?.active).isEqualTo(false)
}
private fun DatabaseAttachment.copy(
@@ -0,0 +1,353 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.messages
import androidx.test.ext.junit.runners.AndroidJUnit4
import assertk.assertThat
import assertk.assertions.isEqualTo
import assertk.assertions.isNotNull
import assertk.assertions.isTrue
import okio.ByteString
import okio.ByteString.Companion.toByteString
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.signal.core.models.ServiceId
import org.signal.core.util.UuidUtil
import org.signal.core.util.orNull
import org.signal.libsignal.protocol.IdentityKeyPair
import org.signal.libsignal.protocol.SignalProtocolAddress
import org.signal.libsignal.protocol.state.KyberPreKeyRecord
import org.signal.libsignal.protocol.state.SignedPreKeyRecord
import org.thoughtcrime.securesms.crypto.PreKeyUtil
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.testing.MessageContentFuzzer
import org.thoughtcrime.securesms.testing.SignalActivityRule
import org.whispersystems.signalservice.api.push.SignalServiceAddress
import org.whispersystems.signalservice.internal.push.Content
import org.whispersystems.signalservice.internal.push.SyncMessage
import java.util.UUID
@Suppress("ClassName")
@RunWith(AndroidJUnit4::class)
class SyncMessageProcessorTest_synchronizePniChangeNumber {
@get:Rule
val harness = SignalActivityRule(createGroup = true)
private lateinit var messageHelper: MessageHelper
private val newPniUuid: UUID = UUID.randomUUID()
private val newPni: ServiceId.PNI = ServiceId.PNI.from(newPniUuid)
// 16-byte raw UUID — matches the actual wire format the server sends (per proto comment and
// iOS/Desktop behavior). Do NOT use `newPni.toByteString()` here — that produces libsignal's
// 17-byte ServiceIdBinary form, which is a different format.
private val newPniBytes: ByteString = UuidUtil.toByteArray(newPniUuid).toByteString()
private val newE164 = "+15555550199"
private val newPniIdentity: IdentityKeyPair = IdentityKeyPair.generate()
private val newSignedPreKey: SignedPreKeyRecord = PreKeyUtil.generateSignedPreKey(1234, newPniIdentity.privateKey)
private val newLastResortKyber: KyberPreKeyRecord = PreKeyUtil.generateLastResortKyberPreKey(5678, newPniIdentity.privateKey)
private val newRegistrationId = 4242
@Before
fun setUp() {
messageHelper = MessageHelper(harness)
SignalStore.account.deviceId = 2
}
@After
fun tearDown() {
messageHelper.tearDown()
}
@Test
fun appliesAllStateOnHappyPath() {
sendPniChangeNumber()
assertThat(SignalStore.account.e164).isEqualTo(newE164)
assertThat(SignalStore.account.pni).isEqualTo(newPni)
assertThat(SignalStore.account.pniRegistrationId).isEqualTo(newRegistrationId)
assertThat(SignalStore.account.pniIdentityKey.publicKey.serialize().toByteString())
.isEqualTo(newPniIdentity.publicKey.serialize().toByteString())
assertThat(SignalStore.account.pniPreKeys.activeSignedPreKeyId).isEqualTo(newSignedPreKey.id)
assertThat(SignalStore.account.pniPreKeys.isSignedPreKeyRegistered).isTrue()
assertThat(SignalStore.account.pniPreKeys.lastResortKyberPreKeyId).isEqualTo(newLastResortKyber.id)
assertThat(SignalStore.misc.forcePniSignedPreKeyRotation).isTrue()
val self = Recipient.self().fresh()
assertThat(self.requireE164()).isEqualTo(newE164)
assertThat(self.pni.orNull()).isEqualTo(newPni)
val pniProtocolStore = AppDependencies.protocolStore.pni()
val storedSigned = pniProtocolStore.loadSignedPreKey(newSignedPreKey.id)
assertThat(storedSigned.serialize().toByteString()).isEqualTo(newSignedPreKey.serialize().toByteString())
val storedKyber = pniProtocolStore.loadLastResortKyberPreKeys().firstOrNull { it.id == newLastResortKyber.id }
assertThat(storedKyber).isNotNull()
assertThat(storedKyber!!.serialize().toByteString()).isEqualTo(newLastResortKyber.serialize().toByteString())
// The IdentityTable cache is keyed by ServiceId string, not RecipientId — for self, that's
// separate ACI and PNI rows. We want the PNI row, so look it up by the new PNI directly.
val selfPniIdentity = pniProtocolStore.getIdentity(SignalProtocolAddress(newPni.toString(), SignalServiceAddress.DEFAULT_DEVICE_ID))
assertThat(selfPniIdentity).isNotNull()
assertThat(selfPniIdentity!!.publicKey.serialize().toByteString())
.isEqualTo(newPniIdentity.publicKey.serialize().toByteString())
}
@Test
fun appliesStateWhenLastResortKyberAbsent() {
val original = captureOriginalState()
sendPniChangeNumber(lastResortKyberPreKey = null)
assertThat(SignalStore.account.e164).isEqualTo(newE164)
assertThat(SignalStore.account.pni).isEqualTo(newPni)
assertThat(SignalStore.account.pniRegistrationId).isEqualTo(newRegistrationId)
assertThat(SignalStore.account.pniPreKeys.activeSignedPreKeyId).isEqualTo(newSignedPreKey.id)
assertThat(SignalStore.account.pniPreKeys.isSignedPreKeyRegistered).isTrue()
// No kyber was supplied, so kyber metadata should be unchanged.
assertThat(SignalStore.account.pniPreKeys.lastResortKyberPreKeyId).isEqualTo(original.lastResortKyberPreKeyId)
assertThat(SignalStore.misc.forcePniSignedPreKeyRotation).isTrue()
}
@Test
fun bailsWhenPrimaryDevice() {
SignalStore.account.deviceId = SignalServiceAddress.DEFAULT_DEVICE_ID
val original = captureOriginalState()
sendPniChangeNumber()
assertOriginalStatePreserved(original)
}
@Test
fun bailsWhenSourceIsNotPrimaryDevice() {
val original = captureOriginalState()
sendPniChangeNumber(sourceDeviceId = 3)
assertOriginalStatePreserved(original)
}
@Test
fun bailsWhenEnvelopePniMissing() {
val original = captureOriginalState()
sendPniChangeNumber(envelopePniBinary = null)
assertOriginalStatePreserved(original)
}
@Test
fun bailsWhenIdentityKeyPairMissing() {
val original = captureOriginalState()
sendPniChangeNumber(identityKeyPair = null)
assertOriginalStatePreserved(original)
}
@Test
fun bailsWhenSignedPreKeyMissing() {
val original = captureOriginalState()
sendPniChangeNumber(signedPreKey = null)
assertOriginalStatePreserved(original)
}
@Test
fun bailsWhenRegistrationIdMissing() {
val original = captureOriginalState()
sendPniChangeNumber(registrationId = null)
assertOriginalStatePreserved(original)
}
@Test
fun bailsWhenRegistrationIdZero() {
val original = captureOriginalState()
sendPniChangeNumber(registrationId = 0)
assertOriginalStatePreserved(original)
}
@Test
fun bailsWhenNewE164Missing() {
val original = captureOriginalState()
sendPniChangeNumber(e164 = null)
assertOriginalStatePreserved(original)
}
@Test
fun bailsWhenNewE164Empty() {
val original = captureOriginalState()
sendPniChangeNumber(e164 = "")
assertOriginalStatePreserved(original)
}
@Test
fun bailsWhenNewE164NotValid() {
val original = captureOriginalState()
sendPniChangeNumber(e164 = "not a phone number")
assertOriginalStatePreserved(original)
}
@Test
fun bailsOnMalformedIdentityKeyPair() {
val original = captureOriginalState()
sendPniChangeNumber(identityKeyPair = malformedBytes())
assertOriginalStatePreserved(original)
}
@Test
fun bailsOnMalformedSignedPreKey() {
val original = captureOriginalState()
sendPniChangeNumber(signedPreKey = malformedBytes())
assertOriginalStatePreserved(original)
}
@Test
fun bailsOnMalformedLastResortKyber() {
val original = captureOriginalState()
sendPniChangeNumber(lastResortKyberPreKey = malformedBytes())
assertOriginalStatePreserved(original)
}
@Test
fun skipsRedeliveryWhenPniAlreadyMatches() {
sendPniChangeNumber()
val afterFirstApply = captureOriginalState()
val otherIdentity = IdentityKeyPair.generate()
val otherSignedPreKey = PreKeyUtil.generateSignedPreKey(9999, otherIdentity.privateKey)
sendPniChangeNumber(
identityKeyPair = otherIdentity.serialize().toByteString(),
signedPreKey = otherSignedPreKey.serialize().toByteString(),
e164 = "+15555550100",
timestamp = messageHelper.nextStartTime() + 1000
)
assertOriginalStatePreserved(afterFirstApply)
}
@Test
fun bailsWhenServerTimestampStale() {
sendPniChangeNumber()
val afterFirstApply = captureOriginalState()
val otherPniUuid = UUID.randomUUID()
val otherPniBytes = UuidUtil.toByteArray(otherPniUuid).toByteString()
sendPniChangeNumber(
envelopePniBinary = otherPniBytes,
e164 = "+15555550100",
timestamp = messageHelper.nextStartTime() - 100_000L
)
assertOriginalStatePreserved(afterFirstApply)
}
private fun captureOriginalState(): OriginalState {
val self = Recipient.self().fresh()
return OriginalState(
e164 = SignalStore.account.e164,
pni = SignalStore.account.pni,
pniRegistrationId = SignalStore.account.pniRegistrationId,
isSignedPreKeyRegistered = SignalStore.account.pniPreKeys.isSignedPreKeyRegistered,
activeSignedPreKeyId = SignalStore.account.pniPreKeys.activeSignedPreKeyId,
lastResortKyberPreKeyId = SignalStore.account.pniPreKeys.lastResortKyberPreKeyId,
pniIdentityPublicKey = SignalStore.account.pniIdentityKey.publicKey.serialize().toByteString(),
selfE164 = self.e164.orNull(),
selfPni = self.pni.orNull(),
forcePniSignedPreKeyRotation = SignalStore.misc.forcePniSignedPreKeyRotation
)
}
private fun assertOriginalStatePreserved(original: OriginalState) {
assertThat(SignalStore.account.e164).isEqualTo(original.e164)
assertThat(SignalStore.account.pni).isEqualTo(original.pni)
assertThat(SignalStore.account.pniRegistrationId).isEqualTo(original.pniRegistrationId)
assertThat(SignalStore.account.pniPreKeys.isSignedPreKeyRegistered).isEqualTo(original.isSignedPreKeyRegistered)
assertThat(SignalStore.account.pniPreKeys.activeSignedPreKeyId).isEqualTo(original.activeSignedPreKeyId)
assertThat(SignalStore.account.pniPreKeys.lastResortKyberPreKeyId).isEqualTo(original.lastResortKyberPreKeyId)
assertThat(SignalStore.account.pniIdentityKey.publicKey.serialize().toByteString())
.isEqualTo(original.pniIdentityPublicKey)
assertThat(SignalStore.misc.forcePniSignedPreKeyRotation).isEqualTo(original.forcePniSignedPreKeyRotation)
val self = Recipient.self().fresh()
assertThat(self.e164.orNull()).isEqualTo(original.selfE164)
assertThat(self.pni.orNull()).isEqualTo(original.selfPni)
}
private data class OriginalState(
val e164: String?,
val pni: ServiceId.PNI?,
val pniRegistrationId: Int,
val isSignedPreKeyRegistered: Boolean,
val activeSignedPreKeyId: Int,
val lastResortKyberPreKeyId: Int,
val pniIdentityPublicKey: ByteString,
val selfE164: String?,
val selfPni: ServiceId.PNI?,
val forcePniSignedPreKeyRotation: Boolean
)
private fun malformedBytes(): ByteString = byteArrayOf(0x00, 0x01, 0x02).toByteString()
private fun sendPniChangeNumber(
identityKeyPair: ByteString? = newPniIdentity.serialize().toByteString(),
signedPreKey: ByteString? = newSignedPreKey.serialize().toByteString(),
lastResortKyberPreKey: ByteString? = newLastResortKyber.serialize().toByteString(),
registrationId: Int? = newRegistrationId,
e164: String? = newE164,
envelopePniBinary: ByteString? = newPniBytes,
sourceDeviceId: Int = SignalServiceAddress.DEFAULT_DEVICE_ID,
timestamp: Long = messageHelper.nextStartTime()
) {
val content = Content(
syncMessage = SyncMessage(
pniChangeNumber = SyncMessage.PniChangeNumber(
identityKeyPair = identityKeyPair,
signedPreKey = signedPreKey,
lastResortKyberPreKey = lastResortKyberPreKey,
registrationId = registrationId,
newE164 = e164
)
)
)
val envelope = MessageContentFuzzer.envelope(
timestamp = timestamp,
updatedPniBinary = envelopePniBinary
)
messageHelper.processor.process(
envelope = envelope,
content = content,
metadata = MessageContentFuzzer.envelopeMetadata(harness.self.id, harness.self.id, sourceDeviceId = sourceDeviceId),
serverDeliveredTimestamp = timestamp + 10
)
}
}
@@ -0,0 +1,27 @@
package org.thoughtcrime.securesms.messages.incomingmessageobserver
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.thoughtcrime.securesms.testing.incomingmessageobserver.IncomingMessageObserverAssertions.assertMessageReceived
import org.thoughtcrime.securesms.testing.incomingmessageobserver.IncomingMessageObserverAssertions.assertNoMessageReceived
import org.thoughtcrime.securesms.testing.incomingmessageobserver.IncomingMessageObserverRule
@RunWith(AndroidJUnit4::class)
class DecryptionErrorTest {
@get:Rule
val rule = IncomingMessageObserverRule(peerCount = 2)
@Test
fun malformedEnvelope_dropsMessage_butPipelineRecovers() {
val peer = rule.peers[0]
rule.deliver { malformedEnvelope() from peer }
assertNoMessageReceived(from = peer, body = "subsequent")
rule.deliver { text("subsequent") from peer }
assertMessageReceived(from = peer, body = "subsequent")
}
}
@@ -0,0 +1,39 @@
package org.thoughtcrime.securesms.messages.incomingmessageobserver
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.thoughtcrime.securesms.testing.incomingmessageobserver.IncomingMessageObserverAssertions.assertGroupMessageReceived
import org.thoughtcrime.securesms.testing.incomingmessageobserver.IncomingMessageObserverRule
@RunWith(AndroidJUnit4::class)
class IncomingGroupMessageTest {
@get:Rule
val rule = IncomingMessageObserverRule(peerCount = 5)
@Test
fun deliveredGroupText_isPersistedInGroupThread() {
val group = rule.testGroup
rule.deliver { groupText("hello group", group = group) from rule.peers[0] }
assertGroupMessageReceived(from = rule.peers[0], group = group, body = "hello group")
}
@Test
fun multipleGroupMembers_messagesPersistedFromEach() {
val group = rule.testGroup
rule.deliver {
groupText("from peer 0", group = group) from rule.peers[0]
groupText("from peer 1", group = group) from rule.peers[1]
groupText("from peer 2", group = group) from rule.peers[2]
}
assertGroupMessageReceived(from = rule.peers[0], group = group, body = "from peer 0")
assertGroupMessageReceived(from = rule.peers[1], group = group, body = "from peer 1")
assertGroupMessageReceived(from = rule.peers[2], group = group, body = "from peer 2")
}
}
@@ -0,0 +1,22 @@
package org.thoughtcrime.securesms.messages.incomingmessageobserver
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.thoughtcrime.securesms.testing.incomingmessageobserver.IncomingMessageObserverAssertions.assertMessageReceived
import org.thoughtcrime.securesms.testing.incomingmessageobserver.IncomingMessageObserverRule
@RunWith(AndroidJUnit4::class)
class IncomingTextMessageTest {
@get:Rule
val rule = IncomingMessageObserverRule(peerCount = 2)
@Test
fun deliveredOneToOneText_isPersisted() {
rule.deliver { text("hello world") from rule.peers[0] }
assertMessageReceived(from = rule.peers[0], body = "hello world")
}
}
@@ -8,9 +8,9 @@ package org.thoughtcrime.securesms.testing
import androidx.test.platform.app.InstrumentationRegistry
import io.mockk.every
import org.junit.rules.ExternalResource
import org.signal.core.util.JsonUtils
import org.signal.network.NetworkResult
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.util.JsonUtils
import org.whispersystems.signalservice.api.NetworkResult
import org.whispersystems.signalservice.internal.push.SubscriptionsConfiguration
/**
@@ -41,11 +41,12 @@ object MessageContentFuzzer {
/**
* Create an [Envelope].
*/
fun envelope(timestamp: Long, serverGuid: UUID = UUID.randomUUID()): Envelope {
fun envelope(timestamp: Long, serverGuid: UUID = UUID.randomUUID(), updatedPniBinary: ByteString? = null): Envelope {
return Envelope.Builder()
.clientTimestamp(timestamp)
.serverTimestamp(timestamp + 5)
.serverGuidBinary(serverGuid.toByteArray().toByteString())
.also { if (updatedPniBinary != null) it.updatedPniBinary(updatedPniBinary) }
.build()
}
@@ -17,7 +17,6 @@ import org.whispersystems.signalservice.internal.push.PreKeyResponse
import org.whispersystems.signalservice.internal.push.PreKeyResponseItem
import org.whispersystems.signalservice.internal.push.PushServiceSocket
import org.whispersystems.signalservice.internal.push.RegistrationSessionMetadataJson
import org.whispersystems.signalservice.internal.push.SenderCertificate
import org.whispersystems.signalservice.internal.push.VerifyAccountResponse
import org.whispersystems.signalservice.internal.push.WhoAmIResponse
import java.security.SecureRandom
@@ -27,8 +26,6 @@ import java.security.SecureRandom
*/
object MockProvider {
val senderCertificate = SenderCertificate().apply { certificate = ByteArray(0) }
val lockedFailure = PushServiceSocket.RegistrationLockFailure().apply {
svr1Credentials = AuthCredentials.create("username", "password")
svr2Credentials = AuthCredentials.create("username", "password")
@@ -0,0 +1,71 @@
package org.thoughtcrime.securesms.testing.incomingmessageobserver
import assertk.assertThat
import assertk.assertions.isEqualTo
import assertk.assertions.isNotNull
import assertk.assertions.isTrue
import org.signal.benchmark.setup.OtherClient
import org.thoughtcrime.securesms.database.MessageTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.whispersystems.signalservice.api.push.SignalServiceAddress
/**
* Reads database state produced by [IncomingMessageObserverRule]-driven tests. Import members
* individually (e.g. `import …IncomingMessageObserverAssertions.assertMessageReceived`) so test
* bodies stay terse.
*/
object IncomingMessageObserverAssertions {
fun OtherClient.recipientId(): RecipientId = Recipient.externalPush(SignalServiceAddress(serviceId, e164)).id
fun findIncomingMessage(from: OtherClient, body: String): MessageRecord? {
val threadId = SignalDatabase.threads.getThreadIdFor(from.recipientId()) ?: return null
return SignalDatabase.messages.getConversation(threadId).use { cursor ->
MessageTable.MmsReader(cursor).use { reader -> reader.firstOrNull { it.body == body } }
}
}
fun findIncomingGroupMessage(from: OtherClient, group: GroupHandle, body: String): MessageRecord? {
val threadId = SignalDatabase.threads.getThreadIdFor(group.recipientId) ?: return null
return SignalDatabase.messages.getConversation(threadId).use { cursor ->
MessageTable.MmsReader(cursor).use { reader ->
reader.firstOrNull { it.body == body && it.fromRecipient.id == from.recipientId() }
}
}
}
fun assertMessageReceived(from: OtherClient, body: String) {
val record = findIncomingMessage(from, body)
assertThat(record, "incoming message with body \"$body\" from ${from.serviceId} not found").isNotNull()
assertThat(record!!.fromRecipient.id, "incoming message sender mismatch for body \"$body\"").isEqualTo(from.recipientId())
}
fun assertGroupMessageReceived(from: OtherClient, group: GroupHandle, body: String) {
val record = findIncomingGroupMessage(from, group, body)
assertThat(record, "group message \"$body\" from ${from.serviceId} in ${group.groupId} not found").isNotNull()
}
fun assertNoMessageReceived(from: OtherClient, body: String) {
val record = findIncomingMessage(from, body)
assertThat(record == null, "expected no message with body \"$body\" from ${from.serviceId}, but found one").isTrue()
}
fun assertNoMessagesInThread(recipientId: RecipientId) {
val threadId = SignalDatabase.threads.getThreadIdFor(recipientId) ?: return
val count = SignalDatabase.messages.getConversation(threadId).use { cursor -> cursor.count }
assertThat(count, "expected thread for $recipientId to be empty, but message count was").isEqualTo(0)
}
fun assertDeliveryReceipt(outgoingMessageId: Long) {
val record = SignalDatabase.messages.getMessageRecord(outgoingMessageId)
assertThat(record.hasDeliveryReceipt(), "expected delivery receipt on outgoing message $outgoingMessageId, but none recorded").isTrue()
}
fun assertReadReceipt(outgoingMessageId: Long) {
val record = SignalDatabase.messages.getMessageRecord(outgoingMessageId)
assertThat(record.hasReadReceipt(), "expected read receipt on outgoing message $outgoingMessageId, but none recorded").isTrue()
}
}
@@ -0,0 +1,63 @@
package org.thoughtcrime.securesms.testing.incomingmessageobserver
import android.app.Application
import org.signal.benchmark.setup.NoOpJob
import org.signal.core.util.UptimeSleepTimer
import org.signal.libsignal.net.Network
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.dependencies.ApplicationDependencyProvider
import org.thoughtcrime.securesms.dependencies.InstrumentationApplicationDependencyProvider
import org.thoughtcrime.securesms.jobmanager.JobManager
import org.thoughtcrime.securesms.jobs.JobManagerFactories
import org.whispersystems.signalservice.api.websocket.SignalWebSocket
import org.whispersystems.signalservice.internal.configuration.SignalServiceConfiguration
import org.whispersystems.signalservice.internal.websocket.BenchmarkWebSocketConnection
import java.util.function.Supplier
import kotlin.time.Duration.Companion.seconds
/**
* Dependency provider used by [org.thoughtcrime.securesms.IncomingMessageObserverInstrumentationApplicationContext].
* Composes [InstrumentationApplicationDependencyProvider] (so existing mocks for the account /
* archive / donations / billing APIs are reused) and overrides:
*
* - the auth and unauth websocket factories with [BenchmarkWebSocketConnection], so tests can
* inject encrypted envelopes through the real ingest pipeline;
* - the job manager, swapping the startup network jobs handled by [NoOpJob.replaceFactories]
* to no-ops so they can't fire against unstubbed mocks during a test.
*/
class IncomingMessageObserverDependencyProvider(
private val application: Application,
default: ApplicationDependencyProvider
) : AppDependencies.Provider by InstrumentationApplicationDependencyProvider(application, default) {
override fun provideAuthWebSocket(
signalServiceConfigurationSupplier: Supplier<SignalServiceConfiguration>,
libSignalNetworkSupplier: Supplier<Network>
): SignalWebSocket.AuthenticatedWebSocket {
return SignalWebSocket.AuthenticatedWebSocket(
connectionFactory = { BenchmarkWebSocketConnection.createAuthInstance() },
canConnect = { true },
sleepTimer = UptimeSleepTimer(),
disconnectTimeoutMs = 15.seconds.inWholeMilliseconds
)
}
override fun provideUnauthWebSocket(
signalServiceConfigurationSupplier: Supplier<SignalServiceConfiguration>,
libSignalNetworkSupplier: Supplier<Network>
): SignalWebSocket.UnauthenticatedWebSocket {
return SignalWebSocket.UnauthenticatedWebSocket(
connectionFactory = { BenchmarkWebSocketConnection.createUnauthInstance() },
canConnect = { true },
sleepTimer = UptimeSleepTimer(),
disconnectTimeoutMs = 15.seconds.inWholeMilliseconds
)
}
override fun provideJobManager(configurationBuilder: JobManager.Configuration.Builder): JobManager {
val config = configurationBuilder
.setJobFactories(NoOpJob.replaceFactories(JobManagerFactories.getJobFactories(application)))
.build()
return JobManager(application, config)
}
}
@@ -0,0 +1,201 @@
package org.thoughtcrime.securesms.testing.incomingmessageobserver
import okio.ByteString.Companion.toByteString
import org.junit.Assume
import org.junit.rules.ExternalResource
import org.signal.benchmark.setup.Generator
import org.signal.benchmark.setup.Harness
import org.signal.benchmark.setup.OtherClient
import org.signal.benchmark.setup.TestUsers
import org.signal.core.util.logging.Log
import org.signal.network.websocket.WebSocketRequestMessage
import org.thoughtcrime.securesms.IncomingMessageObserverInstrumentationApplicationContext
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.jobmanager.Job
import org.thoughtcrime.securesms.jobmanager.JobTracker
import org.thoughtcrime.securesms.jobs.MarkerJob
import org.thoughtcrime.securesms.jobs.PushProcessMessageJob
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.whispersystems.signalservice.internal.push.Envelope
import org.whispersystems.signalservice.internal.websocket.BenchmarkWebSocketConnection
import java.util.concurrent.CopyOnWriteArraySet
import kotlin.random.Random
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
/**
* JUnit rule that drives [org.thoughtcrime.securesms.messages.IncomingMessageObserver] from
* instrumentation tests. Sets up self, registers [peerCount] simulated peers from
* [Harness.otherClients], establishes a Signal double-ratchet session with each, and exposes a
* small DSL for delivering encrypted envelopes through the real ingest pipeline:
*
* ```
* @get:Rule val rule = IncomingMessageObserverRule(peerCount = 2)
*
* @Test fun example() {
* rule.deliver { text("hi") from rule.peers[0] }
* rule.deliver { groupText("hi all", group = rule.testGroup) from rule.peers[0] }
* }
* ```
*
* Run with `-PimoTests`; tests are skipped under the default runner. Throws on drain timeout.
* Mutually exclusive with `SignalDatabaseRule` / `SignalActivityRule` — all three claim the
* local identity.
*/
class IncomingMessageObserverRule(
private val peerCount: Int = 2,
private val drainTimeout: Duration = 30.seconds
) : ExternalResource() {
lateinit var self: Recipient
private set
lateinit var peers: List<OtherClient>
private set
/** Lazily-created group. Touching this from a test triggers setup; tests that don't use groups pay nothing. */
val testGroup: GroupHandle by lazy {
val gid = TestUsers.setupGroup(withLabels = false)
GroupHandle(gid, Recipient.externalGroupExact(gid).id)
}
override fun before() {
Assume.assumeTrue(
"IncomingMessageObserverRule requires the IMO test runner — run with -PimoTests",
AppDependencies.application is IncomingMessageObserverInstrumentationApplicationContext
)
self = TestUsers.setupSelf()
TestUsers.setupTestClients(peerCount)
peers = Harness.otherClients.take(peerCount)
val app = AppDependencies.application as IncomingMessageObserverInstrumentationApplicationContext
app.beginJobLoopForTests()
// IncomingMessageObserver caches `canProcessMessages` from restoreDecisionState at thread
// construction. If it was built before setupSelf() flipped the state it will silently drop
// every message; reset network so a fresh observer is constructed.
AppDependencies.incomingMessageObserver.notifyRestoreDecisionMade()
AppDependencies.startNetwork()
forceObserverConstruction()
val handshakeEnvelopes = peers.map { client ->
client.encrypt(Generator.encryptedTextMessage(System.currentTimeMillis()))
}
deliverEnvelopes(handshakeEnvelopes)
peers.forEach { it.completeSession() }
}
fun deliver(builder: DeliveryBuilder.() -> Unit) {
val collected = DeliveryBuilder().apply(builder).specs
if (collected.isEmpty()) return
deliverEnvelopes(collected.map { it.materialize() })
}
private fun forceObserverConstruction() {
AppDependencies.incomingMessageObserver
}
private fun deliverEnvelopes(envelopes: List<Envelope>) {
val jobManager = AppDependencies.jobManager
val seenQueues = CopyOnWriteArraySet<String>()
val queueListener = object : JobTracker.JobListener {
override fun onStateChanged(job: Job, jobState: JobTracker.JobState) {
job.parameters.queue?.let { queue ->
if (queue.startsWith(PushProcessMessageJob.QUEUE_PREFIX)) {
seenQueues += queue
}
}
}
}
jobManager.addListener({ job: Job -> job.parameters.queue?.startsWith(PushProcessMessageJob.QUEUE_PREFIX) == true }, queueListener)
try {
BenchmarkWebSocketConnection.addPendingMessages(envelopes.map { it.toWebSocketPayload() })
BenchmarkWebSocketConnection.addQueueEmptyMessage()
BenchmarkWebSocketConnection.releaseMessages()
val consumed = BenchmarkWebSocketConnection.awaitAllMessagesConsumed(drainTimeout.inWholeMilliseconds)
check(consumed) { "Timed out waiting for benchmark websocket to consume ${envelopes.size} envelope(s)" }
// PushProcessMessageJob enqueue happens on a background thread after the websocket marks
// messages consumed; this tick lets that settle before we snapshot the queues to wait on.
Thread.sleep(100)
val queuesToDrain = seenQueues.toSet()
Log.d(TAG, "Awaiting ${queuesToDrain.size} PushProcessMessageJob queue(s): $queuesToDrain")
for (queue in queuesToDrain) {
val state = jobManager.runSynchronously(MarkerJob(queue), drainTimeout.inWholeMilliseconds)
check(state.isPresent) { "Timed out waiting for queue $queue to drain" }
}
} finally {
jobManager.removeListener(queueListener)
}
}
companion object {
private val TAG = Log.tag(IncomingMessageObserverRule::class)
private fun Envelope.toWebSocketPayload(): WebSocketRequestMessage = WebSocketRequestMessage(
verb = "PUT",
path = "/api/v1/message",
id = Random.nextLong(),
headers = listOf("X-Signal-Timestamp: $serverTimestamp"),
body = encodeByteString()
)
}
}
/** Identifies the test group created by [IncomingMessageObserverRule]. Hold a reference to pass into the [DeliveryBuilder.groupText] DSL. */
data class GroupHandle(val groupId: GroupId.V2, val recipientId: RecipientId)
/**
* Receiver of the DSL passed to [IncomingMessageObserverRule.deliver]. Construct content with
* [text] / [groupText] / [deliveryReceipts] / [readReceipts] / [malformedEnvelope] and chain
* with the [from] infix to attach a sending peer. Each `from` adds the resulting envelope to
* the batch that will be delivered when the lambda returns.
*/
class DeliveryBuilder internal constructor() {
internal val specs = mutableListOf<EnvelopeSpec>()
fun text(body: String, timestamp: Long = System.currentTimeMillis()): EnvelopeContentSpec = EnvelopeContentSpec.Text(body, timestamp, group = null)
fun groupText(body: String, group: GroupHandle, timestamp: Long = System.currentTimeMillis()): EnvelopeContentSpec = EnvelopeContentSpec.Text(body, timestamp, group)
fun deliveryReceipts(targets: List<Long>, sentAt: Long = System.currentTimeMillis()): EnvelopeContentSpec = EnvelopeContentSpec.DeliveryReceipt(targets, sentAt)
fun readReceipts(targets: List<Long>, sentAt: Long = System.currentTimeMillis()): EnvelopeContentSpec = EnvelopeContentSpec.ReadReceipt(targets, sentAt)
fun malformedEnvelope(timestamp: Long = System.currentTimeMillis()): EnvelopeContentSpec = EnvelopeContentSpec.Malformed(timestamp)
infix fun EnvelopeContentSpec.from(peer: OtherClient) {
specs += EnvelopeSpec(this, peer)
}
}
/** Opaque envelope content returned by [DeliveryBuilder]. Tests never construct or inspect variants directly; the type only appears as a return / receiver of the DSL methods. */
sealed class EnvelopeContentSpec {
internal data class Text(val body: String, val timestamp: Long, val group: GroupHandle?) : EnvelopeContentSpec()
internal data class DeliveryReceipt(val targets: List<Long>, val sentAt: Long) : EnvelopeContentSpec()
internal data class ReadReceipt(val targets: List<Long>, val sentAt: Long) : EnvelopeContentSpec()
internal data class Malformed(val timestamp: Long) : EnvelopeContentSpec()
}
internal data class EnvelopeSpec(val content: EnvelopeContentSpec, val peer: OtherClient) {
fun materialize(): Envelope = when (val c = content) {
is EnvelopeContentSpec.Text ->
peer.encrypt(Generator.encryptedTextMessage(c.timestamp, c.body, c.group?.let { Harness.groupMasterKey }))
is EnvelopeContentSpec.DeliveryReceipt ->
peer.encrypt(Generator.encryptedDeliveryReceipt(c.sentAt, c.targets), c.sentAt)
is EnvelopeContentSpec.ReadReceipt ->
peer.encrypt(Generator.encryptedReadReceipt(c.sentAt, c.targets), c.sentAt)
is EnvelopeContentSpec.Malformed -> {
val valid = peer.encrypt(Generator.encryptedTextMessage(c.timestamp))
val original = valid.content ?: error("Encrypted envelope unexpectedly had no content")
val corrupted = original.toByteArray().also { it[it.size / 2] = (it[it.size / 2].toInt() xor 0x01).toByte() }
valid.copy(content = corrupted.toByteString())
}
}
}
@@ -0,0 +1,18 @@
package org.thoughtcrime.securesms.testing.incomingmessageobserver
import android.app.Application
import android.content.Context
import androidx.test.runner.AndroidJUnitRunner
import org.thoughtcrime.securesms.IncomingMessageObserverInstrumentationApplicationContext
/**
* Test runner that swaps in [IncomingMessageObserverInstrumentationApplicationContext] so the
* `IncomingMessageObserver` test harness can drive a faked websocket. Selected automatically by
* the build when `-PimoTests` is set.
*/
@Suppress("unused")
class IncomingMessageObserverTestRunner : AndroidJUnitRunner() {
override fun newApplication(cl: ClassLoader?, className: String?, context: Context?): Application {
return super.newApplication(cl, IncomingMessageObserverInstrumentationApplicationContext::class.java.name, context)
}
}
@@ -6,56 +6,14 @@
package org.thoughtcrime.securesms
import android.app.Application
import org.signal.benchmark.setup.NoOpJob
import org.signal.core.util.UptimeSleepTimer
import org.signal.libsignal.net.Network
import org.thoughtcrime.securesms.database.JobDatabase
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.dependencies.ApplicationDependencyProvider
import org.thoughtcrime.securesms.jobmanager.Job
import org.thoughtcrime.securesms.jobmanager.JobManager
import org.thoughtcrime.securesms.jobmanager.JobMigrator
import org.thoughtcrime.securesms.jobmanager.impl.FactoryJobPredicate
import org.thoughtcrime.securesms.jobs.AccountConsistencyWorkerJob
import org.thoughtcrime.securesms.jobs.ArchiveBackupIdReservationJob
import org.thoughtcrime.securesms.jobs.AttachmentCompressionJob
import org.thoughtcrime.securesms.jobs.AttachmentUploadJob
import org.thoughtcrime.securesms.jobs.AvatarGroupsV2DownloadJob
import org.thoughtcrime.securesms.jobs.CreateReleaseChannelJob
import org.thoughtcrime.securesms.jobs.DirectoryRefreshJob
import org.thoughtcrime.securesms.jobs.DownloadLatestEmojiDataJob
import org.thoughtcrime.securesms.jobs.EmojiSearchIndexDownloadJob
import org.thoughtcrime.securesms.jobs.FastJobStorage
import org.thoughtcrime.securesms.jobs.FontDownloaderJob
import org.thoughtcrime.securesms.jobs.GroupCallUpdateSendJob
import org.thoughtcrime.securesms.jobs.GroupRingCleanupJob
import org.thoughtcrime.securesms.jobs.GroupV2UpdateSelfProfileKeyJob
import org.thoughtcrime.securesms.jobs.IndividualSendJob
import org.thoughtcrime.securesms.jobs.JobManagerFactories
import org.thoughtcrime.securesms.jobs.LinkedDeviceInactiveCheckJob
import org.thoughtcrime.securesms.jobs.MarkerJob
import org.thoughtcrime.securesms.jobs.MultiDeviceProfileKeyUpdateJob
import org.thoughtcrime.securesms.jobs.PostRegistrationBackupRedemptionJob
import org.thoughtcrime.securesms.jobs.PreKeysSyncJob
import org.thoughtcrime.securesms.jobs.ProfileUploadJob
import org.thoughtcrime.securesms.jobs.PushGroupSendJob
import org.thoughtcrime.securesms.jobs.PushProcessMessageJob
import org.thoughtcrime.securesms.jobs.ReactionSendJob
import org.thoughtcrime.securesms.jobs.RefreshAttributesJob
import org.thoughtcrime.securesms.jobs.RefreshSvrCredentialsJob
import org.thoughtcrime.securesms.jobs.RequestGroupV2InfoJob
import org.thoughtcrime.securesms.jobs.ResetSvrGuessCountJob
import org.thoughtcrime.securesms.jobs.RestoreOptimizedMediaJob
import org.thoughtcrime.securesms.jobs.RetrieveProfileAvatarJob
import org.thoughtcrime.securesms.jobs.RetrieveProfileJob
import org.thoughtcrime.securesms.jobs.RetrieveRemoteAnnouncementsJob
import org.thoughtcrime.securesms.jobs.RotateCertificateJob
import org.thoughtcrime.securesms.jobs.SendDeliveryReceiptJob
import org.thoughtcrime.securesms.jobs.StickerPackDownloadJob
import org.thoughtcrime.securesms.jobs.StorageSyncJob
import org.thoughtcrime.securesms.jobs.StoryOnboardingDownloadJob
import org.thoughtcrime.securesms.jobs.TypingSendJob
import org.thoughtcrime.securesms.net.DeviceTransferBlockingInterceptor
import org.thoughtcrime.securesms.util.TextSecurePreferences
import org.whispersystems.signalservice.api.util.UptimeSleepTimer
import org.whispersystems.signalservice.api.websocket.SignalWebSocket
import org.whispersystems.signalservice.internal.configuration.SignalServiceConfiguration
import org.whispersystems.signalservice.internal.websocket.BenchmarkWebSocketConnection
@@ -97,85 +55,11 @@ class BenchmarkApplicationContext : ApplicationContext() {
)
}
override fun provideJobManager(): JobManager {
val config = JobManager.Configuration.Builder()
.setJobFactories(filterJobFactories(JobManagerFactories.getJobFactories(application)))
.setConstraintFactories(JobManagerFactories.getConstraintFactories(application))
.setConstraintObservers(JobManagerFactories.getConstraintObservers(application))
.setJobStorage(FastJobStorage(JobDatabase.getInstance(application)))
.setJobMigrator(JobMigrator(TextSecurePreferences.getJobManagerVersion(application), JobManager.CURRENT_VERSION, JobManagerFactories.getJobMigrations(application)))
.addReservedJobRunner(FactoryJobPredicate(PushProcessMessageJob.KEY, MarkerJob.KEY))
.addReservedJobRunner(FactoryJobPredicate(AttachmentUploadJob.KEY, AttachmentCompressionJob.KEY))
.addReservedJobRunner(
FactoryJobPredicate(
IndividualSendJob.KEY,
PushGroupSendJob.KEY,
ReactionSendJob.KEY,
TypingSendJob.KEY,
GroupCallUpdateSendJob.KEY,
SendDeliveryReceiptJob.KEY
)
)
override fun provideJobManager(configurationBuilder: JobManager.Configuration.Builder): JobManager {
val config = configurationBuilder
.setJobFactories(NoOpJob.replaceFactories(JobManagerFactories.getJobFactories(application)))
.build()
return JobManager(application, config)
}
private fun filterJobFactories(jobFactories: Map<String, Job.Factory<*>>): Map<String, Job.Factory<*>> {
val blockedJobs = setOf(
AccountConsistencyWorkerJob.KEY,
ArchiveBackupIdReservationJob.KEY,
AvatarGroupsV2DownloadJob.KEY,
CreateReleaseChannelJob.KEY,
DirectoryRefreshJob.KEY,
DownloadLatestEmojiDataJob.KEY,
EmojiSearchIndexDownloadJob.KEY,
FontDownloaderJob.KEY,
GroupRingCleanupJob.KEY,
GroupV2UpdateSelfProfileKeyJob.KEY,
LinkedDeviceInactiveCheckJob.KEY,
MultiDeviceProfileKeyUpdateJob.KEY,
PostRegistrationBackupRedemptionJob.KEY,
PreKeysSyncJob.KEY,
ProfileUploadJob.KEY,
RefreshAttributesJob.KEY,
RefreshSvrCredentialsJob.KEY,
RequestGroupV2InfoJob.KEY,
ResetSvrGuessCountJob.KEY,
RestoreOptimizedMediaJob.KEY,
RetrieveProfileAvatarJob.KEY,
RetrieveProfileJob.KEY,
RetrieveRemoteAnnouncementsJob.KEY,
RotateCertificateJob.KEY,
StickerPackDownloadJob.KEY,
StorageSyncJob.KEY,
StoryOnboardingDownloadJob.KEY
)
return jobFactories.mapValues {
if (it.key in blockedJobs) {
NoOpJob.Factory()
} else {
it.value
}
}
}
}
private class NoOpJob(parameters: Parameters) : Job(parameters) {
companion object {
const val KEY = "NoOpJob"
}
override fun serialize(): ByteArray? = null
override fun getFactoryKey(): String = KEY
override fun run(): Result = Result.success()
override fun onFailure() = Unit
class Factory : Job.Factory<NoOpJob> {
override fun create(parameters: Parameters, serializedData: ByteArray?): NoOpJob {
return NoOpJob(parameters)
}
}
}
}
@@ -23,7 +23,7 @@ import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.recipients.Recipient
import org.whispersystems.signalservice.internal.push.Envelope
import org.whispersystems.signalservice.internal.websocket.BenchmarkWebSocketConnection
import org.whispersystems.signalservice.internal.websocket.WebSocketRequestMessage
import org.signal.network.websocket.WebSocketRequestMessage
import kotlin.random.Random
/**
@@ -0,0 +1,84 @@
package org.signal.benchmark.setup
import org.thoughtcrime.securesms.jobmanager.Job
import org.thoughtcrime.securesms.jobs.AccountConsistencyWorkerJob
import org.thoughtcrime.securesms.jobs.ArchiveBackupIdReservationJob
import org.thoughtcrime.securesms.jobs.AvatarGroupsV2DownloadJob
import org.thoughtcrime.securesms.jobs.CreateReleaseChannelJob
import org.thoughtcrime.securesms.jobs.DirectoryRefreshJob
import org.thoughtcrime.securesms.jobs.DownloadLatestEmojiDataJob
import org.thoughtcrime.securesms.jobs.EmojiSearchIndexDownloadJob
import org.thoughtcrime.securesms.jobs.FontDownloaderJob
import org.thoughtcrime.securesms.jobs.GroupRingCleanupJob
import org.thoughtcrime.securesms.jobs.GroupV2UpdateSelfProfileKeyJob
import org.thoughtcrime.securesms.jobs.LinkedDeviceInactiveCheckJob
import org.thoughtcrime.securesms.jobs.MultiDeviceProfileKeyUpdateJob
import org.thoughtcrime.securesms.jobs.PostRegistrationBackupRedemptionJob
import org.thoughtcrime.securesms.jobs.PreKeysSyncJob
import org.thoughtcrime.securesms.jobs.ProfileUploadJob
import org.thoughtcrime.securesms.jobs.RefreshAttributesJob
import org.thoughtcrime.securesms.jobs.RefreshSvrCredentialsJob
import org.thoughtcrime.securesms.jobs.RequestGroupV2InfoJob
import org.thoughtcrime.securesms.jobs.ResetSvrGuessCountJob
import org.thoughtcrime.securesms.jobs.RestoreOptimizedMediaJob
import org.thoughtcrime.securesms.jobs.RetrieveProfileAvatarJob
import org.thoughtcrime.securesms.jobs.RetrieveProfileJob
import org.thoughtcrime.securesms.jobs.RetrieveRemoteAnnouncementsJob
import org.thoughtcrime.securesms.jobs.RotateCertificateJob
import org.thoughtcrime.securesms.jobs.StickerPackDownloadJob
import org.thoughtcrime.securesms.jobs.StorageSyncJob
import org.thoughtcrime.securesms.jobs.StoryOnboardingDownloadJob
/**
* A [Job] that does nothing and always succeeds. Test setups substitute this for jobs whose
* real implementations would hit the network at startup (and so would either generate noise
* against the [DeviceTransferBlockingInterceptor][org.thoughtcrime.securesms.net.DeviceTransferBlockingInterceptor]
* or fail against unstubbed mocks). Use [replaceFactories] to apply the swap.
*/
class NoOpJob(parameters: Parameters) : Job(parameters) {
override fun serialize(): ByteArray? = null
override fun getFactoryKey(): String = KEY
override fun run(): Result = Result.success()
override fun onFailure() = Unit
class Factory : Job.Factory<NoOpJob> {
override fun create(parameters: Parameters, serializedData: ByteArray?): NoOpJob = NoOpJob(parameters)
}
companion object {
const val KEY = "NoOpJob"
private val STARTUP_NETWORK_JOB_KEYS: Set<String> = setOf(
AccountConsistencyWorkerJob.KEY,
ArchiveBackupIdReservationJob.KEY,
AvatarGroupsV2DownloadJob.KEY,
CreateReleaseChannelJob.KEY,
DirectoryRefreshJob.KEY,
DownloadLatestEmojiDataJob.KEY,
EmojiSearchIndexDownloadJob.KEY,
FontDownloaderJob.KEY,
GroupRingCleanupJob.KEY,
GroupV2UpdateSelfProfileKeyJob.KEY,
LinkedDeviceInactiveCheckJob.KEY,
MultiDeviceProfileKeyUpdateJob.KEY,
PostRegistrationBackupRedemptionJob.KEY,
PreKeysSyncJob.KEY,
ProfileUploadJob.KEY,
RefreshAttributesJob.KEY,
RefreshSvrCredentialsJob.KEY,
RequestGroupV2InfoJob.KEY,
ResetSvrGuessCountJob.KEY,
RestoreOptimizedMediaJob.KEY,
RetrieveProfileAvatarJob.KEY,
RetrieveProfileJob.KEY,
RetrieveRemoteAnnouncementsJob.KEY,
RotateCertificateJob.KEY,
StickerPackDownloadJob.KEY,
StorageSyncJob.KEY,
StoryOnboardingDownloadJob.KEY
)
fun replaceFactories(factories: Map<String, Job.Factory<*>>): Map<String, Job.Factory<*>> =
factories.mapValues { if (it.key in STARTUP_NETWORK_JOB_KEYS) Factory() else it.value }
}
}
@@ -8,7 +8,10 @@ package org.whispersystems.signalservice.internal.websocket
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.subjects.BehaviorSubject
import org.thoughtcrime.securesms.util.JsonUtils
import org.signal.network.websocket.WebSocketRequestMessage
import org.signal.network.websocket.WebSocketResponseMessage
import org.signal.network.websocket.WebsocketResponse
import org.signal.core.util.JsonUtils
import org.thoughtcrime.securesms.util.SignalTrace
import org.whispersystems.signalservice.api.websocket.WebSocketConnectionState
import org.whispersystems.signalservice.internal.push.SendMessageResponse
@@ -65,6 +68,18 @@ class BenchmarkWebSocketConnection : WebSocketConnection {
fun addQueueEmptyMessage() {
authInstances.filterNot(BenchmarkWebSocketConnection::isShutdown).forEach { it.addQueueEmptyMessage() }
}
fun awaitAllMessagesConsumed(timeoutMs: Long): Boolean {
val deadline = System.currentTimeMillis() + timeoutMs
while (System.currentTimeMillis() < deadline) {
val activeInstances = synchronized(this) { authInstances.filterNot(BenchmarkWebSocketConnection::isShutdown).toList() }
if (activeInstances.isNotEmpty() && activeInstances.all { it.incomingRequests.isEmpty() && it.incomingSemaphore.availablePermits() == 0 }) {
return true
}
Thread.sleep(25)
}
return false
}
}
override val name: String = "bench-${System.identityHashCode(this)}"
@@ -1,13 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<application
android:usesCleartextTraffic="true"
tools:replace="android:usesCleartextTraffic"
tools:ignore="UnusedAttribute" />
</manifest>
@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/signal_accent_green"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>
@@ -1,4 +0,0 @@
<?xml version='1.0' encoding='UTF-8'?>
<resources>
<string name="app_name">Signal (Instrumentation)</string>
</resources>
+2 -3
View File
@@ -2,8 +2,6 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-sdk tools:overrideLibrary="androidx.camera.core,androidx.camera.camera2,androidx.camera.lifecycle,androidx.camera.view" />
<!-- ======================================= -->
<!-- Features -->
<!-- ======================================= -->
@@ -40,6 +38,7 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_PHONE_CALL" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_REMOTE_MESSAGING" />
<uses-permission android:name="android.permission.GET_ACCOUNTS" />
@@ -1405,7 +1404,7 @@
<service
android:name="org.thoughtcrime.securesms.service.webrtc.ActiveCallManager$ActiveCallForegroundService"
android:exported="false"
android:foregroundServiceType="dataSync|microphone|camera|phoneCall" />
android:foregroundServiceType="dataSync|microphone|camera|phoneCall|mediaProjection" />
<service
android:name="com.google.android.datatransport.runtime.scheduling.jobscheduling.JobInfoSchedulerService"
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -18,6 +18,7 @@ package org.thoughtcrime.securesms;
import android.app.Application;
import android.content.Context;
import android.content.Intent;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
@@ -77,6 +78,7 @@ import org.thoughtcrime.securesms.jobs.LinkedDeviceInactiveCheckJob;
import org.thoughtcrime.securesms.jobs.MultiDeviceContactUpdateJob;
import org.thoughtcrime.securesms.jobs.PreKeysSyncJob;
import org.thoughtcrime.securesms.jobs.ProfileUploadJob;
import org.thoughtcrime.securesms.jobs.RefreshAttributesJob;
import org.thoughtcrime.securesms.jobs.RefreshSvrCredentialsJob;
import org.thoughtcrime.securesms.jobs.RestoreOptimizedMediaJob;
import org.thoughtcrime.securesms.jobs.RetrieveProfileJob;
@@ -87,7 +89,9 @@ import org.thoughtcrime.securesms.keyvalue.KeepMessagesDuration;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.logging.CustomSignalProtocolLogger;
import org.thoughtcrime.securesms.logging.PersistentLogger;
import org.thoughtcrime.securesms.logsubmit.SubmitDebugLogActivity;
import org.thoughtcrime.securesms.messageprocessingalarm.RoutineMessageFetchReceiver;
import org.thoughtcrime.securesms.messages.IncomingMessageObserver;
import org.thoughtcrime.securesms.migrations.ApplicationMigrations;
import org.thoughtcrime.securesms.mms.SignalGlideModule;
import org.thoughtcrime.securesms.providers.BlobProvider;
@@ -106,11 +110,12 @@ import org.thoughtcrime.securesms.service.webrtc.ActiveCallManager;
import org.thoughtcrime.securesms.service.webrtc.CallingAssets;
import org.thoughtcrime.securesms.service.webrtc.AndroidTelecomUtil;
import org.thoughtcrime.securesms.storage.StorageSyncHelper;
import org.thoughtcrime.securesms.util.AppForegroundObserver;
import org.signal.core.util.AppForegroundObserver;
import org.thoughtcrime.securesms.util.AppStartup;
import org.thoughtcrime.securesms.util.DeviceProperties;
import org.thoughtcrime.securesms.util.DynamicTheme;
import org.thoughtcrime.securesms.util.Environment;
import org.thoughtcrime.securesms.util.PlayServicesUtil;
import org.thoughtcrime.securesms.util.RemoteConfig;
import org.thoughtcrime.securesms.util.SignalLocalMetrics;
import org.thoughtcrime.securesms.util.SignalUncaughtExceptionHandler;
@@ -415,7 +420,11 @@ public class ApplicationContext extends Application implements AppForegroundObse
new org.signal.registration.RegistrationDependencies(
new org.thoughtcrime.securesms.registration.v2.AppRegistrationNetworkController(this, AppDependencies.getPushServiceSocket()),
new org.thoughtcrime.securesms.registration.v2.AppRegistrationStorageController(this),
null
null,
context -> {
context.startActivity(new Intent(context, SubmitDebugLogActivity.class));
return Unit.INSTANCE;
}
)
);
}
@@ -439,14 +448,38 @@ public class ApplicationContext extends Application implements AppForegroundObse
}
private void initializeFcmCheck() {
if (SignalStore.account().isRegistered()) {
if (!SignalStore.account().isRegistered()) {
return;
}
PlayServicesUtil.PlayServicesStatus playServicesStatus = PlayServicesUtil.getPlayServicesStatus(this);
if (playServicesStatus == PlayServicesUtil.PlayServicesStatus.SUCCESS && !SignalStore.account().isFcmEnabled()) {
Log.w(TAG, "Play Services are newly-available. Enabling FCM and updating server.");
SignalStore.account().setFcmEnabled(true);
AppDependencies.getJobManager().startChain(new FcmRefreshJob())
.then(new RefreshAttributesJob())
.enqueue();
AppDependencies.resetNetwork();
AppDependencies.startNetwork();
IncomingMessageObserver.stopForegroundService(this);
} else if (playServicesStatus == PlayServicesUtil.PlayServicesStatus.MISSING && SignalStore.account().isFcmEnabled()) {
Log.w(TAG, "Play Services are no longer available. Attempting to get an FCM token anyway.");
AppDependencies.getJobManager().add(new FcmRefreshJob());
} else if (playServicesStatus == PlayServicesUtil.PlayServicesStatus.MISSING && (System.currentTimeMillis() - SignalStore.misc().getLastMissingPlayServicesFcmVerificationTime()) > TimeUnit.DAYS.toMillis(3)) {
Log.i(TAG, "Play Services are unavailable, but it's been long enough that we should check and see if we can get an FCM token anyway.");
AppDependencies.getJobManager().add(new FcmRefreshJob());
} else if (SignalStore.account().isFcmEnabled()) {
long lastSetTime = SignalStore.account().getFcmTokenLastSetTime();
long nextSetTime = lastSetTime + TimeUnit.HOURS.toMillis(6);
long now = System.currentTimeMillis();
if (SignalStore.account().getFcmToken() == null || nextSetTime <= now || lastSetTime > now) {
Log.i(TAG, "Time for routine FCM token refresh.");
AppDependencies.getJobManager().add(new FcmRefreshJob());
}
} else {
Log.d(TAG, "Play Services status: " + playServicesStatus + ", fcmEnabled: false. Skipping FCM check.");
}
}
@@ -16,7 +16,7 @@ import androidx.core.app.ActivityOptionsCompat;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.dependencies.AppDependencies;
import org.thoughtcrime.securesms.util.AppStartup;
import org.thoughtcrime.securesms.util.ConfigurationUtil;
import org.signal.core.util.ConfigurationUtil;
import org.thoughtcrime.securesms.util.WindowUtil;
import org.thoughtcrime.securesms.util.dynamiclanguage.DynamicLanguageContextWrapper;
@@ -6,7 +6,7 @@ import androidx.lifecycle.LifecycleOwner;
import com.bumptech.glide.RequestManager;
import org.thoughtcrime.securesms.conversationlist.model.ConversationSet;
import org.thoughtcrime.securesms.database.model.ThreadRecord;
import org.thoughtcrime.securesms.database.model.ThreadWithRecipient;
import java.util.Locale;
import java.util.Set;
@@ -14,7 +14,7 @@ import java.util.Set;
public interface BindableConversationListItem extends Unbindable {
void bind(@NonNull LifecycleOwner lifecycleOwner,
@NonNull ThreadRecord thread,
@NonNull ThreadWithRecipient thread,
@NonNull RequestManager requestManager, @NonNull Locale locale,
@NonNull Set<Long> typingThreads,
@NonNull ConversationSet selectedConversations,
@@ -10,8 +10,8 @@ import androidx.activity.result.contract.ActivityResultContract
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt
import androidx.biometric.BiometricPrompt.PromptInfo
import org.signal.core.util.ServiceUtil
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.util.ServiceUtil
/**
* Authentication using phone biometric (face, fingerprint recognition) or device lock (pattern, pin or passphrase).
@@ -23,45 +23,25 @@ public final class BlockUnblockDialog {
private BlockUnblockDialog() {}
public static void showReportSpamFor(@NonNull Context context,
@NonNull Lifecycle lifecycle,
@NonNull Recipient recipient,
@NonNull Runnable onReportSpam,
@Nullable Runnable onBlockAndReportSpam)
{
SimpleTask.run(lifecycle,
() -> buildReportSpamFor(context, recipient, onReportSpam, onBlockAndReportSpam),
AlertDialog.Builder::show);
buildReportSpamFor(context, recipient, onReportSpam, onBlockAndReportSpam).show();
}
public static void showBlockFor(@NonNull Context context,
@NonNull Lifecycle lifecycle,
@NonNull Recipient recipient,
@NonNull Runnable onBlock)
{
SimpleTask.run(lifecycle,
() -> buildBlockFor(context, recipient, onBlock, null),
AlertDialog.Builder::show);
}
public static void showBlockAndReportSpamFor(@NonNull Context context,
@NonNull Lifecycle lifecycle,
@NonNull Recipient recipient,
@NonNull Runnable onBlock,
@NonNull Runnable onBlockAndReportSpam)
{
SimpleTask.run(lifecycle,
() -> buildBlockFor(context, recipient, onBlock, onBlockAndReportSpam),
AlertDialog.Builder::show);
buildBlockFor(context, recipient, onBlock, null).show();
}
public static void showUnblockFor(@NonNull Context context,
@NonNull Lifecycle lifecycle,
@NonNull Recipient recipient,
@NonNull Runnable onUnblock)
{
SimpleTask.run(lifecycle,
() -> buildUnblockFor(context, recipient, onUnblock),
AlertDialog.Builder::show);
buildUnblockFor(context, recipient, onUnblock).show();
}
@WorkerThread
@@ -34,7 +34,6 @@ import org.thoughtcrime.securesms.contacts.sync.ContactDiscovery;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme;
import org.thoughtcrime.securesms.util.DynamicTheme;
import org.thoughtcrime.securesms.util.ServiceUtil;
import java.io.IOException;
import java.lang.ref.WeakReference;
@@ -49,8 +48,7 @@ import java.util.function.Consumer;
*/
public abstract class ContactSelectionActivity extends PassphraseRequiredActivity
implements SwipeRefreshLayout.OnRefreshListener,
ContactSelectionListFragment.OnContactSelectedListener,
ContactSelectionListFragment.ScrollCallback
ContactSelectionListFragment.OnContactSelectedListener
{
private static final String TAG = Log.tag(ContactSelectionActivity.class);
@@ -136,17 +134,6 @@ public abstract class ContactSelectionActivity extends PassphraseRequiredActivit
@Override
public void onContactDeselected(@NonNull Optional<RecipientId> recipientId, String number, @NonNull Optional<ChatType> chatType) {}
@Override
public void onBeginScroll() {
hideKeyboard();
}
private void hideKeyboard() {
ServiceUtil.getInputMethodManager(this)
.hideSoftInputFromWindow(toolbar.getWindowToken(), 0);
toolbar.clearFocus();
}
private static class RefreshDirectoryTask extends AsyncTask<Context, Void, Void> {
private final WeakReference<ContactSelectionActivity> activity;
@@ -1,16 +1,19 @@
package org.thoughtcrime.securesms
import android.content.Context
import android.view.View
import android.widget.TextView
import com.google.android.material.button.MaterialButton
import org.thoughtcrime.securesms.ContactSelectionListModels.FindByPhoneNumberModel
import org.thoughtcrime.securesms.ContactSelectionListModels.FindByUsernameModel
import org.thoughtcrime.securesms.ContactSelectionListModels.FindContactsBannerModel
import org.thoughtcrime.securesms.ContactSelectionListModels.FindContactsModel
import org.thoughtcrime.securesms.ContactSelectionListModels.InviteToSignalModel
import org.thoughtcrime.securesms.ContactSelectionListModels.MoreHeaderModel
import org.thoughtcrime.securesms.ContactSelectionListModels.NewGroupModel
import org.thoughtcrime.securesms.ContactSelectionListModels.RefreshContactsModel
import org.thoughtcrime.securesms.contacts.paged.ContactSearchAdapter
import org.thoughtcrime.securesms.contacts.paged.ContactSearchConfiguration
import org.thoughtcrime.securesms.contacts.paged.ContactSearchData
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel
import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder
class ContactSelectionListAdapter(
context: Context,
@@ -23,152 +26,19 @@ class ContactSelectionListAdapter(
) : ContactSearchAdapter(context, fixedContacts, displayOptions, onClickCallbacks, longClickCallbacks, storyContextMenuCallbacks, callButtonClickCallbacks) {
init {
registerFactory(NewGroupModel::class.java, LayoutFactory({ NewGroupViewHolder(it, onClickCallbacks::onNewGroupClicked) }, R.layout.contact_selection_new_group_item))
registerFactory(InviteToSignalModel::class.java, LayoutFactory({ InviteToSignalViewHolder(it, onClickCallbacks::onInviteToSignalClicked) }, R.layout.contact_selection_invite_action_item))
registerFactory(FindContactsModel::class.java, LayoutFactory({ FindContactsViewHolder(it, onClickCallbacks::onFindContactsClicked) }, R.layout.contact_selection_find_contacts_item))
registerFactory(FindContactsBannerModel::class.java, LayoutFactory({ FindContactsBannerViewHolder(it, onClickCallbacks::onDismissFindContactsBannerClicked, onClickCallbacks::onFindContactsClicked) }, R.layout.contact_selection_find_contacts_banner_item))
registerFactory(RefreshContactsModel::class.java, LayoutFactory({ RefreshContactsViewHolder(it, onClickCallbacks::onRefreshContactsClicked) }, R.layout.contact_selection_refresh_action_item))
registerFactory(MoreHeaderModel::class.java, LayoutFactory({ MoreHeaderViewHolder(it) }, R.layout.contact_search_section_header))
registerFactory(EmptyModel::class.java, LayoutFactory({ EmptyViewHolder(it) }, R.layout.contact_selection_empty_state))
registerFactory(FindByUsernameModel::class.java, LayoutFactory({ FindByUsernameViewHolder(it, onClickCallbacks::onFindByUsernameClicked) }, R.layout.contact_selection_find_by_username_item))
registerFactory(FindByPhoneNumberModel::class.java, LayoutFactory({ FindByPhoneNumberViewHolder(it, onClickCallbacks::onFindByPhoneNumberClicked) }, R.layout.contact_selection_find_by_phone_number_item))
}
class NewGroupModel : MappingModel<NewGroupModel> {
override fun areItemsTheSame(newItem: NewGroupModel): Boolean = true
override fun areContentsTheSame(newItem: NewGroupModel): Boolean = true
}
class InviteToSignalModel : MappingModel<InviteToSignalModel> {
override fun areItemsTheSame(newItem: InviteToSignalModel): Boolean = true
override fun areContentsTheSame(newItem: InviteToSignalModel): Boolean = true
}
class RefreshContactsModel : MappingModel<RefreshContactsModel> {
override fun areItemsTheSame(newItem: RefreshContactsModel): Boolean = true
override fun areContentsTheSame(newItem: RefreshContactsModel): Boolean = true
}
class FindContactsModel : MappingModel<FindContactsModel> {
override fun areItemsTheSame(newItem: FindContactsModel): Boolean = true
override fun areContentsTheSame(newItem: FindContactsModel): Boolean = true
}
class FindContactsBannerModel : MappingModel<FindContactsBannerModel> {
override fun areItemsTheSame(newItem: FindContactsBannerModel): Boolean = true
override fun areContentsTheSame(newItem: FindContactsBannerModel): Boolean = true
}
class FindByUsernameModel : MappingModel<FindByUsernameModel> {
override fun areItemsTheSame(newItem: FindByUsernameModel): Boolean = true
override fun areContentsTheSame(newItem: FindByUsernameModel): Boolean = true
}
class FindByPhoneNumberModel : MappingModel<FindByPhoneNumberModel> {
override fun areItemsTheSame(newItem: FindByPhoneNumberModel): Boolean = true
override fun areContentsTheSame(newItem: FindByPhoneNumberModel): Boolean = true
}
class MoreHeaderModel : MappingModel<MoreHeaderModel> {
override fun areItemsTheSame(newItem: MoreHeaderModel): Boolean = true
override fun areContentsTheSame(newItem: MoreHeaderModel): Boolean = true
}
private class InviteToSignalViewHolder(itemView: View, onClickListener: () -> Unit) : MappingViewHolder<InviteToSignalModel>(itemView) {
init {
itemView.setOnClickListener { onClickListener() }
}
override fun bind(model: InviteToSignalModel) = Unit
}
private class NewGroupViewHolder(itemView: View, onClickListener: () -> Unit) : MappingViewHolder<NewGroupModel>(itemView) {
init {
itemView.setOnClickListener { onClickListener() }
}
override fun bind(model: NewGroupModel) = Unit
}
private class RefreshContactsViewHolder(itemView: View, onClickListener: () -> Unit) : MappingViewHolder<RefreshContactsModel>(itemView) {
init {
itemView.setOnClickListener { onClickListener() }
}
override fun bind(model: RefreshContactsModel) = Unit
}
private class FindContactsViewHolder(itemView: View, onClickListener: () -> Unit) : MappingViewHolder<FindContactsModel>(itemView) {
init {
itemView.setOnClickListener { onClickListener() }
}
override fun bind(model: FindContactsModel) = Unit
}
private class FindContactsBannerViewHolder(itemView: View, onDismissListener: () -> Unit, onClickListener: () -> Unit) : MappingViewHolder<FindContactsBannerModel>(itemView) {
init {
itemView.findViewById<MaterialButton>(R.id.no_thanks_button).setOnClickListener { onDismissListener() }
itemView.findViewById<MaterialButton>(R.id.allow_contacts_button).setOnClickListener { onClickListener() }
}
override fun bind(model: FindContactsBannerModel) = Unit
}
private class MoreHeaderViewHolder(itemView: View) : MappingViewHolder<MoreHeaderModel>(itemView) {
private val headerTextView: TextView = itemView.findViewById(R.id.section_header)
override fun bind(model: MoreHeaderModel) {
headerTextView.setText(R.string.contact_selection_activity__more)
}
}
private class EmptyViewHolder(itemView: View) : MappingViewHolder<EmptyModel>(itemView) {
private val emptyText: TextView = itemView.findViewById(R.id.search_no_results)
override fun bind(model: EmptyModel) {
emptyText.text = context.getString(R.string.SearchFragment_no_results, model.empty.query ?: "")
}
}
private class FindByPhoneNumberViewHolder(itemView: View, onClickListener: () -> Unit) : MappingViewHolder<FindByPhoneNumberModel>(itemView) {
init {
itemView.setOnClickListener { onClickListener() }
}
override fun bind(model: FindByPhoneNumberModel) = Unit
}
private class FindByUsernameViewHolder(itemView: View, onClickListener: () -> Unit) : MappingViewHolder<FindByUsernameModel>(itemView) {
init {
itemView.setOnClickListener { onClickListener() }
}
override fun bind(model: FindByUsernameModel) = Unit
ContactSelectionListModels.registerNewGroup(this, onClickCallbacks::onNewGroupClicked)
ContactSelectionListModels.registerInviteToSignal(this, onClickCallbacks::onInviteToSignalClicked)
ContactSelectionListModels.registerFindContacts(this, onClickCallbacks::onFindContactsClicked)
ContactSelectionListModels.registerFindContactsBanner(this, onClickCallbacks::onDismissFindContactsBannerClicked, onClickCallbacks::onFindContactsClicked)
ContactSelectionListModels.registerRefreshContacts(this, onClickCallbacks::onRefreshContactsClicked)
ContactSelectionListModels.registerMoreHeader(this)
ContactSelectionListModels.registerEmpty(this)
ContactSelectionListModels.registerFindByUsername(this, onClickCallbacks::onFindByUsernameClicked)
ContactSelectionListModels.registerFindByPhoneNumber(this, onClickCallbacks::onFindByPhoneNumberClicked)
}
class ArbitraryRepository : org.thoughtcrime.securesms.contacts.paged.ArbitraryRepository {
enum class ArbitraryRow(val code: String) {
NEW_GROUP("new-group"),
INVITE_TO_SIGNAL("invite-to-signal"),
MORE_HEADING("more-heading"),
REFRESH_CONTACTS("refresh-contacts"),
FIND_CONTACTS("find-contacts"),
FIND_CONTACTS_BANNER("find-contacts-banner"),
FIND_BY_USERNAME("find-by-username"),
FIND_BY_PHONE_NUMBER("find-by-phone-number");
companion object {
fun fromCode(code: String) = entries.first { it.code == code }
}
}
override fun getSize(section: ContactSearchConfiguration.Section.Arbitrary, query: String?): Int {
return section.types.size
}
@@ -179,15 +49,15 @@ class ContactSelectionListAdapter(
}
override fun getMappingModel(arbitrary: ContactSearchData.Arbitrary): MappingModel<*> {
return when (ArbitraryRow.fromCode(arbitrary.type)) {
ArbitraryRow.NEW_GROUP -> NewGroupModel()
ArbitraryRow.INVITE_TO_SIGNAL -> InviteToSignalModel()
ArbitraryRow.MORE_HEADING -> MoreHeaderModel()
ArbitraryRow.REFRESH_CONTACTS -> RefreshContactsModel()
ArbitraryRow.FIND_CONTACTS -> FindContactsModel()
ArbitraryRow.FIND_CONTACTS_BANNER -> FindContactsBannerModel()
ArbitraryRow.FIND_BY_PHONE_NUMBER -> FindByPhoneNumberModel()
ArbitraryRow.FIND_BY_USERNAME -> FindByUsernameModel()
return when (ContactSelectionListModels.ArbitraryRow.fromCode(arbitrary.type)) {
ContactSelectionListModels.ArbitraryRow.NEW_GROUP -> NewGroupModel()
ContactSelectionListModels.ArbitraryRow.INVITE_TO_SIGNAL -> InviteToSignalModel()
ContactSelectionListModels.ArbitraryRow.MORE_HEADING -> MoreHeaderModel()
ContactSelectionListModels.ArbitraryRow.REFRESH_CONTACTS -> RefreshContactsModel()
ContactSelectionListModels.ArbitraryRow.FIND_CONTACTS -> FindContactsModel()
ContactSelectionListModels.ArbitraryRow.FIND_CONTACTS_BANNER -> FindContactsBannerModel()
ContactSelectionListModels.ArbitraryRow.FIND_BY_PHONE_NUMBER -> FindByPhoneNumberModel()
ContactSelectionListModels.ArbitraryRow.FIND_BY_USERNAME -> FindByUsernameModel()
}
}
}
@@ -18,10 +18,8 @@ package org.thoughtcrime.securesms;
import android.Manifest;
import org.signal.core.ui.logging.LoggingFragment;
import android.annotation.SuppressLint;
import android.content.Context;
import android.graphics.Rect;
import android.os.AsyncTask;
import android.os.Bundle;
import android.text.TextUtils;
@@ -38,27 +36,24 @@ import androidx.appcompat.app.AlertDialog;
import androidx.constraintlayout.widget.ConstraintLayout;
import androidx.constraintlayout.widget.ConstraintSet;
import androidx.lifecycle.ViewModelProvider;
import androidx.recyclerview.widget.DefaultItemAnimator;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout;
import androidx.transition.AutoTransition;
import androidx.transition.TransitionManager;
import org.thoughtcrime.securesms.contacts.paged.ContactSearchView;
import org.thoughtcrime.securesms.contacts.paged.ContactSearchViewModel;
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import org.jetbrains.annotations.NotNull;
import org.signal.core.ui.logging.LoggingFragment;
import org.signal.core.ui.permissions.Permissions;
import org.signal.core.util.concurrent.LifecycleDisposable;
import org.signal.core.util.concurrent.SimpleTask;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.calls.YouAreAlreadyInACallSnackbar;
import org.thoughtcrime.securesms.components.RecyclerViewFastScroller;
import org.thoughtcrime.securesms.contacts.ContactChipViewModel;
import org.thoughtcrime.securesms.contacts.ContactSelectionDisplayMode;
import org.thoughtcrime.securesms.contacts.HeaderAction;
import org.thoughtcrime.securesms.contacts.LetterHeaderDecoration;
import org.thoughtcrime.securesms.contacts.SelectedContact;
import org.thoughtcrime.securesms.contacts.SelectedContacts;
import org.thoughtcrime.securesms.contacts.paged.ChatType;
@@ -71,18 +66,19 @@ import org.thoughtcrime.securesms.contacts.paged.ContactSearchPagedDataSourceRep
import org.thoughtcrime.securesms.contacts.paged.ContactSearchRepository;
import org.thoughtcrime.securesms.contacts.paged.ContactSearchSortOrder;
import org.thoughtcrime.securesms.contacts.paged.ContactSearchState;
import org.thoughtcrime.securesms.contacts.paged.ContactSearchView;
import org.thoughtcrime.securesms.contacts.paged.ContactSearchViewModel;
import org.thoughtcrime.securesms.contacts.selection.ContactSelectionArguments;
import org.thoughtcrime.securesms.contacts.sync.ContactDiscovery;
import org.thoughtcrime.securesms.database.RecipientTable;
import org.thoughtcrime.securesms.groups.SelectionLimits;
import org.thoughtcrime.securesms.groups.ui.GroupLimitDialog;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.signal.core.ui.permissions.Permissions;
import org.thoughtcrime.securesms.profiles.manage.UsernameRepository;
import org.thoughtcrime.securesms.search.SearchRepository;
import org.thoughtcrime.securesms.profiles.manage.UsernameRepository.UsernameAciFetchResult;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.search.SearchRepository;
import org.thoughtcrime.securesms.util.CommunicationActions;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.UsernameUtil;
@@ -92,14 +88,13 @@ import org.thoughtcrime.securesms.util.adapter.mapping.MappingModelList;
import org.thoughtcrime.securesms.util.views.SimpleProgressDialog;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.stream.Collectors;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.function.Consumer;
import java.util.stream.Collectors;
import io.reactivex.rxjava3.disposables.Disposable;
import kotlin.Unit;
@@ -126,22 +121,17 @@ public final class ContactSelectionListFragment extends LoggingFragment {
private SwipeRefreshLayout swipeRefresh;
private String cursorFilter;
private ContactSearchView contactSearchView;
private RecyclerViewFastScroller fastScroller;
private RecyclerView chipRecycler;
private OnSelectionLimitReachedListener onSelectionLimitReachedListener;
private MappingAdapter contactChipAdapter;
private ContactChipViewModel contactChipViewModel;
private LifecycleDisposable lifecycleDisposable;
private HeaderActionProvider headerActionProvider;
private TextView headerActionView;
private ContactSearchViewModel contactSearchViewModel;
@Nullable private RecyclerView innerRecyclerView;
@Nullable private LinearLayoutManager innerLayoutManager;
@Nullable private NewConversationCallback newConversationCallback;
@Nullable private FindByCallback findByCallback;
@Nullable private NewCallCallback newCallCallback;
@Nullable private ScrollCallback scrollCallback;
@Nullable private OnItemLongClickListener onItemLongClickListener;
private SelectionLimits selectionLimit = SelectionLimits.NO_LIMITS;
private Set<RecipientId> currentSelection;
@@ -168,14 +158,6 @@ public final class ContactSelectionListFragment extends LoggingFragment {
setNewCallCallback((NewCallCallback) context);
}
if (getParentFragment() instanceof ScrollCallback) {
setScrollCallback((ScrollCallback) getParentFragment());
}
if (context instanceof ScrollCallback) {
setScrollCallback((ScrollCallback) context);
}
if (getParentFragment() instanceof OnContactSelectedListener) {
setOnContactSelectedListener((OnContactSelectedListener) getParentFragment());
}
@@ -221,10 +203,6 @@ public final class ContactSelectionListFragment extends LoggingFragment {
this.newCallCallback = callback;
}
public void setScrollCallback(@Nullable ScrollCallback callback) {
this.scrollCallback = callback;
}
public void setOnContactSelectedListener(@Nullable OnContactSelectedListener listener) {
this.onContactSelectedListener = listener;
}
@@ -259,10 +237,8 @@ public final class ContactSelectionListFragment extends LoggingFragment {
emptyText = view.findViewById(android.R.id.empty);
contactSearchView = view.findViewById(R.id.recycler_view);
swipeRefresh = view.findViewById(R.id.swipe_refresh);
fastScroller = view.findViewById(R.id.fast_scroller);
chipRecycler = view.findViewById(R.id.chipRecycler);
constraintLayout = view.findViewById(R.id.container);
headerActionView = view.findViewById(R.id.header_action);
contactChipViewModel = new ViewModelProvider(this).get(ContactChipViewModel.class);
contactChipAdapter = new MappingAdapter();
@@ -309,133 +285,6 @@ public final class ContactSelectionListFragment extends LoggingFragment {
)
).get(ContactSearchViewModel.class);
List<RecyclerView.OnScrollListener> scrollListeners = new ArrayList<>();
final HeaderAction headerAction;
if (headerActionProvider != null) {
headerAction = headerActionProvider.getHeaderAction();
headerActionView.setEnabled(true);
headerActionView.setText(headerAction.getLabel());
headerActionView.setCompoundDrawablesRelativeWithIntrinsicBounds(headerAction.getIcon(), 0, 0, 0);
headerActionView.setOnClickListener(v -> headerAction.getAction().run());
scrollListeners.add(new RecyclerView.OnScrollListener() {
private final Rect bounds = new Rect();
@Override
public void onScrolled(@NonNull RecyclerView rv, int dx, int dy) {
if (hideLetterHeaders() || innerLayoutManager == null) {
return;
}
int firstPosition = innerLayoutManager.findFirstVisibleItemPosition();
if (firstPosition == 0) {
View firstChild = rv.getChildAt(0);
rv.getDecoratedBoundsWithMargins(firstChild, bounds);
headerActionView.setTranslationY(bounds.top);
}
}
});
} else {
headerActionView.setEnabled(false);
}
scrollListeners.add(new RecyclerView.OnScrollListener() {
@Override
public void onScrollStateChanged(@NonNull RecyclerView rv, int newState) {
if (newState == RecyclerView.SCROLL_STATE_DRAGGING && scrollCallback != null) {
scrollCallback.onBeginScroll();
}
}
});
float contentBottomPaddingDp = fragmentArgs.getRecyclerPadBottom() != -1
? fragmentArgs.getRecyclerPadBottom() / getResources().getDisplayMetrics().density
: 0f;
ContactSearchAdapter.AdapterFactory adapterFactory =
(context, fc, displayOptions, callbacks, longClickCallbacks, storyContextMenuCallbacks, callButtonClickCallbacks) ->
new ContactSelectionListAdapter(
context,
fc,
displayOptions,
new ContactSelectionListAdapter.OnContactSelectionClick() {
@Override
public void onDismissFindContactsBannerClicked() {
SignalStore.uiHints().markDismissedContactsPermissionBanner();
contactSearchViewModel.refresh();
}
@Override
public void onFindContactsClicked() {
requestContactPermissions();
}
@Override
public void onRefreshContactsClicked() {
if (onRefreshListener != null && !isRefreshing()) {
setRefreshing(true);
onRefreshListener.onRefresh();
}
}
@Override
public void onNewGroupClicked() {
newConversationCallback.onNewGroup(false);
}
@Override
public void onFindByPhoneNumberClicked() {
findByCallback.onFindByPhoneNumber();
}
@Override
public void onFindByUsernameClicked() {
findByCallback.onFindByUsername();
}
@Override
public void onInviteToSignalClicked() {
if (newConversationCallback != null) {
newConversationCallback.onInvite();
}
if (newCallCallback != null) {
newCallCallback.onInvite();
}
}
@Override
public void onStoryClicked(@NonNull View view1, @NonNull ContactSearchData.Story story, boolean isSelected) {
throw new UnsupportedOperationException();
}
@Override
public void onKnownRecipientClicked(@NonNull View view1, @NonNull ContactSearchData.KnownRecipient knownRecipient, boolean isSelected) {
listClickListener.onItemClick(knownRecipient.getContactSearchKey());
}
@Override
public void onExpandClicked(@NonNull ContactSearchData.Expand expand) {
callbacks.onExpandClicked(expand);
}
@Override
public void onUnknownRecipientClicked(@NonNull View view, @NonNull ContactSearchData.UnknownRecipient unknownRecipient, boolean isSelected) {
listClickListener.onItemClick(unknownRecipient.getContactSearchKey());
}
@Override
public void onChatTypeClicked(@NonNull View view, @NonNull ContactSearchData.ChatTypeRow chatTypeRow, boolean isSelected) {
listClickListener.onItemClick(chatTypeRow.getContactSearchKey());
}
},
(anchorView, data) -> listClickListener.onItemLongClick(anchorView, data.getContactSearchKey()),
storyContextMenuCallbacks,
new CallButtonClickCallbacks()
);
contactSearchView.bind(
contactSearchViewModel,
getChildFragmentManager(),
@@ -452,25 +301,83 @@ public final class ContactSelectionListFragment extends LoggingFragment {
onLoadFinished(size);
}
},
Collections.singletonList(new LetterHeaderDecoration(requireContext(), this::hideLetterHeaders)),
contentBottomPaddingDp,
adapterFactory,
scrollListeners,
rv -> {
innerRecyclerView = rv;
innerLayoutManager = (LinearLayoutManager) rv.getLayoutManager();
rv.setItemAnimator(new DefaultItemAnimator() {
@Override
public boolean canReuseUpdatedViewHolder(@NonNull RecyclerView.ViewHolder viewHolder) {
return true;
}
ContactSelectionListModels.composeEntries(
new ContactSelectionListModels.Callback() {
@Override
public void onNewGroupClicked() {
newConversationCallback.onNewGroup(false);
}
@Override
public void onAnimationFinished(@NonNull RecyclerView.ViewHolder viewHolder) {
contactSearchView.setAlpha(1f);
@Override
public void onInviteToSignalClicked() {
if (newConversationCallback != null) {
newConversationCallback.onInvite();
}
if (newCallCallback != null) {
newCallCallback.onInvite();
}
}
@Override
public void onFindContactsClicked() {
requestContactPermissions();
}
@Override
public void onDismissFindContactsBannerClicked() {
SignalStore.uiHints().markDismissedContactsPermissionBanner();
contactSearchViewModel.refresh();
}
@Override
public void onRefreshContactsClicked() {
if (onRefreshListener != null && !isRefreshing()) {
setRefreshing(true);
onRefreshListener.onRefresh();
}
}
@Override
public void onFindByUsernameClicked() {
findByCallback.onFindByUsername();
}
@Override
public void onFindByPhoneNumberClicked() {
findByCallback.onFindByPhoneNumber();
}
}
});
}
),
new ContactSearchAdapter.ClickCallbacks() {
@Override
public void onStoryClicked(@NotNull View view, ContactSearchData.@NotNull Story story, boolean isSelected) {
throw new UnsupportedOperationException();
}
@Override
public void onKnownRecipientClicked(@NotNull View view, ContactSearchData.@NotNull KnownRecipient knownRecipient, boolean isSelected) {
listClickListener.onItemClick(knownRecipient.getContactSearchKey());
}
@Override
public void onExpandClicked(ContactSearchData.@NotNull Expand expand) {
contactSearchViewModel.expandSection(expand.getSectionKey());
}
@Override
public void onChatTypeClicked(@NotNull View view, ContactSearchData.@NotNull ChatTypeRow chatTypeRow, boolean isSelected) {
listClickListener.onItemClick(chatTypeRow.getContactSearchKey());
}
@Override
public void onUnknownRecipientClicked(@NotNull View view, ContactSearchData.@NotNull UnknownRecipient unknownRecipient, boolean isSelected) {
listClickListener.onItemClick(unknownRecipient.getContactSearchKey());
}
},
(anchorView, data) -> listClickListener.onItemLongClick(anchorView, data.getContactSearchKey()),
null,
new CallButtonClickCallbacks()
);
return view;
@@ -595,32 +502,23 @@ public final class ContactSelectionListFragment extends LoggingFragment {
public void reset() {
contactSearchViewModel.clearSelection();
contactSearchViewModel.refresh();
fastScroller.setVisibility(View.GONE);
headerActionView.setVisibility(View.GONE);
contactSearchViewModel.setFastScrollEnabled(false);
}
private void onLoadFinished(int count) {
if (resetPositionOnCommit && innerRecyclerView != null) {
if (resetPositionOnCommit) {
resetPositionOnCommit = false;
innerRecyclerView.scrollToPosition(0);
contactSearchViewModel.requestScrollPosition(0);
}
swipeRefresh.setVisibility(View.VISIBLE);
emptyText.setText(R.string.contact_selection_group_activity__no_contacts);
boolean useFastScroller = count > 20;
if (useFastScroller && innerRecyclerView != null) {
fastScroller.setVisibility(View.VISIBLE);
fastScroller.setRecyclerView(innerRecyclerView);
if (useFastScroller) {
contactSearchViewModel.setFastScrollEnabled(true);
} else {
fastScroller.setRecyclerView(null);
fastScroller.setVisibility(View.GONE);
}
if (headerActionView.isEnabled() && !hasQueryFilter()) {
headerActionView.setVisibility(View.VISIBLE);
} else {
headerActionView.setVisibility(View.GONE);
contactSearchViewModel.setFastScrollEnabled(false);
}
}
@@ -790,8 +688,8 @@ public final class ContactSelectionListFragment extends LoggingFragment {
}
public boolean onItemLongClick(View anchorView, ContactSearchKey item) {
if (onItemLongClickListener != null && innerRecyclerView != null) {
return onItemLongClickListener.onLongClick(anchorView, item, innerRecyclerView);
if (onItemLongClickListener != null) {
return onItemLongClickListener.onLongClick(anchorView, item, isDisplayingContextMenu -> contactSearchViewModel.setDisplayingContextMenu(isDisplayingContextMenu));
} else {
return false;
}
@@ -933,19 +831,19 @@ public final class ContactSelectionListFragment extends LoggingFragment {
!SignalStore.uiHints().getDismissedContactsPermissionBanner() &&
!hasQuery)
{
builder.arbitrary(ContactSelectionListAdapter.ArbitraryRepository.ArbitraryRow.FIND_CONTACTS_BANNER.getCode());
builder.arbitrary(ContactSelectionListModels.ArbitraryRow.FIND_CONTACTS_BANNER.getCode());
}
if (fragmentArgs.getEnableCreateNewGroup() && !hasQuery) {
builder.arbitrary(ContactSelectionListAdapter.ArbitraryRepository.ArbitraryRow.NEW_GROUP.getCode());
builder.arbitrary(ContactSelectionListModels.ArbitraryRow.NEW_GROUP.getCode());
}
if (fragmentArgs.getEnableFindByUsername() && !hasQuery) {
builder.arbitrary(ContactSelectionListAdapter.ArbitraryRepository.ArbitraryRow.FIND_BY_USERNAME.getCode());
builder.arbitrary(ContactSelectionListModels.ArbitraryRow.FIND_BY_USERNAME.getCode());
}
if (fragmentArgs.getEnableFindByPhoneNumber() && !hasQuery) {
builder.arbitrary(ContactSelectionListAdapter.ArbitraryRepository.ArbitraryRow.FIND_BY_PHONE_NUMBER.getCode());
builder.arbitrary(ContactSelectionListModels.ArbitraryRow.FIND_BY_PHONE_NUMBER.getCode());
}
if (includeChatTypes && !hasQuery) {
@@ -967,10 +865,12 @@ public final class ContactSelectionListFragment extends LoggingFragment {
}
boolean hideHeader = newCallCallback != null || (newConversationCallback != null && !hasQuery);
HeaderAction sectionHeaderAction = (headerActionProvider != null && !hasQuery) ? headerActionProvider.getHeaderAction() : null;
builder.addSection(new ContactSearchConfiguration.Section.Individuals(
includeSelf ? new RecipientTable.IncludeSelfMode.IncludeWithRemap(getString(R.string.note_to_self)) : RecipientTable.IncludeSelfMode.Exclude.INSTANCE,
transportType,
!hideHeader,
sectionHeaderAction,
null,
!hideLetterHeaders(),
newConversationCallback != null ? ContactSearchSortOrder.RECENCY : ContactSearchSortOrder.NATURAL
@@ -1017,13 +917,13 @@ public final class ContactSelectionListFragment extends LoggingFragment {
}
private void addMoreSection(@NonNull ContactSearchConfiguration.Builder builder) {
builder.arbitrary(ContactSelectionListAdapter.ArbitraryRepository.ArbitraryRow.MORE_HEADING.getCode());
builder.arbitrary(ContactSelectionListModels.ArbitraryRow.MORE_HEADING.getCode());
if (hasContactsPermissions(requireContext())) {
builder.arbitrary(ContactSelectionListAdapter.ArbitraryRepository.ArbitraryRow.REFRESH_CONTACTS.getCode());
builder.arbitrary(ContactSelectionListModels.ArbitraryRow.REFRESH_CONTACTS.getCode());
} else if (SignalStore.uiHints().getDismissedContactsPermissionBanner()) {
builder.arbitrary(ContactSelectionListAdapter.ArbitraryRepository.ArbitraryRow.FIND_CONTACTS.getCode());
builder.arbitrary(ContactSelectionListModels.ArbitraryRow.FIND_CONTACTS.getCode());
}
builder.arbitrary(ContactSelectionListAdapter.ArbitraryRepository.ArbitraryRow.INVITE_TO_SIGNAL.getCode());
builder.arbitrary(ContactSelectionListModels.ArbitraryRow.INVITE_TO_SIGNAL.getCode());
}
private static @Nullable ContactSearchConfiguration.TransportType resolveTransportType(boolean includePushContacts, boolean includeSmsContacts) {
@@ -1113,15 +1013,11 @@ public final class ContactSelectionListFragment extends LoggingFragment {
void onInvite();
}
public interface ScrollCallback {
void onBeginScroll();
}
public interface HeaderActionProvider {
@NonNull HeaderAction getHeaderAction();
}
public interface OnItemLongClickListener {
boolean onLongClick(View anchorView, ContactSearchKey contactSearchKey, RecyclerView recyclerView);
boolean onLongClick(View anchorView, ContactSearchKey contactSearchKey, Consumer<Boolean> setIsDisplayingContextMenu);
}
}
@@ -0,0 +1,299 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms
import android.view.View
import android.widget.FrameLayout
import android.widget.TextView
import com.google.android.material.button.MaterialButton
import org.thoughtcrime.securesms.contacts.paged.ContactSearchModels.EmptyModel
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel
import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder
import org.thoughtcrime.securesms.util.adapter.mapping.compose.MappingEntryProvider
import org.thoughtcrime.securesms.util.adapter.mapping.compose.MappingEntryProviderBuilder
/**
* Holds the [MappingModel]s and [MappingViewHolder]s used by [ContactSelectionListAdapter] on top of
* the base set in [org.thoughtcrime.securesms.contacts.paged.ContactSearchModels], along with helpers
* for registering them on a [MappingAdapter] (RecyclerView) or building a [MappingEntryProvider]
* (Compose).
*/
object ContactSelectionListModels {
fun registerNewGroup(mappingAdapter: MappingAdapter, onClick: () -> Unit) {
mappingAdapter.registerFactory(
NewGroupModel::class.java,
LayoutFactory({ NewGroupViewHolder(it, onClick) }, R.layout.contact_selection_new_group_item)
)
}
fun registerInviteToSignal(mappingAdapter: MappingAdapter, onClick: () -> Unit) {
mappingAdapter.registerFactory(
InviteToSignalModel::class.java,
LayoutFactory({ InviteToSignalViewHolder(it, onClick) }, R.layout.contact_selection_invite_action_item)
)
}
fun registerFindContacts(mappingAdapter: MappingAdapter, onClick: () -> Unit) {
mappingAdapter.registerFactory(
FindContactsModel::class.java,
LayoutFactory({ FindContactsViewHolder(it, onClick) }, R.layout.contact_selection_find_contacts_item)
)
}
fun registerFindContactsBanner(mappingAdapter: MappingAdapter, onDismiss: () -> Unit, onClick: () -> Unit) {
mappingAdapter.registerFactory(
FindContactsBannerModel::class.java,
LayoutFactory({ FindContactsBannerViewHolder(it, onDismiss, onClick) }, R.layout.contact_selection_find_contacts_banner_item)
)
}
fun registerRefreshContacts(mappingAdapter: MappingAdapter, onClick: () -> Unit) {
mappingAdapter.registerFactory(
RefreshContactsModel::class.java,
LayoutFactory({ RefreshContactsViewHolder(it, onClick) }, R.layout.contact_selection_refresh_action_item)
)
}
fun registerMoreHeader(mappingAdapter: MappingAdapter) {
mappingAdapter.registerFactory(
MoreHeaderModel::class.java,
LayoutFactory({ MoreHeaderViewHolder(it) }, R.layout.contact_search_section_header)
)
}
fun registerEmpty(mappingAdapter: MappingAdapter) {
mappingAdapter.registerFactory(
EmptyModel::class.java,
LayoutFactory({ EmptyViewHolder(it) }, R.layout.contact_selection_empty_state)
)
}
fun registerFindByUsername(mappingAdapter: MappingAdapter, onClick: () -> Unit) {
mappingAdapter.registerFactory(
FindByUsernameModel::class.java,
LayoutFactory({ FindByUsernameViewHolder(it, onClick) }, R.layout.contact_selection_find_by_username_item)
)
}
fun registerFindByPhoneNumber(mappingAdapter: MappingAdapter, onClick: () -> Unit) {
mappingAdapter.registerFactory(
FindByPhoneNumberModel::class.java,
LayoutFactory({ FindByPhoneNumberViewHolder(it, onClick) }, R.layout.contact_selection_find_by_phone_number_item)
)
}
/**
* Returns a [MappingEntryProvider] containing the same set of view holders registered by the
* adapter-side `register*` methods, suitable for use with a Compose `MappingLazyColumn`.
*/
@JvmStatic
fun composeEntries(
callback: Callback
): MappingEntryProvider<Any> {
return MappingEntryProviderBuilder<Any>().apply {
viewHolder<NewGroupModel> { context ->
LayoutFactory(
{ view -> NewGroupViewHolder(view, callback::onNewGroupClicked) },
R.layout.contact_selection_new_group_item
).createViewHolder(FrameLayout(context))
}
viewHolder<InviteToSignalModel> { context ->
LayoutFactory(
{ view -> InviteToSignalViewHolder(view, callback::onInviteToSignalClicked) },
R.layout.contact_selection_invite_action_item
).createViewHolder(FrameLayout(context))
}
viewHolder<FindContactsModel> { context ->
LayoutFactory(
{ view -> FindContactsViewHolder(view, callback::onFindContactsClicked) },
R.layout.contact_selection_find_contacts_item
).createViewHolder(FrameLayout(context))
}
viewHolder<FindContactsBannerModel> { context ->
LayoutFactory(
{ view -> FindContactsBannerViewHolder(view, callback::onDismissFindContactsBannerClicked, callback::onFindContactsClicked) },
R.layout.contact_selection_find_contacts_banner_item
).createViewHolder(FrameLayout(context))
}
viewHolder<RefreshContactsModel> { context ->
LayoutFactory(
{ view -> RefreshContactsViewHolder(view, callback::onRefreshContactsClicked) },
R.layout.contact_selection_refresh_action_item
).createViewHolder(FrameLayout(context))
}
viewHolder<MoreHeaderModel> { context ->
LayoutFactory(
{ view -> MoreHeaderViewHolder(view) },
R.layout.contact_search_section_header
).createViewHolder(FrameLayout(context))
}
viewHolder<EmptyModel> { context ->
LayoutFactory(
{ view -> EmptyViewHolder(view) },
R.layout.contact_selection_empty_state
).createViewHolder(FrameLayout(context))
}
viewHolder<FindByUsernameModel> { context ->
LayoutFactory(
{ view -> FindByUsernameViewHolder(view, callback::onFindByUsernameClicked) },
R.layout.contact_selection_find_by_username_item
).createViewHolder(FrameLayout(context))
}
viewHolder<FindByPhoneNumberModel> { context ->
LayoutFactory(
{ view -> FindByPhoneNumberViewHolder(view, callback::onFindByPhoneNumberClicked) },
R.layout.contact_selection_find_by_phone_number_item
).createViewHolder(FrameLayout(context))
}
}.build()
}
interface Callback {
fun onNewGroupClicked()
fun onInviteToSignalClicked()
fun onFindContactsClicked()
fun onDismissFindContactsBannerClicked()
fun onRefreshContactsClicked()
fun onFindByUsernameClicked()
fun onFindByPhoneNumberClicked()
}
enum class ArbitraryRow(val code: String) {
NEW_GROUP("new-group"),
INVITE_TO_SIGNAL("invite-to-signal"),
MORE_HEADING("more-heading"),
REFRESH_CONTACTS("refresh-contacts"),
FIND_CONTACTS("find-contacts"),
FIND_CONTACTS_BANNER("find-contacts-banner"),
FIND_BY_USERNAME("find-by-username"),
FIND_BY_PHONE_NUMBER("find-by-phone-number");
companion object {
fun fromCode(code: String) = entries.first { it.code == code }
}
}
class NewGroupModel : MappingModel<NewGroupModel> {
override fun areItemsTheSame(newItem: NewGroupModel): Boolean = true
override fun areContentsTheSame(newItem: NewGroupModel): Boolean = true
}
class InviteToSignalModel : MappingModel<InviteToSignalModel> {
override fun areItemsTheSame(newItem: InviteToSignalModel): Boolean = true
override fun areContentsTheSame(newItem: InviteToSignalModel): Boolean = true
}
class RefreshContactsModel : MappingModel<RefreshContactsModel> {
override fun areItemsTheSame(newItem: RefreshContactsModel): Boolean = true
override fun areContentsTheSame(newItem: RefreshContactsModel): Boolean = true
}
class FindContactsModel : MappingModel<FindContactsModel> {
override fun areItemsTheSame(newItem: FindContactsModel): Boolean = true
override fun areContentsTheSame(newItem: FindContactsModel): Boolean = true
}
class FindContactsBannerModel : MappingModel<FindContactsBannerModel> {
override fun areItemsTheSame(newItem: FindContactsBannerModel): Boolean = true
override fun areContentsTheSame(newItem: FindContactsBannerModel): Boolean = true
}
class FindByUsernameModel : MappingModel<FindByUsernameModel> {
override fun areItemsTheSame(newItem: FindByUsernameModel): Boolean = true
override fun areContentsTheSame(newItem: FindByUsernameModel): Boolean = true
}
class FindByPhoneNumberModel : MappingModel<FindByPhoneNumberModel> {
override fun areItemsTheSame(newItem: FindByPhoneNumberModel): Boolean = true
override fun areContentsTheSame(newItem: FindByPhoneNumberModel): Boolean = true
}
class MoreHeaderModel : MappingModel<MoreHeaderModel> {
override fun areItemsTheSame(newItem: MoreHeaderModel): Boolean = true
override fun areContentsTheSame(newItem: MoreHeaderModel): Boolean = true
}
private class InviteToSignalViewHolder(itemView: View, onClickListener: () -> Unit) : MappingViewHolder<InviteToSignalModel>(itemView) {
init {
itemView.setOnClickListener { onClickListener() }
}
override fun bind(model: InviteToSignalModel) = Unit
}
private class NewGroupViewHolder(itemView: View, onClickListener: () -> Unit) : MappingViewHolder<NewGroupModel>(itemView) {
init {
itemView.setOnClickListener { onClickListener() }
}
override fun bind(model: NewGroupModel) = Unit
}
private class RefreshContactsViewHolder(itemView: View, onClickListener: () -> Unit) : MappingViewHolder<RefreshContactsModel>(itemView) {
init {
itemView.setOnClickListener { onClickListener() }
}
override fun bind(model: RefreshContactsModel) = Unit
}
private class FindContactsViewHolder(itemView: View, onClickListener: () -> Unit) : MappingViewHolder<FindContactsModel>(itemView) {
init {
itemView.setOnClickListener { onClickListener() }
}
override fun bind(model: FindContactsModel) = Unit
}
private class FindContactsBannerViewHolder(itemView: View, onDismissListener: () -> Unit, onClickListener: () -> Unit) : MappingViewHolder<FindContactsBannerModel>(itemView) {
init {
itemView.findViewById<MaterialButton>(R.id.no_thanks_button).setOnClickListener { onDismissListener() }
itemView.findViewById<MaterialButton>(R.id.allow_contacts_button).setOnClickListener { onClickListener() }
}
override fun bind(model: FindContactsBannerModel) = Unit
}
private class MoreHeaderViewHolder(itemView: View) : MappingViewHolder<MoreHeaderModel>(itemView) {
private val headerTextView: TextView = itemView.findViewById(R.id.section_header)
override fun bind(model: MoreHeaderModel) {
headerTextView.setText(R.string.contact_selection_activity__more)
}
}
private class EmptyViewHolder(itemView: View) : MappingViewHolder<EmptyModel>(itemView) {
private val emptyText: TextView = itemView.findViewById(R.id.search_no_results)
override fun bind(model: EmptyModel) {
emptyText.text = context.getString(R.string.SearchFragment_no_results, model.empty.query ?: "")
}
}
private class FindByPhoneNumberViewHolder(itemView: View, onClickListener: () -> Unit) : MappingViewHolder<FindByPhoneNumberModel>(itemView) {
init {
itemView.setOnClickListener { onClickListener() }
}
override fun bind(model: FindByPhoneNumberModel) = Unit
}
private class FindByUsernameViewHolder(itemView: View, onClickListener: () -> Unit) : MappingViewHolder<FindByUsernameModel>(itemView) {
init {
itemView.setOnClickListener { onClickListener() }
}
override fun bind(model: FindByUsernameModel) = Unit
}
}
@@ -87,6 +87,7 @@ import org.signal.core.ui.compose.Snackbars
import org.signal.core.ui.compose.theme.SignalTheme
import org.signal.core.ui.permissions.Permissions
import org.signal.core.ui.rememberIsSplitPane
import org.signal.core.util.AppForegroundObserver
import org.signal.core.util.Util
import org.signal.core.util.concurrent.LifecycleDisposable
import org.signal.core.util.getParcelableCompat
@@ -116,6 +117,7 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.GooglePay
import org.thoughtcrime.securesms.components.snackbars.LocalSnackbarStateConsumerRegistry
import org.thoughtcrime.securesms.components.snackbars.SnackbarHostKey
import org.thoughtcrime.securesms.components.snackbars.SnackbarState
import org.thoughtcrime.securesms.components.verificationrequested.VerificationCodeRequestedBottomSheet
import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaController
import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaControllerOwner
import org.thoughtcrime.securesms.conversation.ConversationIntents
@@ -178,7 +180,6 @@ import org.thoughtcrime.securesms.stories.Stories
import org.thoughtcrime.securesms.stories.archive.StoryArchiveActivity
import org.thoughtcrime.securesms.stories.landing.StoriesLandingFragment
import org.thoughtcrime.securesms.stories.settings.StorySettingsActivity
import org.thoughtcrime.securesms.util.AppForegroundObserver
import org.thoughtcrime.securesms.util.AppStartup
import org.thoughtcrime.securesms.util.CachedInflater
import org.thoughtcrime.securesms.util.CommunicationActions
@@ -195,6 +196,7 @@ import org.thoughtcrime.securesms.window.AppScaffoldNavigator
import org.thoughtcrime.securesms.window.NavigationType
import org.thoughtcrime.securesms.window.rememberThreePaneScaffoldNavigatorDelegate
import org.whispersystems.signalservice.api.websocket.WebSocketConnectionState
import kotlin.time.Duration.Companion.minutes
import org.signal.core.ui.R as CoreUiR
class MainActivity :
@@ -357,6 +359,25 @@ class MainActivity :
}
}
}
launch {
repeatOnLifecycle(Lifecycle.State.RESUMED) {
SignalStore
.account
.verificationCodeRequestedAtMsFlow
.filter { it > 0L }
.collect { requestedAt ->
val notificationThreshold = requestedAt + 10.minutes.inWholeMilliseconds
if (System.currentTimeMillis() < notificationThreshold) {
VerificationCodeRequestedBottomSheet.show(supportFragmentManager, requestedAt)
} else {
Log.i(TAG, "Verification code requested but is older than 10 minutes, not showing sheet")
}
SignalStore.account.verificationCodeRequestedAtMs = 0L
}
}
}
}
supportFragmentManager.setFragmentResultListener(
@@ -33,7 +33,7 @@ import org.thoughtcrime.securesms.registration.ui.RegistrationActivity;
import org.thoughtcrime.securesms.util.Environment;
import org.thoughtcrime.securesms.restore.RestoreActivity;
import org.thoughtcrime.securesms.service.KeyCachingService;
import org.thoughtcrime.securesms.util.AppForegroundObserver;
import org.signal.core.util.AppForegroundObserver;
import org.thoughtcrime.securesms.util.AppStartup;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
@@ -10,12 +10,15 @@ import androidx.annotation.Nullable;
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.app.TaskStackBuilder;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.util.CommunicationActions;
public class ShortcutLauncherActivity extends AppCompatActivity {
private static final String TAG = Log.tag(ShortcutLauncherActivity.class);
private static final String KEY_RECIPIENT = "recipient_id";
public static Intent createIntent(@NonNull Context context, @NonNull RecipientId recipientId) {
@@ -30,9 +33,18 @@ public class ShortcutLauncherActivity extends AppCompatActivity {
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
String rawId = getIntent().getStringExtra(KEY_RECIPIENT);
String rawId = getIntent().getStringExtra(KEY_RECIPIENT);
RecipientId recipientId = null;
if (rawId == null) {
if (rawId != null) {
try {
recipientId = RecipientId.from(rawId);
} catch (Throwable t) {
Log.w(TAG, "Failed to parse recipientId from intent.", t);
}
}
if (recipientId == null) {
Toast.makeText(this, R.string.ShortcutLauncherActivity_invalid_shortcut, Toast.LENGTH_SHORT).show();
// TODO [greyson] Navigation
startActivity(MainActivity.clearTop(this));
@@ -40,7 +52,7 @@ public class ShortcutLauncherActivity extends AppCompatActivity {
return;
}
Recipient recipient = Recipient.live(RecipientId.from(rawId)).get();
Recipient recipient = Recipient.live(recipientId).get();
// TODO [greyson] Navigation
TaskStackBuilder backStack = TaskStackBuilder.create(this)
.addNextIntent(MainActivity.clearTop(this));
@@ -11,6 +11,7 @@ import android.content.Context
import android.content.Intent
import android.content.pm.PackageInstaller
import android.os.Build
import org.signal.core.util.AppForegroundObserver
import org.signal.core.util.PendingIntentFlags
import org.signal.core.util.StreamUtil
import org.signal.core.util.getDownloadManager
@@ -18,7 +19,6 @@ import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobs.ApkUpdateJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.AppForegroundObserver
import org.thoughtcrime.securesms.util.FileUtils
import java.io.FileInputStream
import java.io.IOException
@@ -12,12 +12,12 @@ import android.content.Intent
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import org.signal.core.util.PendingIntentFlags
import org.signal.core.util.ServiceUtil
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.MainActivity
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.notifications.NotificationChannels
import org.thoughtcrime.securesms.notifications.NotificationIds
import org.thoughtcrime.securesms.util.ServiceUtil
object ApkUpdateNotifications {
@@ -10,11 +10,11 @@ import android.os.Parcelable
import androidx.core.os.ParcelCompat
import org.signal.blurhash.BlurHash
import org.signal.core.models.media.TransformProperties
import org.signal.core.util.ParcelUtil
import org.signal.core.util.UuidUtil
import org.thoughtcrime.securesms.audio.AudioHash
import org.thoughtcrime.securesms.database.AttachmentTable
import org.thoughtcrime.securesms.stickers.StickerLocator
import org.thoughtcrime.securesms.util.ParcelUtil
import java.util.UUID
import kotlin.time.Duration.Companion.days
import kotlin.time.Duration.Companion.milliseconds
@@ -5,11 +5,11 @@ import android.os.Parcel
import androidx.core.os.ParcelCompat
import org.signal.blurhash.BlurHash
import org.signal.core.models.media.TransformProperties
import org.signal.core.util.ParcelUtil
import org.thoughtcrime.securesms.audio.AudioHash
import org.thoughtcrime.securesms.database.AttachmentTable
import org.thoughtcrime.securesms.mms.PartAuthority
import org.thoughtcrime.securesms.stickers.StickerLocator
import org.thoughtcrime.securesms.util.ParcelUtil
import java.util.UUID
class DatabaseAttachment : Attachment {
@@ -7,7 +7,7 @@ import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.thoughtcrime.securesms.database.model.databaseprotos.AudioWaveFormData;
import org.thoughtcrime.securesms.util.ParcelUtil;
import org.signal.core.util.ParcelUtil;
import org.signal.core.util.Base64;
import java.io.IOException;
@@ -7,7 +7,7 @@ import android.media.AudioManager
import android.media.AudioManager.OnAudioFocusChangeListener
import android.os.Build
import androidx.annotation.RequiresApi
import org.thoughtcrime.securesms.util.ServiceUtil
import org.signal.core.util.ServiceUtil
abstract class AudioRecorderFocusManager(val context: Context) {
protected val audioManager: AudioManager = ServiceUtil.getAudioManager(context)
@@ -6,8 +6,8 @@
package org.thoughtcrime.securesms.backup.v2
import androidx.annotation.VisibleForTesting
import org.signal.core.util.ThrottledDebouncer
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.util.ThrottledDebouncer
import java.util.concurrent.ExecutionException
import java.util.concurrent.Executors
import kotlin.time.Duration.Companion.seconds
@@ -40,6 +40,7 @@ import org.signal.core.util.CursorUtil
import org.signal.core.util.DiskUtil
import org.signal.core.util.EventTimer
import org.signal.core.util.PendingIntentFlags.cancelCurrent
import org.signal.core.util.ServiceUtil
import org.signal.core.util.Stopwatch
import org.signal.core.util.bytes
import org.signal.core.util.concurrent.LimitedWorker
@@ -66,7 +67,12 @@ import org.signal.libsignal.messagebackup.BackupForwardSecrecyToken
import org.signal.libsignal.zkgroup.VerificationFailedException
import org.signal.libsignal.zkgroup.backups.BackupLevel
import org.signal.libsignal.zkgroup.profiles.ProfileKey
import org.signal.network.ApplicationErrorAction
import org.signal.network.NetworkResult
import org.signal.network.StatusCodeErrorAction
import org.signal.network.api.SvrBApi
import org.signal.network.exceptions.NonSuccessfulResponseCodeException
import org.signal.network.rest.toNetworkResult
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.attachments.AttachmentId
import org.thoughtcrime.securesms.attachments.Cdn
@@ -142,12 +148,8 @@ import org.thoughtcrime.securesms.service.BackupMediaRestoreService
import org.thoughtcrime.securesms.service.BackupProgressService
import org.thoughtcrime.securesms.storage.StorageSyncHelper
import org.thoughtcrime.securesms.util.RemoteConfig
import org.thoughtcrime.securesms.util.ServiceUtil
import org.thoughtcrime.securesms.util.TextSecurePreferences
import org.thoughtcrime.securesms.util.toMillis
import org.whispersystems.signalservice.api.ApplicationErrorAction
import org.whispersystems.signalservice.api.NetworkResult
import org.whispersystems.signalservice.api.StatusCodeErrorAction
import org.whispersystems.signalservice.api.archive.ArchiveGetMediaItemsResponse
import org.whispersystems.signalservice.api.archive.ArchiveKeyRotationLimitResponse
import org.whispersystems.signalservice.api.archive.ArchiveMediaRequest
@@ -161,7 +163,6 @@ import org.whispersystems.signalservice.api.crypto.AttachmentCipherStreamUtil
import org.whispersystems.signalservice.api.link.TransferArchiveResponse
import org.whispersystems.signalservice.api.messages.AttachmentTransferProgress
import org.whispersystems.signalservice.api.messages.SignalServiceAttachment.ProgressListener
import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException
import org.whispersystems.signalservice.internal.crypto.PaddingInputStream
import org.whispersystems.signalservice.internal.push.AttachmentUploadForm
import org.whispersystems.signalservice.internal.push.AuthCredentials
@@ -1628,19 +1629,6 @@ object BackupRepository {
}
}
fun getResumableMessagesBackupUploadSpec(backupFileSize: Long): NetworkResult<ResumableMessagesBackupUploadSpec> {
return initBackupAndFetchAuth()
.then { credential ->
SignalNetwork.archive.getMessageBackupUploadForm(SignalStore.account.requireAci(), credential.messageBackupAccess, backupFileSize)
.also { Log.i(TAG, "UploadFormResult: ${it::class.simpleName}") }
}
.then { form ->
SignalNetwork.archive.getBackupResumableUploadUrl(form)
.also { Log.i(TAG, "ResumableUploadUrlResult: ${it::class.simpleName}") }
.map { ResumableMessagesBackupUploadSpec(attachmentUploadForm = form, resumableUri = it) }
}
}
fun getMessageBackupUploadForm(backupFileSize: Long): NetworkResult<AttachmentUploadForm> {
return initBackupAndFetchAuth()
.then { credential ->
@@ -61,7 +61,7 @@ class ChatArchiveExporter(private val cursor: Cursor, private val db: SignalData
expirationTimerMs = cursor.requireLong(RecipientTable.MESSAGE_EXPIRATION_TIME).seconds.inWholeMilliseconds.takeIf { it > 0 },
expireTimerVersion = cursor.requireInt(RecipientTable.MESSAGE_EXPIRATION_TIME_VERSION),
muteUntilMs = cursor.requireLong(RecipientTable.MUTE_UNTIL).takeIf { it > 0 },
markedUnread = ThreadTable.ReadStatus.deserialize(cursor.requireInt(ThreadTable.READ)) == ThreadTable.ReadStatus.FORCED_UNREAD,
markedUnread = ThreadTable.ReadStatus.deserialize(cursor.requireInt(ThreadTable.READ)) == ThreadTable.ReadStatus.ForcedUnread,
dontNotifyForMentionsIfMuted = RecipientTable.NotificationSetting.DO_NOT_NOTIFY.id == cursor.requireInt(RecipientTable.MENTION_SETTING),
style = ChatStyleConverter.constructRemoteChatStyle(
db = db,
@@ -45,6 +45,7 @@ import org.signal.core.models.ServiceId
import org.signal.core.util.Base64
import org.signal.core.util.EventTimer
import org.signal.core.util.Hex
import org.signal.core.util.JsonUtils
import org.signal.core.util.ParallelEventTimer
import org.signal.core.util.StringUtil
import org.signal.core.util.UuidUtil
@@ -105,7 +106,6 @@ import org.thoughtcrime.securesms.payments.FailureReason
import org.thoughtcrime.securesms.payments.State
import org.thoughtcrime.securesms.polls.PollRecord
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.JsonUtils
import org.thoughtcrime.securesms.util.MediaUtil
import org.thoughtcrime.securesms.util.mb
import java.io.Closeable
@@ -46,7 +46,7 @@ object ChatArchiveImporter {
ThreadTable.RECIPIENT_ID to recipientId.serialize(),
ThreadTable.PINNED_ORDER to chat.pinnedOrder,
ThreadTable.ARCHIVED to chat.archived.toInt(),
ThreadTable.READ to if (chat.markedUnread) ThreadTable.ReadStatus.FORCED_UNREAD.serialize() else ThreadTable.ReadStatus.READ.serialize(),
ThreadTable.READ to if (chat.markedUnread) ThreadTable.ReadStatus.ForcedUnread.serialize() else ThreadTable.ReadStatus.Read.serialize(),
ThreadTable.ACTIVE to 1
)
.run()
@@ -27,6 +27,7 @@ import org.signal.archive.proto.ViewOnceMessage
import org.signal.core.models.ServiceId
import org.signal.core.util.Base64
import org.signal.core.util.Hex
import org.signal.core.util.JsonUtils
import org.signal.core.util.SqlUtil
import org.signal.core.util.UuidUtil
import org.signal.core.util.asList
@@ -81,7 +82,6 @@ import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.stickers.StickerLocator
import org.thoughtcrime.securesms.util.Environment
import org.thoughtcrime.securesms.util.JsonUtils
import org.thoughtcrime.securesms.util.MessageUtil
import org.whispersystems.signalservice.api.payments.Money
import org.whispersystems.signalservice.internal.push.DataMessage
@@ -156,8 +156,7 @@ object AccountDataArchiveProcessor {
navigationBarSize = signalStore.settingsValues.useCompactNavigationBar.toRemoteNavigationBarSize()
).takeUnless { Environment.IS_INSTRUMENTATION && SignalStore.backup.importedEmptyAndroidSettings },
bioText = selfRecord.about ?: "",
bioEmoji = selfRecord.aboutEmoji ?: "",
keyTransparencyData = selfRecord.keyTransparencyData?.toByteString()
bioEmoji = selfRecord.aboutEmoji ?: ""
)
)
)
@@ -251,7 +250,7 @@ object AccountDataArchiveProcessor {
SignalStore.account.usernameLink = null
}
SignalDatabase.recipients.setKeyTransparencyData(Recipient.self().aci.get(), accountData.keyTransparencyData?.toByteArray())
SignalDatabase.recipients.clearSelfKeyTransparencyData()
SignalDatabase.runPostSuccessfulTransaction { ProfileUtil.handleSelfProfileKeyChange() }
@@ -93,7 +93,8 @@ fun EnterKeyScreen(
val updateEnteredBackupKey = { input: String ->
enteredBackupKey = AccountEntropyPool.removeIllegalCharacters(input).uppercase()
isBackupKeyValid = enteredBackupKey == backupKey
val normalized = AccountEntropyPool.formatForStorage(enteredBackupKey)
isBackupKeyValid = normalized.equals(AccountEntropyPool.formatForStorage(backupKey), ignoreCase = true)
showError = !isBackupKeyValid && enteredBackupKey.length >= backupKey.length
}
@@ -52,6 +52,7 @@ import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.Scaffolds
import org.signal.core.ui.compose.SignalIcons
import org.signal.core.ui.compose.theme.SignalTheme
import org.signal.core.util.ByteUnit
import org.signal.core.util.billing.BillingResponseCode
import org.signal.core.util.bytes
import org.signal.core.util.money.FiatMoney
@@ -60,7 +61,6 @@ import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
import org.thoughtcrime.securesms.fonts.SignalSymbols
import org.thoughtcrime.securesms.fonts.SignalSymbols.signalSymbolText
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
import org.thoughtcrime.securesms.util.ByteUnit
import java.math.BigDecimal
import java.util.Currency
import kotlin.time.Duration.Companion.days
@@ -13,6 +13,7 @@ import com.google.android.material.button.MaterialButton
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.subjects.PublishSubject
import org.signal.core.util.Debouncer
import org.signal.core.util.concurrent.LifecycleDisposable
import org.signal.core.util.getParcelableCompat
import org.signal.core.util.money.FiatMoney
@@ -39,7 +40,6 @@ import org.thoughtcrime.securesms.keyboard.emoji.EmojiKeyboardPageFragment
import org.thoughtcrime.securesms.keyboard.emoji.search.EmojiSearchFragment
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
import org.thoughtcrime.securesms.payments.currency.CurrencyUtil
import org.thoughtcrime.securesms.util.Debouncer
import org.thoughtcrime.securesms.util.activityViewModel
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import org.thoughtcrime.securesms.util.navigation.safeNavigate
@@ -14,13 +14,13 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Previews
import org.signal.core.util.ServiceUtil
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.banner.Banner
import org.thoughtcrime.securesms.banner.ui.compose.Action
import org.thoughtcrime.securesms.banner.ui.compose.DefaultBanner
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.PowerManagerCompat
import org.thoughtcrime.securesms.util.ServiceUtil
import org.thoughtcrime.securesms.util.TextSecurePreferences
class DozeBanner(private val context: Context, private val onDismissListener: () -> Unit) : Banner<Unit>() {
@@ -78,7 +78,7 @@ public class BlockedUsersFragment extends Fragment {
}
private void handleRecipientClicked(@NonNull Recipient recipient) {
BlockUnblockDialog.showUnblockFor(requireContext(), getViewLifecycleOwner().getLifecycle(), recipient, () -> {
BlockUnblockDialog.showUnblockFor(requireContext(), recipient, () -> {
viewModel.unblock(recipient.getId());
});
}
@@ -9,14 +9,14 @@ import android.os.Bundle
import android.os.ResultReceiver
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import org.signal.core.util.ThrottledDebouncer
import org.signal.core.util.concurrent.SerialExecutor
import org.signal.core.util.concurrent.SignalExecutors
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.service.webrtc.ActiveCallData
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId
import org.thoughtcrime.securesms.util.ThrottledDebouncer
import org.thoughtcrime.securesms.util.concurrent.SerialExecutor
import java.util.concurrent.Executor
import kotlin.time.Duration.Companion.days
import kotlin.time.Duration.Companion.seconds
@@ -15,6 +15,7 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.signal.core.util.logging.Log
import org.signal.network.NetworkResult
import org.thoughtcrime.securesms.calls.new.NewCallUiState.CallType
import org.thoughtcrime.securesms.calls.new.NewCallUiState.UserMessage
import org.thoughtcrime.securesms.contacts.sync.ContactDiscovery
@@ -25,7 +26,6 @@ import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.recipients.RecipientRepository
import org.thoughtcrime.securesms.recipients.ui.RecipientSelection
import org.whispersystems.signalservice.api.NetworkResult
class NewCallViewModel : ViewModel() {
companion object {
@@ -21,7 +21,7 @@ import androidx.core.widget.TextViewCompat;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.util.EditTextExtensionsKt;
import org.thoughtcrime.securesms.util.ServiceUtil;
import org.signal.core.util.ServiceUtil;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.ViewUtil;
@@ -13,7 +13,7 @@ import org.signal.core.util.ContextUtil;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.emoji.SimpleEmojiTextView;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.util.DrawableUtil;
import org.signal.core.util.DrawableUtil;
import org.thoughtcrime.securesms.util.RemoteConfig;
import org.thoughtcrime.securesms.util.SpanUtil;
import org.thoughtcrime.securesms.util.ViewUtil;
@@ -8,7 +8,7 @@ import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.thoughtcrime.securesms.components.KeyboardAwareLinearLayout.OnKeyboardShownListener;
import org.thoughtcrime.securesms.util.ServiceUtil;
import org.signal.core.util.ServiceUtil;
public class InputAwareLayout extends KeyboardAwareLinearLayout implements OnKeyboardShownListener {
private InputView current;
@@ -30,7 +30,7 @@ import androidx.appcompat.widget.LinearLayoutCompat;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.util.ServiceUtil;
import org.signal.core.util.ServiceUtil;
import org.signal.core.util.Util;
import org.thoughtcrime.securesms.util.ViewUtil;
@@ -12,8 +12,11 @@ import androidx.appcompat.app.AlertDialog
import androidx.constraintlayout.widget.ConstraintLayout
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.progressindicator.CircularProgressIndicator
import org.signal.core.util.dp
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.util.ViewUtil
import org.thoughtcrime.securesms.util.padding
import org.thoughtcrime.securesms.util.visible
/**
* Wraps a normal progress dialog for showing blocking in-progress UI.
@@ -81,9 +84,15 @@ class SignalProgressDialog private constructor(
val progressView: CircularProgressIndicator = customView.findViewById(R.id.progress_dialog_progressbar)
titleView.text = title
titleView.visible = title != null
messageView.text = message
messageView.visible = message != null
progressView.isIndeterminate = indeterminate
if (title == null && message == null) {
progressView.padding(top = 32.dp, bottom = 32.dp)
}
builder.setView(customView)
val dialog = builder.show()
@@ -36,6 +36,7 @@ import com.bumptech.glide.request.Request;
import com.bumptech.glide.request.RequestListener;
import com.bumptech.glide.request.RequestOptions;
import org.signal.core.models.media.TransformProperties;
import org.signal.core.util.concurrent.ListenableFuture;
import org.signal.core.util.concurrent.SettableFuture;
import org.signal.core.util.logging.Log;
@@ -608,7 +609,14 @@ public class ThumbnailView extends FrameLayout {
}
private RequestBuilder<Drawable> buildThumbnailRequestBuilder(@NonNull RequestManager requestManager, @NonNull Slide slide) {
RequestBuilder<Drawable> requestBuilder = applySizing(requestManager.load(new DecryptableUri(Objects.requireNonNull(slide.getDisplayUri())))
long videoTrimStartTimeUs = 0;
TransformProperties transformProperties = slide.asAttachment().transformProperties;
if (transformProperties != null && !transformProperties.shouldSkipTransform()) {
videoTrimStartTimeUs = transformProperties.videoTrimStartTimeUs;
}
RequestBuilder<Drawable> requestBuilder = applySizing(requestManager.load(new DecryptableUri(Objects.requireNonNull(slide.getDisplayUri()), videoTrimStartTimeUs))
.diskCacheStrategy(DiskCacheStrategy.RESOURCE)
.downsample(SignalDownsampleStrategy.CENTER_OUTSIDE_NO_UPSCALE)
.transition(withCrossFade()));
@@ -18,7 +18,7 @@ import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.emoji.EmojiProvider.EmojiDrawable;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.util.EditTextExtensionsKt;
import org.thoughtcrime.securesms.util.ServiceUtil;
import org.signal.core.util.ServiceUtil;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.signal.core.util.Util;
@@ -20,7 +20,7 @@ import org.thoughtcrime.securesms.R;
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.util.DrawableUtil;
import org.signal.core.util.DrawableUtil;
import org.thoughtcrime.securesms.util.ViewUtil;
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel;
@@ -16,7 +16,7 @@ import com.fasterxml.jackson.databind.type.TypeFactory;
import org.signal.core.util.concurrent.SignalExecutors;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.util.JsonUtils;
import org.signal.core.util.JsonUtils;
import java.io.IOException;
import java.util.ArrayList;
@@ -6,9 +6,9 @@ import android.text.Spanned
import android.text.TextUtils
import android.util.AttributeSet
import androidx.appcompat.widget.AppCompatTextView
import org.signal.core.util.ThrottledDebouncer
import org.thoughtcrime.securesms.components.spoiler.SpoilerRendererDelegate
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.ThrottledDebouncer
import java.util.Optional
open class SimpleEmojiTextView @JvmOverloads constructor(
@@ -13,7 +13,7 @@ import com.google.android.gms.maps.model.LatLng;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.maps.AddressData;
import org.thoughtcrime.securesms.util.JsonUtils;
import org.signal.core.util.JsonUtils;
import java.io.IOException;
@@ -14,7 +14,7 @@ import androidx.core.graphics.drawable.DrawableCompat;
import org.signal.core.util.ContextUtil;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.spoiler.SpoilerAnnotation;
import org.thoughtcrime.securesms.util.DrawableUtil;
import org.signal.core.util.DrawableUtil;
import org.signal.core.util.Util;
import org.thoughtcrime.securesms.util.ViewUtil;
@@ -86,6 +86,7 @@ class AppSettingsActivity : DSLSettingsActivity(), GooglePayComponent {
is AppSettingsRoute.BackupsRoute.Backups -> AppSettingsFragmentDirections.actionDirectToBackupsSettingsFragment().setLaunchCheckoutFlow(appSettingsRoute.launchCheckoutFlow)
AppSettingsRoute.Invite -> AppSettingsFragmentDirections.actionDirectToInviteFragment()
AppSettingsRoute.DataAndStorageRoute.DataAndStorage -> AppSettingsFragmentDirections.actionDirectToStoragePreferenceFragment()
AppSettingsRoute.AccountRoute.Account -> AppSettingsFragmentDirections.actionDirectToAccountSettingsFragment()
else -> error("Unsupported start location: ${appSettingsRoute?.javaClass?.name}")
}
}
@@ -177,6 +178,9 @@ class AppSettingsActivity : DSLSettingsActivity(), GooglePayComponent {
@JvmStatic
fun changeNumber(context: Context): Intent = getIntentForStartLocation(context, AppSettingsRoute.ChangeNumberRoute.Start)
@JvmStatic
fun account(context: Context): Intent = getIntentForStartLocation(context, AppSettingsRoute.AccountRoute.Account)
@JvmStatic
fun subscriptions(context: Context): Intent = getIntentForStartLocation(context, AppSettingsRoute.DonationsRoute.Donations(directToCheckoutType = InAppPaymentType.RECURRING_DONATION))
@@ -312,49 +312,49 @@ private fun AppSettingsContent(
enabled = isRegisteredAndUpToDate
)
}
}
item {
val context = LocalContext.current
val donateUrl = stringResource(R.string.donate_url)
item {
val context = LocalContext.current
val donateUrl = stringResource(R.string.donate_url)
Rows.TextRow(
text = {
Text(
text = stringResource(R.string.preferences__donate_to_signal),
modifier = Modifier.weight(1f)
)
Rows.TextRow(
text = {
Text(
text = stringResource(R.string.preferences__donate_to_signal),
modifier = Modifier.weight(1f)
)
if (state.hasExpiredGiftBadge) {
Icon(
painter = painterResource(R.drawable.symbol_info_fill_24),
tint = colorResource(R.color.signal_accent_primary),
contentDescription = null
)
}
},
icon = {
if (state.hasExpiredGiftBadge) {
Icon(
painter = painterResource(R.drawable.symbol_heart_24),
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurface
painter = painterResource(R.drawable.symbol_info_fill_24),
tint = colorResource(R.color.signal_accent_primary),
contentDescription = null
)
},
onClick = {
if (state.allowUserToGoToDonationManagementScreen) {
callbacks.navigate(AppSettingsRoute.DonationsRoute.Donations())
} else {
CommunicationActions.openBrowserLink(context, donateUrl)
}
},
onLongClick = {
callbacks.copyDonorBadgeSubscriberIdToClipboard()
}
)
}
},
icon = {
Icon(
painter = painterResource(R.drawable.symbol_heart_24),
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurface
)
},
onClick = {
if (state.allowUserToGoToDonationManagementScreen) {
callbacks.navigate(AppSettingsRoute.DonationsRoute.Donations())
} else {
CommunicationActions.openBrowserLink(context, donateUrl)
}
},
onLongClick = {
callbacks.copyDonorBadgeSubscriberIdToClipboard()
}
)
}
item {
Dividers.Default()
}
item {
Dividers.Default()
}
item {
@@ -45,6 +45,7 @@ import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.Rows
import org.signal.core.ui.compose.Scaffolds
import org.signal.core.ui.compose.Texts
import org.signal.core.util.ServiceUtil
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.compose.rememberStatusBarColorNestedScrollModifier
import org.thoughtcrime.securesms.contactshare.SimpleTextWatcher
@@ -56,7 +57,6 @@ import org.thoughtcrime.securesms.lock.v2.SvrConstants
import org.thoughtcrime.securesms.pin.RegistrationLockV2Dialog
import org.thoughtcrime.securesms.registration.ui.RegistrationActivity
import org.thoughtcrime.securesms.util.PlayStoreUtil
import org.thoughtcrime.securesms.util.ServiceUtil
import org.thoughtcrime.securesms.util.ViewUtil
import org.thoughtcrime.securesms.util.navigation.safeNavigate
import org.whispersystems.signalservice.api.kbs.PinHashUtil
@@ -5,10 +5,10 @@ import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.databind.node.ObjectNode
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.schedulers.Schedulers
import org.signal.core.util.JsonUtils
import org.signal.network.NetworkResult
import org.thoughtcrime.securesms.net.SignalNetwork
import org.thoughtcrime.securesms.providers.BlobProvider
import org.thoughtcrime.securesms.util.JsonUtils
import org.whispersystems.signalservice.api.NetworkResult
class ExportAccountDataRepository {
@@ -21,6 +21,7 @@ import org.signal.core.util.logging.Log
import org.signal.core.util.money.FiatMoney
import org.signal.core.util.throttleLatest
import org.signal.donations.InAppPaymentType
import org.signal.network.NetworkResult
import org.thoughtcrime.securesms.backup.v2.BackupRepository
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsType
@@ -33,7 +34,6 @@ import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.InternetConnectionObserver
import org.whispersystems.signalservice.api.NetworkResult
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription
import java.math.BigDecimal
import java.util.Currency
@@ -16,12 +16,12 @@ import kotlinx.coroutines.withContext
import org.signal.core.models.AccountEntropyPool
import org.signal.core.util.concurrent.SignalDispatchers
import org.signal.core.util.logging.Log
import org.signal.network.NetworkResult
import org.thoughtcrime.securesms.backup.v2.BackupRepository
import org.thoughtcrime.securesms.backup.v2.StagedBackupKeyRotations
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobs.RestoreOptimizedMediaJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.whispersystems.signalservice.api.NetworkResult
class BackupKeyDisplayViewModel : ViewModel(), BackupKeyCredentialManagerHandler {
@@ -29,6 +29,7 @@ import org.signal.core.util.logging.Log
import org.signal.core.util.mebiBytes
import org.signal.core.util.throttleLatest
import org.signal.donations.InAppPaymentType
import org.signal.network.NetworkResult
import org.thoughtcrime.securesms.backup.ArchiveUploadProgress
import org.thoughtcrime.securesms.backup.DeletionState
import org.thoughtcrime.securesms.backup.v2.ArchiveRestoreProgress
@@ -51,7 +52,6 @@ import org.thoughtcrime.securesms.keyvalue.protos.ArchiveUploadProgressState
import org.thoughtcrime.securesms.util.Environment
import org.thoughtcrime.securesms.util.RemoteConfig
import org.thoughtcrime.securesms.util.TextSecurePreferences
import org.whispersystems.signalservice.api.NetworkResult
import kotlin.time.Duration.Companion.days
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
@@ -33,6 +33,7 @@ import org.thoughtcrime.securesms.registration.sms.ReceivedSmsEvent
import org.thoughtcrime.securesms.util.concurrent.AssertedSuccessListener
import org.thoughtcrime.securesms.util.navigation.safeNavigate
import org.thoughtcrime.securesms.util.visible
import kotlin.math.ceil
import kotlin.time.Duration.Companion.milliseconds
/**
@@ -168,7 +169,7 @@ class ChangeNumberEnterCodeFragment : LoggingFragment(R.layout.fragment_change_n
is ChangeNumberResult.RegistrationLocked -> presentRegistrationLocked(result.timeRemaining)
is ChangeNumberResult.AuthorizationFailed -> presentIncorrectCodeDialog()
is ChangeNumberResult.AttemptsExhausted -> presentAccountLocked()
is ChangeNumberResult.RateLimited -> presentRateLimitedDialog()
is ChangeNumberResult.RateLimited -> presentRateLimitedDialog(result.timeRemaining)
else -> presentGenericError(result)
}
@@ -195,13 +196,25 @@ class ChangeNumberEnterCodeFragment : LoggingFragment(R.layout.fragment_change_n
)
}
private fun presentRateLimitedDialog() {
private fun presentRateLimitedDialog(retryAfterSeconds: Long = 0) {
binding.codeEntryLayout.keyboard.displayFailure().addListener(
object : AssertedSuccessListener<Boolean?>() {
override fun onSuccess(result: Boolean?) {
MaterialAlertDialogBuilder(requireContext()).apply {
setTitle(R.string.RegistrationActivity_too_many_attempts)
setMessage(R.string.RegistrationActivity_you_have_made_too_many_attempts_please_try_again_later)
if (retryAfterSeconds > 0) {
val minutes = ceil(retryAfterSeconds / 60.0).toInt().coerceAtLeast(1)
setMessage(
if (minutes >= 60) {
val hours = ceil(minutes / 60.0).toInt()
resources.getQuantityString(R.plurals.ChangeNumberEnterCodeFragment__too_many_attempts_try_again_in_hours, hours, hours)
} else {
resources.getQuantityString(R.plurals.ChangeNumberEnterCodeFragment__too_many_attempts_try_again_in_minutes, minutes, minutes)
}
)
} else {
setMessage(R.string.RegistrationActivity_you_have_made_too_many_attempts_please_try_again_later)
}
setPositiveButton(android.R.string.ok) { _: DialogInterface?, _: Int ->
binding.codeEntryLayout.callMeCountDown.visibility = View.VISIBLE
binding.codeEntryLayout.resendSmsCountDown.visibility = View.VISIBLE

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