Compare commits

..

258 Commits

Author SHA1 Message Date
Alex Hart ff55bc8209 Bump version to 6.22.4 2023-05-31 16:47:39 -03:00
Alex Hart b375c9efdc Updated baseline profile. 2023-05-31 16:47:27 -03:00
Alex Hart 67b4fde5c3 Updated language translations. 2023-05-31 16:42:28 -03:00
Clark 63c6581d14 Fix transaction issues with thread update. 2023-05-31 14:46:25 -04:00
Nicholas 3ddd01981d Prevent crashing when forwarding edited media. 2023-05-31 14:45:56 -04:00
Alex Hart bbd845905a Bump version to 6.22.3 2023-05-30 17:27:36 -03:00
Alex Hart 3f53c37187 Updated baseline profile. 2023-05-30 17:26:36 -03:00
Clark 159c0d1104 Fix child transaction causing batch to be discarded. 2023-05-30 15:18:05 -04:00
Nicholas Tinsley 82db08b76f Catch native RuntimeExceptions in voice memo recording start. 2023-05-30 15:07:58 -04:00
Nicholas Tinsley 83e84228f5 Bolster Bluetooth headset detection for Android 11 and older. 2023-05-30 15:00:49 -04:00
Clark 05edc715ef Fix thread update race for draft update. 2023-05-30 10:44:52 -04:00
Alex Hart c503df5eec Bump version to 6.22.2 2023-05-26 15:55:12 -03:00
Alex Hart 1f242473fe Updated baseline profile. 2023-05-26 15:51:55 -03:00
Alex Hart f7db5f8ae0 Updated language translations. 2023-05-26 15:46:39 -03:00
Greyson Parrelli 3b88d7cf94 Update notifications after transaction completes. 2023-05-26 14:23:07 -04:00
Nicholas Tinsley cac82f2eba Make license screen content static. 2023-05-26 11:56:27 -04:00
Greyson Parrelli 5811b469cf Observe empty state on main thread. 2023-05-26 09:35:07 -04:00
Nicholas Tinsley af1175f32e Update CameraX. 2023-05-25 18:03:45 -04:00
Nicholas Tinsley 7a5ce5761f Add tap to send debug log to account locked screen.
Addresses #12950.
2023-05-25 18:02:25 -04:00
Nicholas 34104355cb Bump version to 6.22.1 2023-05-25 17:12:21 -04:00
Nicholas 143c1255d8 Updated baseline profile. 2023-05-25 17:11:52 -04:00
Nicholas a9d0e5ac81 Updated language translations. 2023-05-25 17:09:14 -04:00
Greyson Parrelli c8b3ee51ed Acquire group lock before processing a message batch. 2023-05-25 16:07:26 -04:00
Cody Henthorne c964067139 Fix placeholder in username string. 2023-05-25 15:59:32 -04:00
Nicholas Tinsley 539f590c4c Disconnect Bluetooth SCO when user cancels recording. 2023-05-25 11:57:13 -04:00
Nicholas Tinsley 71dddd4a1b Add some more Bluetooth connection logging. 2023-05-25 11:55:42 -04:00
Alex Hart 792f5dd7b5 Always display bottom bar. 2023-05-25 12:49:36 -03:00
Nicholas a3af49d92a Bump version to 6.22.0 2023-05-24 12:15:26 -04:00
Nicholas 4289b43a81 Updated baseline profile. 2023-05-24 12:14:47 -04:00
Nicholas 7f55623acf Updated language translations. 2023-05-24 12:11:15 -04:00
Nicholas Tinsley 52060b65be Disable all icons other than the active one. 2023-05-24 12:05:23 -04:00
Clark fd826749e4 Edit message design tweaks. 2023-05-24 12:05:23 -04:00
Clark 627e15c3dd Add thumbnail for when editing message with media. 2023-05-24 12:05:23 -04:00
Clark 90f6890180 Enqueue thread update job after transaction completes. 2023-05-24 12:05:23 -04:00
Nicholas Tinsley 61eb397d2b Simplify notification for saving media.
Addresses #11759.
2023-05-24 12:05:23 -04:00
Greyson Parrelli 3a5e5364c7 Remove support for legacy gv1 sync messages. 2023-05-24 12:05:23 -04:00
Greyson Parrelli 25779d04a6 Regularly run account consistency checks. 2023-05-24 12:05:23 -04:00
Clark 242900e87f Dont requery attachments and add all jobs at once. 2023-05-24 12:05:23 -04:00
Nicholas Tinsley 05f07d1788 Handle SmsRetriever initialization cancellation. 2023-05-24 12:05:23 -04:00
Alex Hart f961f4ccac Add initial CFV2 long press state implementation. 2023-05-24 12:05:23 -04:00
Nicholas Tinsley 145377b05f Add accessibility labels to navigate up button in ConversationFragment.
Addresses #12951.
2023-05-24 12:05:23 -04:00
Cody Henthorne bc88887195 Animate CFv2 with keyboard opening or closing. 2023-05-24 12:05:23 -04:00
Nicholas Tinsley 5362b1c21c Prevent NPE when finishing voice memo recording. 2023-05-24 12:05:23 -04:00
Clark 0cfd3265ba Fix post transaction tasks not actually running. 2023-05-24 12:05:23 -04:00
Cody Henthorne 1099128513 Add rendering and handling for various disabled input states in CFv2. 2023-05-24 12:05:23 -04:00
Greyson Parrelli ad50c81a6b Remove unnecessary validation check. 2023-05-24 12:05:23 -04:00
Clark 0817f113c6 Schedule media downloads after successful transaction. 2023-05-24 12:05:23 -04:00
Clark 4a9a07a9ef Run post transaction tasks only after root transaction ends. 2023-05-24 12:05:23 -04:00
Nicholas 61f50cfe60 Add license screen to settings page. 2023-05-24 12:05:23 -04:00
Nicholas 92888778c2 Restart websocket immediately upon network change. 2023-05-24 12:05:23 -04:00
Alex Hart 987f9b9dba Allow call links to exist in the calls tab. 2023-05-24 12:05:23 -04:00
Clark 97d95f37cc Rotate profile key when contact hidden. 2023-05-24 11:29:59 -04:00
Clark 836cd04564 Inline message processing when we can. 2023-05-24 11:29:59 -04:00
Clark c26f54161d Use original message id for edit message history. 2023-05-24 11:29:59 -04:00
Clark b540009ce6 Only call start foreground once from FCM. 2023-05-24 11:29:59 -04:00
Alex Hart e58e209950 Remove ripple from tab buttons. 2023-05-24 11:29:58 -04:00
Alex Hart 4597a23104 Fix new call item margins. 2023-05-24 11:29:58 -04:00
Alex Hart 3aacf4bcd2 Add search highlight to call rows. 2023-05-24 11:29:58 -04:00
Alex Hart 6e6b663fac Reposition unread dots according to figma. 2023-05-24 11:29:58 -04:00
Alex Hart 6dad7eafcf Fix call tab color and spacing. 2023-05-24 11:29:58 -04:00
Alex Hart 5a38143987 Integrate call links create/update/read apis. 2023-05-24 11:29:58 -04:00
Greyson Parrelli 4d6d31d624 Make attachment count/size remote configurable. 2023-05-24 11:29:58 -04:00
Greyson Parrelli 938c82be3f Inline the calls tab feature flag. 2023-05-24 11:29:58 -04:00
Greyson Parrelli dc2e249566 Add QR scanning to username link flow. 2023-05-24 11:29:58 -04:00
Greyson Parrelli bb8fdcabcb Update to libsignal 0.25.0 2023-05-24 11:29:58 -04:00
Greyson Parrelli 6cf4dbc78c Add pre-alpha support for SVR2. 2023-05-24 11:29:58 -04:00
Jim Gustafson 8cd0ac5451 Update to RingRTC v2.27.0 2023-05-24 11:29:58 -04:00
Clark 33745f0b0c Fix edit message showing twice in notifications. 2023-05-24 11:29:58 -04:00
Nicholas 29ffed219f Bump version to 6.21.3 2023-05-24 10:52:40 -04:00
Nicholas 9629d0f715 Updated baseline profile. 2023-05-24 10:51:42 -04:00
Nicholas 01651984d7 Updated language translations. 2023-05-24 10:36:15 -04:00
Nicholas Tinsley c7c15250ca Clarify app icon string descriptions. 2023-05-24 10:00:38 -04:00
Greyson Parrelli 36b96eafcc Bump version to 6.21.2 2023-05-19 16:37:26 -04:00
Greyson Parrelli 94f930ee22 Updated language translations. 2023-05-19 16:37:12 -04:00
Greyson Parrelli 3f740d2904 Tweak network timeout settings. 2023-05-19 16:30:19 -04:00
Nicholas Tinsley f8529adfcf Design tweaks for app icon switching. 2023-05-19 16:30:19 -04:00
Alex Hart 77ccbdd322 Deduplicate in migration to prevent constraint breakage. 2023-05-19 16:30:19 -04:00
Cody Henthorne f2846efd2c Fix split second spoiler reveal when quoting a message with a spoiler. 2023-05-19 16:30:19 -04:00
Nicholas Tinsley 131f9c4bc9 Move app icon composables outside of mutable Fragment class.
This way, the composables do not receive an implicit mutable parameter, which allows the compiler to mark them as skippable.
2023-05-19 16:30:19 -04:00
Greyson Parrelli d7c06fff50 Bump version to 6.21.1 2023-05-18 20:35:52 -04:00
Greyson Parrelli 09a22d9dc4 Updated language translations. 2023-05-18 20:35:52 -04:00
Greyson Parrelli 0fbab04253 Animate transitions in icon selection. 2023-05-18 20:35:51 -04:00
Nicholas c963e99dca Introduce the ability to change the app icon. 2023-05-18 20:35:51 -04:00
Alex Hart 7a555d127f Tighten migration and remove null peer events. 2023-05-18 20:35:51 -04:00
Cody Henthorne 866408f673 Limit body ranges processed on received messages. 2023-05-18 20:35:51 -04:00
Greyson Parrelli a7e5ab1a6a Update inbound attachment processing. 2023-05-18 12:22:35 -04:00
Alex Hart 14d16d61e6 Only update text fields if contents changed. 2023-05-18 10:27:17 -03:00
Greyson Parrelli b988e4a813 Disable foreign key constraints during backup restore updgrades. 2023-05-17 18:44:23 -04:00
Greyson Parrelli ae33c8db1b Bump version to 6.21.0 2023-05-17 15:50:42 -04:00
Greyson Parrelli 9f1c5ac6bb Updated language translations. 2023-05-17 15:45:48 -04:00
Alex Hart 448e7d0739 Don't collapsed missed calls with outgoing or incoming. 2023-05-17 15:30:27 -04:00
Clark 8971ff9057 Fix for ForegroundServiceDidNotStartInTimeException for FcmFetchForegroundService. 2023-05-17 15:30:26 -04:00
Clark 2d6b16b2ce Introduce extra caching for group message processing. 2023-05-17 15:30:26 -04:00
Greyson Parrelli 44ab1643fa Fix group membership recipient remapping. 2023-05-17 15:30:26 -04:00
Cody Henthorne a64bffd83a Complete text formatting. 2023-05-17 15:30:26 -04:00
Clark 534c5c3c64 Try not blocking main threads to start foreground service. 2023-05-17 15:30:26 -04:00
Cody Henthorne 99d3f9918f Fix crash and bug with ellipsizing 'About' in Settings screen.
Fixes #12895
Closes #12905
2023-05-17 15:30:26 -04:00
Ehren Kret aaebf029db Remove unused capabilities. 2023-05-17 15:30:26 -04:00
Greyson Parrelli e2c2ace0e3 Add initial storage interfaces for kyber prekeys. 2023-05-17 15:30:26 -04:00
Nicholas Tinsley c76002663f Push bubbled conversation onto back stack. 2023-05-17 15:30:26 -04:00
Nicholas Tinsley c5317370c8 Prevent duplicate reactions bottom sheet. 2023-05-17 15:30:26 -04:00
Cody Henthorne 4b09f4a654 Add basic attachment keyboard support to CFv2. 2023-05-17 15:30:26 -04:00
Alex Hart 0c57113d8e Add migration for call link recipient link. 2023-05-17 15:30:26 -04:00
Alex Hart d7dd77a5af Add additional logging around thumbnail loading. 2023-05-17 15:30:26 -04:00
Greyson Parrelli 5c5b88ebcc Pluralize some strings. 2023-05-17 15:30:26 -04:00
Alex Hart 8df0248d4f Utilize receipts instead of messagerecord count for views. 2023-05-17 15:30:26 -04:00
Alex Hart 58e48fdf14 Fix switch toggling after first toggle. 2023-05-17 15:30:26 -04:00
Alex Hart 407fc56218 Utilize conversationRecipient for displaying whom a payment is to or from in conversation. 2023-05-17 15:30:26 -04:00
Alex Hart 398527d3f1 Ignore table update when remap already exists. 2023-05-17 15:30:26 -04:00
Greyson Parrelli 59745a695c Add context to some strings. 2023-05-17 15:30:26 -04:00
Cody Henthorne 2aaeda6ca8 Add initial send support to CFv2. 2023-05-17 15:30:26 -04:00
Alex Hart 5c78de2f46 Update nullability of CallLinkTable column. 2023-05-17 15:30:09 -04:00
Clark 93efc21452 Propogate read sync message to latest revision. 2023-05-17 15:30:09 -04:00
Clark 50ad005e7c Update edit message history dialog to match designs. 2023-05-17 15:30:09 -04:00
Clark 71c3bcdd29 Convert MSL delete entries for recipient to collection query. 2023-05-17 15:30:08 -04:00
Clark e5bf04a407 Cleanup Recipient.externalPush to use RecipientId cache. 2023-05-17 15:30:08 -04:00
Clark fe8b2cb761 Reduce db operations when updating thread snippet. 2023-05-17 15:30:08 -04:00
Clark 7c37f929a5 Go back to enqueuing thread update job. 2023-05-17 15:30:08 -04:00
Cody Henthorne 1b82d10b39 Squelch notifications in noisy groups and during large initial message processing. 2023-05-17 15:30:08 -04:00
Clark 6b6e2490e7 Add performance logging for message processing. 2023-05-17 15:30:08 -04:00
Greyson Parrelli ebee54cf92 Add button to grow query area in Spinner. 2023-05-17 15:30:08 -04:00
Greyson Parrelli b7acfb0dcc Add a better editor to Spinner. 2023-05-17 15:30:08 -04:00
Greyson Parrelli 65dab45582 Update string for sticker uninstall button label. 2023-05-17 15:30:08 -04:00
Greyson Parrelli 43086f9582 Fix more validation spots around unknown UUIDs.
This was a legacy path that got missed.
2023-05-17 15:30:08 -04:00
Clark ed1aa74aff Reduce number of thread snippet updates from receipts. 2023-05-17 15:30:08 -04:00
Cody Henthorne 3ba128793a Display thread header in CFv2. 2023-05-17 15:30:08 -04:00
Clark ffbbdc1576 Add PushProcessMessageJobV2 to reserved job queue. 2023-05-17 15:30:08 -04:00
Clark 6ff55cfff7 Reduce expensive group operations during message processing. 2023-05-17 15:29:31 -04:00
Clark e9f1f781e1 Reduce number of db calls to getGroup. 2023-05-17 15:29:31 -04:00
Greyson Parrelli 6da36fe098 Deprecate the SyncMessage.pniIdentity field. 2023-05-17 15:29:31 -04:00
Greyson Parrelli acb6510312 Switch to libsignal for PIN hashing. 2023-05-17 15:29:30 -04:00
Greyson Parrelli 13248506c5 Bump version to 6.20.5 2023-05-17 15:25:38 -04:00
Greyson Parrelli fb9740f5c3 Updated language translations. 2023-05-17 15:25:16 -04:00
Nicholas Tinsley 61bf788f52 Tear down Bluetooth connection after voice memo recording. 2023-05-17 14:30:30 -04:00
Greyson Parrelli fd116e0178 Bump version to 6.20.4 2023-05-15 21:29:08 -04:00
Greyson Parrelli 661d981231 Updated language translations. 2023-05-15 21:28:16 -04:00
Greyson Parrelli 6a701591af End transaction before handling db error. 2023-05-15 21:14:29 -04:00
Greyson Parrelli 02431c6ef4 Refactor array creation to a function. 2023-05-15 13:21:57 -04:00
Nicholas Tinsley 802b179880 Update license to AGPLv3. 2023-05-15 10:23:28 -04:00
Cody Henthorne 6c2104b84b Fix media not auto-downloading in groups bug. 2023-05-12 15:39:32 -04:00
Cody Henthorne a3f432dc88 Bump version to 6.20.3 2023-05-12 14:14:43 -04:00
Cody Henthorne 7f4caedf40 Updated baseline profile. 2023-05-12 14:04:16 -04:00
Cody Henthorne eebd06f0d8 Updated language translations. 2023-05-12 13:59:34 -04:00
Greyson Parrelli 0ea66b6bb0 Update apkdiff to ignore baseline profile. 2023-05-12 10:38:04 -04:00
Greyson Parrelli 084cdd7200 Fix de-duping migration when resolving date conflicts. 2023-05-12 09:45:02 -04:00
Greyson Parrelli 2eff9e0230 Update default conflict method to be 'ignore'. 2023-05-12 09:26:44 -04:00
Greyson Parrelli 6615bc4a2a Fix param on sync message. 2023-05-11 18:47:04 -04:00
Greyson Parrelli 387f18be98 Avoid some 401 errors during story sends. 2023-05-11 16:54:57 -04:00
Cody Henthorne 3b5a3eccfe Bump version to 6.20.2 2023-05-11 15:34:49 -04:00
Cody Henthorne 36083a8bd9 Updated baseline profile. 2023-05-11 15:28:56 -04:00
Cody Henthorne 7e16825bf4 Updated language translations. 2023-05-11 15:26:01 -04:00
Greyson Parrelli 9ba2724d0a Fix V190 database migration with a new migration. 2023-05-11 15:21:22 -04:00
Greyson Parrelli 7866851f5d Temporarily give up on the V190 migration. 2023-05-11 11:17:01 -04:00
Greyson Parrelli c938035ec1 Improve error handling around unknown UUIDs. 2023-05-11 11:15:13 -04:00
Nicholas Tinsley 965fdc5e9b Fix linked devices reminder appearing all the time. 2023-05-11 11:08:06 -04:00
Nicholas Tinsley eab932b4a0 Fix parsing for registration 502 errors. 2023-05-11 10:58:04 -04:00
Cody Henthorne 27a24262c8 Bump version to 6.20.1 2023-05-10 16:18:21 -04:00
Cody Henthorne 31584de225 Updated baseline profile. 2023-05-10 16:10:41 -04:00
Cody Henthorne 6b2f90019a Updated language translations. 2023-05-10 16:05:55 -04:00
Nicholas 208147db9e Make change number error notifications more prominent. 2023-05-10 15:59:26 -04:00
Nicholas e4f70fa4fe Delegate to system to handle rotation in video call PiP. 2023-05-10 15:59:26 -04:00
Cody Henthorne 4d09abd0d3 Fix group state loss during concurrent updates.
Storage sync and a message process can both attempt to create a group at the same time. Message processing caches the local group state and performs networking which allows the cached state to become stale. The state was being used to decide to call create instead of update and the create would fail silently as the group record already exists. This would cause state to not be persisted and result in odd double events.
2023-05-10 15:59:26 -04:00
Greyson Parrelli 1304f4dc39 Update apkdiff.py to return non-zero exit codes on mismatch. 2023-05-10 15:59:26 -04:00
Nicholas Tinsley ac027d9267 Store linked devices presence in separate key than reminder. 2023-05-10 15:59:26 -04:00
Cody Henthorne 0b6d343616 Fix tint on call rows in chat settings. 2023-05-10 15:59:26 -04:00
Nicholas 92e8f125f9 Improve nullability for setting communication devices. 2023-05-10 15:59:26 -04:00
Greyson Parrelli bef15482af Add unique index on message (sentTimestamp, author, thread). 2023-05-10 15:59:26 -04:00
Greyson Parrelli 93d78b3b2e Improve conditional logic around prekey refresh schedule. 2023-05-09 15:35:48 -04:00
Cody Henthorne d38b7deeeb Bump version to 6.20.0 2023-05-09 14:17:49 -04:00
Cody Henthorne b656e1dd0a Updated baseline profile. 2023-05-09 13:53:50 -04:00
Cody Henthorne f1cec895b9 Updated language translations. 2023-05-09 13:48:06 -04:00
Greyson Parrelli 9bf6922d97 Ensure users have a service identifier before sending receipts. 2023-05-09 13:41:28 -04:00
Greyson Parrelli 41fc4096e4 Fix migration crash if user is unregistered. 2023-05-09 13:41:28 -04:00
Nicholas e46564cb7e Add jitter to backup scheduling. 2023-05-09 13:41:28 -04:00
Clark 77751c1d28 Add read through cache for thread id. 2023-05-09 13:41:28 -04:00
Clark 054a1e4017 Reduce number of db calls when processing data messages. 2023-05-09 13:41:28 -04:00
Greyson Parrelli da27d74111 Improve rendering of nulls in Spinner results. 2023-05-09 13:41:28 -04:00
Greyson Parrelli 970278228d Replace 'audio call' with 'voice call'. 2023-05-09 13:41:28 -04:00
Greyson Parrelli ee3f2d62cf Add extra guard against inserting unnecessary error messages. 2023-05-09 13:41:28 -04:00
Greyson Parrelli ec6d5031cf Made table headers in Spinner sticky. 2023-05-09 13:41:28 -04:00
Greyson Parrelli 80f338c3af Increase Spinner query history to 25 items. 2023-05-09 13:41:28 -04:00
Cody Henthorne 268c9a1c26 Fix conversation list not updating with current state. 2023-05-09 13:41:27 -04:00
Jon Chambers e8e01b5965 Remove vestiges of CDS "classic". 2023-05-09 13:41:27 -04:00
Alex Hart 23a042b667 Fix translatable value. 2023-05-09 13:41:27 -04:00
Clark c2c1537858 Disable interactions while user is unregistered or expired. 2023-05-09 13:41:27 -04:00
Cody Henthorne 65d5f4c426 Add ConversationAdapterV2. 2023-05-09 13:41:27 -04:00
Alex Hart a1eb33b1f6 CallLinkTable migration to add necessary columns for integration. 2023-05-09 13:41:27 -04:00
Alex Hart 9eadd92d05 Ensure websocket state changes are handled on main thread. 2023-05-05 13:55:45 -03:00
Nicholas b0e1294584 Only show linked devices reminder if devices previously linked. 2023-05-05 12:49:18 -03:00
Nicholas f1fd29a477 Use Bluetooth headset mic to record voice notes. 2023-05-05 12:49:18 -03:00
Cody Henthorne fc9a6b98d1 Add sync group sent text message processing test. 2023-05-05 12:49:18 -03:00
Alex Hart 305f6c610c Fix bad formatting in CallTable. 2023-05-05 12:49:16 -03:00
Alex Hart 66f4732db5 Reimplement MessageRequestViewModel for CFV2. 2023-05-05 12:48:53 -03:00
Nicholas ccdfa546b4 Prevent launching multiple audio device dialogs during call. 2023-05-05 12:48:53 -03:00
Greyson Parrelli 855e194baa Add initial username link screen + QR code generation. 2023-05-05 12:48:53 -03:00
Alex Hart e0c06615fb Upgrade to libsignal 0.23.1 2023-05-05 12:48:53 -03:00
Alex Hart 03a4809866 Add local directory to .gitignore. 2023-05-05 12:48:53 -03:00
Clark 0aa7bd6a4e Reorder security provider initialization. 2023-05-05 12:48:53 -03:00
Greyson Parrelli 78b530f8b8 Show toast to internal users for invalid messages. 2023-05-05 12:48:53 -03:00
Nicholas Tinsley ace47c61b1 Update top-level LICENSE file to AGPL 2023-05-05 12:48:53 -03:00
Clark 29796f51d7 Try calling startForeground in onCreate. 2023-05-05 12:48:53 -03:00
Alex Hart 4d2ce7a2be Batch call event syncs.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2023-05-05 12:48:53 -03:00
Alex Hart 8dc45263cd Add requestLayout when textfields are updated.
These fields appear to not resize themselves correctly and there appears
to be a possible race where they can end up wrongly ellipsizing
themselves.
2023-05-05 12:48:53 -03:00
Greyson Parrelli d8d95d8efe Update SQLCipher to 4.5.4-S1 2023-05-05 12:48:53 -03:00
Greyson Parrelli f081591354 Add Android 14 improvements for dynamic shortcuts.
Closes #12923
Co-authored-by: Yuichi Araki <yaraki@google.com>
2023-05-05 12:48:53 -03:00
Nicholas 35e96fecdb Launch the MediaPreviewV2Activity in its own stack from Bubbles. 2023-05-05 12:48:53 -03:00
Nicholas 841fbfa7ee Improve error handling for external failures during registration.
Addresses #10711 and #12927.
2023-05-05 12:48:53 -03:00
Nicholas 89397ae7cc Picture-in-Picture call improvements. 2023-05-05 12:48:53 -03:00
Alex Hart 6c57c2ac2a Fix crash from external share. 2023-05-05 12:48:53 -03:00
Jim Gustafson 40663eb52f Update to RingRTC v2.26.3 2023-05-05 12:48:53 -03:00
Greyson Parrelli 90c9cc17b9 Handle unregistered responses in more locations.
There were some send jobs where we knew users were unregistered, but we
weren't marking them as such in the DB.
2023-05-05 12:48:53 -03:00
Nicholas 125c4f43cf Make audio device button directly toggle when only two devices are present. 2023-05-05 12:48:53 -03:00
Greyson Parrelli 634e4abcc1 Use the word 'chat' instead of 'conversation'. 2023-05-05 12:48:53 -03:00
Greyson Parrelli a5431330d1 Ensure user has a serviceId/e164 before attempting a read receipt. 2023-05-05 12:48:53 -03:00
Alex Hart 30fc6d94c5 Flesh out event listeners and add load sequencing to CFV2. 2023-05-05 12:48:53 -03:00
Alex Hart 694d8f1984 Add scroll buttons to CFV2. 2023-05-05 12:48:53 -03:00
Alex Hart bff8fc8230 Add call link details screen scaffolding. 2023-05-05 12:48:53 -03:00
Alex Hart 5f7414e84c Bump version to 6.19.8 2023-05-05 12:47:12 -03:00
Greyson Parrelli 8c707555f2 Fix bad migration state that could happen during a device transfer. 2023-05-05 12:42:31 -03:00
Greyson Parrelli 63ce2de3bf Add some more missing indexes for foreign keys and create test. 2023-05-05 09:27:40 -04:00
Alex Hart 5e86cca277 Ensure call events are reverse-chron by timestamp. 2023-05-05 10:12:58 -03:00
Alex Hart 4bbed2601c Bump version to 6.19.7 2023-05-04 13:57:20 -03:00
Alex Hart e0bda8cf53 Fix group call ring state for some calls. 2023-05-04 13:31:09 -03:00
Greyson Parrelli a1c807d65b Force usage of best index for conversation query. 2023-05-04 09:35:45 -04:00
Greyson Parrelli 77f5c290cc Add 'if not exists' to index migration. 2023-05-04 09:33:29 -04:00
Alex Hart 4294b446f3 Hide call events from hidden and blocked contacts. 2023-05-04 10:02:15 -03:00
Alex Hart 664e8e5526 Bump version to 6.19.6 2023-05-03 13:23:13 -03:00
Alex Hart 264ade3db9 Updated baseline profile. 2023-05-03 13:16:07 -03:00
Alex Hart 1a6e5e9e2b Updated language translations. 2023-05-03 13:11:34 -03:00
Greyson Parrelli 6aa4bb549a Add database indices to improve message delete performance. 2023-05-03 09:59:47 -04:00
Alex Hart dd76909f02 Bump version to 6.19.5 2023-05-02 14:51:17 -03:00
Alex Hart a454adece8 Updated baseline profile. 2023-05-02 14:50:48 -03:00
Alex Hart f2adc4d283 Updated language translations. 2023-05-02 14:46:00 -03:00
Clark 6af9835f74 Fix sync edit message group validation. 2023-05-02 12:41:33 -04:00
Alex Hart b823baa387 Animate when entering and exiting call log multiselect. 2023-05-02 13:24:08 -03:00
Alex Hart 6791d2d46e Add animation when switching from chats to calls. 2023-05-02 13:10:39 -03:00
Alex Hart 3c343af562 Fix dancing call icon when a new message is recieved. 2023-05-02 13:10:23 -03:00
Greyson Parrelli fe9ed4c5f7 Attempt to repair local recipient state in the V185 migration. 2023-05-02 10:04:57 -04:00
Alex Hart 7374e7ee23 Fix PiP crash on devices that lie about support.
Fixes #12924
2023-05-02 09:51:45 -03:00
Alex Hart 3c9c0e244a Bump version to 6.19.4 2023-05-01 15:31:50 -03:00
Alex Hart 64c219b02d Updated baseline profile. 2023-05-01 15:31:15 -03:00
Alex Hart 7e68f8faf2 Updated language translations. 2023-05-01 15:26:06 -03:00
Greyson Parrelli c868098042 Fix 'Sent from' section in message details. 2023-05-01 13:13:13 -04:00
Greyson Parrelli b3c0cda2be Fix rendering of some update message types. 2023-05-01 10:09:57 -04:00
Nicholas 2176d1f3df Bump version to 6.19.3 2023-04-25 16:19:26 -04:00
Nicholas 979b9859af Updated baseline profile. 2023-04-25 16:08:42 -04:00
Nicholas a9ce4a1aed Updated language translations. 2023-04-25 16:03:22 -04:00
Greyson Parrelli a01fb7ff1c Fix foreign key constraint issues with backup restore. 2023-04-25 15:52:09 -04:00
Alex Hart 0e631508b2 Fix call log multiselect deletions. 2023-04-25 16:42:33 -03:00
Greyson Parrelli eb9915d445 Fix FTS rebuild retry. 2023-04-25 14:45:39 -04:00
Alex Hart eedf7d4280 Update threads on call event message deletes. 2023-04-25 14:49:49 -03:00
Cody Henthorne aaca487b8f Improve performance around marking messages read. 2023-04-25 11:29:45 -04:00
Alex Hart a7d6c0f25c Prevent filtering when in multiselect mode. 2023-04-25 11:46:37 -03:00
Nicholas 158a250357 Force WiFi-to-cellular popup to use light mode text color. 2023-04-25 10:03:34 -04:00
Cody Henthorne b9d7d19dea Fix multiple issues with rendering spoilers as story captions. 2023-04-25 09:51:11 -04:00
Alex Hart a837f86999 Disable scrolling when context menu is open. 2023-04-25 10:36:27 -03:00
Cody Henthorne a0e4b1aaf9 Fix weird highlight shown after revealing a spoiler. 2023-04-24 22:45:06 -04:00
Cody Henthorne 4d10be2aa5 Fix spoiler reveal in full screen media viewer. 2023-04-24 21:27:11 -04:00
637 changed files with 45476 additions and 13906 deletions
+1
View File
@@ -28,3 +28,4 @@ jni/libspeex/.deps/
pkcs11.password
dev.keystore
maps.key
local/
+6
View File
@@ -0,0 +1,6 @@
<component name="CopyrightManager">
<copyright>
<option name="notice" value="Copyright &amp;#36;today.year Signal Messenger, LLC&#10;SPDX-License-Identifier: AGPL-3.0-only" />
<option name="myName" value="Signal" />
</copyright>
</component>
+7
View File
@@ -0,0 +1,7 @@
<component name="CopyrightManager">
<settings>
<module2copyright>
<element module="All" copyright="Signal" />
</module2copyright>
</settings>
</component>
+9
View File
@@ -0,0 +1,9 @@
/*
* Copyright ${YEAR} Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
#if (${PACKAGE_NAME} && ${PACKAGE_NAME} != "")package ${PACKAGE_NAME};#end
#parse("File Header.java")
public @interface ${NAME} {
}
+9
View File
@@ -0,0 +1,9 @@
/*
* Copyright ${YEAR} Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
#if (${PACKAGE_NAME} && ${PACKAGE_NAME} != "")package ${PACKAGE_NAME};#end
#parse("File Header.java")
public class ${NAME} {
}
+9
View File
@@ -0,0 +1,9 @@
/*
* Copyright ${YEAR} Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
#if (${PACKAGE_NAME} && ${PACKAGE_NAME} != "")package ${PACKAGE_NAME};#end
#parse("File Header.java")
public enum ${NAME} {
}
+9
View File
@@ -0,0 +1,9 @@
/*
* Copyright ${YEAR} Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
#if (${PACKAGE_NAME} && ${PACKAGE_NAME} != "")package ${PACKAGE_NAME};#end
#parse("File Header.java")
public interface ${NAME} {
}
+11
View File
@@ -0,0 +1,11 @@
/*
* Copyright ${YEAR} Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
#if (${PACKAGE_NAME} && ${PACKAGE_NAME} != "")package ${PACKAGE_NAME}
#end
#parse("File Header.java")
class ${NAME} {
}
+11
View File
@@ -0,0 +1,11 @@
/*
* Copyright ${YEAR} Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
#if (${PACKAGE_NAME} && ${PACKAGE_NAME} != "")package ${PACKAGE_NAME}
#end
#parse("File Header.java")
enum class ${NAME} {
}
+9
View File
@@ -0,0 +1,9 @@
/*
* Copyright ${YEAR} Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
#if (${PACKAGE_NAME} && ${PACKAGE_NAME} != "")package ${PACKAGE_NAME}
#end
#parse("File Header.java")
+11
View File
@@ -0,0 +1,11 @@
/*
* Copyright ${YEAR} Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
#if (${PACKAGE_NAME} && ${PACKAGE_NAME} != "")package ${PACKAGE_NAME}
#end
#parse("File Header.java")
interface ${NAME} {
}
+96 -56
View File
@@ -1,23 +1,21 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
software for all its users.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
@@ -26,44 +24,34 @@ them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
The precise terms and conditions for copying, distribution and
modification follow.
@@ -72,7 +60,7 @@ modification follow.
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
@@ -549,35 +537,45 @@ to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
@@ -619,3 +617,45 @@ Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>.
+2 -2
View File
@@ -54,8 +54,8 @@ The form and manner of this distribution makes it eligible for export under the
## License
Copyright 2013-2022 Signal
Copyright 2013-2023 Signal
Licensed under the GPLv3: http://www.gnu.org/licenses/gpl-3.0.html
Licensed under the GNU AGPLv3: https://www.gnu.org/licenses/agpl-3.0.html
Google Play and the Google Play logo are trademarks of Google LLC.
+8 -11
View File
@@ -46,8 +46,8 @@ ktlint {
version = "0.47.1"
}
def canonicalVersionCode = 1250
def canonicalVersionName = "6.19.2"
def canonicalVersionCode = 1271
def canonicalVersionName = "6.22.4"
def postFixSize = 100
def abiPostFix = ['universal' : 0,
@@ -182,10 +182,10 @@ android {
buildConfigField "String", "STORAGE_URL", "\"https://storage.signal.org\""
buildConfigField "String", "SIGNAL_CDN_URL", "\"https://cdn.signal.org\""
buildConfigField "String", "SIGNAL_CDN2_URL", "\"https://cdn2.signal.org\""
buildConfigField "String", "SIGNAL_CONTACT_DISCOVERY_URL", "\"https://api.directory.signal.org\""
buildConfigField "String", "SIGNAL_CDSI_URL", "\"https://cdsi.signal.org\""
buildConfigField "String", "SIGNAL_SERVICE_STATUS_URL", "\"uptime.signal.org\""
buildConfigField "String", "SIGNAL_KEY_BACKUP_URL", "\"https://api.backup.signal.org\""
buildConfigField "String", "SIGNAL_SVR2_URL", "\"https://svr2.staging.signal.org\""
buildConfigField "String", "SIGNAL_SFU_URL", "\"https://sfu.voip.signal.org\""
buildConfigField "String", "SIGNAL_STAGING_SFU_URL", "\"https://sfu.staging.voip.signal.org\""
buildConfigField "String[]", "SIGNAL_SFU_INTERNAL_NAMES", "new String[]{\"Test\", \"Staging\", \"Development\"}"
@@ -196,12 +196,12 @@ android {
buildConfigField "String[]", "SIGNAL_STORAGE_IPS", storage_ips
buildConfigField "String[]", "SIGNAL_CDN_IPS", cdn_ips
buildConfigField "String[]", "SIGNAL_CDN2_IPS", cdn2_ips
buildConfigField "String[]", "SIGNAL_CDS_IPS", cds_ips
buildConfigField "String[]", "SIGNAL_KBS_IPS", kbs_ips
buildConfigField "String[]", "SIGNAL_SFU_IPS", sfu_ips
buildConfigField "String[]", "SIGNAL_CONTENT_PROXY_IPS", content_proxy_ips
buildConfigField "String", "SIGNAL_AGENT", "\"OWA\""
buildConfigField "String", "CDSI_MRENCLAVE", "\"0f6fd79cdfdaa5b2e6337f534d3baf999318b0c462a7ac1f41297a3e4b424a57\""
buildConfigField "String", "SVR2_MRENCLAVE", "\"dc9fd472a5a9c871a3c7f76f1af60aa9c1f314abf2e8d1e0c4ba25c8aaa2848c\""
buildConfigField "org.thoughtcrime.securesms.KbsEnclave", "KBS_ENCLAVE", "new org.thoughtcrime.securesms.KbsEnclave(\"e18376436159cda3ad7a45d9320e382e4a497f26b0dca34d8eab0bd0139483b5\", " +
"\"3a485adb56e2058ef7737764c738c4069dd62bc457637eafb6bbce1ce29ddb89\", " +
"\"45627094b2ea4a66f4cf0b182858a8dcf4b8479122c3820fe7fd0551a6d4cf5c\")"
@@ -210,6 +210,7 @@ android {
"\"ee19f1965b1eefa3dc4204eb70c04f397755f771b8c1909d080c04dad2a6a9ba\") }"
buildConfigField "String", "UNIDENTIFIED_SENDER_TRUST_ROOT", "\"BXu6QIKVz5MA8gstzfOgRQGqyLqOwNKHL6INkv3IHWMF\""
buildConfigField "String", "ZKGROUP_SERVER_PUBLIC_PARAMS", "\"AMhf5ywVwITZMsff/eCyudZx9JDmkkkbV6PInzG4p8x3VqVJSFiMvnvlEKWuRob/1eaIetR31IYeAbm0NdOuHH8Qi+Rexi1wLlpzIo1gstHWBfZzy1+qHRV5A4TqPp15YzBPm0WSggW6PbSn+F4lf57VCnHF7p8SvzAA2ZZJPYJURt8X7bbg+H3i+PEjH9DXItNEqs2sNcug37xZQDLm7X36nOoGPs54XsEGzPdEV+itQNGUFEjY6X9Uv+Acuks7NpyGvCoKxGwgKgE5XyJ+nNKlyHHOLb6N1NuHyBrZrgtY/JYJHRooo5CEqYKBqdFnmbTVGEkCvJKxLnjwKWf+fEPoWeQFj5ObDjcKMZf2Jm2Ae69x+ikU5gBXsRmoF94GXTLfN0/vLt98KDPnxwAQL9j5V1jGOY8jQl6MLxEs56cwXN0dqCnImzVH3TZT1cJ8SW1BRX6qIVxEzjsSGx3yxF3suAilPMqGRp4ffyopjMD1JXiKR2RwLKzizUe5e8XyGOy9fplzhw3jVzTRyUZTRSZKkMLWcQ/gv0E4aONNqs4P\""
buildConfigField "String", "GENERIC_SERVER_PUBLIC_PARAMS", "\"AByD873dTilmOSG0TjKrvpeaKEsUmIO8Vx9BeMmftwUs9v7ikPwM8P3OHyT0+X3EUMZrSe9VUp26Wai51Q9I8mdk0hX/yo7CeFGJyzoOqn8e/i4Ygbn5HoAyXJx5eXfIbqpc0bIxzju4H/HOQeOpt6h742qii5u/cbwOhFZCsMIbElZTaeU+BWMBQiZHIGHT5IE0qCordQKZ5iPZom0HeFa8Yq0ShuEyAl0WINBiY6xE3H/9WnvzXBbMuuk//eRxXgzO8ieCeK8FwQNxbfXqZm6Ro1cMhCOF3u7xoX83QhpN\""
buildConfigField "String[]", "LANGUAGES", "new String[]{\"" + autoResConfig().collect { s -> s.replace('-r', '_') }.join('", "') + '"}'
buildConfigField "int", "CANONICAL_VERSION_CODE", "$canonicalVersionCode"
buildConfigField "String", "DEFAULT_CURRENCIES", "\"EUR,AUD,GBP,CAD,CNY\""
@@ -375,7 +376,6 @@ android {
buildConfigField "String", "STORAGE_URL", "\"https://storage-staging.signal.org\""
buildConfigField "String", "SIGNAL_CDN_URL", "\"https://cdn-staging.signal.org\""
buildConfigField "String", "SIGNAL_CDN2_URL", "\"https://cdn2-staging.signal.org\""
buildConfigField "String", "SIGNAL_CONTACT_DISCOVERY_URL", "\"https://api-staging.directory.signal.org\""
buildConfigField "String", "SIGNAL_CDSI_URL", "\"https://cdsi.staging.signal.org\""
buildConfigField "String", "SIGNAL_KEY_BACKUP_URL", "\"https://api-staging.backup.signal.org\""
buildConfigField "org.thoughtcrime.securesms.KbsEnclave", "KBS_ENCLAVE", "new org.thoughtcrime.securesms.KbsEnclave(\"39963b736823d5780be96ab174869a9499d56d66497aa8f9b2244f777ebc366b\", " +
@@ -386,6 +386,7 @@ android {
"\"ee19f1965b1eefa3dc4204eb70c04f397755f771b8c1909d080c04dad2a6a9ba\") }"
buildConfigField "String", "UNIDENTIFIED_SENDER_TRUST_ROOT", "\"BbqY1DzohE4NUZoVF+L18oUPrK3kILllLEJh2UnPSsEx\""
buildConfigField "String", "ZKGROUP_SERVER_PUBLIC_PARAMS", "\"ABSY21VckQcbSXVNCGRYJcfWHiAMZmpTtTELcDmxgdFbtp/bWsSxZdMKzfCp8rvIs8ocCU3B37fT3r4Mi5qAemeGeR2X+/YmOGR5ofui7tD5mDQfstAI9i+4WpMtIe8KC3wU5w3Inq3uNWVmoGtpKndsNfwJrCg0Hd9zmObhypUnSkfYn2ooMOOnBpfdanRtrvetZUayDMSC5iSRcXKpdlukrpzzsCIvEwjwQlJYVPOQPj4V0F4UXXBdHSLK05uoPBCQG8G9rYIGedYsClJXnbrgGYG3eMTG5hnx4X4ntARBgELuMWWUEEfSK0mjXg+/2lPmWcTZWR9nkqgQQP0tbzuiPm74H2wMO4u1Wafe+UwyIlIT9L7KLS19Aw8r4sPrXZSSsOZ6s7M1+rTJN0bI5CKY2PX29y5Ok3jSWufIKcgKOnWoP67d5b2du2ZVJjpjfibNIHbT/cegy/sBLoFwtHogVYUewANUAXIaMPyCLRArsKhfJ5wBtTminG/PAvuBdJ70Z/bXVPf8TVsR292zQ65xwvWTejROW6AZX6aqucUj\""
buildConfigField "String", "GENERIC_SERVER_PUBLIC_PARAMS", "\"AHILOIrFPXX9laLbalbA9+L1CXpSbM/bTJXZGZiuyK1JaI6dK5FHHWL6tWxmHKYAZTSYmElmJ5z2A5YcirjO/yfoemE03FItyaf8W1fE4p14hzb5qnrmfXUSiAIVrhaXVwIwSzH6RL/+EO8jFIjJ/YfExfJ8aBl48CKHgu1+A6kWynhttonvWWx6h7924mIzW0Czj2ROuh4LwQyZypex4GuOPW8sgIT21KNZaafgg+KbV7XM1x1tF3XA17B4uGUaDbDw2O+nR1+U5p6qHPzmJ7ggFjSN6Utu+35dS1sS0P9N\""
buildConfigField "String", "MOBILE_COIN_ENVIRONMENT", "\"testnet\""
buildConfigField "String", "SIGNAL_CAPTCHA_URL", "\"https://signalcaptchas.org/staging/registration/generate.html\""
buildConfigField "String", "RECAPTCHA_PROOF_URL", "\"https://signalcaptchas.org/staging/challenge/generate.html\""
@@ -466,6 +467,7 @@ dependencies {
implementation libs.androidx.legacy.preference
implementation libs.androidx.gridlayout
implementation libs.androidx.exifinterface
implementation libs.androidx.compose.rxjava3
implementation libs.androidx.constraintlayout
implementation libs.androidx.multidex
implementation libs.androidx.navigation.fragment.ktx
@@ -523,12 +525,6 @@ dependencies {
exclude group: 'com.google.protobuf'
}
implementation(libs.signal.argon2) {
artifact {
type = "aar"
}
}
implementation libs.signal.ringrtc
implementation libs.leolin.shortcutbadger
@@ -566,6 +562,7 @@ dependencies {
}
implementation libs.dnsjava
implementation libs.kotlinx.collections.immutable
implementation libs.accompanist.permissions
spinnerImplementation project(":spinner")
@@ -21,7 +21,6 @@ class CallTableTest {
@Test
fun givenACall_whenISetTimestamp_thenIExpectUpdatedTimestamp() {
val callId = 1L
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
val now = System.currentTimeMillis()
SignalDatabase.calls.insertAcceptedGroupCall(
callId,
@@ -30,8 +29,8 @@ class CallTableTest {
now
)
SignalDatabase.calls.setTimestamp(callId, conversationId, -1L)
val call = SignalDatabase.calls.getCallById(callId, conversationId)
SignalDatabase.calls.setTimestamp(callId, harness.others[0], -1L)
val call = SignalDatabase.calls.getCallById(callId, harness.others[0])
assertNotNull(call)
assertEquals(-1L, call?.timestamp)
@@ -43,7 +42,6 @@ class CallTableTest {
@Test
fun givenPreExistingEvent_whenIDeleteGroupCall_thenIMarkDeletedAndSetTimestamp() {
val callId = 1L
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
val now = System.currentTimeMillis()
SignalDatabase.calls.insertAcceptedGroupCall(
callId,
@@ -52,10 +50,10 @@ class CallTableTest {
now
)
val call = SignalDatabase.calls.getCallById(callId, conversationId)
val call = SignalDatabase.calls.getCallById(callId, harness.others[0])
SignalDatabase.calls.deleteGroupCall(call!!)
val deletedCall = SignalDatabase.calls.getCallById(callId, conversationId)
val deletedCall = SignalDatabase.calls.getCallById(callId, harness.others[0])
val oldestDeletionTimestamp = SignalDatabase.calls.getOldestDeletionTimestamp()
assertEquals(CallTable.Event.DELETE, deletedCall?.event)
@@ -66,7 +64,6 @@ class CallTableTest {
@Test
fun givenNoPreExistingEvent_whenIDeleteGroupCall_thenIInsertAndMarkCallDeleted() {
val callId = 1L
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
SignalDatabase.calls.insertDeletedGroupCallFromSyncEvent(
callId,
harness.others[0],
@@ -74,7 +71,7 @@ class CallTableTest {
System.currentTimeMillis()
)
val call = SignalDatabase.calls.getCallById(callId, conversationId)
val call = SignalDatabase.calls.getCallById(callId, harness.others[0])
assertNotNull(call)
val oldestDeletionTimestamp = SignalDatabase.calls.getOldestDeletionTimestamp()
@@ -87,7 +84,6 @@ class CallTableTest {
@Test
fun givenNoPriorEvent_whenIInsertAcceptedOutgoingGroupCall_thenIExpectLocalRingerAndOutgoingRing() {
val callId = 1L
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
SignalDatabase.calls.insertAcceptedGroupCall(
callId,
harness.others[0],
@@ -95,7 +91,7 @@ class CallTableTest {
System.currentTimeMillis()
)
val call = SignalDatabase.calls.getCallById(callId, conversationId)
val call = SignalDatabase.calls.getCallById(callId, harness.others[0])
assertNotNull(call)
assertEquals(CallTable.Event.OUTGOING_RING, call?.event)
assertEquals(harness.self.id, call?.ringerRecipient)
@@ -105,7 +101,6 @@ class CallTableTest {
@Test
fun givenNoPriorEvent_whenIInsertAcceptedIncomingGroupCall_thenIExpectJoined() {
val callId = 1L
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
SignalDatabase.calls.insertAcceptedGroupCall(
callId,
harness.others[0],
@@ -113,7 +108,7 @@ class CallTableTest {
System.currentTimeMillis()
)
val call = SignalDatabase.calls.getCallById(callId, conversationId)
val call = SignalDatabase.calls.getCallById(callId, harness.others[0])
assertNotNull(call)
assertEquals(CallTable.Event.JOINED, call?.event)
assertNull(call?.ringerRecipient)
@@ -123,7 +118,6 @@ class CallTableTest {
@Test
fun givenARingingCall_whenIAcceptedIncomingGroupCall_thenIExpectAccepted() {
val callId = 1L
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
SignalDatabase.calls.insertOrUpdateGroupCallFromRingState(
ringId = callId,
groupRecipientId = harness.others[0],
@@ -132,7 +126,7 @@ class CallTableTest {
ringState = CallManager.RingUpdate.REQUESTED
)
val call = SignalDatabase.calls.getCallById(callId, conversationId)
val call = SignalDatabase.calls.getCallById(callId, harness.others[0])
assertNotNull(call)
assertEquals(CallTable.Event.RINGING, call?.event)
@@ -140,14 +134,13 @@ class CallTableTest {
call!!
)
val acceptedCall = SignalDatabase.calls.getCallById(callId, conversationId)
val acceptedCall = SignalDatabase.calls.getCallById(callId, harness.others[0])
assertEquals(CallTable.Event.ACCEPTED, acceptedCall?.event)
}
@Test
fun givenAMissedCall_whenIAcceptedIncomingGroupCall_thenIExpectAccepted() {
val callId = 1L
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
SignalDatabase.calls.insertOrUpdateGroupCallFromRingState(
ringId = callId,
groupRecipientId = harness.others[0],
@@ -156,7 +149,7 @@ class CallTableTest {
ringState = CallManager.RingUpdate.EXPIRED_REQUEST
)
val call = SignalDatabase.calls.getCallById(callId, conversationId)
val call = SignalDatabase.calls.getCallById(callId, harness.others[0])
assertNotNull(call)
assertEquals(CallTable.Event.MISSED, call?.event)
@@ -164,14 +157,13 @@ class CallTableTest {
call!!
)
val acceptedCall = SignalDatabase.calls.getCallById(callId, conversationId)
val acceptedCall = SignalDatabase.calls.getCallById(callId, harness.others[0])
assertEquals(CallTable.Event.ACCEPTED, acceptedCall?.event)
}
@Test
fun givenADeclinedCall_whenIAcceptedIncomingGroupCall_thenIExpectAccepted() {
val callId = 1L
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
SignalDatabase.calls.insertOrUpdateGroupCallFromRingState(
ringId = callId,
groupRecipientId = harness.others[0],
@@ -180,7 +172,7 @@ class CallTableTest {
ringState = CallManager.RingUpdate.DECLINED_ON_ANOTHER_DEVICE
)
val call = SignalDatabase.calls.getCallById(callId, conversationId)
val call = SignalDatabase.calls.getCallById(callId, harness.others[0])
assertNotNull(call)
assertEquals(CallTable.Event.DECLINED, call?.event)
@@ -188,7 +180,7 @@ class CallTableTest {
call!!
)
val acceptedCall = SignalDatabase.calls.getCallById(callId, conversationId)
val acceptedCall = SignalDatabase.calls.getCallById(callId, harness.others[0])
assertEquals(CallTable.Event.ACCEPTED, acceptedCall?.event)
}
@@ -196,7 +188,6 @@ class CallTableTest {
fun givenAGenericGroupCall_whenIAcceptedIncomingGroupCall_thenIExpectAccepted() {
val era = "aaa"
val callId = CallId.fromEra(era).longValue()
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
SignalDatabase.calls.insertOrUpdateGroupCallFromLocalEvent(
groupRecipientId = harness.others[0],
sender = harness.others[1],
@@ -206,7 +197,7 @@ class CallTableTest {
isCallFull = false
)
val call = SignalDatabase.calls.getCallById(callId, conversationId)
val call = SignalDatabase.calls.getCallById(callId, harness.others[0])
assertNotNull(call)
assertEquals(CallTable.Event.GENERIC_GROUP_CALL, call?.event)
@@ -214,7 +205,7 @@ class CallTableTest {
call!!
)
val acceptedCall = SignalDatabase.calls.getCallById(callId, conversationId)
val acceptedCall = SignalDatabase.calls.getCallById(callId, harness.others[0])
assertEquals(CallTable.Event.JOINED, acceptedCall?.event)
}
@@ -222,7 +213,6 @@ class CallTableTest {
fun givenNoPriorCallEvent_whenIReceiveAGroupCallUpdateMessage_thenIExpectAGenericGroupCall() {
val era = "aaa"
val callId = CallId.fromEra(era).longValue()
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
SignalDatabase.calls.insertOrUpdateGroupCallFromLocalEvent(
groupRecipientId = harness.others[0],
sender = harness.others[1],
@@ -232,7 +222,7 @@ class CallTableTest {
isCallFull = false
)
val call = SignalDatabase.calls.getCallById(callId, conversationId)
val call = SignalDatabase.calls.getCallById(callId, harness.others[0])
assertNotNull(call)
assertEquals(CallTable.Event.GENERIC_GROUP_CALL, call?.event)
}
@@ -242,7 +232,6 @@ class CallTableTest {
val era = "aaa"
val callId = CallId.fromEra(era).longValue()
val now = System.currentTimeMillis()
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
SignalDatabase.calls.insertOrUpdateGroupCallFromLocalEvent(
groupRecipientId = harness.others[0],
sender = harness.others[1],
@@ -252,7 +241,7 @@ class CallTableTest {
isCallFull = false
)
SignalDatabase.calls.getCallById(callId, conversationId).let {
SignalDatabase.calls.getCallById(callId, harness.others[0]).let {
assertNotNull(it)
assertEquals(now, it?.timestamp)
}
@@ -266,7 +255,7 @@ class CallTableTest {
isCallFull = false
)
val call = SignalDatabase.calls.getCallById(callId, conversationId)
val call = SignalDatabase.calls.getCallById(callId, harness.others[0])
assertNotNull(call)
assertEquals(CallTable.Event.GENERIC_GROUP_CALL, call?.event)
assertEquals(1L, call?.timestamp)
@@ -275,7 +264,6 @@ class CallTableTest {
@Test
fun givenADeletedCallEvent_whenIReceiveARingUpdate_thenIIgnoreTheRingUpdate() {
val callId = 1L
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
SignalDatabase.calls.insertDeletedGroupCallFromSyncEvent(
callId = callId,
recipientId = harness.others[0],
@@ -291,7 +279,7 @@ class CallTableTest {
ringState = CallManager.RingUpdate.REQUESTED
)
val call = SignalDatabase.calls.getCallById(callId, conversationId)
val call = SignalDatabase.calls.getCallById(callId, harness.others[0])
assertNotNull(call)
assertEquals(CallTable.Event.DELETE, call?.event)
}
@@ -301,7 +289,6 @@ class CallTableTest {
val era = "aaa"
val callId = CallId.fromEra(era).longValue()
val now = System.currentTimeMillis()
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
SignalDatabase.calls.insertOrUpdateGroupCallFromLocalEvent(
groupRecipientId = harness.others[0],
sender = harness.others[1],
@@ -319,7 +306,7 @@ class CallTableTest {
CallManager.RingUpdate.REQUESTED
)
val call = SignalDatabase.calls.getCallById(callId, conversationId)
val call = SignalDatabase.calls.getCallById(callId, harness.others[0])
assertNotNull(call)
assertEquals(CallTable.Event.RINGING, call?.event)
assertEquals(harness.others[1], call?.ringerRecipient)
@@ -330,7 +317,6 @@ class CallTableTest {
val era = "aaa"
val callId = CallId.fromEra(era).longValue()
val now = System.currentTimeMillis()
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
SignalDatabase.calls.insertAcceptedGroupCall(
callId,
harness.others[0],
@@ -346,7 +332,7 @@ class CallTableTest {
CallManager.RingUpdate.REQUESTED
)
val call = SignalDatabase.calls.getCallById(callId, conversationId)
val call = SignalDatabase.calls.getCallById(callId, harness.others[0])
assertNotNull(call)
assertEquals(CallTable.Event.ACCEPTED, call?.event)
assertEquals(harness.others[1], call?.ringerRecipient)
@@ -357,7 +343,6 @@ class CallTableTest {
val era = "aaa"
val callId = CallId.fromEra(era).longValue()
val now = System.currentTimeMillis()
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
SignalDatabase.calls.insertOrUpdateGroupCallFromLocalEvent(
groupRecipientId = harness.others[0],
sender = harness.others[1],
@@ -375,7 +360,7 @@ class CallTableTest {
CallManager.RingUpdate.EXPIRED_REQUEST
)
val call = SignalDatabase.calls.getCallById(callId, conversationId)
val call = SignalDatabase.calls.getCallById(callId, harness.others[0])
assertNotNull(call)
assertEquals(CallTable.Event.MISSED, call?.event)
assertEquals(harness.others[1], call?.ringerRecipient)
@@ -386,7 +371,6 @@ class CallTableTest {
val era = "aaa"
val callId = CallId.fromEra(era).longValue()
val now = System.currentTimeMillis()
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
SignalDatabase.calls.insertOrUpdateGroupCallFromLocalEvent(
groupRecipientId = harness.others[0],
sender = harness.others[1],
@@ -412,7 +396,7 @@ class CallTableTest {
CallManager.RingUpdate.EXPIRED_REQUEST
)
val call = SignalDatabase.calls.getCallById(callId, conversationId)
val call = SignalDatabase.calls.getCallById(callId, harness.others[0])
assertNotNull(call)
assertEquals(CallTable.Event.MISSED, call?.event)
assertEquals(harness.others[1], call?.ringerRecipient)
@@ -423,7 +407,6 @@ class CallTableTest {
val era = "aaa"
val callId = CallId.fromEra(era).longValue()
val now = System.currentTimeMillis()
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
SignalDatabase.calls.insertAcceptedGroupCall(
callId,
harness.others[0],
@@ -439,7 +422,7 @@ class CallTableTest {
CallManager.RingUpdate.BUSY_LOCALLY
)
val call = SignalDatabase.calls.getCallById(callId, conversationId)
val call = SignalDatabase.calls.getCallById(callId, harness.others[0])
assertNotNull(call)
assertEquals(CallTable.Event.ACCEPTED, call?.event)
}
@@ -449,7 +432,6 @@ class CallTableTest {
val era = "aaa"
val callId = CallId.fromEra(era).longValue()
val now = System.currentTimeMillis()
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
SignalDatabase.calls.insertAcceptedGroupCall(
callId,
harness.others[0],
@@ -465,7 +447,7 @@ class CallTableTest {
CallManager.RingUpdate.BUSY_ON_ANOTHER_DEVICE
)
val call = SignalDatabase.calls.getCallById(callId, conversationId)
val call = SignalDatabase.calls.getCallById(callId, harness.others[0])
assertNotNull(call)
assertEquals(CallTable.Event.ACCEPTED, call?.event)
}
@@ -475,7 +457,6 @@ class CallTableTest {
val era = "aaa"
val callId = CallId.fromEra(era).longValue()
val now = System.currentTimeMillis()
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
SignalDatabase.calls.insertOrUpdateGroupCallFromLocalEvent(
groupRecipientId = harness.others[0],
sender = harness.others[1],
@@ -501,7 +482,7 @@ class CallTableTest {
CallManager.RingUpdate.BUSY_LOCALLY
)
val call = SignalDatabase.calls.getCallById(callId, conversationId)
val call = SignalDatabase.calls.getCallById(callId, harness.others[0])
assertNotNull(call)
assertEquals(CallTable.Event.MISSED, call?.event)
}
@@ -511,7 +492,6 @@ class CallTableTest {
val era = "aaa"
val callId = CallId.fromEra(era).longValue()
val now = System.currentTimeMillis()
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
SignalDatabase.calls.insertOrUpdateGroupCallFromLocalEvent(
groupRecipientId = harness.others[0],
sender = harness.others[1],
@@ -537,7 +517,7 @@ class CallTableTest {
CallManager.RingUpdate.BUSY_ON_ANOTHER_DEVICE
)
val call = SignalDatabase.calls.getCallById(callId, conversationId)
val call = SignalDatabase.calls.getCallById(callId, harness.others[0])
assertNotNull(call)
assertEquals(CallTable.Event.MISSED, call?.event)
}
@@ -547,7 +527,6 @@ class CallTableTest {
val era = "aaa"
val callId = CallId.fromEra(era).longValue()
val now = System.currentTimeMillis()
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
SignalDatabase.calls.insertOrUpdateGroupCallFromLocalEvent(
groupRecipientId = harness.others[0],
sender = harness.others[1],
@@ -565,7 +544,7 @@ class CallTableTest {
CallManager.RingUpdate.ACCEPTED_ON_ANOTHER_DEVICE
)
val call = SignalDatabase.calls.getCallById(callId, conversationId)
val call = SignalDatabase.calls.getCallById(callId, harness.others[0])
assertNotNull(call)
assertEquals(CallTable.Event.ACCEPTED, call?.event)
}
@@ -574,7 +553,6 @@ class CallTableTest {
fun givenARingingCallEvent_whenRingDeclinedOnAnotherDevice_thenIMoveToDeclinedState() {
val era = "aaa"
val callId = CallId.fromEra(era).longValue()
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
SignalDatabase.calls.insertOrUpdateGroupCallFromRingState(
callId,
@@ -592,7 +570,7 @@ class CallTableTest {
CallManager.RingUpdate.DECLINED_ON_ANOTHER_DEVICE
)
val call = SignalDatabase.calls.getCallById(callId, conversationId)
val call = SignalDatabase.calls.getCallById(callId, harness.others[0])
assertNotNull(call)
assertEquals(CallTable.Event.DECLINED, call?.event)
}
@@ -601,7 +579,6 @@ class CallTableTest {
fun givenAMissedCallEvent_whenRingDeclinedOnAnotherDevice_thenIMoveToDeclinedState() {
val era = "aaa"
val callId = CallId.fromEra(era).longValue()
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
SignalDatabase.calls.insertOrUpdateGroupCallFromRingState(
callId,
@@ -619,7 +596,7 @@ class CallTableTest {
CallManager.RingUpdate.DECLINED_ON_ANOTHER_DEVICE
)
val call = SignalDatabase.calls.getCallById(callId, conversationId)
val call = SignalDatabase.calls.getCallById(callId, harness.others[0])
assertNotNull(call)
assertEquals(CallTable.Event.DECLINED, call?.event)
}
@@ -628,7 +605,6 @@ class CallTableTest {
fun givenAnOutgoingRingCallEvent_whenRingDeclinedOnAnotherDevice_thenIDoNotChangeState() {
val era = "aaa"
val callId = CallId.fromEra(era).longValue()
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
SignalDatabase.calls.insertAcceptedGroupCall(
callId,
@@ -645,7 +621,7 @@ class CallTableTest {
CallManager.RingUpdate.DECLINED_ON_ANOTHER_DEVICE
)
val call = SignalDatabase.calls.getCallById(callId, conversationId)
val call = SignalDatabase.calls.getCallById(callId, harness.others[0])
assertNotNull(call)
assertEquals(CallTable.Event.OUTGOING_RING, call?.event)
}
@@ -653,7 +629,6 @@ class CallTableTest {
@Test
fun givenNoPriorEvent_whenRingRequested_thenICreateAnEventInTheRingingStateAndSetRinger() {
val callId = 1L
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
SignalDatabase.calls.insertOrUpdateGroupCallFromRingState(
callId,
harness.others[0],
@@ -662,7 +637,7 @@ class CallTableTest {
CallManager.RingUpdate.REQUESTED
)
val call = SignalDatabase.calls.getCallById(callId, conversationId)
val call = SignalDatabase.calls.getCallById(callId, harness.others[0])
assertNotNull(call)
assertEquals(CallTable.Event.RINGING, call?.event)
assertEquals(harness.others[1], call?.ringerRecipient)
@@ -672,7 +647,6 @@ class CallTableTest {
@Test
fun givenNoPriorEvent_whenRingExpired_thenICreateAnEventInTheMissedStateAndSetRinger() {
val callId = 1L
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
SignalDatabase.calls.insertOrUpdateGroupCallFromRingState(
callId,
harness.others[0],
@@ -681,7 +655,7 @@ class CallTableTest {
CallManager.RingUpdate.EXPIRED_REQUEST
)
val call = SignalDatabase.calls.getCallById(callId, conversationId)
val call = SignalDatabase.calls.getCallById(callId, harness.others[0])
assertNotNull(call)
assertEquals(CallTable.Event.MISSED, call?.event)
assertEquals(harness.others[1], call?.ringerRecipient)
@@ -691,7 +665,6 @@ class CallTableTest {
@Test
fun givenNoPriorEvent_whenRingCancelledByRinger_thenICreateAnEventInTheMissedStateAndSetRinger() {
val callId = 1L
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
SignalDatabase.calls.insertOrUpdateGroupCallFromRingState(
callId,
harness.others[0],
@@ -700,7 +673,7 @@ class CallTableTest {
CallManager.RingUpdate.CANCELLED_BY_RINGER
)
val call = SignalDatabase.calls.getCallById(callId, conversationId)
val call = SignalDatabase.calls.getCallById(callId, harness.others[0])
assertNotNull(call)
assertEquals(CallTable.Event.MISSED, call?.event)
assertEquals(harness.others[1], call?.ringerRecipient)
@@ -710,7 +683,6 @@ class CallTableTest {
@Test
fun givenNoPriorEvent_whenRingCancelledBecauseUserIsBusyLocally_thenICreateAnEventInTheMissedState() {
val callId = 1L
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
SignalDatabase.calls.insertOrUpdateGroupCallFromRingState(
callId,
harness.others[0],
@@ -719,7 +691,7 @@ class CallTableTest {
CallManager.RingUpdate.BUSY_LOCALLY
)
val call = SignalDatabase.calls.getCallById(callId, conversationId)
val call = SignalDatabase.calls.getCallById(callId, harness.others[0])
assertNotNull(call)
assertEquals(CallTable.Event.MISSED, call?.event)
assertNotNull(call?.messageId)
@@ -728,7 +700,6 @@ class CallTableTest {
@Test
fun givenNoPriorEvent_whenRingCancelledBecauseUserIsBusyOnAnotherDevice_thenICreateAnEventInTheMissedState() {
val callId = 1L
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
SignalDatabase.calls.insertOrUpdateGroupCallFromRingState(
callId,
harness.others[0],
@@ -737,7 +708,7 @@ class CallTableTest {
CallManager.RingUpdate.BUSY_ON_ANOTHER_DEVICE
)
val call = SignalDatabase.calls.getCallById(callId, conversationId)
val call = SignalDatabase.calls.getCallById(callId, harness.others[0])
assertNotNull(call)
assertEquals(CallTable.Event.MISSED, call?.event)
assertNotNull(call?.messageId)
@@ -746,7 +717,6 @@ class CallTableTest {
@Test
fun givenNoPriorEvent_whenRingAcceptedOnAnotherDevice_thenICreateAnEventInTheAcceptedState() {
val callId = 1L
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
SignalDatabase.calls.insertOrUpdateGroupCallFromRingState(
callId,
harness.others[0],
@@ -755,7 +725,7 @@ class CallTableTest {
CallManager.RingUpdate.ACCEPTED_ON_ANOTHER_DEVICE
)
val call = SignalDatabase.calls.getCallById(callId, conversationId)
val call = SignalDatabase.calls.getCallById(callId, harness.others[0])
assertNotNull(call)
assertEquals(CallTable.Event.ACCEPTED, call?.event)
assertNotNull(call?.messageId)
@@ -764,7 +734,6 @@ class CallTableTest {
@Test
fun givenNoPriorEvent_whenRingDeclinedOnAnotherDevice_thenICreateAnEventInTheDeclinedState() {
val callId = 1L
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
SignalDatabase.calls.insertOrUpdateGroupCallFromRingState(
callId,
harness.others[0],
@@ -773,7 +742,7 @@ class CallTableTest {
CallManager.RingUpdate.DECLINED_ON_ANOTHER_DEVICE
)
val call = SignalDatabase.calls.getCallById(callId, conversationId)
val call = SignalDatabase.calls.getCallById(callId, harness.others[0])
assertNotNull(call)
assertEquals(CallTable.Event.DECLINED, call?.event)
assertNotNull(call?.messageId)
@@ -8,6 +8,10 @@ import net.zetetic.database.sqlcipher.SQLiteOpenHelper
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.signal.core.util.ForeignKeyConstraint
import org.signal.core.util.Index
import org.signal.core.util.getForeignKeys
import org.signal.core.util.getIndexes
import org.signal.core.util.readToList
import org.signal.core.util.requireNonNullString
import org.thoughtcrime.securesms.database.helpers.SignalDatabaseMigrations
@@ -24,7 +28,7 @@ class DatabaseConsistencyTest {
val harness = SignalActivityRule()
@Test
fun test() {
fun testUpgradeConsistency() {
val currentVersionStatements = SignalDatabase.rawDatabase.getAllCreateStatements()
val testHelper = InMemoryTestHelper(ApplicationDependencies.getApplication()).also {
it.onUpgrade(it.writableDatabase, 181, SignalDatabaseMigrations.DATABASE_VERSION)
@@ -61,6 +65,30 @@ class DatabaseConsistencyTest {
}
}
@Test
fun testForeignKeyIndexCoverage() {
/** We may deem certain indexes non-critical if deletion frequency is low or table size is small. */
val ignoredColumns: List<Pair<String, String>> = listOf(
StorySendTable.TABLE_NAME to StorySendTable.DISTRIBUTION_ID
)
val foreignKeys: List<ForeignKeyConstraint> = SignalDatabase.rawDatabase.getForeignKeys()
val indexesByFirstColumn: List<Index> = SignalDatabase.rawDatabase.getIndexes()
val notFound: List<Pair<String, String>> = foreignKeys
.filterNot { ignoredColumns.contains(it.table to it.column) }
.filterNot { foreignKey ->
indexesByFirstColumn.hasPrimaryIndexFor(foreignKey.table, foreignKey.column)
}
.map { it.table to it.column }
assertTrue("Missing indexes to cover: $notFound", notFound.isEmpty())
}
private fun List<Index>.hasPrimaryIndexFor(table: String, column: String): Boolean {
return this.any { index -> index.table == table && index.columns[0] == column }
}
private data class Statement(
val name: String,
val sql: String
@@ -74,6 +102,7 @@ class DatabaseConsistencyTest {
sql = cursor.requireNonNullString("sql").normalizeSql()
)
}
.filterNot { it.name.startsWith("sqlite_stat") }
.sortedBy { it.name }
}
@@ -82,10 +111,11 @@ class DatabaseConsistencyTest {
.split("\n")
.map { it.trim() }
.joinToString(separator = " ")
.replace(Regex.fromLiteral(" ,"), ",")
.replace(Regex("\\s+"), " ")
.replace(Regex.fromLiteral("( "), "(")
.replace(Regex.fromLiteral(" )"), ")")
.replace(Regex("CREATE TABLE \"([a-z]+)\""), "CREATE TABLE $1") // for some reason SQLite will wrap table names in quotes for upgraded tables. This unwraps them.
.replace(Regex("CREATE TABLE \"([a-zA-Z_]+)\""), "CREATE TABLE $1") // for some reason SQLite will wrap table names in quotes for upgraded tables. This unwraps them.
}
private class InMemoryTestHelper(private val application: Application) : SQLiteOpenHelper(application, null, null, 1) {
@@ -122,6 +122,37 @@ class GroupTableTest {
assertEquals(setOf(harness.self.id, harness.others[1]), groupRecord.members.toSet())
}
@Test
fun givenAGroup_whenIRemapRecipientsThatHaveAConflict_thenIExpectDeletion() {
val v2Group = insertPushGroupWithSelfAndOthers(
listOf(
harness.others[0],
harness.others[1]
)
)
insertThread(v2Group)
groupTable.remapRecipient(harness.others[0], harness.others[1])
val groupRecord = groupTable.getGroup(v2Group).get()
assertEquals(setOf(harness.self.id, harness.others[1]), groupRecord.members.toSet())
}
@Test
fun givenAGroup_whenIRemapRecipients_thenIExpectRemap() {
val v2Group = insertPushGroup()
insertThread(v2Group)
val newId = harness.others[1]
groupTable.remapRecipient(harness.others[0], newId)
val groupRecord = groupTable.getGroup(v2Group).get()
assertEquals(setOf(harness.self.id, newId), groupRecord.members.toSet())
}
@Test
fun givenAGroupAndMember_whenIIsCurrentMember_thenIExpectTrue() {
val v2Group = insertPushGroup()
@@ -280,6 +311,31 @@ class GroupTableTest {
.setRevision(0)
.build()
return groupTable.create(groupMasterKey, decryptedGroupState)
return groupTable.create(groupMasterKey, decryptedGroupState)!!
}
private fun insertPushGroupWithSelfAndOthers(others: List<RecipientId>): GroupId {
val groupMasterKey = GroupMasterKey(Random.nextBytes(GroupMasterKey.SIZE))
val selfMember: DecryptedMember = DecryptedMember.newBuilder()
.setUuid(harness.self.requireServiceId().toByteString())
.setJoinedAtRevision(0)
.setRole(Member.Role.DEFAULT)
.build()
val otherMembers: List<DecryptedMember> = others.map { id ->
DecryptedMember.newBuilder()
.setUuid(Recipient.resolved(id).requireServiceId().toByteString())
.setJoinedAtRevision(0)
.setRole(Member.Role.DEFAULT)
.build()
}
val decryptedGroupState = DecryptedGroup.newBuilder()
.addAllMembers(listOf(selfMember) + otherMembers)
.setRevision(0)
.build()
return groupTable.create(groupMasterKey, decryptedGroupState)!!
}
}
@@ -180,4 +180,45 @@ class SQLiteDatabaseTest {
assertTrue(hasRun1.get())
assertTrue(hasRun2.get())
}
@Test
fun runPostSuccessfulTransaction_runsAfterMainTransactionInNestedTransaction() {
val hasRun1 = AtomicBoolean(false)
val hasRun2 = AtomicBoolean(false)
db.beginTransaction()
db.runPostSuccessfulTransaction {
assertFalse(hasRun1.get())
assertFalse(hasRun2.get())
hasRun1.set(true)
}
assertFalse(hasRun1.get())
assertFalse(hasRun2.get())
db.beginTransaction()
db.runPostSuccessfulTransaction {
assertTrue(hasRun1.get())
assertFalse(hasRun2.get())
hasRun2.set(true)
}
db.setTransactionSuccessful()
assertFalse(hasRun1.get())
assertFalse(hasRun2.get())
db.endTransaction()
db.setTransactionSuccessful()
assertFalse(hasRun1.get())
assertFalse(hasRun2.get())
db.endTransaction()
assertTrue(hasRun1.get())
assertTrue(hasRun2.get())
}
}
@@ -82,10 +82,12 @@ class InstrumentationApplicationDependencyProvider(application: Application, def
arrayOf(SignalKeyBackupServiceUrl(baseUrl, "localhost", serviceTrustStore, ConnectionSpec.CLEARTEXT)),
arrayOf(SignalStorageUrl(baseUrl, "localhost", serviceTrustStore, ConnectionSpec.CLEARTEXT)),
arrayOf(SignalCdsiUrl(baseUrl, "localhost", serviceTrustStore, ConnectionSpec.CLEARTEXT)),
emptyArray(),
emptyList(),
Optional.of(SignalServiceNetworkAccess.DNS),
Optional.empty(),
Base64.decode(BuildConfig.ZKGROUP_SERVER_PUBLIC_PARAMS)
Base64.decode(BuildConfig.ZKGROUP_SERVER_PUBLIC_PARAMS),
Base64.decode(BuildConfig.GENERIC_SERVER_PUBLIC_PARAMS)
)
serviceNetworkAccessMock = mock {
@@ -5,9 +5,10 @@ import androidx.test.ext.junit.runners.AndroidJUnit4;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.signal.core.util.Hex;
import org.whispersystems.signalservice.api.kbs.HashedPin;
import org.signal.libsignal.svr2.PinHash;
import org.whispersystems.signalservice.api.kbs.KbsData;
import org.whispersystems.signalservice.api.kbs.MasterKey;
import org.whispersystems.signalservice.api.kbs.PinHashUtil;
import java.io.IOException;
@@ -24,16 +25,16 @@ public final class PinHashing_hashPin_Test {
byte[] backupId = Hex.fromStringCondensed("000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f");
MasterKey masterKey = new MasterKey(Hex.fromStringCondensed("202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f"));
HashedPin hashedPin = PinHashing.hashPin(pin, () -> backupId);
KbsData kbsData = hashedPin.createNewKbsData(masterKey);
PinHash hashedPin = PinHashUtil.hashPin(pin, new byte[]{});
KbsData kbsData = PinHashUtil.createNewKbsData(hashedPin, masterKey);
assertArrayEquals(hashedPin.getKbsAccessKey(), kbsData.getKbsAccessKey());
assertArrayEquals(hashedPin.accessKey(), kbsData.getKbsAccessKey());
assertArrayEquals(Hex.fromStringCondensed("ab7e8499d21f80a6600b3b9ee349ac6d72c07e3359fe885a934ba7aa844429f8"), kbsData.getKbsAccessKey());
assertArrayEquals(Hex.fromStringCondensed("3f33ce58eb25b40436592a30eae2a8fabab1899095f4e2fba6e2d0dc43b4a2d9cac5a3931748522393951e0e54dec769"), kbsData.getCipherText());
assertEquals(masterKey, kbsData.getMasterKey());
String localPinHash = PinHashing.localPinHash(pin);
assertTrue(PinHashing.verifyLocalPinHash(localPinHash, pin));
String localPinHash = PinHashUtil.localPinHash(pin);
assertTrue(PinHashUtil.verifyLocalPinHash(localPinHash, pin));
}
@Test
@@ -42,16 +43,16 @@ public final class PinHashing_hashPin_Test {
byte[] backupId = Hex.fromStringCondensed("202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f");
MasterKey masterKey = new MasterKey(Hex.fromStringCondensed("88a787415a2ecd79da0d1016a82a27c5c695c9a19b88b0aa1d35683280aa9a67"));
HashedPin hashedPin = PinHashing.hashPin(pin, () -> backupId);
KbsData kbsData = hashedPin.createNewKbsData(masterKey);
PinHash hashedPin = PinHashUtil.hashPin(pin, new byte[]{});
KbsData kbsData = PinHashUtil.createNewKbsData(hashedPin, masterKey);
assertArrayEquals(hashedPin.getKbsAccessKey(), kbsData.getKbsAccessKey());
assertArrayEquals(hashedPin.accessKey(), kbsData.getKbsAccessKey());
assertArrayEquals(Hex.fromStringCondensed("301d9dd1e96f20ce51083f67d3298fd37b97525de8324d5e12ed2d407d3d927b"), kbsData.getKbsAccessKey());
assertArrayEquals(Hex.fromStringCondensed("9d9b05402ea39c17ff1c9298c8a0e86784a352aa02a74943bf8bcf07ec0f4b574a5b786ad0182c8d308d9eb06538b8c9"), kbsData.getCipherText());
assertEquals(masterKey, kbsData.getMasterKey());
String localPinHash = PinHashing.localPinHash(pin);
assertTrue(PinHashing.verifyLocalPinHash(localPinHash, pin));
String localPinHash = PinHashUtil.localPinHash(pin);
assertTrue(PinHashUtil.verifyLocalPinHash(localPinHash, pin));
}
@Test
@@ -60,18 +61,18 @@ public final class PinHashing_hashPin_Test {
byte[] backupId = Hex.fromStringCondensed("cba811749042b303a6a7efa5ccd160aea5e3ea243c8d2692bd13d515732f51a8");
MasterKey masterKey = new MasterKey(Hex.fromStringCondensed("9571f3fde1e58588ba49bcf82be1b301ca3859a6f59076f79a8f47181ef952bf"));
HashedPin hashedPin = PinHashing.hashPin(pin, () -> backupId);
KbsData kbsData = hashedPin.createNewKbsData(masterKey);
PinHash hashedPin = PinHashUtil.hashPin(pin, new byte[]{});
KbsData kbsData = PinHashUtil.createNewKbsData(hashedPin, masterKey);
assertArrayEquals(hashedPin.getKbsAccessKey(), kbsData.getKbsAccessKey());
assertArrayEquals(hashedPin.accessKey(), kbsData.getKbsAccessKey());
assertArrayEquals(Hex.fromStringCondensed("ab645acdccc1652a48a34b2ac6926340ff35c03034013f68760f20013f028dd8"), kbsData.getKbsAccessKey());
assertArrayEquals(Hex.fromStringCondensed("11c0ba1834db15e47c172f6c987c64bd4cfc69c6047dd67a022afeec0165a10943f204d5b8f37b3cb7bab21c6dfc39c8"), kbsData.getCipherText());
assertEquals(masterKey, kbsData.getMasterKey());
assertEquals("577939bccb2b6638c39222d5a97998a867c5e154e30b82cc120f2dd07a3de987", kbsData.getMasterKey().deriveRegistrationLock());
String localPinHash = PinHashing.localPinHash(pin);
assertTrue(PinHashing.verifyLocalPinHash(localPinHash, pin));
String localPinHash = PinHashUtil.localPinHash(pin);
assertTrue(PinHashUtil.verifyLocalPinHash(localPinHash, pin));
}
@Test
@@ -80,18 +81,17 @@ public final class PinHashing_hashPin_Test {
byte[] backupId = Hex.fromStringCondensed("717dc111a98423a57196512606822fca646c653facd037c10728f14ba0be2ab3");
MasterKey masterKey = new MasterKey(Hex.fromStringCondensed("0432d735b32f66d0e3a70d4f9cc821a8529521a4937d26b987715d8eff4e4c54"));
HashedPin hashedPin = PinHashing.hashPin(pin, () -> backupId);
KbsData kbsData = hashedPin.createNewKbsData(masterKey);
PinHash hashedPin = PinHashUtil.hashPin(pin, new byte[]{});
KbsData kbsData = PinHashUtil.createNewKbsData(hashedPin, masterKey);
assertArrayEquals(hashedPin.getKbsAccessKey(), kbsData.getKbsAccessKey());
assertArrayEquals(hashedPin.accessKey(), kbsData.getKbsAccessKey());
assertArrayEquals(Hex.fromStringCondensed("d2fedabd0d4c17a371491c9722578843a26be3b4923e28d452ab2fc5491e794b"), kbsData.getKbsAccessKey());
assertArrayEquals(Hex.fromStringCondensed("877ef871ef1fc668401c717ef21aa12e8523579fb1ff4474b76f28c2293537c80cc7569996c9e0229bea7f378e3a824e"), kbsData.getCipherText());
assertEquals(masterKey, kbsData.getMasterKey());
assertEquals("23a75cb1df1a87df45cc2ed167c2bdc85ab1220b847c88761b0005cac907fce5", kbsData.getMasterKey().deriveRegistrationLock());
String localPinHash = PinHashing.localPinHash(pin);
assertTrue(PinHashing.verifyLocalPinHash(localPinHash, pin));
String localPinHash = PinHashUtil.localPinHash(pin);
assertTrue(PinHashUtil.verifyLocalPinHash(localPinHash, pin));
}
}
@@ -21,7 +21,7 @@ import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.testing.MessageContentFuzzer
import org.thoughtcrime.securesms.testing.SignalActivityRule
import org.thoughtcrime.securesms.testing.assertIs
import org.thoughtcrime.securesms.util.MessageTableUtils
import org.thoughtcrime.securesms.util.MessageTableTestUtils
import org.whispersystems.signalservice.internal.push.SignalServiceProtos
import org.whispersystems.signalservice.internal.push.SignalServiceProtos.EditMessage
import kotlin.time.Duration.Companion.seconds
@@ -238,7 +238,7 @@ class EditMessageSyncProcessorTest {
else -> cursor.getString(index)
}
if (table == MessageTable.TABLE_NAME && column == MessageTable.TYPE) {
data = MessageTableUtils.typeColumnToString(cursor.getLong(index))
data = MessageTableTestUtils.typeColumnToString(cursor.getLong(index))
}
column to data
@@ -21,7 +21,7 @@ import org.thoughtcrime.securesms.testing.InMemoryLogger
import org.thoughtcrime.securesms.testing.MessageContentFuzzer
import org.thoughtcrime.securesms.testing.SignalActivityRule
import org.thoughtcrime.securesms.testing.assertIs
import org.thoughtcrime.securesms.util.MessageTableUtils
import org.thoughtcrime.securesms.util.MessageTableTestUtils
import org.whispersystems.signalservice.api.crypto.EnvelopeMetadata
import org.whispersystems.signalservice.api.messages.SignalServiceContent
import org.whispersystems.signalservice.api.messages.SignalServiceMetadata
@@ -278,7 +278,7 @@ class MessageContentProcessorTestV2 {
else -> cursor.getString(index)
}
if (table == MessageTable.TABLE_NAME && column == MessageTable.TYPE) {
data = MessageTableUtils.typeColumnToString(cursor.getLong(index))
data = MessageTableTestUtils.typeColumnToString(cursor.getLong(index))
}
column to data
@@ -0,0 +1,84 @@
package org.thoughtcrime.securesms.messages
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.thoughtcrime.securesms.database.GroupReceiptTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.toProtoByteString
import org.thoughtcrime.securesms.messages.SignalServiceProtoUtil.buildWith
import org.thoughtcrime.securesms.testing.GroupTestingUtils
import org.thoughtcrime.securesms.testing.GroupTestingUtils.asMember
import org.thoughtcrime.securesms.testing.MessageContentFuzzer
import org.thoughtcrime.securesms.testing.SignalActivityRule
import org.thoughtcrime.securesms.testing.assertIs
import org.thoughtcrime.securesms.util.MessageTableTestUtils
import org.whispersystems.signalservice.internal.push.SignalServiceProtos.DataMessage
import org.whispersystems.signalservice.internal.push.SignalServiceProtos.GroupContextV2
@Suppress("ClassName")
@RunWith(AndroidJUnit4::class)
class MessageContentProcessorV2__recipientStatusTest {
@get:Rule
val harness = SignalActivityRule()
private lateinit var processorV2: MessageContentProcessorV2
private var envelopeTimestamp: Long = 0
@Before
fun setup() {
processorV2 = MessageContentProcessorV2(harness.context)
envelopeTimestamp = System.currentTimeMillis()
}
/**
* Process sync group sent text transcript with partial send and then process second sync with recipient update
* flag set to true with the rest of the send completed.
*/
@Test
fun syncGroupSentTextMessageWithRecipientUpdateFollowup() {
val (groupId, masterKey, groupRecipientId) = GroupTestingUtils.insertGroup(revision = 0, harness.self.asMember(), harness.others[0].asMember(), harness.others[1].asMember())
val groupContextV2 = GroupContextV2.newBuilder().setRevision(0).setMasterKey(masterKey.serialize().toProtoByteString()).build()
val initialTextMessage = DataMessage.newBuilder().buildWith {
body = MessageContentFuzzer.string()
groupV2 = groupContextV2
timestamp = envelopeTimestamp
}
processorV2.process(
envelope = MessageContentFuzzer.envelope(envelopeTimestamp),
content = MessageContentFuzzer.syncSentTextMessage(initialTextMessage, deliveredTo = listOf(harness.others[0])),
metadata = MessageContentFuzzer.envelopeMetadata(harness.self.id, harness.self.id, groupId),
serverDeliveredTimestamp = MessageContentFuzzer.fuzzServerDeliveredTimestamp(envelopeTimestamp)
)
val threadId = SignalDatabase.threads.getThreadIdFor(groupRecipientId)!!
val firstSyncMessages = MessageTableTestUtils.getMessages(threadId)
val firstMessageId = firstSyncMessages[0].id
val firstReceiptInfo = SignalDatabase.groupReceipts.getGroupReceiptInfo(firstMessageId)
processorV2.process(
envelope = MessageContentFuzzer.envelope(envelopeTimestamp),
content = MessageContentFuzzer.syncSentTextMessage(initialTextMessage, deliveredTo = listOf(harness.others[0], harness.others[1]), recipientUpdate = true),
metadata = MessageContentFuzzer.envelopeMetadata(harness.self.id, harness.self.id, groupId),
serverDeliveredTimestamp = MessageContentFuzzer.fuzzServerDeliveredTimestamp(envelopeTimestamp)
)
val secondSyncMessages = MessageTableTestUtils.getMessages(threadId)
val secondReceiptInfo = SignalDatabase.groupReceipts.getGroupReceiptInfo(firstMessageId)
firstSyncMessages.size assertIs 1
firstSyncMessages[0].body assertIs initialTextMessage.body
firstReceiptInfo.first { it.recipientId == harness.others[0] }.status assertIs GroupReceiptTable.STATUS_UNDELIVERED
firstReceiptInfo.first { it.recipientId == harness.others[1] }.status assertIs GroupReceiptTable.STATUS_UNKNOWN
secondSyncMessages.size assertIs 1
secondSyncMessages[0].body assertIs initialTextMessage.body
secondReceiptInfo.first { it.recipientId == harness.others[0] }.status assertIs GroupReceiptTable.STATUS_UNDELIVERED
secondReceiptInfo.first { it.recipientId == harness.others[1] }.status assertIs GroupReceiptTable.STATUS_UNDELIVERED
}
}
@@ -111,7 +111,7 @@ class MessageContentProcessor__handleStoryMessageTest : MessageContentProcessorT
decryptedGroupState
)
val groupRecipient = Recipient.externalGroupExact(group)
val groupRecipient = Recipient.externalGroupExact(group!!)
val threadForGroup = SignalDatabase.threads.getOrCreateThreadIdFor(groupRecipient)
val insertResult = MmsHelper.insert(
@@ -9,6 +9,7 @@ import org.signal.libsignal.protocol.SignalProtocolAddress
import org.signal.libsignal.protocol.ecc.ECKeyPair
import org.signal.libsignal.protocol.groups.state.SenderKeyRecord
import org.signal.libsignal.protocol.state.IdentityKeyStore
import org.signal.libsignal.protocol.state.KyberPreKeyRecord
import org.signal.libsignal.protocol.state.PreKeyBundle
import org.signal.libsignal.protocol.state.PreKeyRecord
import org.signal.libsignal.protocol.state.SessionRecord
@@ -155,6 +156,11 @@ class BobClient(val serviceId: ServiceId, val e164: String, val identityKeyPair:
override fun storeSignedPreKey(signedPreKeyId: Int, record: SignedPreKeyRecord?) = throw UnsupportedOperationException()
override fun containsSignedPreKey(signedPreKeyId: Int): Boolean = throw UnsupportedOperationException()
override fun removeSignedPreKey(signedPreKeyId: Int) = throw UnsupportedOperationException()
override fun loadKyberPreKey(kyberPreKeyId: Int): KyberPreKeyRecord = throw UnsupportedOperationException()
override fun loadKyberPreKeys(): MutableList<KyberPreKeyRecord> = throw UnsupportedOperationException()
override fun storeKyberPreKey(kyberPreKeyId: Int, record: KyberPreKeyRecord?) = throw UnsupportedOperationException()
override fun containsKyberPreKey(kyberPreKeyId: Int): Boolean = throw UnsupportedOperationException()
override fun markKyberPreKeyUsed(kyberPreKeyId: Int) = throw UnsupportedOperationException()
override fun storeSenderKey(sender: SignalProtocolAddress?, distributionId: UUID?, record: SenderKeyRecord?) = throw UnsupportedOperationException()
override fun loadSenderKey(sender: SignalProtocolAddress?, distributionId: UUID?): SenderKeyRecord = throw UnsupportedOperationException()
override fun archiveSession(address: SignalProtocolAddress?) = throw UnsupportedOperationException()
@@ -48,7 +48,7 @@ object FakeClientHelpers {
val selfUnidentifiedAccessKey = UnidentifiedAccess.deriveAccessKeyFrom(myProfileKey)
val themUnidentifiedAccessKey = UnidentifiedAccess.deriveAccessKeyFrom(theirProfileKey)
return UnidentifiedAccessPair(UnidentifiedAccess(selfUnidentifiedAccessKey, senderCertificate.serialized), UnidentifiedAccess(themUnidentifiedAccessKey, senderCertificate.serialized)).targetUnidentifiedAccess
return UnidentifiedAccessPair(UnidentifiedAccess(selfUnidentifiedAccessKey, senderCertificate.serialized, false), UnidentifiedAccess(themUnidentifiedAccessKey, senderCertificate.serialized, false)).targetUnidentifiedAccess
}
fun encryptedTextMessage(now: Long, message: String = "Test body message"): EnvelopeContent {
@@ -0,0 +1,50 @@
package org.thoughtcrime.securesms.testing
import org.signal.libsignal.zkgroup.groups.GroupMasterKey
import org.signal.storageservice.protos.groups.Member
import org.signal.storageservice.protos.groups.local.DecryptedGroup
import org.signal.storageservice.protos.groups.local.DecryptedMember
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.whispersystems.signalservice.api.push.ServiceId
import kotlin.random.Random
/**
* Helper methods for creating groups for message processing tests et al.
*/
object GroupTestingUtils {
fun member(serviceId: ServiceId, revision: Int = 0, role: Member.Role = Member.Role.ADMINISTRATOR): DecryptedMember {
return DecryptedMember.newBuilder()
.setUuid(serviceId.toByteString())
.setJoinedAtRevision(revision)
.setRole(role)
.build()
}
fun insertGroup(revision: Int = 0, vararg members: DecryptedMember): TestGroupInfo {
val groupMasterKey = GroupMasterKey(Random.nextBytes(GroupMasterKey.SIZE))
val decryptedGroupState = DecryptedGroup.newBuilder()
.addAllMembers(members.toList())
.setRevision(revision)
.setTitle(MessageContentFuzzer.string())
.build()
val groupId = SignalDatabase.groups.create(groupMasterKey, decryptedGroupState)!!
val groupRecipientId = SignalDatabase.recipients.getOrInsertFromGroupId(groupId)
SignalDatabase.recipients.setProfileSharing(groupRecipientId, true)
return TestGroupInfo(groupId, groupMasterKey, groupRecipientId)
}
fun RecipientId.asMember(): DecryptedMember {
return Recipient.resolved(this).asMember()
}
fun Recipient.asMember(): DecryptedMember {
return member(serviceId = requireServiceId())
}
data class TestGroupInfo(val groupId: GroupId.V2, val masterKey: GroupMasterKey, val recipientId: RecipientId)
}
@@ -2,6 +2,8 @@ package org.thoughtcrime.securesms.testing
import com.google.protobuf.ByteString
import org.thoughtcrime.securesms.database.model.toProtoByteString
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.messages.SignalServiceProtoUtil.buildWith
import org.thoughtcrime.securesms.messages.TestMessage
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
@@ -12,6 +14,8 @@ import org.whispersystems.signalservice.internal.push.SignalServiceProtos.Attach
import org.whispersystems.signalservice.internal.push.SignalServiceProtos.Content
import org.whispersystems.signalservice.internal.push.SignalServiceProtos.DataMessage
import org.whispersystems.signalservice.internal.push.SignalServiceProtos.Envelope
import org.whispersystems.signalservice.internal.push.SignalServiceProtos.GroupContextV2
import org.whispersystems.signalservice.internal.push.SignalServiceProtos.SyncMessage
import java.util.UUID
import kotlin.random.Random
import kotlin.random.nextInt
@@ -41,13 +45,13 @@ object MessageContentFuzzer {
/**
* Create metadata to match an [Envelope].
*/
fun envelopeMetadata(source: RecipientId, destination: RecipientId): EnvelopeMetadata {
fun envelopeMetadata(source: RecipientId, destination: RecipientId, groupId: GroupId.V2? = null): EnvelopeMetadata {
return EnvelopeMetadata(
sourceServiceId = Recipient.resolved(source).requireServiceId(),
sourceE164 = null,
sourceDeviceId = 1,
sealedSender = true,
groupId = null,
groupId = groupId?.decodedId,
destinationServiceId = Recipient.resolved(destination).requireServiceId()
)
}
@@ -57,30 +61,60 @@ object MessageContentFuzzer {
* - An expire timer value
* - Bold style body ranges
*/
fun fuzzTextMessage(): Content {
fun fuzzTextMessage(groupContextV2: GroupContextV2? = null): Content {
return Content.newBuilder()
.setDataMessage(
DataMessage.newBuilder().run {
DataMessage.newBuilder().buildWith {
body = string()
if (random.nextBoolean()) {
expireTimer = random.nextInt(0..28.days.inWholeSeconds.toInt())
}
if (random.nextBoolean()) {
addBodyRanges(
SignalServiceProtos.BodyRange.newBuilder().run {
SignalServiceProtos.BodyRange.newBuilder().buildWith {
start = 0
length = 1
style = SignalServiceProtos.BodyRange.Style.BOLD
build()
}
)
}
build()
if (groupContextV2 != null) {
groupV2 = groupContextV2
}
}
)
.build()
}
/**
* Create a sync sent text message for the given [DataMessage].
*/
fun syncSentTextMessage(
textMessage: DataMessage,
deliveredTo: List<RecipientId>,
recipientUpdate: Boolean = false
): Content {
return Content
.newBuilder()
.setSyncMessage(
SyncMessage.newBuilder().buildWith {
sent = SyncMessage.Sent.newBuilder().buildWith {
timestamp = textMessage.timestamp
message = textMessage
isRecipientUpdate = recipientUpdate
addAllUnidentifiedStatus(
deliveredTo.map {
SyncMessage.Sent.UnidentifiedDeliveryStatus.newBuilder().buildWith {
destinationUuid = Recipient.resolved(it).requireServiceId().toString()
unidentified = true
}
}
)
}
}
).build()
}
/**
* Create a random media message that may be:
* - A text body
@@ -91,7 +125,7 @@ object MessageContentFuzzer {
fun fuzzMediaMessageWithBody(quoteAble: List<TestMessage> = emptyList()): Content {
return Content.newBuilder()
.setDataMessage(
DataMessage.newBuilder().run {
DataMessage.newBuilder().buildWith {
if (random.nextBoolean()) {
body = string()
}
@@ -99,24 +133,22 @@ object MessageContentFuzzer {
if (random.nextBoolean() && quoteAble.isNotEmpty()) {
body = string()
val quoted = quoteAble.random(random)
quote = DataMessage.Quote.newBuilder().run {
quote = DataMessage.Quote.newBuilder().buildWith {
id = quoted.envelope.timestamp
authorUuid = quoted.metadata.sourceServiceId.toString()
text = quoted.content.dataMessage.body
addAllAttachments(quoted.content.dataMessage.attachmentsList)
addAllBodyRanges(quoted.content.dataMessage.bodyRangesList)
type = DataMessage.Quote.Type.NORMAL
build()
}
}
if (random.nextFloat() < 0.1 && quoteAble.isNotEmpty()) {
val quoted = quoteAble.random(random)
quote = DataMessage.Quote.newBuilder().run {
quote = DataMessage.Quote.newBuilder().buildWith {
id = random.nextLong(quoted.envelope.timestamp - 1000000, quoted.envelope.timestamp)
authorUuid = quoted.metadata.sourceServiceId.toString()
text = quoted.content.dataMessage.body
build()
}
}
@@ -124,8 +156,6 @@ object MessageContentFuzzer {
val total = random.nextInt(1, 2)
(0..total).forEach { _ -> addAttachments(attachmentPointer()) }
}
build()
}
)
.build()
@@ -138,19 +168,16 @@ object MessageContentFuzzer {
fun fuzzMediaMessageNoContent(previousMessages: List<TestMessage> = emptyList()): Content {
return Content.newBuilder()
.setDataMessage(
DataMessage.newBuilder().run {
DataMessage.newBuilder().buildWith {
if (random.nextFloat() < 0.25) {
val reactTo = previousMessages.random(random)
reaction = DataMessage.Reaction.newBuilder().run {
reaction = DataMessage.Reaction.newBuilder().buildWith {
emoji = emojis.random(random)
remove = false
targetAuthorUuid = reactTo.metadata.sourceServiceId.toString()
targetSentTimestamp = reactTo.envelope.timestamp
build()
}
}
build()
}
).build()
}
@@ -162,18 +189,16 @@ object MessageContentFuzzer {
fun fuzzMediaMessageNoText(previousMessages: List<TestMessage> = emptyList()): Content {
return Content.newBuilder()
.setDataMessage(
DataMessage.newBuilder().run {
DataMessage.newBuilder().buildWith {
if (random.nextFloat() < 0.9) {
sticker = DataMessage.Sticker.newBuilder().run {
sticker = DataMessage.Sticker.newBuilder().buildWith {
packId = byteString(length = 24)
packKey = byteString(length = 128)
stickerId = random.nextInt()
data = attachmentPointer()
emoji = emojis.random(random)
build()
}
}
build()
}
).build()
}
@@ -11,6 +11,7 @@ import org.signal.libsignal.protocol.ecc.Curve
import org.signal.libsignal.protocol.state.PreKeyRecord
import org.signal.libsignal.protocol.util.KeyHelper
import org.signal.libsignal.protocol.util.Medium
import org.signal.libsignal.svr2.PinHash
import org.thoughtcrime.securesms.crypto.PreKeyUtil
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore
@@ -19,7 +20,6 @@ import org.thoughtcrime.securesms.pin.TokenData
import org.thoughtcrime.securesms.test.BuildConfig
import org.whispersystems.signalservice.api.KbsPinData
import org.whispersystems.signalservice.api.KeyBackupService
import org.whispersystems.signalservice.api.kbs.HashedPin
import org.whispersystems.signalservice.api.kbs.MasterKey
import org.whispersystems.signalservice.api.messages.multidevice.DeviceInfo
import org.whispersystems.signalservice.api.push.ServiceId
@@ -97,7 +97,7 @@ object MockProvider {
val session: KeyBackupService.RestoreSession = object : KeyBackupService.RestoreSession {
override fun hashSalt(): ByteArray = Hex.fromStringCondensed("cba811749042b303a6a7efa5ccd160aea5e3ea243c8d2692bd13d515732f51a8")
override fun restorePin(hashedPin: HashedPin?): KbsPinData = KbsPinData(MasterKey.createNew(SecureRandom()), null)
override fun restorePin(hashedPin: PinHash?): KbsPinData = KbsPinData(MasterKey.createNew(SecureRandom()), null)
}
val kbsService = ApplicationDependencies.getKeyBackupService(BuildConfig.KBS_ENCLAVE)
@@ -1,8 +1,21 @@
package org.thoughtcrime.securesms.util
import org.thoughtcrime.securesms.database.MessageTable
import org.thoughtcrime.securesms.database.MessageTypes
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.MessageRecord
/**
* Helper methods for interacting with [MessageTable] in tests.
*/
object MessageTableTestUtils {
fun getMessages(threadId: Long): List<MessageRecord> {
return MessageTable.mmsReaderFor(SignalDatabase.messages.getConversation(threadId)).use {
it.toList()
}
}
object MessageTableUtils {
fun typeColumnToString(type: Long): String {
return """
isOutgoingMessageType:${MessageTypes.isOutgoingMessageType(type)}
+314 -5
View File
@@ -257,6 +257,307 @@
</activity-alias>
<activity-alias
android:name=".RoutingActivityAltYellow"
android:enabled="false"
android:exported="true"
android:icon="@mipmap/ic_launcher_alt_yellow"
android:targetActivity=".MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<category android:name="android.intent.category.MULTIWINDOW_LAUNCHER" />
</intent-filter>
<meta-data
android:name="com.sec.minimode.icon.portrait.normal"
android:resource="@mipmap/ic_launcher_alt_yellow" />
<meta-data
android:name="com.sec.minimode.icon.landscape.normal"
android:resource="@mipmap/ic_launcher_alt_yellow" />
<meta-data
android:name="android.app.shortcuts"
android:resource="@xml/shortcuts" />
</activity-alias>
<activity-alias
android:name=".RoutingActivityAltBubbles"
android:enabled="false"
android:exported="true"
android:icon="@mipmap/ic_launcher_alt_bubbles"
android:targetActivity=".MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<category android:name="android.intent.category.MULTIWINDOW_LAUNCHER" />
</intent-filter>
<meta-data
android:name="com.sec.minimode.icon.portrait.normal"
android:resource="@mipmap/ic_launcher_alt_bubbles" />
<meta-data
android:name="com.sec.minimode.icon.landscape.normal"
android:resource="@mipmap/ic_launcher_alt_bubbles" />
<meta-data
android:name="android.app.shortcuts"
android:resource="@xml/shortcuts" />
</activity-alias>
<activity-alias
android:name=".RoutingActivityAltChat"
android:enabled="false"
android:exported="true"
android:icon="@mipmap/ic_launcher_alt_chat"
android:targetActivity=".MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<category android:name="android.intent.category.MULTIWINDOW_LAUNCHER" />
</intent-filter>
<meta-data
android:name="com.sec.minimode.icon.portrait.normal"
android:resource="@mipmap/ic_launcher_alt_chat" />
<meta-data
android:name="com.sec.minimode.icon.landscape.normal"
android:resource="@mipmap/ic_launcher_alt_chat" />
<meta-data
android:name="android.app.shortcuts"
android:resource="@xml/shortcuts" />
</activity-alias>
<activity-alias
android:name=".RoutingActivityAltNews"
android:enabled="false"
android:exported="true"
android:icon="@mipmap/ic_launcher_alt_news"
android:label="@string/app_icon_label_news"
android:targetActivity=".MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<category android:name="android.intent.category.MULTIWINDOW_LAUNCHER" />
</intent-filter>
<meta-data
android:name="com.sec.minimode.icon.portrait.normal"
android:resource="@mipmap/ic_launcher_alt_news" />
<meta-data
android:name="com.sec.minimode.icon.landscape.normal"
android:resource="@mipmap/ic_launcher_alt_news" />
<meta-data
android:name="android.app.shortcuts"
android:resource="@xml/shortcuts" />
</activity-alias>
<activity-alias
android:name=".RoutingActivityAltNotes"
android:enabled="false"
android:exported="true"
android:icon="@mipmap/ic_launcher_alt_notes"
android:label="@string/app_icon_label_notes"
android:targetActivity=".MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<category android:name="android.intent.category.MULTIWINDOW_LAUNCHER" />
</intent-filter>
<meta-data
android:name="com.sec.minimode.icon.portrait.normal"
android:resource="@mipmap/ic_launcher_alt_notes" />
<meta-data
android:name="com.sec.minimode.icon.landscape.normal"
android:resource="@mipmap/ic_launcher_alt_notes" />
<meta-data
android:name="android.app.shortcuts"
android:resource="@xml/shortcuts" />
</activity-alias>
<activity-alias
android:name=".RoutingActivityAltColor"
android:enabled="false"
android:exported="true"
android:icon="@mipmap/ic_launcher_alt_signal_color"
android:targetActivity=".MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<category android:name="android.intent.category.MULTIWINDOW_LAUNCHER" />
</intent-filter>
<meta-data
android:name="com.sec.minimode.icon.portrait.normal"
android:resource="@mipmap/ic_launcher_alt_signal_color" />
<meta-data
android:name="com.sec.minimode.icon.landscape.normal"
android:resource="@mipmap/ic_launcher_alt_signal_color" />
<meta-data
android:name="android.app.shortcuts"
android:resource="@xml/shortcuts" />
</activity-alias>
<activity-alias
android:name=".RoutingActivityAltDark"
android:enabled="false"
android:exported="true"
android:icon="@mipmap/ic_launcher_alt_signal_dark"
android:targetActivity=".MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<category android:name="android.intent.category.MULTIWINDOW_LAUNCHER" />
</intent-filter>
<meta-data
android:name="com.sec.minimode.icon.portrait.normal"
android:resource="@mipmap/ic_launcher_alt_signal_dark" />
<meta-data
android:name="com.sec.minimode.icon.landscape.normal"
android:resource="@mipmap/ic_launcher_alt_signal_dark" />
<meta-data
android:name="android.app.shortcuts"
android:resource="@xml/shortcuts" />
</activity-alias>
<activity-alias
android:name=".RoutingActivityAltDarkVariant"
android:enabled="false"
android:exported="true"
android:icon="@mipmap/ic_launcher_alt_signal_dark_variant"
android:targetActivity=".MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<category android:name="android.intent.category.MULTIWINDOW_LAUNCHER" />
</intent-filter>
<meta-data
android:name="com.sec.minimode.icon.portrait.normal"
android:resource="@mipmap/ic_launcher_alt_signal_dark_variant" />
<meta-data
android:name="com.sec.minimode.icon.landscape.normal"
android:resource="@mipmap/ic_launcher_alt_signal_dark_variant" />
<meta-data
android:name="android.app.shortcuts"
android:resource="@xml/shortcuts" />
</activity-alias>
<activity-alias
android:name=".RoutingActivityAltWhite"
android:enabled="false"
android:exported="true"
android:icon="@mipmap/ic_launcher_alt_signal_white"
android:targetActivity=".MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<category android:name="android.intent.category.MULTIWINDOW_LAUNCHER" />
</intent-filter>
<meta-data
android:name="com.sec.minimode.icon.portrait.normal"
android:resource="@mipmap/ic_launcher_alt_signal_white" />
<meta-data
android:name="com.sec.minimode.icon.landscape.normal"
android:resource="@mipmap/ic_launcher_alt_signal_white" />
<meta-data
android:name="android.app.shortcuts"
android:resource="@xml/shortcuts" />
</activity-alias>
<activity-alias
android:name=".RoutingActivityAltWaves"
android:enabled="false"
android:exported="true"
android:icon="@mipmap/ic_launcher_alt_waves"
android:label="@string/app_icon_label_waves"
android:targetActivity=".MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<category android:name="android.intent.category.MULTIWINDOW_LAUNCHER" />
</intent-filter>
<meta-data
android:name="com.sec.minimode.icon.portrait.normal"
android:resource="@mipmap/ic_launcher_alt_waves" />
<meta-data
android:name="com.sec.minimode.icon.landscape.normal"
android:resource="@mipmap/ic_launcher_alt_waves" />
<meta-data
android:name="android.app.shortcuts"
android:resource="@xml/shortcuts" />
</activity-alias>
<activity-alias
android:name=".RoutingActivityAltWeather"
android:enabled="false"
android:exported="true"
android:icon="@mipmap/ic_launcher_alt_weather"
android:label="@string/app_icon_label_weather"
android:targetActivity=".MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<category android:name="android.intent.category.MULTIWINDOW_LAUNCHER" />
</intent-filter>
<meta-data
android:name="com.sec.minimode.icon.portrait.normal"
android:resource="@mipmap/ic_launcher_alt_weather" />
<meta-data
android:name="com.sec.minimode.icon.landscape.normal"
android:resource="@mipmap/ic_launcher_alt_weather" />
<meta-data
android:name="android.app.shortcuts"
android:resource="@xml/shortcuts" />
</activity-alias>
<activity android:name=".deeplinks.DeepLinkEntryActivity"
android:exported="true"
android:noHistory="true"
@@ -298,6 +599,14 @@
<data android:scheme="sgnl"
android:host="signal.me" />
</intent-filter>
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https"
android:host="signal.link" />
</intent-filter>
</activity>
<activity android:name=".conversation.v2.ConversationActivity"
@@ -364,6 +673,11 @@
android:windowSoftInputMode="stateAlwaysVisible"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
<activity android:name=".calls.links.details.CallLinkDetailsActivity"
android:theme="@style/Theme.Signal.DayNight.NoActionBar"
android:windowSoftInputMode="stateAlwaysHidden"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
<activity android:name=".calls.new.NewCallActivity"
android:theme="@style/Theme.Signal.DayNight.NoActionBar"
android:windowSoftInputMode="stateAlwaysVisible"
@@ -628,11 +942,6 @@
<activity android:name=".contacts.TurnOffContactJoinedNotificationsActivity"
android:theme="@style/Theme.AppCompat.Dialog.Alert" />
<activity android:name=".messagerequests.MessageRequestMegaphoneActivity"
android:theme="@style/TextSecure.LightRegistrationTheme"
android:windowSoftInputMode="adjustResize"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
<activity android:name=".contactshare.ContactShareEditActivity"
android:theme="@style/TextSecure.LightTheme"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
File diff suppressed because it is too large Load Diff
@@ -11,10 +11,7 @@ object AppCapabilities {
@JvmStatic
fun getCapabilities(storageCapable: Boolean): AccountAttributes.Capabilities {
return AccountAttributes.Capabilities(
uuid = false,
gv2 = true,
storage = storageCapable,
gv1Migration = true,
senderKey = true,
announcementGroup = true,
changeNumber = true,
@@ -48,6 +48,7 @@ import org.thoughtcrime.securesms.dependencies.ApplicationDependencyProvider;
import org.thoughtcrime.securesms.emoji.EmojiSource;
import org.thoughtcrime.securesms.emoji.JumboEmoji;
import org.thoughtcrime.securesms.gcm.FcmJobService;
import org.thoughtcrime.securesms.jobs.AccountConsistencyWorkerJob;
import org.thoughtcrime.securesms.jobs.CheckServiceReachabilityJob;
import org.thoughtcrime.securesms.jobs.DownloadLatestEmojiDataJob;
import org.thoughtcrime.securesms.jobs.EmojiSearchIndexDownloadJob;
@@ -143,8 +144,7 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
super.onCreate();
AppStartup.getInstance().addBlocking("security-provider", this::initializeSecurityProvider)
.addBlocking("sqlcipher-init", () -> {
AppStartup.getInstance().addBlocking("sqlcipher-init", () -> {
SqlCipherLibraryLoader.load();
SignalDatabase.init(this,
DatabaseSecretProvider.getOrCreateDatabaseSecret(this),
@@ -154,6 +154,7 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
initializeLogging();
Log.i(TAG, "onCreate()");
})
.addBlocking("security-provider", this::initializeSecurityProvider)
.addBlocking("crash-handling", this::initializeCrashHandling)
.addBlocking("rx-init", this::initializeRx)
.addBlocking("event-bus", () -> EventBus.builder().logNoSubscriberMessages(false).installDefaultEventBus())
@@ -211,6 +212,7 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
.addPostRender(PnpInitializeDevicesJob::enqueueIfNecessary)
.addPostRender(() -> ApplicationDependencies.getExoPlayerPool().getPoolStats().getMaxUnreserved())
.addPostRender(() -> ApplicationDependencies.getRecipientCache().warmUp())
.addPostRender(AccountConsistencyWorkerJob::enqueueIfNecessary)
.execute();
Log.d(TAG, "onCreate() took " + (System.currentTimeMillis() - startTime) + " ms");
@@ -272,13 +274,6 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
}
private void initializeSecurityProvider() {
try {
Class.forName("org.signal.aesgcmprovider.AesGcmCipher");
} catch (ClassNotFoundException e) {
Log.e(TAG, "Failed to find AesGcmCipher class");
throw new ProviderInitializationException();
}
int aesPosition = Security.insertProviderAt(new AesGcmProvider(), 1);
Log.i(TAG, "Installed AesGcmProvider: " + aesPosition);
@@ -108,12 +108,13 @@ public interface BindableConversationItem extends Unbindable, GiphyMp4Playable,
void onInviteToSignalClicked();
void onActivatePaymentsClicked();
void onSendPaymentClicked(@NonNull RecipientId recipientId);
void onScheduledIndicatorClicked(@NonNull View view, @NonNull MessageRecord messageRecord);
void onScheduledIndicatorClicked(@NonNull View view, @NonNull ConversationMessage conversationMessage);
/** @return true if handled, false if you want to let the normal url handling continue */
boolean onUrlClicked(@NonNull String url);
void onViewGiftBadgeClicked(@NonNull MessageRecord messageRecord);
void onGiftBadgeRevealed(@NonNull MessageRecord messageRecord);
void goToMediaPreview(ConversationItem parent, View sharedElement, MediaIntentFactory.MediaPreviewArgs args);
void onEditedIndicatorClicked(@NonNull MessageRecord messageRecord);
void onShowGroupDescriptionClicked(@NonNull String groupName, @NonNull String description, boolean shouldLinkifyWebLinks);
}
}
@@ -27,6 +27,7 @@ import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.database.loaders.DeviceListLoader;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.devicelist.Device;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.task.ProgressDialogAsyncTask;
import org.whispersystems.signalservice.api.SignalServiceAccountManager;
@@ -106,7 +107,9 @@ public class DeviceListFragment extends ListFragment
if (data.isEmpty()) {
empty.setVisibility(View.VISIBLE);
TextSecurePreferences.setMultiDevice(getActivity(), false);
SignalStore.misc().setHasLinkedDevices(false);
} else {
SignalStore.misc().setHasLinkedDevices(true);
empty.setVisibility(View.GONE);
}
}
@@ -18,7 +18,6 @@ import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaControllerOwner
import org.thoughtcrime.securesms.conversationlist.RelinkDevicesReminderBottomSheetFragment;
import org.thoughtcrime.securesms.devicetransfer.olddevice.OldDeviceTransferLockedDialog;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.stories.Stories;
import org.thoughtcrime.securesms.stories.tabs.ConversationListTabRepository;
import org.thoughtcrime.securesms.stories.tabs.ConversationListTabsViewModel;
import org.thoughtcrime.securesms.util.AppStartup;
@@ -26,7 +25,6 @@ import org.thoughtcrime.securesms.util.CachedInflater;
import org.thoughtcrime.securesms.util.CommunicationActions;
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme;
import org.thoughtcrime.securesms.util.DynamicTheme;
import org.thoughtcrime.securesms.util.FeatureFlags;
import org.thoughtcrime.securesms.util.SplashScreenUtil;
import org.thoughtcrime.securesms.util.WindowUtil;
@@ -82,6 +80,7 @@ public class MainActivity extends PassphraseRequiredActivity implements VoiceNot
handleGroupLinkInIntent(getIntent());
handleProxyInIntent(getIntent());
handleSignalMeIntent(getIntent());
handleCallLinkInIntent(getIntent());
CachedInflater.from(this).clear();
@@ -148,14 +147,8 @@ public class MainActivity extends PassphraseRequiredActivity implements VoiceNot
}
private void updateTabVisibility() {
if (Stories.isFeatureEnabled() || FeatureFlags.callsTab()) {
findViewById(R.id.conversation_list_tabs).setVisibility(View.VISIBLE);
WindowUtil.setNavigationBarColor(this, ContextCompat.getColor(this, R.color.signal_colorSurface2));
} else {
findViewById(R.id.conversation_list_tabs).setVisibility(View.GONE);
WindowUtil.setNavigationBarColor(this, ContextCompat.getColor(this, R.color.signal_colorBackground));
conversationListTabsViewModel.onChatsSelected();
}
findViewById(R.id.conversation_list_tabs).setVisibility(View.VISIBLE);
WindowUtil.setNavigationBarColor(this, ContextCompat.getColor(this, R.color.signal_colorSurface2));
}
public @NonNull MainNavigator getNavigator() {
@@ -183,6 +176,13 @@ public class MainActivity extends PassphraseRequiredActivity implements VoiceNot
}
}
private void handleCallLinkInIntent(Intent intent) {
Uri data = intent.getData();
if (data != null) {
CommunicationActions.handlePotentialCallLinkUrl(this, data.toString());
}
}
public void onFirstRender() {
onFirstRender = true;
}
@@ -219,10 +219,6 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
Log.i(TAG, "onPause");
super.onPause();
if (!isInPipMode() || isFinishing()) {
EventBus.getDefault().unregister(this);
}
if (!viewModel.isCallStarting()) {
CallParticipantsState state = viewModel.getCallParticipantsState().getValue();
if (state != null && state.getCallState().isPreJoinOrNetworkUnavailable()) {
@@ -281,7 +277,7 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
if (viewModel.canEnterPipMode()) {
try {
enterPictureInPictureMode(pipBuilderParams.build());
} catch (IllegalStateException e) {
} catch (Exception e) {
Log.w(TAG, "Device lied to us about supporting PiP.", e);
return false;
}
@@ -369,6 +365,10 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
viewModel.setIsInPipMode(info.isInPictureInPictureMode());
participantUpdateWindow.setEnabled(!info.isInPictureInPictureMode());
callStateUpdatePopupWindow.setEnabled(!info.isInPictureInPictureMode());
if (info.isInPictureInPictureMode()) {
callScreen.maybeDismissAudioPicker();
}
viewModel.setIsLandscapeEnabled(info.isInPictureInPictureMode());
});
}
@@ -380,7 +380,11 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
pipBuilderParams.setAutoEnterEnabled(true);
}
if (Build.VERSION.SDK_INT >= 26) {
setPictureInPictureParams(pipBuilderParams.build());
try {
setPictureInPictureParams(pipBuilderParams.build());
} catch (Exception e) {
Log.w(TAG, "System lied about having PiP available.", e);
}
}
}
}
@@ -828,7 +832,7 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
@RequiresApi(31)
@Override
public void onAudioOutputChanged31(@NonNull int audioDeviceInfo) {
public void onAudioOutputChanged31(@NonNull Integer audioDeviceInfo) {
ApplicationDependencies.getSignalCallManager().selectAudioDevice(new SignalAudioManager.ChosenAudioDeviceIdentifier(audioDeviceInfo));
}
@@ -0,0 +1,13 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.audio
/**
* A listener for when audio devices are added or removed, for example if a wired headset is plugged/unplugged or Bluetooth connected/disconnected.
*/
interface AudioDeviceUpdatedListener {
fun onAudioDeviceUpdated()
}
@@ -89,7 +89,7 @@ public class AudioRecorder {
}
recorder.start(fds[1]);
this.recordingSubject = recordingSingle;
} catch (IOException e) {
} catch (IOException | RuntimeException e) {
recordingSingle.onError(e);
recorder = null;
Log.w(TAG, e);
@@ -0,0 +1,142 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.audio
import android.content.Context
import android.media.AudioDeviceInfo
import android.os.Build
import android.os.Handler
import android.os.HandlerThread
import androidx.annotation.RequiresApi
import org.signal.core.util.ThreadUtil
import org.signal.core.util.concurrent.SignalExecutors
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.webrtc.audio.SignalAudioHandler
internal const val TAG = "BluetoothVoiceNoteUtil"
sealed interface BluetoothVoiceNoteUtil {
fun connectBluetoothScoConnection()
fun disconnectBluetoothScoConnection()
fun destroy()
companion object {
fun create(context: Context, listener: () -> Unit, bluetoothPermissionDeniedHandler: () -> Unit): BluetoothVoiceNoteUtil {
return if (Build.VERSION.SDK_INT >= 31) BluetoothVoiceNoteUtil31(listener) else BluetoothVoiceNoteUtilLegacy(context, listener, bluetoothPermissionDeniedHandler)
}
}
}
@RequiresApi(31)
private class BluetoothVoiceNoteUtil31(val listener: () -> Unit) : BluetoothVoiceNoteUtil {
override fun connectBluetoothScoConnection() {
val audioManager = ApplicationDependencies.getAndroidCallAudioManager()
val device: AudioDeviceInfo? = audioManager.connectedBluetoothDevice
if (device != null) {
val result: Boolean = audioManager.setCommunicationDevice(device)
if (result) {
Log.d(TAG, "Successfully set Bluetooth device as active communication device.")
} else {
Log.d(TAG, "Found Bluetooth device but failed to set it as active communication device.")
}
} else {
Log.d(TAG, "Could not find Bluetooth device in list of communications devices, falling back to current input.")
}
listener()
}
override fun disconnectBluetoothScoConnection() {
Log.d(TAG, "Clearing call manager communication device.")
ApplicationDependencies.getAndroidCallAudioManager().clearCommunicationDevice()
}
override fun destroy() = Unit
}
/**
* Encapsulated logic for managing a Bluetooth connection withing the Fragment lifecycle for voice notes.
*
* @param context Context with reference to the main thread.
* @param listener This will be executed on the main thread after the Bluetooth connection connects, or if it doesn't.
* @param bluetoothPermissionDeniedHandler called when we detect the Bluetooth permission has been denied to our app.
*/
private class BluetoothVoiceNoteUtilLegacy(val context: Context, val listener: () -> Unit, val bluetoothPermissionDeniedHandler: () -> Unit) : BluetoothVoiceNoteUtil {
private val commandAndControlThread: HandlerThread = SignalExecutors.getAndStartHandlerThread("voice-note-audio", ThreadUtil.PRIORITY_IMPORTANT_BACKGROUND_THREAD)
private val uiThreadHandler = Handler(context.mainLooper)
private val audioHandler: SignalAudioHandler = SignalAudioHandler(commandAndControlThread.looper)
private val deviceUpdatedListener: AudioDeviceUpdatedListener = object : AudioDeviceUpdatedListener {
override fun onAudioDeviceUpdated() {
if (signalBluetoothManager.state == SignalBluetoothManager.State.CONNECTED) {
Log.d(TAG, "Bluetooth SCO connected. Starting voice note recording on UI thread.")
uiThreadHandler.post { listener() }
}
}
}
private val signalBluetoothManager: SignalBluetoothManager = SignalBluetoothManager(context, deviceUpdatedListener, audioHandler)
private var hasWarnedAboutBluetooth = false
init {
if (Build.VERSION.SDK_INT < 31) {
audioHandler.post {
signalBluetoothManager.start()
Log.d(TAG, "Bluetooth manager started.")
}
}
}
override fun connectBluetoothScoConnection() {
if (Build.VERSION.SDK_INT >= 31) {
val audioManager = ApplicationDependencies.getAndroidCallAudioManager()
val device: AudioDeviceInfo? = audioManager.connectedBluetoothDevice
if (device != null) {
val result: Boolean = audioManager.setCommunicationDevice(device)
if (result) {
Log.d(TAG, "Successfully set Bluetooth device as active communication device.")
} else {
Log.d(TAG, "Found Bluetooth device but failed to set it as active communication device.")
}
} else {
Log.d(TAG, "Could not find Bluetooth device in list of communications devices, falling back to current input.")
}
listener()
} else {
audioHandler.post {
if (signalBluetoothManager.state.shouldUpdate()) {
signalBluetoothManager.updateDevice()
}
val currentState = signalBluetoothManager.state
if (currentState == SignalBluetoothManager.State.AVAILABLE) {
signalBluetoothManager.startScoAudio()
} else {
Log.d(TAG, "Recording from phone mic because bluetooth state was " + currentState + ", not " + SignalBluetoothManager.State.AVAILABLE)
uiThreadHandler.post {
if (currentState == SignalBluetoothManager.State.PERMISSION_DENIED && !hasWarnedAboutBluetooth) {
bluetoothPermissionDeniedHandler()
hasWarnedAboutBluetooth = true
}
listener()
}
}
}
}
}
override fun disconnectBluetoothScoConnection() {
audioHandler.post {
if (signalBluetoothManager.state == SignalBluetoothManager.State.CONNECTED) {
signalBluetoothManager.stopScoAudio()
}
}
}
override fun destroy() {
audioHandler.post {
signalBluetoothManager.stop()
}
}
}
@@ -1,4 +1,9 @@
package org.thoughtcrime.securesms.webrtc.audio
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.audio
import android.annotation.SuppressLint
import android.bluetooth.BluetoothAdapter
@@ -13,18 +18,19 @@ import android.media.AudioManager
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.util.safeUnregisterReceiver
import org.thoughtcrime.securesms.webrtc.audio.SignalAudioHandler
import java.util.concurrent.TimeUnit
/**
* Manages the bluetooth lifecycle with a headset. This class doesn't make any
* determination on if bluetooth should be used. It determines if a device is connected,
* reports that to the [SignalAudioManager], and then handles connecting/disconnecting
* to the device if requested by [SignalAudioManager].
* reports that to the [org.thoughtcrime.securesms.webrtc.audio.SignalAudioManager], and then handles connecting/disconnecting
* to the device if requested by [org.thoughtcrime.securesms.webrtc.audio.SignalAudioManager].
*/
@SuppressLint("MissingPermission") // targetSdkVersion is still 30 (https://issuetracker.google.com/issues/201454155)
class SignalBluetoothManager(
private val context: Context,
private val audioManager: FullSignalAudioManager,
private val audioDeviceUpdatedListener: AudioDeviceUpdatedListener,
private val handler: SignalAudioHandler
) {
@@ -139,11 +145,6 @@ class SignalBluetoothManager(
return false
}
if (androidAudioManager.isBluetoothScoOn) {
Log.i(TAG, "SCO connection already started")
return true
}
state = State.CONNECTING
androidAudioManager.startBluetoothSco()
androidAudioManager.isBluetoothScoOn = true
@@ -202,10 +203,6 @@ class SignalBluetoothManager(
}
}
private fun updateAudioDeviceState() {
audioManager.updateAudioDeviceState()
}
private fun startTimer() {
handler.postDelayed(bluetoothTimeout, SCO_TIMEOUT)
}
@@ -243,12 +240,12 @@ class SignalBluetoothManager(
stopScoAudio()
}
updateAudioDeviceState()
audioDeviceUpdatedListener.onAudioDeviceUpdated()
}
private fun onServiceConnected(proxy: BluetoothHeadset?) {
bluetoothHeadset = proxy
updateAudioDeviceState()
audioDeviceUpdatedListener.onAudioDeviceUpdated()
}
private fun onServiceDisconnected() {
@@ -256,7 +253,7 @@ class SignalBluetoothManager(
bluetoothHeadset = null
bluetoothDevice = null
state = State.UNAVAILABLE
updateAudioDeviceState()
audioDeviceUpdatedListener.onAudioDeviceUpdated()
}
private fun onHeadsetConnectionStateChanged(connectionState: Int) {
@@ -265,12 +262,12 @@ class SignalBluetoothManager(
when (connectionState) {
BluetoothHeadset.STATE_CONNECTED -> {
scoConnectionAttempts = 0
updateAudioDeviceState()
audioDeviceUpdatedListener.onAudioDeviceUpdated()
}
BluetoothHeadset.STATE_DISCONNECTED -> {
stopScoAudio()
updateAudioDeviceState()
audioDeviceUpdatedListener.onAudioDeviceUpdated()
}
}
}
@@ -284,7 +281,7 @@ class SignalBluetoothManager(
Log.d(TAG, "Bluetooth audio SCO is now connected")
state = State.CONNECTED
scoConnectionAttempts = 0
updateAudioDeviceState()
audioDeviceUpdatedListener.onAudioDeviceUpdated()
} else {
Log.w(TAG, "Unexpected state ${audioState.toStateString()}")
}
@@ -296,7 +293,7 @@ class SignalBluetoothManager(
Log.d(TAG, "Ignore ${audioState.toStateString()} initial sticky broadcast.")
return
}
updateAudioDeviceState()
audioDeviceUpdatedListener.onAudioDeviceUpdated()
}
}
@@ -347,7 +344,9 @@ class SignalBluetoothManager(
}
} else if (intent.action == AudioManager.ACTION_SCO_AUDIO_STATE_UPDATED) {
if (wasScoDisconnected(intent)) {
handler.post(::updateAudioDeviceState)
handler.post {
audioDeviceUpdatedListener.onAudioDeviceUpdated()
}
}
} else {
Log.d(TAG, "Received broadcast of ${intent.action}")
@@ -11,7 +11,6 @@ import android.util.Pair;
import androidx.annotation.NonNull;
import androidx.annotation.VisibleForTesting;
import net.zetetic.database.sqlcipher.SQLiteConstraintException;
import net.zetetic.database.sqlcipher.SQLiteDatabase;
import org.greenrobot.eventbus.EventBus;
@@ -38,6 +37,7 @@ import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.profiles.AvatarHelper;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.util.BackupUtil;
import org.thoughtcrime.securesms.util.Util;
import java.io.ByteArrayOutputStream;
import java.io.File;
@@ -80,6 +80,7 @@ public class FullBackupImporter extends FullBackupBase {
SQLiteDatabase keyValueDatabase = KeyValueDatabase.getInstance(ApplicationDependencies.getApplication()).getSqlCipherDatabase();
db.setForeignKeyConstraintsEnabled(false);
db.beginTransaction();
keyValueDatabase.beginTransaction();
try {
@@ -94,7 +95,7 @@ public class FullBackupImporter extends FullBackupBase {
count++;
if (frame.version != null) processVersion(db, frame.version);
else if (frame.statement != null) tryProcessStatement(db, frame.statement);
else if (frame.statement != null) processStatement(db, frame.statement);
else if (frame.preference != null) processPreference(context, frame.preference);
else if (frame.attachment != null) processAttachment(context, attachmentSecret, db, frame.attachment, inputStream);
else if (frame.sticker != null) processSticker(context, attachmentSecret, db, frame.sticker, inputStream);
@@ -106,8 +107,20 @@ public class FullBackupImporter extends FullBackupBase {
db.setTransactionSuccessful();
keyValueDatabase.setTransactionSuccessful();
} finally {
List<SqlUtil.ForeignKeyViolation> violations = SqlUtil.getForeignKeyViolations(db)
.stream()
.filter(it -> !it.getTable().startsWith("msl_"))
.collect(Collectors.toList());
if (violations.size() > 0) {
Log.w(TAG, "Foreign key constraints failed!\n" + Util.join(violations, "\n"));
//noinspection ThrowFromFinallyBlock
throw new ForeignKeyViolationException(violations);
}
db.endTransaction();
keyValueDatabase.endTransaction();
db.setForeignKeyConstraintsEnabled(true);
}
EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.FINISHED, count, 0));
@@ -129,31 +142,6 @@ public class FullBackupImporter extends FullBackupBase {
db.setVersion(version.version);
}
private static void tryProcessStatement(@NonNull SQLiteDatabase db, SqlStatement statement) {
try {
processStatement(db, statement);
} catch (SQLiteConstraintException e) {
String tableName = "?";
String statementString = statement.statement;
if (statementString != null && statementString.startsWith("INSERT INTO ")) {
int nameStart = "INSERT INTO ".length();
int nameEnd = statementString.indexOf(" ", "INSERT INTO ".length());
if (nameStart < statementString.length() && nameEnd > nameStart) {
tableName = statementString.substring(nameStart, nameEnd);
}
}
if (tableName.startsWith("msl_")) {
Log.w(TAG, "Constraint failed when inserting into " + tableName + ". Ignoring.");
} else {
Log.w(TAG, "Constraint failed when inserting into " + tableName + ". Throwing!");
throw e;
}
}
}
private static void processStatement(@NonNull SQLiteDatabase db, SqlStatement statement) {
if (statement.statement == null) {
Log.w(TAG, "Null statement!");
@@ -373,4 +361,19 @@ public class FullBackupImporter extends FullBackupBase {
super("Tried to import a backup with version " + backupVersion + " into a database with version " + currentVersion);
}
}
public static class ForeignKeyViolationException extends IOException {
public ForeignKeyViolationException(List<SqlUtil.ForeignKeyViolation> violations) {
super(buildMessage(violations));
}
private static String buildMessage(List<SqlUtil.ForeignKeyViolation> violations) {
Set<String> unique = violations
.stream()
.map(it -> it.getTable() + " -> " + it.getColumn())
.collect(Collectors.toSet());
return Util.join(unique, ", ");
}
}
}
@@ -0,0 +1,83 @@
/**
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.calls.links
import io.reactivex.rxjava3.core.Observable
import org.signal.core.util.Hex
import org.signal.core.util.logging.Log
import org.signal.ringrtc.CallLinkRootKey
import org.thoughtcrime.securesms.database.CallLinkTable
import org.thoughtcrime.securesms.database.DatabaseObserver
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId
import java.net.URLDecoder
/**
* Utility object for call links to try to keep some common logic in one place.
*/
object CallLinks {
private const val ROOT_KEY = "key"
private val TAG = Log.tag(CallLinks::class.java)
fun url(linkKeyBytes: ByteArray) = "https://signal.link/call/#key=${Hex.dump(linkKeyBytes)}"
fun watchCallLink(roomId: CallLinkRoomId): Observable<CallLinkTable.CallLink> {
return Observable.create { emitter ->
fun refresh() {
val callLink = SignalDatabase.callLinks.getCallLinkByRoomId(roomId)
if (callLink != null) {
emitter.onNext(callLink)
}
}
val observer = DatabaseObserver.Observer {
refresh()
}
ApplicationDependencies.getDatabaseObserver().registerCallLinkObserver(roomId, observer)
emitter.setCancellable {
ApplicationDependencies.getDatabaseObserver().unregisterObserver(observer)
}
refresh()
}
}
@JvmStatic
fun parseUrl(url: String): CallLinkRootKey? {
val parts = url.split("#")
if (parts.size != 2) {
Log.w(TAG, "Invalid fragment delimiter count in url.")
return null
}
val fragment = parts[1]
val fragmentParts = fragment.split("&")
val fragmentQuery = fragmentParts.associate {
val kv = it.split("=")
if (kv.size != 2) {
Log.w(TAG, "Invalid fragment keypair. Skipping.")
}
val key = URLDecoder.decode(kv[0], "utf8")
val value = URLDecoder.decode(kv[1], "utf8")
key to value
}
val key = fragmentQuery[ROOT_KEY]
if (key == null) {
Log.w(TAG, "Root key not found in fragment query string.")
return null
}
// TODO Parse the key into a byte array
return null
}
}
@@ -1,3 +1,8 @@
/**
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.calls.links
import android.app.Dialog
@@ -28,18 +33,21 @@ import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.fragment.app.viewModels
import androidx.core.os.bundleOf
import androidx.fragment.app.setFragmentResult
import androidx.navigation.fragment.navArgs
import org.signal.core.ui.Buttons
import org.signal.core.ui.Scaffolds
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.calls.links.create.CreateCallLinkViewModel
import org.thoughtcrime.securesms.compose.ComposeDialogFragment
class EditCallLinkNameDialogFragment : ComposeDialogFragment() {
private val viewModel: CreateCallLinkViewModel by viewModels(
ownerProducer = { requireParentFragment() }
)
companion object {
const val RESULT_KEY = "edit_call_link_name"
}
private val args: EditCallLinkNameDialogFragmentArgs by navArgs()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -57,12 +65,11 @@ class EditCallLinkNameDialogFragment : ComposeDialogFragment() {
@Preview
@Composable
override fun DialogContent() {
val viewModelCallName by viewModel.callName
var callName by remember {
mutableStateOf(
TextFieldValue(
text = viewModelCallName,
selection = TextRange(viewModelCallName.length)
text = args.name,
selection = TextRange(args.name.length)
)
)
}
@@ -97,7 +104,7 @@ class EditCallLinkNameDialogFragment : ComposeDialogFragment() {
Spacer(modifier = Modifier.weight(1f))
Buttons.MediumTonal(
onClick = {
viewModel.setCallName(callName.text)
setFragmentResult(RESULT_KEY, bundleOf(RESULT_KEY to callName.text))
dismiss()
},
modifier = Modifier.align(End)
@@ -1,3 +1,8 @@
/**
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.calls.links
import androidx.compose.foundation.Image
@@ -15,12 +20,14 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment.Companion.CenterVertically
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
@@ -28,15 +35,39 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import org.signal.core.ui.Buttons
import org.signal.core.ui.theme.SignalTheme
import org.signal.ringrtc.CallLinkRootKey
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.conversation.colors.AvatarColor
import org.thoughtcrime.securesms.conversation.colors.AvatarColorPair
import org.thoughtcrime.securesms.database.CallLinkTable
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkCredentials
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId
import org.thoughtcrime.securesms.service.webrtc.links.SignalCallLinkState
import java.time.Instant
@Preview
@Composable
private fun SignalCallRowPreview() {
val avatarColor = remember { AvatarColor.random() }
val callLink = remember {
val credentials = CallLinkCredentials.generate()
CallLinkTable.CallLink(
recipientId = RecipientId.UNKNOWN,
roomId = CallLinkRoomId.fromCallLinkRootKey(CallLinkRootKey(credentials.linkKeyBytes)),
credentials = credentials,
state = SignalCallLinkState(
name = "Call Name",
restrictions = org.signal.ringrtc.CallLinkState.Restrictions.NONE,
expiration = Instant.MAX,
revoked = false
),
avatarColor = avatarColor
)
}
SignalTheme(false) {
SignalCallRow(
callName = "Call Name",
callLink = "https://call.signal.org#blahblahblah",
callLink = callLink,
onJoinClicked = {}
)
}
@@ -44,8 +75,7 @@ private fun SignalCallRowPreview() {
@Composable
fun SignalCallRow(
callName: String,
callLink: String,
callLink: CallLinkTable.CallLink,
onJoinClicked: () -> Unit,
modifier: Modifier = Modifier
) {
@@ -60,15 +90,17 @@ fun SignalCallRow(
)
.padding(16.dp)
) {
val callColorPair = AvatarColorPair.create(LocalContext.current, callLink.avatarColor)
Image(
imageVector = ImageVector.vectorResource(id = R.drawable.symbol_video_display_bold_40),
contentScale = ContentScale.Inside,
contentDescription = null,
colorFilter = ColorFilter.tint(Color(0xFF5151F6)),
colorFilter = ColorFilter.tint(Color(callColorPair.foregroundColor)),
modifier = Modifier
.size(64.dp)
.background(
color = Color(0xFFE5E5FE),
color = Color(callColorPair.backgroundColor),
shape = CircleShape
)
)
@@ -81,10 +113,10 @@ fun SignalCallRow(
.align(CenterVertically)
) {
Text(
text = callName.ifEmpty { stringResource(id = R.string.CreateCallLinkBottomSheetDialogFragment__signal_call) }
text = callLink.state.name.ifEmpty { stringResource(id = R.string.CreateCallLinkBottomSheetDialogFragment__signal_call) }
)
Text(
text = callLink,
text = callLink.credentials?.let { CallLinks.url(it.linkKeyBytes) } ?: "",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -0,0 +1,64 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.calls.links
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.schedulers.Schedulers
import org.signal.ringrtc.CallLinkState
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkCredentials
import org.thoughtcrime.securesms.service.webrtc.links.SignalCallLinkManager
import org.thoughtcrime.securesms.service.webrtc.links.UpdateCallLinkResult
/**
* Repository for performing update operations on call links:
* <ul>
* <li>Set name</li>
* <li>Set restrictions</li>
* <li>Revoke link</li>
* </ul>
*
* All of these will delegate to the [SignalCallLinkManager] but will additionally update the database state.
*/
class UpdateCallLinkRepository(
private val callLinkManager: SignalCallLinkManager = ApplicationDependencies.getSignalCallManager().callLinkManager
) {
fun setCallName(credentials: CallLinkCredentials, name: String): Single<UpdateCallLinkResult> {
return callLinkManager
.updateCallLinkName(
credentials = credentials,
name = name
)
.doOnSuccess(updateState(credentials))
.subscribeOn(Schedulers.io())
}
fun setCallRestrictions(credentials: CallLinkCredentials, restrictions: CallLinkState.Restrictions): Single<UpdateCallLinkResult> {
return callLinkManager
.updateCallLinkRestrictions(
credentials = credentials,
restrictions = restrictions
)
.doOnSuccess(updateState(credentials))
.subscribeOn(Schedulers.io())
}
fun revokeCallLink(credentials: CallLinkCredentials): Single<UpdateCallLinkResult> {
return callLinkManager
.updateCallLinkRevoked(credentials, true)
.doOnSuccess(updateState(credentials))
.subscribeOn(Schedulers.io())
}
private fun updateState(credentials: CallLinkCredentials): (UpdateCallLinkResult) -> Unit {
return { result ->
if (result is UpdateCallLinkResult.Success) {
SignalDatabase.callLinks.updateCallLinkState(credentials.roomId, result.state)
}
}
}
}
@@ -1,7 +1,14 @@
/**
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.calls.links.create
import android.content.ActivityNotFoundException
import android.content.Intent
import android.os.Bundle
import android.view.View
import android.widget.Toast
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
@@ -25,16 +32,26 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.core.app.ShareCompat
import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController
import io.reactivex.rxjava3.kotlin.subscribeBy
import org.signal.core.ui.Buttons
import org.signal.core.ui.Dividers
import org.signal.core.ui.Rows
import org.signal.core.util.concurrent.LifecycleDisposable
import org.signal.core.util.logging.Log
import org.signal.ringrtc.CallLinkState
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.calls.links.CallLinks
import org.thoughtcrime.securesms.calls.links.EditCallLinkNameDialogFragment
import org.thoughtcrime.securesms.calls.links.SignalCallRow
import org.thoughtcrime.securesms.compose.ComposeBottomSheetDialogFragment
import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardFragment
import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardFragmentArgs
import org.thoughtcrime.securesms.database.CallLinkTable
import org.thoughtcrime.securesms.service.webrtc.links.CreateCallLinkResult
import org.thoughtcrime.securesms.service.webrtc.links.UpdateCallLinkResult
import org.thoughtcrime.securesms.sharing.MultiShareArgs
import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.Util
/**
@@ -42,10 +59,24 @@ import org.thoughtcrime.securesms.util.Util
*/
class CreateCallLinkBottomSheetDialogFragment : ComposeBottomSheetDialogFragment() {
companion object {
private val TAG = Log.tag(CreateCallLinkBottomSheetDialogFragment::class.java)
}
private val viewModel: CreateCallLinkViewModel by viewModels()
private val lifecycleDisposable = LifecycleDisposable()
override val peekHeightPercentage: Float = 1f
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
lifecycleDisposable.bindTo(viewLifecycleOwner)
parentFragmentManager.setFragmentResultListener(EditCallLinkNameDialogFragment.RESULT_KEY, viewLifecycleOwner) { resultKey, bundle ->
if (bundle.containsKey(resultKey)) {
setCallName(bundle.getString(resultKey)!!)
}
}
}
@Composable
override fun SheetContent() {
Column(
@@ -53,9 +84,7 @@ class CreateCallLinkBottomSheetDialogFragment : ComposeBottomSheetDialogFragment
.fillMaxWidth()
.wrapContentSize(Alignment.Center)
) {
val callName: String by viewModel.callName
val callLink: String by viewModel.callLink
val approveAllMembers: Boolean by viewModel.approveAllMembers
val callLink: CallLinkTable.CallLink by viewModel.callLink
Handle(modifier = Modifier.align(Alignment.CenterHorizontally))
@@ -71,7 +100,6 @@ class CreateCallLinkBottomSheetDialogFragment : ComposeBottomSheetDialogFragment
Spacer(modifier = Modifier.height(24.dp))
SignalCallRow(
callName = callName,
callLink = callLink,
onJoinClicked = this@CreateCallLinkBottomSheetDialogFragment::onJoinClicked
)
@@ -84,10 +112,10 @@ class CreateCallLinkBottomSheetDialogFragment : ComposeBottomSheetDialogFragment
)
Rows.ToggleRow(
checked = approveAllMembers,
checked = callLink.state.restrictions == CallLinkState.Restrictions.ADMIN_APPROVAL,
text = stringResource(id = R.string.CreateCallLinkBottomSheetDialogFragment__approve_all_members),
onCheckChanged = viewModel::setApproveAllMembers,
modifier = Modifier.clickable(onClick = viewModel::toggleApproveAllMembers)
onCheckChanged = this@CreateCallLinkBottomSheetDialogFragment::setApproveAllMembers,
modifier = Modifier.clickable(onClick = this@CreateCallLinkBottomSheetDialogFragment::toggleApproveAllMembers)
)
Dividers.Default()
@@ -123,51 +151,133 @@ class CreateCallLinkBottomSheetDialogFragment : ComposeBottomSheetDialogFragment
}
}
private fun setCallName(callName: String) {
lifecycleDisposable += viewModel.setCallName(callName).subscribeBy(onSuccess = {
if (it !is UpdateCallLinkResult.Success) {
Log.w(TAG, "Failed to update call link name")
toastFailure()
}
}, onError = this::handleError)
}
private fun setApproveAllMembers(approveAllMembers: Boolean) {
lifecycleDisposable += viewModel.setApproveAllMembers(approveAllMembers).subscribeBy(onSuccess = {
if (it !is UpdateCallLinkResult.Success) {
Log.w(TAG, "Failed to update call link restrictions")
toastFailure()
}
}, onError = this::handleError)
}
private fun toggleApproveAllMembers() {
lifecycleDisposable += viewModel.toggleApproveAllMembers().subscribeBy(onSuccess = {
if (it !is UpdateCallLinkResult.Success) {
Log.w(TAG, "Failed to update call link restrictions")
toastFailure()
}
}, onError = this::handleError)
}
private fun onAddACallNameClicked() {
EditCallLinkNameDialogFragment().show(childFragmentManager, null)
}
private fun onJoinClicked() {
}
private fun onDoneClicked() {
}
private fun onShareViaSignalClicked() {
val snapshot = viewModel.callLink.value
MultiselectForwardFragment.showFullScreen(
childFragmentManager,
MultiselectForwardFragmentArgs(
canSendToNonPush = false,
multiShareArgs = listOf(
MultiShareArgs.Builder()
.withDraftText(snapshot)
.build()
)
)
findNavController().navigate(
CreateCallLinkBottomSheetDialogFragmentDirections.actionCreateCallLinkBottomSheetToEditCallLinkNameDialogFragment(snapshot.state.name)
)
}
private fun onJoinClicked() {
lifecycleDisposable += viewModel.commitCallLink().subscribeBy(onSuccess = {
when (it) {
is EnsureCallLinkCreatedResult.Success -> {
CommunicationActions.startVideoCall(requireActivity(), it.recipient)
dismissAllowingStateLoss()
}
is EnsureCallLinkCreatedResult.Failure -> handleCreateCallLinkFailure(it.failure)
}
}, onError = this::handleError)
}
private fun onDoneClicked() {
lifecycleDisposable += viewModel.commitCallLink().subscribeBy(onSuccess = {
when (it) {
is EnsureCallLinkCreatedResult.Success -> dismissAllowingStateLoss()
is EnsureCallLinkCreatedResult.Failure -> handleCreateCallLinkFailure(it.failure)
}
}, onError = this::handleError)
}
private fun onShareViaSignalClicked() {
lifecycleDisposable += viewModel.commitCallLink().subscribeBy(onSuccess = {
when (it) {
is EnsureCallLinkCreatedResult.Success -> {
MultiselectForwardFragment.showFullScreen(
childFragmentManager,
MultiselectForwardFragmentArgs(
canSendToNonPush = false,
multiShareArgs = listOf(
MultiShareArgs.Builder()
.withDraftText(CallLinks.url(viewModel.linkKeyBytes))
.build()
)
)
)
}
is EnsureCallLinkCreatedResult.Failure -> handleCreateCallLinkFailure(it.failure)
}
}, onError = this::handleError)
}
private fun onCopyLinkClicked() {
val snapshot = viewModel.callLink.value
Util.copyToClipboard(requireContext(), snapshot)
Toast.makeText(requireContext(), R.string.CreateCallLinkBottomSheetDialogFragment__copied_to_clipboard, Toast.LENGTH_LONG).show()
lifecycleDisposable += viewModel.commitCallLink().subscribeBy(onSuccess = {
when (it) {
is EnsureCallLinkCreatedResult.Success -> {
Util.copyToClipboard(requireContext(), CallLinks.url(viewModel.linkKeyBytes))
Toast.makeText(requireContext(), R.string.CreateCallLinkBottomSheetDialogFragment__copied_to_clipboard, Toast.LENGTH_LONG).show()
}
is EnsureCallLinkCreatedResult.Failure -> handleCreateCallLinkFailure(it.failure)
}
}, onError = this::handleError)
}
private fun onShareLinkClicked() {
val snapshot = viewModel.callLink.value
val mimeType = Intent.normalizeMimeType("text/plain")
val shareIntent = ShareCompat.IntentBuilder(requireContext())
.setText(snapshot)
.setType(mimeType)
.createChooserIntent()
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
lifecycleDisposable += viewModel.commitCallLink().subscribeBy {
when (it) {
is EnsureCallLinkCreatedResult.Success -> {
val mimeType = Intent.normalizeMimeType("text/plain")
val shareIntent = ShareCompat.IntentBuilder(requireContext())
.setText(CallLinks.url(viewModel.linkKeyBytes))
.setType(mimeType)
.createChooserIntent()
try {
startActivity(shareIntent)
} catch (e: ActivityNotFoundException) {
Toast.makeText(requireContext(), R.string.CreateCallLinkBottomSheetDialogFragment__failed_to_open_share_sheet, Toast.LENGTH_LONG).show()
try {
startActivity(shareIntent)
} catch (e: ActivityNotFoundException) {
Toast.makeText(requireContext(), R.string.CreateCallLinkBottomSheetDialogFragment__failed_to_open_share_sheet, Toast.LENGTH_LONG).show()
}
}
is EnsureCallLinkCreatedResult.Failure -> {
Log.w(TAG, "Failed to create link: $it")
toastFailure()
}
}
}
}
private fun handleCreateCallLinkFailure(failure: CreateCallLinkResult.Failure) {
Log.w(TAG, "Failed to create call link: $failure")
toastFailure()
}
private fun handleError(throwable: Throwable) {
Log.w(TAG, "Failed to create call link.", throwable)
toastFailure()
}
private fun toastFailure() {
Toast.makeText(requireContext(), R.string.CallLinkDetailsFragment__couldnt_save_changes, Toast.LENGTH_LONG).show()
}
}
@@ -0,0 +1,62 @@
/**
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.calls.links.create
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.schedulers.Schedulers
import org.thoughtcrime.securesms.conversation.colors.AvatarColor
import org.thoughtcrime.securesms.database.CallLinkTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkCredentials
import org.thoughtcrime.securesms.service.webrtc.links.CreateCallLinkResult
import org.thoughtcrime.securesms.service.webrtc.links.SignalCallLinkManager
/**
* Repository for creating new call links. This will delegate to the [SignalCallLinkManager]
* but will also ensure the database is updated.
*/
class CreateCallLinkRepository(
private val callLinkManager: SignalCallLinkManager = ApplicationDependencies.getSignalCallManager().callLinkManager
) {
fun ensureCallLinkCreated(credentials: CallLinkCredentials, avatarColor: AvatarColor): Single<EnsureCallLinkCreatedResult> {
val callLinkRecipientId = Single.fromCallable {
SignalDatabase.recipients.getByCallLinkRoomId(credentials.roomId)
}
return callLinkRecipientId.flatMap { recipientId ->
if (recipientId.isPresent) {
Single.just(EnsureCallLinkCreatedResult.Success(Recipient.resolved(recipientId.get())))
} else {
callLinkManager.createCallLink(credentials).map {
when (it) {
is CreateCallLinkResult.Success -> {
SignalDatabase.callLinks.insertCallLink(
CallLinkTable.CallLink(
recipientId = RecipientId.UNKNOWN,
roomId = credentials.roomId,
credentials = credentials,
state = it.state,
avatarColor = avatarColor
)
)
EnsureCallLinkCreatedResult.Success(
Recipient.resolved(
SignalDatabase.recipients.getByCallLinkRoomId(credentials.roomId).get()
)
)
}
is CreateCallLinkResult.Failure -> EnsureCallLinkCreatedResult.Failure(it)
}
}
}
}.subscribeOn(Schedulers.io())
}
}
@@ -1,32 +1,98 @@
/**
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.calls.links.create
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.kotlin.plusAssign
import io.reactivex.rxjava3.kotlin.subscribeBy
import org.signal.ringrtc.CallLinkState.Restrictions
import org.thoughtcrime.securesms.calls.links.CallLinks
import org.thoughtcrime.securesms.calls.links.UpdateCallLinkRepository
import org.thoughtcrime.securesms.conversation.colors.AvatarColor
import org.thoughtcrime.securesms.database.CallLinkTable
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkCredentials
import org.thoughtcrime.securesms.service.webrtc.links.SignalCallLinkState
import org.thoughtcrime.securesms.service.webrtc.links.UpdateCallLinkResult
import java.time.Instant
class CreateCallLinkViewModel : ViewModel() {
private val _callName: MutableState<String> = mutableStateOf("")
private val _callLink: MutableState<String> = mutableStateOf("")
private val _approveAllMembers: MutableState<Boolean> = mutableStateOf(false)
class CreateCallLinkViewModel(
private val repository: CreateCallLinkRepository = CreateCallLinkRepository(),
private val mutationRepository: UpdateCallLinkRepository = UpdateCallLinkRepository()
) : ViewModel() {
private val credentials = CallLinkCredentials.generate()
private val avatarColor = AvatarColor.random()
private val _callLink: MutableState<CallLinkTable.CallLink> = mutableStateOf(
CallLinkTable.CallLink(
recipientId = RecipientId.UNKNOWN,
roomId = credentials.roomId,
credentials = credentials,
state = SignalCallLinkState(
name = "",
restrictions = Restrictions.NONE,
revoked = false,
expiration = Instant.MAX
),
avatarColor = avatarColor
)
)
val callName: State<String> = _callName
val callLink: State<String> = _callLink
val approveAllMembers: State<Boolean> = _approveAllMembers
val callLink: State<CallLinkTable.CallLink> = _callLink
val linkKeyBytes: ByteArray = credentials.linkKeyBytes
fun setApproveAllMembers(approveAllMembers: Boolean) {
_approveAllMembers.value = approveAllMembers
private val disposables = CompositeDisposable()
init {
disposables += CallLinks.watchCallLink(credentials.roomId)
.subscribeBy {
_callLink.value = it
}
}
fun toggleApproveAllMembers() {
_approveAllMembers.value = !_approveAllMembers.value
override fun onCleared() {
super.onCleared()
disposables.dispose()
}
fun setCallName(callName: String) {
_callName.value = callName
fun commitCallLink(): Single<EnsureCallLinkCreatedResult> {
return repository.ensureCallLinkCreated(credentials, avatarColor)
}
fun setCallLink(callLink: String) {
_callLink.value = callLink
fun setApproveAllMembers(approveAllMembers: Boolean): Single<UpdateCallLinkResult> {
return commitCallLink()
.flatMap {
when (it) {
is EnsureCallLinkCreatedResult.Success -> mutationRepository.setCallRestrictions(
credentials,
if (approveAllMembers) Restrictions.ADMIN_APPROVAL else Restrictions.NONE
)
is EnsureCallLinkCreatedResult.Failure -> Single.just(UpdateCallLinkResult.Failure(it.failure.status))
}
}
}
fun toggleApproveAllMembers(): Single<UpdateCallLinkResult> {
return setApproveAllMembers(_callLink.value.state.restrictions != Restrictions.ADMIN_APPROVAL)
}
fun setCallName(callName: String): Single<UpdateCallLinkResult> {
return commitCallLink()
.flatMap {
when (it) {
is EnsureCallLinkCreatedResult.Success -> mutationRepository.setCallName(
credentials,
callName
)
is EnsureCallLinkCreatedResult.Failure -> Single.just(UpdateCallLinkResult.Failure(it.failure.status))
}
}
}
}
@@ -0,0 +1,14 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.calls.links.create
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.service.webrtc.links.CreateCallLinkResult
sealed interface EnsureCallLinkCreatedResult {
data class Success(val recipient: Recipient) : EnsureCallLinkCreatedResult
data class Failure(val failure: CreateCallLinkResult.Failure) : EnsureCallLinkCreatedResult
}
@@ -0,0 +1,28 @@
/**
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.calls.links.details
import android.content.Context
import android.content.Intent
import androidx.fragment.app.Fragment
import androidx.navigation.fragment.NavHostFragment
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.FragmentWrapperActivity
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId
class CallLinkDetailsActivity : FragmentWrapperActivity() {
override fun getFragment(): Fragment = NavHostFragment.create(R.navigation.call_link_details, intent.extras!!.getBundle(BUNDLE))
companion object {
private const val BUNDLE = "bundle"
fun createIntent(context: Context, callLinkRoomId: CallLinkRoomId): Intent {
return Intent(context, CallLinkDetailsActivity::class.java)
.putExtra(BUNDLE, CallLinkDetailsFragmentArgs.Builder(callLinkRoomId).build().toBundle())
}
}
}
@@ -0,0 +1,287 @@
/**
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.calls.links.details
import android.content.ActivityNotFoundException
import android.content.Intent
import android.os.Bundle
import android.view.View
import android.widget.Toast
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.core.app.ActivityCompat
import androidx.core.app.ShareCompat
import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.kotlin.subscribeBy
import org.signal.core.ui.Dialogs
import org.signal.core.ui.Dividers
import org.signal.core.ui.Rows
import org.signal.core.ui.Scaffolds
import org.signal.core.ui.theme.SignalTheme
import org.signal.core.util.concurrent.LifecycleDisposable
import org.signal.core.util.logging.Log
import org.signal.ringrtc.CallLinkState.Restrictions
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.calls.links.CallLinks
import org.thoughtcrime.securesms.calls.links.EditCallLinkNameDialogFragment
import org.thoughtcrime.securesms.calls.links.SignalCallRow
import org.thoughtcrime.securesms.compose.ComposeFragment
import org.thoughtcrime.securesms.conversation.colors.AvatarColor
import org.thoughtcrime.securesms.database.CallLinkTable
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkCredentials
import org.thoughtcrime.securesms.service.webrtc.links.SignalCallLinkState
import org.thoughtcrime.securesms.service.webrtc.links.UpdateCallLinkResult
import org.thoughtcrime.securesms.util.CommunicationActions
import java.time.Instant
/**
* Provides detailed info about a call link and allows the owner of that link
* to modify call properties.
*/
class CallLinkDetailsFragment : ComposeFragment(), CallLinkDetailsCallback {
companion object {
private val TAG = Log.tag(CallLinkDetailsFragment::class.java)
}
private val args: CallLinkDetailsFragmentArgs by navArgs()
private val viewModel: CallLinkDetailsViewModel by viewModels(factoryProducer = {
CallLinkDetailsViewModel.Factory(args.roomId)
})
private val lifecycleDisposable = LifecycleDisposable()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
lifecycleDisposable.bindTo(viewLifecycleOwner)
parentFragmentManager.setFragmentResultListener(EditCallLinkNameDialogFragment.RESULT_KEY, viewLifecycleOwner) { resultKey, bundle ->
if (bundle.containsKey(resultKey)) {
setName(bundle.getString(resultKey)!!)
}
}
}
@Composable
override fun FragmentContent() {
val state by viewModel.state
CallLinkDetails(
state,
this
)
}
override fun onNavigationClicked() {
ActivityCompat.finishAfterTransition(requireActivity())
}
override fun onJoinClicked() {
val recipientSnapshot = viewModel.recipientSnapshot
if (recipientSnapshot != null) {
CommunicationActions.startVideoCall(this, recipientSnapshot)
}
}
override fun onEditNameClicked() {
val name = viewModel.nameSnapshot
findNavController().navigate(
CallLinkDetailsFragmentDirections.actionCallLinkDetailsFragmentToEditCallLinkNameDialogFragment(name)
)
}
override fun onShareClicked() {
val mimeType = Intent.normalizeMimeType("text/plain")
val shareIntent = ShareCompat.IntentBuilder(requireContext())
.setText(CallLinks.url(viewModel.rootKeySnapshot))
.setType(mimeType)
.createChooserIntent()
try {
startActivity(shareIntent)
} catch (e: ActivityNotFoundException) {
Toast.makeText(requireContext(), R.string.CreateCallLinkBottomSheetDialogFragment__failed_to_open_share_sheet, Toast.LENGTH_LONG).show()
}
}
override fun onDeleteClicked() {
viewModel.setDisplayRevocationDialog(true)
}
override fun onDeleteConfirmed() {
viewModel.setDisplayRevocationDialog(false)
lifecycleDisposable += viewModel.revoke().observeOn(AndroidSchedulers.mainThread()).subscribeBy(onSuccess = {
when (it) {
is UpdateCallLinkResult.Success -> ActivityCompat.finishAfterTransition(requireActivity())
else -> {
Log.w(TAG, "Failed to revoke. $it")
toastFailure()
}
}
}, onError = handleError("onDeleteClicked"))
}
override fun onDeleteCanceled() {
viewModel.setDisplayRevocationDialog(false)
}
override fun onApproveAllMembersChanged(checked: Boolean) {
lifecycleDisposable += viewModel.setApproveAllMembers(checked).observeOn(AndroidSchedulers.mainThread()).subscribeBy(onSuccess = {
if (it !is UpdateCallLinkResult.Success) {
Log.w(TAG, "Failed to change restrictions. $it")
toastFailure()
}
}, onError = handleError("onApproveAllMembersChanged"))
}
private fun setName(name: String) {
lifecycleDisposable += viewModel.setName(name).observeOn(AndroidSchedulers.mainThread()).subscribeBy(onSuccess = {
if (it !is UpdateCallLinkResult.Success) {
Log.w(TAG, "Failed to set name. $it")
toastFailure()
}
}, onError = handleError("setName"))
}
private fun handleError(method: String): (throwable: Throwable) -> Unit {
return {
Log.w(TAG, "Failure during $method", it)
toastFailure()
}
}
private fun toastFailure() {
Toast.makeText(requireContext(), R.string.CallLinkDetailsFragment__couldnt_save_changes, Toast.LENGTH_LONG).show()
}
}
private interface CallLinkDetailsCallback {
fun onNavigationClicked()
fun onJoinClicked()
fun onEditNameClicked()
fun onShareClicked()
fun onDeleteClicked()
fun onDeleteConfirmed()
fun onDeleteCanceled()
fun onApproveAllMembersChanged(checked: Boolean)
}
@Preview
@Composable
private fun CallLinkDetailsPreview() {
val avatarColor = remember {
AvatarColor.random()
}
val callLink = remember {
val credentials = CallLinkCredentials.generate()
CallLinkTable.CallLink(
recipientId = RecipientId.UNKNOWN,
roomId = credentials.roomId,
credentials = credentials,
state = SignalCallLinkState(
name = "Call Name",
revoked = false,
restrictions = Restrictions.NONE,
expiration = Instant.MAX
),
avatarColor = avatarColor
)
}
SignalTheme(false) {
CallLinkDetails(
CallLinkDetailsState(
false,
callLink
),
object : CallLinkDetailsCallback {
override fun onDeleteConfirmed() = Unit
override fun onDeleteCanceled() = Unit
override fun onNavigationClicked() = Unit
override fun onJoinClicked() = Unit
override fun onEditNameClicked() = Unit
override fun onShareClicked() = Unit
override fun onDeleteClicked() = Unit
override fun onApproveAllMembersChanged(checked: Boolean) = Unit
}
)
}
}
@Composable
private fun CallLinkDetails(
state: CallLinkDetailsState,
callback: CallLinkDetailsCallback
) {
Scaffolds.Settings(
title = stringResource(id = R.string.CallLinkDetailsFragment__call_details),
onNavigationClick = callback::onNavigationClicked,
navigationIconPainter = painterResource(id = R.drawable.ic_arrow_left_24)
) { paddingValues ->
if (state.callLink == null) {
return@Settings
}
Column(modifier = Modifier.padding(paddingValues)) {
SignalCallRow(
callLink = state.callLink,
onJoinClicked = callback::onJoinClicked,
modifier = Modifier.padding(top = 16.dp, bottom = 12.dp)
)
Rows.TextRow(
text = stringResource(id = R.string.CallLinkDetailsFragment__add_call_name),
modifier = Modifier.clickable(onClick = callback::onEditNameClicked)
)
Rows.ToggleRow(
checked = state.callLink.state.restrictions == Restrictions.ADMIN_APPROVAL,
text = stringResource(id = R.string.CallLinkDetailsFragment__approve_all_members),
onCheckChanged = callback::onApproveAllMembersChanged
)
Dividers.Default()
Rows.TextRow(
text = stringResource(id = R.string.CallLinkDetailsFragment__share_link),
icon = ImageVector.vectorResource(id = R.drawable.symbol_link_24),
modifier = Modifier.clickable(onClick = callback::onShareClicked)
)
Rows.TextRow(
text = stringResource(id = R.string.CallLinkDetailsFragment__delete_call_link),
icon = ImageVector.vectorResource(id = R.drawable.symbol_trash_24),
foregroundTint = MaterialTheme.colorScheme.error,
modifier = Modifier.clickable(onClick = callback::onDeleteClicked)
)
}
if (state.displayRevocationDialog) {
Dialogs.SimpleAlertDialog(
title = stringResource(R.string.CallLinkDetailsFragment__delete_link),
body = stringResource(id = R.string.CallLinkDetailsFragment__this_link_will_no_longer_work),
confirm = stringResource(id = R.string.delete),
dismiss = stringResource(id = android.R.string.cancel),
onConfirm = callback::onDeleteConfirmed,
onDismiss = callback::onDeleteCanceled
)
}
}
}
@@ -0,0 +1,44 @@
/**
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.calls.links.details
import io.reactivex.rxjava3.core.Maybe
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.disposables.Disposable
import io.reactivex.rxjava3.kotlin.subscribeBy
import io.reactivex.rxjava3.schedulers.Schedulers
import org.signal.core.util.orNull
import org.thoughtcrime.securesms.database.CallLinkTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId
import org.thoughtcrime.securesms.service.webrtc.links.ReadCallLinkResult
import org.thoughtcrime.securesms.service.webrtc.links.SignalCallLinkManager
class CallLinkDetailsRepository(
private val callLinkManager: SignalCallLinkManager = ApplicationDependencies.getSignalCallManager().callLinkManager
) {
fun refreshCallLinkState(callLinkRoomId: CallLinkRoomId): Disposable {
return Maybe.fromCallable<CallLinkTable.CallLink> { SignalDatabase.callLinks.getCallLinkByRoomId(callLinkRoomId) }
.flatMapSingle { callLinkManager.readCallLink(it.credentials!!) }
.subscribeOn(Schedulers.io())
.subscribeBy { result ->
when (result) {
is ReadCallLinkResult.Success -> SignalDatabase.callLinks.updateCallLinkState(callLinkRoomId, result.callLinkState)
is ReadCallLinkResult.Failure -> Unit
}
}
}
fun watchCallLinkRecipient(callLinkRoomId: CallLinkRoomId): Observable<Recipient> {
return Maybe.fromCallable<RecipientId> { SignalDatabase.recipients.getByCallLinkRoomId(callLinkRoomId).orNull() }
.flatMapObservable { Recipient.observable(it) }
.distinctUntilChanged { a, b -> a.hasSameContent(b) }
.subscribeOn(Schedulers.io())
}
}
@@ -0,0 +1,15 @@
/**
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.calls.links.details
import androidx.compose.runtime.Immutable
import org.thoughtcrime.securesms.database.CallLinkTable
@Immutable
data class CallLinkDetailsState(
val displayRevocationDialog: Boolean = false,
val callLink: CallLinkTable.CallLink? = null
)
@@ -0,0 +1,84 @@
/**
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.calls.links.details
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.kotlin.plusAssign
import io.reactivex.rxjava3.kotlin.subscribeBy
import io.reactivex.rxjava3.subjects.BehaviorSubject
import org.signal.ringrtc.CallLinkState
import org.thoughtcrime.securesms.calls.links.CallLinks
import org.thoughtcrime.securesms.calls.links.UpdateCallLinkRepository
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId
import org.thoughtcrime.securesms.service.webrtc.links.UpdateCallLinkResult
class CallLinkDetailsViewModel(
callLinkRoomId: CallLinkRoomId,
repository: CallLinkDetailsRepository = CallLinkDetailsRepository(),
private val mutationRepository: UpdateCallLinkRepository = UpdateCallLinkRepository()
) : ViewModel() {
private val disposables = CompositeDisposable()
private val _state: MutableState<CallLinkDetailsState> = mutableStateOf(CallLinkDetailsState())
val state: State<CallLinkDetailsState> = _state
val nameSnapshot: String
get() = state.value.callLink?.state?.name ?: error("Call link not loaded yet.")
val rootKeySnapshot: ByteArray
get() = state.value.callLink?.credentials?.linkKeyBytes ?: error("Call link not loaded yet.")
private val recipientSubject = BehaviorSubject.create<Recipient>()
val recipientSnapshot: Recipient?
get() = recipientSubject.value
init {
disposables += repository.refreshCallLinkState(callLinkRoomId)
disposables += CallLinks.watchCallLink(callLinkRoomId).subscribeBy {
_state.value = _state.value.copy(callLink = it)
}
disposables += repository
.watchCallLinkRecipient(callLinkRoomId)
.subscribeBy(onNext = recipientSubject::onNext)
}
override fun onCleared() {
super.onCleared()
disposables.dispose()
}
fun setDisplayRevocationDialog(displayRevocationDialog: Boolean) {
_state.value = _state.value.copy(displayRevocationDialog = displayRevocationDialog)
}
fun setApproveAllMembers(approveAllMembers: Boolean): Single<UpdateCallLinkResult> {
val credentials = _state.value.callLink?.credentials ?: error("User cannot change the name of this call.")
return mutationRepository.setCallRestrictions(credentials, if (approveAllMembers) CallLinkState.Restrictions.ADMIN_APPROVAL else CallLinkState.Restrictions.NONE)
}
fun setName(name: String): Single<UpdateCallLinkResult> {
val credentials = _state.value.callLink?.credentials ?: error("User cannot change the name of this call.")
return mutationRepository.setCallName(credentials, name)
}
fun revoke(): Single<UpdateCallLinkResult> {
val credentials = _state.value.callLink?.credentials ?: error("User cannot change the name of this call.")
return mutationRepository.revokeCallLink(credentials)
}
class Factory(private val callLinkRoomId: CallLinkRoomId) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return modelClass.cast(CallLinkDetailsViewModel(callLinkRoomId)) as T
}
}
}
@@ -1,6 +1,7 @@
package org.thoughtcrime.securesms.calls.log
import android.content.res.ColorStateList
import android.text.style.TextAppearanceSpan
import android.view.View
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
@@ -15,6 +16,7 @@ import org.thoughtcrime.securesms.databinding.ConversationListItemClearFilterBin
import org.thoughtcrime.securesms.mms.GlideApp
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.DateUtils
import org.thoughtcrime.securesms.util.SearchUtil
import org.thoughtcrime.securesms.util.adapter.mapping.BindingFactory
import org.thoughtcrime.securesms.util.adapter.mapping.BindingViewHolder
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel
@@ -60,6 +62,14 @@ class CallLogAdapter(
inflater = CallLogCreateCallLinkItemBinding::inflate
)
)
registerFactory(
CallLinkModel::class.java,
BindingFactory(
creator = { CallLinkModelViewHolder(it, callbacks::onCallLinkClicked, callbacks::onCallLinkLongClicked, callbacks::onStartVideoCallClicked) },
inflater = CallLogAdapterItemBinding::inflate
)
)
}
fun submitCallRows(
@@ -74,6 +84,7 @@ class CallLogAdapter(
.map {
when (it) {
is CallLogRow.Call -> CallModel(it, selectionState, itemCount)
is CallLogRow.CallLink -> CallLinkModel(it, selectionState, itemCount)
is CallLogRow.ClearFilter -> ClearFilterModel()
is CallLogRow.CreateCallLink -> CreateCallLinkModel()
}
@@ -118,6 +129,44 @@ class CallLogAdapter(
}
}
private class CallLinkModel(
val callLink: CallLogRow.CallLink,
val selectionState: CallLogSelectionState,
val itemCount: Int
) : MappingModel<CallLinkModel> {
companion object {
const val PAYLOAD_SELECTION_STATE = "PAYLOAD_SELECTION_STATE"
}
override fun areItemsTheSame(newItem: CallLinkModel): Boolean {
return callLink.record.roomId == newItem.callLink.record.roomId
}
override fun areContentsTheSame(newItem: CallLinkModel): Boolean {
return callLink == newItem.callLink &&
isSelectionStateTheSame(newItem) &&
isItemCountTheSame(newItem)
}
override fun getChangePayload(newItem: CallLinkModel): Any? {
return if (callLink == newItem.callLink && (!isSelectionStateTheSame(newItem) || !isItemCountTheSame(newItem))) {
CallModel.PAYLOAD_SELECTION_STATE
} else {
null
}
}
private fun isSelectionStateTheSame(newItem: CallLinkModel): Boolean {
return selectionState.contains(callLink.id) == newItem.selectionState.contains(newItem.callLink.id) &&
selectionState.isNotEmpty(itemCount) == newItem.selectionState.isNotEmpty(newItem.itemCount)
}
private fun isItemCountTheSame(newItem: CallLinkModel): Boolean {
return itemCount == newItem.itemCount
}
}
private class ClearFilterModel : MappingModel<ClearFilterModel> {
override fun areItemsTheSame(newItem: ClearFilterModel): Boolean = true
override fun areContentsTheSame(newItem: ClearFilterModel): Boolean = true
@@ -129,6 +178,54 @@ class CallLogAdapter(
override fun areContentsTheSame(newItem: CreateCallLinkModel): Boolean = true
}
private class CallLinkModelViewHolder(
binding: CallLogAdapterItemBinding,
private val onCallLinkClicked: (CallLogRow.CallLink) -> Unit,
private val onCallLinkLongClicked: (View, CallLogRow.CallLink) -> Boolean,
private val onStartVideoCallClicked: (Recipient) -> Unit
) : BindingViewHolder<CallLinkModel, CallLogAdapterItemBinding>(binding) {
override fun bind(model: CallLinkModel) {
itemView.setOnClickListener {
onCallLinkClicked(model.callLink)
}
itemView.setOnLongClickListener {
onCallLinkLongClicked(itemView, model.callLink)
}
itemView.isSelected = model.selectionState.contains(model.callLink.id)
binding.callSelected.isChecked = model.selectionState.contains(model.callLink.id)
binding.callSelected.visible = model.selectionState.isNotEmpty(model.itemCount)
if (payload.contains(CallModel.PAYLOAD_SELECTION_STATE)) {
return
}
binding.callRecipientAvatar.setAvatar(model.callLink.recipient)
val callLinkName = model.callLink.record.state.name.takeIf { it.isNotEmpty() }
?: context.getString(R.string.WebRtcCallView__signal_call)
binding.callRecipientName.text = SearchUtil.getHighlightedSpan(
Locale.getDefault(),
{ arrayOf(TextAppearanceSpan(context, R.style.Signal_Text_TitleSmall)) },
callLinkName,
model.callLink.searchQuery,
SearchUtil.MATCH_ALL
)
binding.callInfo.setRelativeDrawables(start = R.drawable.symbol_link_compact_16)
binding.callInfo.setText(R.string.CallLogAdapter__call_link)
binding.callType.setImageResource(R.drawable.symbol_video_24)
binding.callType.setOnClickListener {
onStartVideoCallClicked(model.callLink.recipient)
}
binding.callType.visible = true
binding.groupCallButton.visible = false
}
}
private class CallModelViewHolder(
binding: CallLogAdapterItemBinding,
private val onCallClicked: (CallLogRow.Call) -> Unit,
@@ -153,13 +250,27 @@ class CallLogAdapter(
return
}
binding.callRecipientAvatar.setAvatar(GlideApp.with(binding.callRecipientAvatar), model.call.peer, true)
binding.callRecipientBadge.setBadgeFromRecipient(model.call.peer)
binding.callRecipientName.text = model.call.peer.getDisplayName(context)
presentRecipientDetails(model.call.peer, model.call.searchQuery)
presentCallInfo(model.call, model.call.date)
presentCallType(model)
}
private fun presentRecipientDetails(recipient: Recipient, searchQuery: String?) {
binding.callRecipientAvatar.setAvatar(GlideApp.with(binding.callRecipientAvatar), recipient, true)
binding.callRecipientBadge.setBadgeFromRecipient(recipient)
binding.callRecipientName.text = if (searchQuery != null) {
SearchUtil.getHighlightedSpan(
Locale.getDefault(),
{ arrayOf(TextAppearanceSpan(context, R.style.Signal_Text_TitleSmall)) },
recipient.getDisplayName(context),
searchQuery,
SearchUtil.MATCH_ALL
)
} else {
recipient.getDisplayName(context)
}
}
private fun presentCallInfo(call: CallLogRow.Call, date: Long) {
val callState = context.getString(getCallStateStringRes(call.record))
binding.callInfo.text = context.getString(
@@ -181,7 +292,7 @@ class CallLogAdapter(
if (call.record.event == CallTable.Event.MISSED) {
R.color.signal_colorError
} else {
R.color.signal_colorOnSurface
R.color.signal_colorOnSurfaceVariant
}
)
@@ -219,6 +330,7 @@ class CallLogAdapter(
binding.callType.visible = true
binding.groupCallButton.visible = false
}
CallLogRow.GroupCallState.ACTIVE, CallLogRow.GroupCallState.LOCAL_USER_JOINED -> {
binding.callType.visible = false
binding.groupCallButton.visible = true
@@ -249,6 +361,7 @@ class CallLogAdapter(
call.direction == CallTable.Direction.OUTGOING -> R.drawable.symbol_arrow_upright_compact_16
else -> throw AssertionError()
}
else -> error("Unexpected type ${call.type}")
}
}
@@ -269,6 +382,7 @@ class CallLogAdapter(
call.direction == CallTable.Direction.OUTGOING -> R.string.CallLogAdapter__outgoing
else -> throw AssertionError()
}
else -> error("Unexpected type ${call.messageType}")
}
}
@@ -308,11 +422,21 @@ class CallLogAdapter(
*/
fun onCallClicked(callLogRow: CallLogRow.Call)
/**
* Invoked when a call link row is clicked
*/
fun onCallLinkClicked(callLogRow: CallLogRow.CallLink)
/**
* Invoked when a call row is long-clicked
*/
fun onCallLongClicked(itemView: View, callLogRow: CallLogRow.Call): Boolean
/**
* Invoked when a call link row is long-clicked
*/
fun onCallLinkLongClicked(itemView: View, callLinkLogRow: CallLogRow.CallLink): Boolean
/**
* Invoked when the clear filter button is pressed
*/
@@ -321,11 +445,11 @@ class CallLogAdapter(
/**
* Invoked when user presses the audio icon
*/
fun onStartAudioCallClicked(peer: Recipient)
fun onStartAudioCallClicked(recipient: Recipient)
/**
* Invoked when user presses the video icon
*/
fun onStartVideoCallClicked(peer: Recipient)
fun onStartVideoCallClicked(recipient: Recipient)
}
}
@@ -3,12 +3,15 @@ package org.thoughtcrime.securesms.calls.log
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.recyclerview.widget.RecyclerView
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.calls.links.details.CallLinkDetailsActivity
import org.thoughtcrime.securesms.components.menu.ActionItem
import org.thoughtcrime.securesms.components.menu.SignalContextMenu
import org.thoughtcrime.securesms.components.settings.conversation.ConversationSettingsActivity
import org.thoughtcrime.securesms.conversation.ConversationIntents
import org.thoughtcrime.securesms.database.CallTable
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.CommunicationActions
/**
@@ -18,35 +21,58 @@ class CallLogContextMenu(
private val fragment: Fragment,
private val callbacks: Callbacks
) {
fun show(anchor: View, call: CallLogRow.Call) {
fun show(recyclerView: RecyclerView, anchor: View, call: CallLogRow.Call) {
recyclerView.suppressLayout(true)
anchor.isSelected = true
SignalContextMenu.Builder(anchor, anchor.parent as ViewGroup)
.preferredVerticalPosition(SignalContextMenu.VerticalPosition.BELOW)
.onDismiss { anchor.isSelected = false }
.onDismiss {
anchor.isSelected = false
recyclerView.suppressLayout(false)
}
.show(
listOfNotNull(
getVideoCallActionItem(call),
getVideoCallActionItem(call.peer),
getAudioCallActionItem(call),
getGoToChatActionItem(call),
getInfoActionItem(call),
getInfoActionItem(call.peer, (call.id as CallLogRow.Id.Call).children.toLongArray()),
getSelectActionItem(call),
getDeleteActionItem(call)
)
)
}
private fun getVideoCallActionItem(call: CallLogRow.Call): ActionItem {
fun show(recyclerView: RecyclerView, anchor: View, callLink: CallLogRow.CallLink) {
recyclerView.suppressLayout(true)
anchor.isSelected = true
SignalContextMenu.Builder(anchor, anchor.parent as ViewGroup)
.preferredVerticalPosition(SignalContextMenu.VerticalPosition.BELOW)
.onDismiss {
anchor.isSelected = false
recyclerView.suppressLayout(false)
}
.show(
listOfNotNull(
getVideoCallActionItem(callLink.recipient),
getInfoActionItem(callLink.recipient, longArrayOf()),
getSelectActionItem(callLink),
getDeleteActionItem(callLink)
)
)
}
private fun getVideoCallActionItem(peer: Recipient): ActionItem {
// TODO [alex] -- Need group calling disposition to make this correct
return ActionItem(
iconRes = R.drawable.symbol_video_24,
title = fragment.getString(R.string.CallContextMenu__video_call)
) {
CommunicationActions.startVideoCall(fragment, call.peer)
CommunicationActions.startVideoCall(fragment, peer)
}
}
private fun getAudioCallActionItem(call: CallLogRow.Call): ActionItem? {
if (call.peer.isGroup) {
if (call.peer.isCallLink || call.peer.isGroup) {
return null
}
@@ -58,26 +84,32 @@ class CallLogContextMenu(
}
}
private fun getGoToChatActionItem(call: CallLogRow.Call): ActionItem {
return ActionItem(
iconRes = R.drawable.symbol_open_24,
title = fragment.getString(R.string.CallContextMenu__go_to_chat)
) {
fragment.startActivity(ConversationIntents.createBuilder(fragment.requireContext(), call.peer.id, -1L).build())
private fun getGoToChatActionItem(call: CallLogRow.Call): ActionItem? {
return when {
call.peer.isCallLink -> null
else -> ActionItem(
iconRes = R.drawable.symbol_open_24,
title = fragment.getString(R.string.CallContextMenu__go_to_chat)
) {
fragment.startActivity(ConversationIntents.createBuilder(fragment.requireContext(), call.peer.id, -1L).build())
}
}
}
private fun getInfoActionItem(call: CallLogRow.Call): ActionItem {
private fun getInfoActionItem(peer: Recipient, messageIds: LongArray): ActionItem {
return ActionItem(
iconRes = R.drawable.symbol_info_24,
title = fragment.getString(R.string.CallContextMenu__info)
) {
val intent = ConversationSettingsActivity.forCall(fragment.requireContext(), call.peer, longArrayOf(call.record.messageId!!))
val intent = when {
peer.isCallLink -> CallLinkDetailsActivity.createIntent(fragment.requireContext(), peer.requireCallLinkRoomId())
else -> ConversationSettingsActivity.forCall(fragment.requireContext(), peer, messageIds)
}
fragment.startActivity(intent)
}
}
private fun getSelectActionItem(call: CallLogRow.Call): ActionItem {
private fun getSelectActionItem(call: CallLogRow): ActionItem {
return ActionItem(
iconRes = R.drawable.symbol_check_circle_24,
title = fragment.getString(R.string.CallContextMenu__select)
@@ -86,8 +118,8 @@ class CallLogContextMenu(
}
}
private fun getDeleteActionItem(call: CallLogRow.Call): ActionItem? {
if (call.record.event == CallTable.Event.ONGOING) {
private fun getDeleteActionItem(call: CallLogRow): ActionItem? {
if (call is CallLogRow.Call && call.record.event == CallTable.Event.ONGOING) {
return null
}
@@ -100,7 +132,7 @@ class CallLogContextMenu(
}
interface Callbacks {
fun startSelection(call: CallLogRow.Call)
fun deleteCall(call: CallLogRow.Call)
fun startSelection(call: CallLogRow)
fun deleteCall(call: CallLogRow)
}
}
@@ -11,19 +11,24 @@ import androidx.activity.OnBackPressedCallback
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.view.ActionMode
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.app.SharedElementCallback
import androidx.core.view.MenuProvider
import androidx.core.view.ViewCompat
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController
import androidx.transition.TransitionInflater
import com.google.android.material.appbar.AppBarLayout
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.kotlin.Flowables
import io.reactivex.rxjava3.kotlin.subscribeBy
import org.signal.core.util.DimensionUnit
import org.signal.core.util.concurrent.LifecycleDisposable
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.calls.links.details.CallLinkDetailsActivity
import org.thoughtcrime.securesms.calls.new.NewCallActivity
import org.thoughtcrime.securesms.components.Material3SearchToolbar
import org.thoughtcrime.securesms.components.ScrollToPositionDelegate
@@ -52,6 +57,7 @@ import org.thoughtcrime.securesms.util.doAfterNextLayout
import org.thoughtcrime.securesms.util.fragments.requireListener
import org.thoughtcrime.securesms.util.visible
import java.util.Objects
import java.util.concurrent.TimeUnit
/**
* Call Log tab.
@@ -94,6 +100,7 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
requireActivity().addMenuProvider(menuProvider, viewLifecycleOwner)
initializeSharedElementTransition()
val adapter = CallLogAdapter(this)
disposables.bindTo(viewLifecycleOwner)
@@ -181,6 +188,26 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
viewModel.markAllCallEventsRead()
}
private fun initializeSharedElementTransition() {
ViewCompat.setTransitionName(binding.fab, "new_convo_fab")
ViewCompat.setTransitionName(binding.fabSharedElementTarget, "camera_fab")
sharedElementEnterTransition = TransitionInflater.from(requireContext()).inflateTransition(R.transition.change_transform_fabs)
setEnterSharedElementCallback(object : SharedElementCallback() {
override fun onSharedElementStart(sharedElementNames: MutableList<String>?, sharedElements: MutableList<View>?, sharedElementSnapshots: MutableList<View>?) {
if (sharedElementNames?.contains("camera_fab") == true) {
this@CallLogFragment.binding.fab.setImageResource(R.drawable.symbol_edit_24)
disposables += Single.timer(200, TimeUnit.MILLISECONDS)
.observeOn(AndroidSchedulers.mainThread())
.subscribeBy {
this@CallLogFragment.binding.fab.setImageResource(R.drawable.symbol_phone_plus_24)
this@CallLogFragment.binding.fabSharedElementTarget.alpha = 0f
}
}
}
})
}
private fun initializeTapToScrollToTop(scrollToPositionDelegate: ScrollToPositionDelegate) {
disposables += tabsViewModel.tabClickEvents
.filter { it == ConversationListTab.CALLS }
@@ -268,7 +295,7 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
}
override fun canStartNestedScroll(): Boolean {
return !callLogActionMode.isInActionMode() || !isSearchOpen() || binding.pullView.isCloseable()
return !callLogActionMode.isInActionMode() && !isSearchOpen()
}
}
@@ -285,14 +312,33 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
override fun onCallClicked(callLogRow: CallLogRow.Call) {
if (viewModel.selectionStateSnapshot.isNotEmpty(binding.recycler.adapter!!.itemCount)) {
viewModel.toggleSelected(callLogRow.id)
} else {
val intent = ConversationSettingsActivity.forCall(requireContext(), callLogRow.peer, (callLogRow.id as CallLogRow.Id.Call).children.toLongArray())
} else if (!callLogRow.peer.isCallLink) {
val intent = ConversationSettingsActivity.forCall(
requireContext(),
callLogRow.peer,
(callLogRow.id as CallLogRow.Id.Call).children.toLongArray()
)
startActivity(intent)
} else {
startActivity(CallLinkDetailsActivity.createIntent(requireContext(), callLogRow.peer.requireCallLinkRoomId()))
}
}
override fun onCallLinkClicked(callLogRow: CallLogRow.CallLink) {
if (viewModel.selectionStateSnapshot.isNotEmpty(binding.recycler.adapter!!.itemCount)) {
viewModel.toggleSelected(callLogRow.id)
} else {
startActivity(CallLinkDetailsActivity.createIntent(requireContext(), callLogRow.record.roomId))
}
}
override fun onCallLongClicked(itemView: View, callLogRow: CallLogRow.Call): Boolean {
callLogContextMenu.show(itemView, callLogRow)
callLogContextMenu.show(binding.recycler, itemView, callLogRow)
return true
}
override fun onCallLinkLongClicked(itemView: View, callLinkLogRow: CallLogRow.CallLink): Boolean {
callLogContextMenu.show(binding.recycler, itemView, callLinkLogRow)
return true
}
@@ -301,20 +347,20 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
binding.recyclerCoordinatorAppBar.setExpanded(false, true)
}
override fun onStartAudioCallClicked(peer: Recipient) {
CommunicationActions.startVoiceCall(this, peer)
override fun onStartAudioCallClicked(recipient: Recipient) {
CommunicationActions.startVoiceCall(this, recipient)
}
override fun onStartVideoCallClicked(peer: Recipient) {
CommunicationActions.startVideoCall(this, peer)
override fun onStartVideoCallClicked(recipient: Recipient) {
CommunicationActions.startVideoCall(this, recipient)
}
override fun startSelection(call: CallLogRow.Call) {
override fun startSelection(call: CallLogRow) {
callLogActionMode.start()
viewModel.toggleSelected(call.id)
}
override fun deleteCall(call: CallLogRow.Call) {
override fun deleteCall(call: CallLogRow) {
MaterialAlertDialogBuilder(requireContext())
.setTitle(resources.getQuantityString(R.plurals.CallLogFragment__delete_d_calls, 1, 1))
.setPositiveButton(R.string.CallLogFragment__delete_for_me) { _, _ ->
@@ -12,28 +12,62 @@ class CallLogPagedDataSource(
private val hasFilter = filter == CallLogFilter.MISSED
private val hasCallLinkRow = FeatureFlags.adHocCalling() && filter == CallLogFilter.ALL && query.isNullOrEmpty()
private var callsCount = 0
private var callEventsCount = 0
private var callLinksCount = 0
override fun size(): Int {
callsCount = repository.getCallsCount(query, filter)
return callsCount + hasFilter.toInt() + hasCallLinkRow.toInt()
callEventsCount = repository.getCallsCount(query, filter)
callLinksCount = repository.getCallLinksCount(query, filter)
return callEventsCount + callLinksCount + hasFilter.toInt() + hasCallLinkRow.toInt()
}
override fun load(start: Int, length: Int, cancellationSignal: PagedDataSource.CancellationSignal): MutableList<CallLogRow> {
val calls = mutableListOf<CallLogRow>()
val callLimit = length - hasCallLinkRow.toInt()
if (start == 0 && length >= 1 && hasCallLinkRow) {
calls.add(CallLogRow.CreateCallLink)
override fun load(start: Int, length: Int, totalSize: Int, cancellationSignal: PagedDataSource.CancellationSignal): MutableList<CallLogRow> {
val callLogRows = mutableListOf<CallLogRow>()
if (length <= 0) {
return callLogRows
}
calls.addAll(repository.getCalls(query, filter, start, callLimit).toMutableList())
val callLinkStart = if (hasCallLinkRow) 1 else 0
val callEventStart = callLinkStart + callLinksCount
val clearFilterStart = callEventStart + callEventsCount
if (calls.size < length && hasFilter) {
calls.add(CallLogRow.ClearFilter)
var remaining = length
if (start < callLinkStart) {
callLogRows.add(CallLogRow.CreateCallLink)
remaining -= 1
}
return calls
if (start < callEventStart && remaining > 0) {
val callLinks = repository.getCallLinks(
query,
filter,
start,
remaining
)
callLogRows.addAll(callLinks)
remaining -= callLinks.size
}
if (start < clearFilterStart && remaining > 0) {
val callEvents = repository.getCalls(
query,
filter,
start - callLinksCount,
remaining
)
callLogRows.addAll(callEvents)
remaining -= callEvents.size
}
if (start <= clearFilterStart && remaining > 0) {
callLogRows.add(CallLogRow.ClearFilter)
}
return callLogRows
}
override fun getKey(data: CallLogRow): CallLogRow.Id = data.id
@@ -47,5 +81,7 @@ class CallLogPagedDataSource(
interface CallRepository {
fun getCallsCount(query: String?, filter: CallLogFilter): Int
fun getCalls(query: String?, filter: CallLogFilter, start: Int, length: Int): List<CallLogRow>
fun getCallLinksCount(query: String?, filter: CallLogFilter): Int
fun getCallLinks(query: String?, filter: CallLogFilter, start: Int, length: Int): List<CallLogRow>
}
}
@@ -17,6 +17,20 @@ class CallLogRepository : CallLogPagedDataSource.CallRepository {
return SignalDatabase.calls.getCalls(start, length, query, filter)
}
override fun getCallLinksCount(query: String?, filter: CallLogFilter): Int {
return when (filter) {
CallLogFilter.MISSED -> 0
CallLogFilter.ALL -> SignalDatabase.callLinks.getCallLinksCount(query)
}
}
override fun getCallLinks(query: String?, filter: CallLogFilter, start: Int, length: Int): List<CallLogRow> {
return when (filter) {
CallLogFilter.MISSED -> emptyList()
CallLogFilter.ALL -> SignalDatabase.callLinks.getCallLinks(query, start, length)
}
}
fun markAllCallEventsRead() {
SignalExecutors.BOUNDED_IO.execute {
SignalDatabase.messages.markAllCallEventsRead()
@@ -51,10 +65,11 @@ class CallLogRepository : CallLogPagedDataSource.CallRepository {
}
fun deleteAllCallLogsExcept(
selectedCallRowIds: Set<Long>
selectedCallRowIds: Set<Long>,
missedOnly: Boolean
): Completable {
return Completable.fromAction {
SignalDatabase.calls.deleteAllCallEventsExcept(selectedCallRowIds)
SignalDatabase.calls.deleteAllCallEventsExcept(selectedCallRowIds, missedOnly)
}.observeOn(Schedulers.io())
}
}
@@ -1,8 +1,15 @@
/**
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.calls.log
import org.thoughtcrime.securesms.database.CallLinkTable
import org.thoughtcrime.securesms.database.CallTable
import org.thoughtcrime.securesms.database.model.databaseprotos.GroupCallUpdateDetails
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId
/**
* A row to be displayed in the call log
@@ -11,6 +18,16 @@ sealed class CallLogRow {
abstract val id: Id
/**
* A call link with no "active" events.
*/
data class CallLink(
val record: CallLinkTable.CallLink,
val recipient: Recipient,
val searchQuery: String?,
override val id: Id = Id.CallLink(record.roomId)
) : CallLogRow()
/**
* An incoming, outgoing, or missed call.
*/
@@ -20,6 +37,7 @@ sealed class CallLogRow {
val date: Long,
val groupCallState: GroupCallState,
val children: Set<Long>,
val searchQuery: String?,
override val id: Id = Id.Call(children)
) : CallLogRow()
@@ -36,6 +54,7 @@ sealed class CallLogRow {
sealed class Id {
data class Call(val children: Set<Long>) : Id()
data class CallLink(val roomId: CallLinkRoomId) : Id()
object ClearFilter : Id()
object CreateCallLink : Id()
}
@@ -6,6 +6,7 @@ import androidx.annotation.MainThread
* Encapsulates a single deletion action
*/
class CallLogStagedDeletion(
private val filter: CallLogFilter,
private val stateSnapshot: CallLogSelectionState,
private val repository: CallLogRepository
) {
@@ -35,7 +36,7 @@ class CallLogStagedDeletion(
.toSet()
if (stateSnapshot.isExclusionary()) {
repository.deleteAllCallLogsExcept(callRowIds).subscribe()
repository.deleteAllCallLogsExcept(callRowIds, filter == CallLogFilter.MISSED).subscribe()
} else {
repository.deleteSelectedCallLogs(callRowIds).subscribe()
}
@@ -96,11 +96,12 @@ class CallLogViewModel(
}
@MainThread
fun stageCallDeletion(call: CallLogRow.Call) {
fun stageCallDeletion(call: CallLogRow) {
callLogStore.state.stagedDeletion?.commit()
callLogStore.update {
it.copy(
stagedDeletion = CallLogStagedDeletion(
it.filter,
CallLogSelectionState.empty().toggle(call.id),
callLogRepository
)
@@ -114,6 +115,7 @@ class CallLogViewModel(
callLogStore.update {
it.copy(
stagedDeletion = CallLogStagedDeletion(
it.filter,
it.selectionState,
callLogRepository
)
@@ -302,9 +302,7 @@ public class ComposeText extends EmojiEditText {
addTextChangedListener(mentionValidatorWatcher);
if (FeatureFlags.textFormatting()) {
if (FeatureFlags.textFormattingSpoilerSend()) {
spoilerRendererDelegate = new SpoilerRendererDelegate(this, true);
}
spoilerRendererDelegate = new SpoilerRendererDelegate(this, true);
addTextChangedListener(new ComposeTextStyleWatcher());
@@ -323,10 +321,7 @@ public class ComposeText extends EmojiEditText {
menu.add(0, R.id.edittext_italic, largestOrder, getContext().getString(R.string.TextFormatting_italic));
menu.add(0, R.id.edittext_strikethrough, largestOrder, getContext().getString(R.string.TextFormatting_strikethrough));
menu.add(0, R.id.edittext_monospace, largestOrder, getContext().getString(R.string.TextFormatting_monospace));
if (FeatureFlags.textFormattingSpoilerSend()) {
menu.add(0, R.id.edittext_spoiler, largestOrder, getContext().getString(R.string.TextFormatting_spoiler));
}
menu.add(0, R.id.edittext_spoiler, largestOrder, getContext().getString(R.string.TextFormatting_spoiler));
return true;
}
@@ -3,6 +3,9 @@ package org.thoughtcrime.securesms.components;
import android.content.Context;
import android.content.res.TypedArray;
import android.util.AttributeSet;
import android.view.View;
import android.view.animation.Animation;
import android.view.animation.AnimationUtils;
import android.widget.FrameLayout;
import android.widget.ImageView;
import android.widget.TextView;
@@ -14,11 +17,14 @@ import androidx.annotation.Nullable;
import com.airbnb.lottie.SimpleColorFilter;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.util.ViewUtil;
public final class ConversationScrollToView extends FrameLayout {
private final TextView unreadCount;
private final ImageView scrollButton;
private final Animation inAnimation;
private final Animation outAnimation;
public ConversationScrollToView(@NonNull Context context) {
this(context, null);
@@ -44,6 +50,20 @@ public final class ConversationScrollToView extends FrameLayout {
array.recycle();
}
inAnimation = AnimationUtils.loadAnimation(context, R.anim.fade_scale_in);
outAnimation = AnimationUtils.loadAnimation(context, R.anim.fade_scale_out);
inAnimation.setDuration(100);
outAnimation.setDuration(50);
}
public void setShown(boolean isShown) {
if (isShown) {
ViewUtil.animateIn(this, inAnimation);
} else {
ViewUtil.animateOut(this, outAnimation, View.INVISIBLE);
}
}
public void setWallpaperEnabled(boolean hasWallpaper) {
@@ -8,10 +8,13 @@ import androidx.annotation.Nullable;
import com.bumptech.glide.request.target.DrawableImageViewTarget;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.util.concurrent.SettableFuture;
public class GlideDrawableListeningTarget extends DrawableImageViewTarget {
private static final String TAG = Log.tag(GlideDrawableListeningTarget.class);
private final SettableFuture<Boolean> loaded;
public GlideDrawableListeningTarget(@NonNull ImageView view, @NonNull SettableFuture<Boolean> loaded) {
@@ -21,6 +24,12 @@ public class GlideDrawableListeningTarget extends DrawableImageViewTarget {
@Override
protected void setResource(@Nullable Drawable resource) {
if (resource == null) {
Log.d(TAG, "Loaded null resource");
} else {
Log.d(TAG, "Loaded resource of w " + resource.getIntrinsicWidth() + " by h " + resource.getIntrinsicHeight());
}
super.setResource(resource);
loaded.set(true);
}
@@ -0,0 +1,97 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components
import android.content.Context
import android.util.AttributeSet
import android.widget.EditText
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.util.ViewUtil
/**
* A flavor of [InsetAwareConstraintLayout] that allows "replacing" the keyboard with our
* own input fragment.
*/
class InputAwareConstraintLayout @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : InsetAwareConstraintLayout(context, attrs, defStyleAttr) {
private var inputId: Int? = null
private var input: Fragment? = null
lateinit var fragmentManager: FragmentManager
var listener: Listener? = null
fun showSoftkey(editText: EditText) {
ViewUtil.focusAndShowKeyboard(editText)
hideInput(resetKeyboardGuideline = false)
}
fun toggleInput(fragmentCreator: FragmentCreator, imeTarget: EditText, toggled: (Boolean) -> Unit = { }) {
if (fragmentCreator.id == inputId) {
hideInput(resetKeyboardGuideline = true)
toggled(false)
} else {
hideInput(resetKeyboardGuideline = false)
showInput(fragmentCreator, imeTarget)
}
}
fun hideInput() {
hideInput(resetKeyboardGuideline = true)
}
private fun showInput(fragmentCreator: FragmentCreator, imeTarget: EditText) {
inputId = fragmentCreator.id
input = fragmentCreator.create()
fragmentManager
.beginTransaction()
.replace(R.id.input_container, input!!)
.commit()
overrideKeyboardGuidelineWithPreviousHeight()
ViewUtil.hideKeyboard(context, imeTarget)
listener?.onInputShown()
}
private fun hideInput(resetKeyboardGuideline: Boolean) {
val inputHidden = input != null
input?.let {
fragmentManager
.beginTransaction()
.remove(it)
.commit()
}
input = null
inputId = null
if (resetKeyboardGuideline) {
resetKeyboardGuideline()
} else {
clearKeyboardGuidelineOverride()
}
if (inputHidden) {
listener?.onInputHidden()
}
}
interface FragmentCreator {
val id: Int
fun create(): Fragment
}
interface Listener {
fun onInputShown()
fun onInputHidden()
}
}
@@ -17,6 +17,7 @@ import android.view.animation.AnimationSet;
import android.view.animation.Interpolator;
import android.view.animation.TranslateAnimation;
import android.widget.ImageButton;
import android.widget.ImageView;
import android.widget.LinearLayout;
import android.widget.TextView;
import android.widget.Toast;
@@ -30,6 +31,8 @@ import androidx.lifecycle.Observer;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import com.bumptech.glide.load.engine.DiskCacheStrategy;
import org.signal.core.util.ThreadUtil;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.R;
@@ -44,6 +47,7 @@ import org.thoughtcrime.securesms.conversation.ConversationMessage;
import org.thoughtcrime.securesms.conversation.ConversationStickerSuggestionAdapter;
import org.thoughtcrime.securesms.conversation.VoiceNoteDraftView;
import org.thoughtcrime.securesms.database.DraftTable;
import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord;
import org.thoughtcrime.securesms.database.model.MessageId;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.database.model.Quote;
@@ -52,9 +56,11 @@ import org.thoughtcrime.securesms.keyboard.KeyboardPage;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.linkpreview.LinkPreview;
import org.thoughtcrime.securesms.linkpreview.LinkPreviewRepository;
import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader;
import org.thoughtcrime.securesms.mms.GlideApp;
import org.thoughtcrime.securesms.mms.GlideRequests;
import org.thoughtcrime.securesms.mms.QuoteModel;
import org.thoughtcrime.securesms.mms.Slide;
import org.thoughtcrime.securesms.mms.SlideDeck;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
@@ -94,8 +100,9 @@ public class InputPanel extends LinearLayout
private View recordingContainer;
private View recordLockCancel;
private ViewGroup composeContainer;
private View editMessageLabel;
private View editMessageCancel;
private ImageView editMessageThumbnail;
private View editMessageHeader;
private MicrophoneRecorderView microphoneRecorderView;
private SlideToCancel slideToCancel;
@@ -153,8 +160,9 @@ public class InputPanel extends LinearLayout
findViewById(R.id.microphone),
TimeUnit.HOURS.toSeconds(1),
() -> microphoneRecorderView.cancelAction(false));
this.editMessageLabel = findViewById(R.id.edit_message);
this.editMessageCancel = findViewById(R.id.input_panel_exit_edit_mode);
this.editMessageHeader = findViewById(R.id.edit_message_compose_header);
this.editMessageThumbnail = findViewById(R.id.edit_message_thumbnail);
this.recordLockCancel.setOnClickListener(v -> microphoneRecorderView.cancelAction(true));
@@ -385,22 +393,43 @@ public class InputPanel extends LinearLayout
quoteView.setWallpaperEnabled(enabled);
}
public void enterEditMessageMode(@NonNull GlideRequests glideRequests, @NonNull ConversationMessage messageToEdit, boolean fromDraft) {
SpannableString textToEdit = messageToEdit.getDisplayBody(getContext());
public void enterEditMessageMode(@NonNull GlideRequests glideRequests, @NonNull ConversationMessage conversationMessageToEdit, boolean fromDraft) {
SpannableString textToEdit = conversationMessageToEdit.getDisplayBody(getContext());
if (!fromDraft) {
composeText.setText(textToEdit);
composeText.setSelection(textToEdit.length());
}
Quote quote = MessageRecordUtil.getQuote(messageToEdit.getMessageRecord());
Quote quote = MessageRecordUtil.getQuote(conversationMessageToEdit.getMessageRecord());
if (quote == null) {
clearQuote();
} else {
setQuote(glideRequests, quote.getId(), Recipient.resolved(quote.getAuthor()), quote.getDisplayText(), quote.getAttachment(), quote.getQuoteType());
}
this.messageToEdit = messageToEdit.getMessageRecord();
this.messageToEdit = conversationMessageToEdit.getMessageRecord();
updateEditModeThumbnail(glideRequests);
updateEditModeUi();
}
private void updateEditModeThumbnail(@NonNull GlideRequests glideRequests) {
if (messageToEdit instanceof MediaMmsMessageRecord) {
MediaMmsMessageRecord mediaEditMessage = (MediaMmsMessageRecord) messageToEdit;
SlideDeck slideDeck = mediaEditMessage.getSlideDeck();
Slide imageVideoSlide = slideDeck.getSlides().stream().filter(s -> s.hasImage() || s.hasVideo() || s.hasSticker()).findFirst().orElse(null);
if (imageVideoSlide != null && imageVideoSlide.getUri() != null) {
editMessageThumbnail.setVisibility(VISIBLE);
glideRequests.load(new DecryptableStreamUriLoader.DecryptableUri(imageVideoSlide.getUri()))
.centerCrop()
.diskCacheStrategy(DiskCacheStrategy.RESOURCE)
.into(editMessageThumbnail);
} else {
editMessageThumbnail.setVisibility(View.GONE);
}
} else {
editMessageThumbnail.setVisibility(View.GONE);
}
}
public void exitEditMessageMode() {
if (messageToEdit != null) {
composeText.setText("");
@@ -413,13 +442,13 @@ public class InputPanel extends LinearLayout
private void updateEditModeUi() {
if (inEditMessageMode()) {
ViewUtil.focusAndShowKeyboard(composeText);
editMessageLabel.setVisibility(View.VISIBLE);
editMessageHeader.setVisibility(View.VISIBLE);
editMessageCancel.setVisibility(View.VISIBLE);
if (listener != null) {
listener.onEnterEditMode();
}
} else {
editMessageLabel.setVisibility(View.GONE);
editMessageHeader.setVisibility(View.GONE);
editMessageCancel.setVisibility(View.GONE);
if (listener != null) {
listener.onExitEditMode();
@@ -1,89 +0,0 @@
package org.thoughtcrime.securesms.components;
import android.content.Context;
import android.util.AttributeSet;
import android.view.WindowInsets;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.constraintlayout.widget.ConstraintLayout;
import androidx.constraintlayout.widget.Guideline;
import androidx.core.graphics.Insets;
import androidx.core.view.WindowInsetsCompat;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.util.ViewUtil;
public class InsetAwareConstraintLayout extends ConstraintLayout {
private WindowInsetsTypeProvider windowInsetsTypeProvider = WindowInsetsTypeProvider.ALL;
private Insets insets;
public InsetAwareConstraintLayout(@NonNull Context context) {
super(context);
}
public InsetAwareConstraintLayout(@NonNull Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
}
public InsetAwareConstraintLayout(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
public void setWindowInsetsTypeProvider(@NonNull WindowInsetsTypeProvider windowInsetsTypeProvider) {
this.windowInsetsTypeProvider = windowInsetsTypeProvider;
requestLayout();
}
@Override
public WindowInsets onApplyWindowInsets(WindowInsets insets) {
WindowInsetsCompat windowInsetsCompat = WindowInsetsCompat.toWindowInsetsCompat(insets);
Insets newInsets = windowInsetsCompat.getInsets(windowInsetsTypeProvider.getInsetsType());
applyInsets(newInsets);
return super.onApplyWindowInsets(insets);
}
public void applyInsets(@NonNull Insets insets) {
Guideline statusBarGuideline = findViewById(R.id.status_bar_guideline);
Guideline navigationBarGuideline = findViewById(R.id.navigation_bar_guideline);
Guideline parentStartGuideline = findViewById(R.id.parent_start_guideline);
Guideline parentEndGuideline = findViewById(R.id.parent_end_guideline);
if (statusBarGuideline != null) {
statusBarGuideline.setGuidelineBegin(insets.top);
}
if (navigationBarGuideline != null) {
navigationBarGuideline.setGuidelineEnd(insets.bottom);
}
if (parentStartGuideline != null) {
if (ViewUtil.isLtr(this)) {
parentStartGuideline.setGuidelineBegin(insets.left);
} else {
parentStartGuideline.setGuidelineBegin(insets.right);
}
}
if (parentEndGuideline != null) {
if (ViewUtil.isLtr(this)) {
parentEndGuideline.setGuidelineEnd(insets.right);
} else {
parentEndGuideline.setGuidelineEnd(insets.left);
}
}
}
public interface WindowInsetsTypeProvider {
WindowInsetsTypeProvider ALL = () ->
WindowInsetsCompat.Type.ime() |
WindowInsetsCompat.Type.systemBars() |
WindowInsetsCompat.Type.displayCutout();
@WindowInsetsCompat.Type.InsetsType
int getInsetsType();
}
}
@@ -0,0 +1,219 @@
package org.thoughtcrime.securesms.components
import android.content.Context
import android.os.Build
import android.util.AttributeSet
import android.util.DisplayMetrics
import android.view.Surface
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.constraintlayout.widget.Guideline
import androidx.core.content.withStyledAttributes
import androidx.core.graphics.Insets
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsAnimationCompat
import androidx.core.view.WindowInsetsCompat
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.ServiceUtil
import org.thoughtcrime.securesms.util.ViewUtil
/**
* A specialized [ConstraintLayout] that sets guidelines based on the window insets provided
* by the system. For improved backwards-compatibility we must use [ViewCompat] for configuring
* the inset change callbacks.
*
* In portrait mode these are how the guidelines will be configured:
*
* - [R.id.status_bar_guideline] is set to the bottom of the status bar
* - [R.id.navigation_bar_guideline] is set to the top of the navigation bar
* - [R.id.parent_start_guideline] is set to the start of the parent
* - [R.id.parent_end_guideline] is set to the end of the parent
* - [R.id.keyboard_guideline] will be set to the top of the keyboard and will
* change as the keyboard is shown or hidden
*
* In landscape, the spirit of the guidelines are maintained but their names may not
* correlated exactly to the inset they are providing.
*
* These guidelines will only be updated if present in your layout, you can use
* `<include layout="@layout/system_ui_guidelines" />` to quickly include them.
*/
@Suppress("LeakingThis")
open class InsetAwareConstraintLayout @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : ConstraintLayout(context, attrs, defStyleAttr) {
companion object {
private val keyboardType = WindowInsetsCompat.Type.ime()
private val windowTypes = WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout()
}
private val statusBarGuideline: Guideline? by lazy { findViewById(R.id.status_bar_guideline) }
private val navigationBarGuideline: Guideline? by lazy { findViewById(R.id.navigation_bar_guideline) }
private val parentStartGuideline: Guideline? by lazy { findViewById(R.id.parent_start_guideline) }
private val parentEndGuideline: Guideline? by lazy { findViewById(R.id.parent_end_guideline) }
private val keyboardGuideline: Guideline? by lazy { findViewById(R.id.keyboard_guideline) }
private val keyboardAnimator = KeyboardInsetAnimator()
private val displayMetrics = DisplayMetrics()
private var overridingKeyboard: Boolean = false
init {
ViewCompat.setOnApplyWindowInsetsListener(this) { _, windowInsetsCompat ->
applyInsets(windowInsets = windowInsetsCompat.getInsets(windowTypes), keyboardInsets = windowInsetsCompat.getInsets(keyboardType))
windowInsetsCompat
}
if (attrs != null) {
context.withStyledAttributes(attrs, R.styleable.InsetAwareConstraintLayout) {
if (getBoolean(R.styleable.InsetAwareConstraintLayout_animateKeyboardChanges, false)) {
ViewCompat.setWindowInsetsAnimationCallback(this@InsetAwareConstraintLayout, keyboardAnimator)
}
}
}
}
private fun applyInsets(windowInsets: Insets, keyboardInsets: Insets) {
val isLtr = ViewUtil.isLtr(this)
statusBarGuideline?.setGuidelineBegin(windowInsets.top)
navigationBarGuideline?.setGuidelineEnd(windowInsets.bottom)
parentStartGuideline?.setGuidelineBegin(if (isLtr) windowInsets.left else windowInsets.right)
parentEndGuideline?.setGuidelineEnd(if (isLtr) windowInsets.right else windowInsets.left)
if (keyboardInsets.bottom > 0) {
setKeyboardHeight(keyboardInsets.bottom)
if (!keyboardAnimator.animating) {
keyboardGuideline?.setGuidelineEnd(keyboardInsets.bottom)
} else {
keyboardAnimator.endingGuidelineEnd = keyboardInsets.bottom
}
} else if (!overridingKeyboard) {
if (!keyboardAnimator.animating) {
keyboardGuideline?.setGuidelineEnd(windowInsets.bottom)
} else {
keyboardAnimator.endingGuidelineEnd = windowInsets.bottom
}
}
}
protected fun overrideKeyboardGuidelineWithPreviousHeight() {
overridingKeyboard = true
keyboardGuideline?.setGuidelineEnd(getKeyboardHeight())
}
protected fun clearKeyboardGuidelineOverride() {
overridingKeyboard = false
}
protected fun resetKeyboardGuideline() {
clearKeyboardGuidelineOverride()
keyboardGuideline?.setGuidelineEnd(navigationBarGuideline.guidelineEnd)
}
private fun getKeyboardHeight(): Int {
val height = if (isLandscape()) {
SignalStore.misc().keyboardLandscapeHeight
} else {
SignalStore.misc().keyboardPortraitHeight
}
return if (height <= 0) {
resources.getDimensionPixelSize(R.dimen.default_custom_keyboard_size)
} else {
height
}
}
private fun setKeyboardHeight(height: Int) {
if (isLandscape()) {
SignalStore.misc().keyboardLandscapeHeight = height
} else {
SignalStore.misc().keyboardPortraitHeight = height
}
}
private fun isLandscape(): Boolean {
val rotation = getDeviceRotation()
return rotation == Surface.ROTATION_90
}
@Suppress("DEPRECATION")
private fun getDeviceRotation(): Int {
if (isInEditMode) {
return Surface.ROTATION_0
}
if (Build.VERSION.SDK_INT >= 30) {
context.display?.getRealMetrics(displayMetrics)
} else {
ServiceUtil.getWindowManager(context).defaultDisplay.getRealMetrics(displayMetrics)
}
return if (displayMetrics.widthPixels > displayMetrics.heightPixels) Surface.ROTATION_90 else Surface.ROTATION_0
}
private val Guideline?.guidelineEnd: Int
get() = if (this == null) 0 else (layoutParams as LayoutParams).guideEnd
/**
* Adjusts the [keyboardGuideline] to move with the IME keyboard opening or closing.
*/
private inner class KeyboardInsetAnimator : WindowInsetsAnimationCompat.Callback(DISPATCH_MODE_STOP) {
var animating = false
private set
private var startingGuidelineEnd: Int = 0
var endingGuidelineEnd: Int = 0
set(value) {
field = value
growing = value > startingGuidelineEnd
}
private var growing: Boolean = false
override fun onPrepare(animation: WindowInsetsAnimationCompat) {
if (overridingKeyboard) {
return
}
animating = true
startingGuidelineEnd = keyboardGuideline.guidelineEnd
}
override fun onProgress(insets: WindowInsetsCompat, runningAnimations: MutableList<WindowInsetsAnimationCompat>): WindowInsetsCompat {
if (overridingKeyboard) {
return insets
}
val imeAnimation = runningAnimations.find { it.typeMask and WindowInsetsCompat.Type.ime() != 0 }
if (imeAnimation == null) {
return insets
}
val estimatedKeyboardHeight: Int = if (growing) {
endingGuidelineEnd * imeAnimation.interpolatedFraction
} else {
startingGuidelineEnd * (1f - imeAnimation.interpolatedFraction)
}.toInt()
if (growing) {
keyboardGuideline?.setGuidelineEnd(estimatedKeyboardHeight.coerceAtLeast(startingGuidelineEnd))
} else {
keyboardGuideline?.setGuidelineEnd(estimatedKeyboardHeight.coerceAtLeast(endingGuidelineEnd))
}
return insets
}
override fun onEnd(animation: WindowInsetsAnimationCompat) {
if (overridingKeyboard) {
return
}
keyboardGuideline?.setGuidelineEnd(endingGuidelineEnd)
animating = false
}
}
}
@@ -5,7 +5,6 @@ import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.Canvas;
import android.os.Build;
import android.text.Spannable;
import android.text.TextUtils;
import android.util.AttributeSet;
import android.view.View;
@@ -31,7 +30,6 @@ import org.thoughtcrime.securesms.components.emoji.EmojiImageView;
import org.thoughtcrime.securesms.components.emoji.EmojiTextView;
import org.thoughtcrime.securesms.components.mention.MentionAnnotation;
import org.thoughtcrime.securesms.components.quotes.QuoteViewColorTheme;
import org.thoughtcrime.securesms.components.spoiler.SpoilerAnnotation;
import org.thoughtcrime.securesms.conversation.MessageStyler;
import org.thoughtcrime.securesms.database.model.Mention;
import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList;
@@ -166,7 +164,6 @@ public class QuoteView extends FrameLayout implements RecipientForeverObserver {
setMessageType(messageType);
bodyView.enableSpoilerFiltering();
dismissView.setOnClickListener(view -> setVisibility(GONE));
}
@@ -1,6 +1,7 @@
package org.thoughtcrime.securesms.components
import android.view.View
import androidx.annotation.AnyThread
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
@@ -36,7 +37,6 @@ class ScrollToPositionDelegate private constructor(
private val EMPTY = ScrollToPositionRequest(
position = NO_POSITION,
smooth = true,
awaitLayout = true,
scrollStrategy = DefaultScrollStrategy
)
}
@@ -57,14 +57,8 @@ class ScrollToPositionDelegate private constructor(
.filter { it.position >= 0 && canJumpToPosition(it.position) }
.map { it.copy(position = mapToTruePosition(it.position)) }
.subscribeBy(onNext = { position ->
if (position.awaitLayout) {
recyclerView.doAfterNextLayout {
handleScrollPositionRequest(position, recyclerView)
}
} else {
recyclerView.post {
handleScrollPositionRequest(position, recyclerView)
}
recyclerView.doAfterNextLayout {
handleScrollPositionRequest(position, recyclerView)
}
if (!(recyclerView.isLayoutRequested || recyclerView.isInLayout)) {
@@ -78,21 +72,21 @@ class ScrollToPositionDelegate private constructor(
*
* @param position The desired position to jump to. -1 to clear the current request.
* @param smooth Whether a smooth scroll will be attempted. Only done if we are within a certain distance.
* @param awaitLayout Whether this scroll should await for the next layout to complete before being attempted.
* @param scrollStrategy See [ScrollStrategy]
*/
@AnyThread
fun requestScrollPosition(
position: Int,
smooth: Boolean = true,
awaitLayout: Boolean = true,
scrollStrategy: ScrollStrategy = DefaultScrollStrategy
) {
scrollPositionRequested.onNext(ScrollToPositionRequest(position, smooth, awaitLayout, scrollStrategy))
scrollPositionRequested.onNext(ScrollToPositionRequest(position, smooth, scrollStrategy))
}
/**
* Reset the scroll position to 0
*/
@AnyThread
fun resetScrollPosition() {
requestScrollPosition(0, true)
}
@@ -100,6 +94,7 @@ class ScrollToPositionDelegate private constructor(
/**
* This should be called every time a list is submitted to the RecyclerView's adapter.
*/
@AnyThread
fun notifyListCommitted() {
listCommitted.onNext(Unit)
}
@@ -135,7 +130,6 @@ class ScrollToPositionDelegate private constructor(
private data class ScrollToPositionRequest(
val position: Int,
val smooth: Boolean,
val awaitLayout: Boolean,
val scrollStrategy: ScrollStrategy
)
@@ -216,7 +216,7 @@ class SendButton(context: Context, attributeSet: AttributeSet?) : AppCompatImage
.show(items)
}
interface SendTypeChangedListener {
fun interface SendTypeChangedListener {
fun onSendTypeChanged(newType: MessageSendType, manuallySelected: Boolean)
}
@@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.components.emoji;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.Paint.FontMetricsInt;
import android.graphics.drawable.Drawable;
@@ -55,6 +56,10 @@ public class EmojiSpan extends AnimatingImageSpan {
@Override
public void draw(@NonNull Canvas canvas, CharSequence text, int start, int end, float x, int top, int y, int bottom, @NonNull Paint paint) {
if (paint.getColor() == Color.TRANSPARENT) {
return;
}
int height = bottom - top;
int centeringMargin = (height - size) / 2;
int adjustedMargin = (int) (centeringMargin * SHIFT_FACTOR);
@@ -68,11 +68,9 @@ public class EmojiTextView extends AppCompatTextView {
private TextDirectionHeuristic textDirection;
private boolean isJumbomoji;
private boolean forceJumboEmoji;
private boolean isInOnDraw;
private MentionRendererDelegate mentionRendererDelegate;
private final SpoilerRendererDelegate spoilerRendererDelegate;
private SpoilerFilteringSpannableFactory spoilerFilteringSpannableFactory;
private MentionRendererDelegate mentionRendererDelegate;
private final SpoilerRendererDelegate spoilerRendererDelegate;
public EmojiTextView(Context context) {
this(context, null);
@@ -108,15 +106,13 @@ public class EmojiTextView extends AppCompatTextView {
setEmojiCompatEnabled(useSystemEmoji());
}
public void enableSpoilerFiltering() {
spoilerFilteringSpannableFactory = new SpoilerFilteringSpannableFactory(() -> isInOnDraw);
setSpannableFactory(spoilerFilteringSpannableFactory);
public void setMaxLength(int maxLength) {
this.maxLength = maxLength;
setText(getText());
}
@Override
protected void onDraw(Canvas canvas) {
isInOnDraw = true;
boolean hasSpannedText = getText() instanceof Spanned;
boolean hasLayout = getLayout() != null;
@@ -129,8 +125,6 @@ public class EmojiTextView extends AppCompatTextView {
if (hasSpannedText && !hasLayout && getLayout() != null) {
drawSpecialRenderers(canvas, null, spoilerRendererDelegate);
}
isInOnDraw = false;
}
private void drawSpecialRenderers(@NonNull Canvas canvas, @Nullable MentionRendererDelegate mentionDelegate, @NonNull SpoilerRendererDelegate spoilerDelegate) {
@@ -182,9 +176,6 @@ public class EmojiTextView extends AppCompatTextView {
textToSet = new SpannableStringBuilder(EmojiProvider.emojify(candidates, text, this, isJumbomoji || forceJumboEmoji));
}
if (spoilerFilteringSpannableFactory != null) {
textToSet = spoilerFilteringSpannableFactory.wrap(textToSet);
}
super.setText(textToSet, BufferType.SPANNABLE);
// Android fails to ellipsize spannable strings. (https://issuetracker.google.com/issues/36991688)
@@ -315,23 +306,7 @@ public class EmojiTextView extends AppCompatTextView {
if (maxLength > 0 && getText().length() > maxLength + 1) {
SpannableStringBuilder newContent = new SpannableStringBuilder();
SpannableString shortenedText = new SpannableString(getText().subSequence(0, maxLength));
List<Annotation> mentionAnnotations = MentionAnnotation.getMentionAnnotations(shortenedText, maxLength - 1, maxLength);
if (!mentionAnnotations.isEmpty()) {
shortenedText = new SpannableString(shortenedText.subSequence(0, shortenedText.getSpanStart(mentionAnnotations.get(0))));
}
Object[] endSpans = shortenedText.getSpans(shortenedText.length() - 1, shortenedText.length(), Object.class);
for (Object span : endSpans) {
if (shortenedText.getSpanFlags(span) == Spanned.SPAN_EXCLUSIVE_INCLUSIVE) {
int start = shortenedText.getSpanStart(span);
int end = shortenedText.getSpanEnd(span);
shortenedText.removeSpan(span);
shortenedText.setSpan(span, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
}
}
newContent.append(shortenedText)
newContent.append(getText(maxLength))
.append(ELLIPSIS)
.append(Util.emptyIfNull(overflowText));
@@ -344,11 +319,7 @@ public class EmojiTextView extends AppCompatTextView {
newTextToSet = EmojiProvider.emojify(newCandidates, newContent, this, isJumbomoji || forceJumboEmoji);
}
if (spoilerFilteringSpannableFactory != null) {
spoilerFilteringSpannableFactory.wrap(newTextToSet);
}
super.setText(newContent, BufferType.SPANNABLE);
super.setText(newTextToSet, BufferType.SPANNABLE);
}
}
@@ -374,9 +345,14 @@ public class EmojiTextView extends AppCompatTextView {
CharSequence ellipsized = StringUtil.trim(TextUtils.ellipsize(overflow, getPaint(), getWidth() - adjust, TextUtils.TruncateAt.END));
SpannableStringBuilder newContent = new SpannableStringBuilder();
newContent.append(getText().subSequence(0, overflowStart))
.append(ellipsized.subSequence(0, ellipsized.length()))
.append(Optional.ofNullable(overflowText).orElse(""));
newContent.append(getText().subSequence(0, overflowStart).toString())
.append(ellipsized.subSequence(0, ellipsized.length()).toString());
if (newContent.length() > 0) {
TextUtils.copySpansFrom(getText(newContent.length() - 1), 0, newContent.length() - 1, Object.class, newContent, 0);
}
newContent.append(Optional.ofNullable(overflowText).orElse(""));
EmojiParser.CandidateList newCandidates = isInEditMode() ? null : EmojiProvider.getCandidates(newContent);
@@ -401,6 +377,27 @@ public class EmojiTextView extends AppCompatTextView {
}
}
/** Get text but truncated to maxLength, adjusts for end mentions and converts style spans to be exclusive on start and end. */
private SpannableString getText(int maxLength) {
SpannableString shortenedText = new SpannableString(getText().subSequence(0, maxLength));
List<Annotation> mentionAnnotations = MentionAnnotation.getMentionAnnotations(shortenedText, maxLength - 1, maxLength);
if (!mentionAnnotations.isEmpty()) {
shortenedText = new SpannableString(shortenedText.subSequence(0, shortenedText.getSpanStart(mentionAnnotations.get(0))));
}
Object[] endSpans = shortenedText.getSpans(shortenedText.length() - 1, shortenedText.length(), Object.class);
for (Object span : endSpans) {
if (shortenedText.getSpanFlags(span) == Spanned.SPAN_EXCLUSIVE_INCLUSIVE) {
int start = shortenedText.getSpanStart(span);
int end = shortenedText.getSpanEnd(span);
shortenedText.removeSpan(span);
shortenedText.setSpan(span, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
}
}
return shortenedText;
}
private boolean unchanged(CharSequence text, CharSequence overflowText, BufferType bufferType) {
return Util.equals(previousText, text) &&
Util.equals(previousOverflowText, overflowText) &&
@@ -2,7 +2,6 @@ package org.thoughtcrime.securesms.components.emoji
import android.content.Context
import android.graphics.Canvas
import android.text.Spannable
import android.text.Spanned
import android.text.TextUtils
import android.util.AttributeSet
@@ -21,8 +20,6 @@ open class SimpleEmojiTextView @JvmOverloads constructor(
private var bufferType: BufferType? = null
private val sizeChangeDebouncer: ThrottledDebouncer = ThrottledDebouncer(200)
private val spoilerRendererDelegate: SpoilerRendererDelegate
private var spoilerFilteringSpannableFactory: SpoilerFilteringSpannableFactory? = null
private var isInOnDraw: Boolean = false
init {
isEmojiCompatEnabled = isInEditMode || SignalStore.settings().isPreferSystemEmoji
@@ -30,8 +27,6 @@ open class SimpleEmojiTextView @JvmOverloads constructor(
}
override fun onDraw(canvas: Canvas) {
isInOnDraw = true
if (text is Spanned && layout != null) {
val checkpoint = canvas.save()
canvas.translate(totalPaddingLeft.toFloat(), totalPaddingTop.toFloat())
@@ -41,9 +36,8 @@ open class SimpleEmojiTextView @JvmOverloads constructor(
canvas.restoreToCount(checkpoint)
}
}
super.onDraw(canvas)
isInOnDraw = false
super.onDraw(canvas)
}
override fun setText(text: CharSequence?, type: BufferType?) {
@@ -69,10 +63,6 @@ open class SimpleEmojiTextView @JvmOverloads constructor(
TextUtils.ellipsize(newText, paint, (adjustedWidth * maxLines).toFloat(), TextUtils.TruncateAt.END, false, null)
}
if (newContent is Spannable && spoilerFilteringSpannableFactory != null) {
newContent = spoilerFilteringSpannableFactory!!.wrap(newContent)
}
bufferType = BufferType.SPANNABLE
super.setText(newContent, type)
}
@@ -86,9 +76,4 @@ open class SimpleEmojiTextView @JvmOverloads constructor(
}
}
}
fun enableSpoilerFiltering() {
spoilerFilteringSpannableFactory = SpoilerFilteringSpannableFactory { isInOnDraw }
setSpannableFactory(spoilerFilteringSpannableFactory!!)
}
}
@@ -1,21 +0,0 @@
package org.thoughtcrime.securesms.components.emoji
import android.text.Spannable
import org.thoughtcrime.securesms.util.SpoilerFilteringSpannable
import org.thoughtcrime.securesms.util.SpoilerFilteringSpannable.InOnDrawProvider
/**
* Spannable factory used to help ensure spans are copied/maintained properly through the
* Android text handling system.
*
* @param inOnDraw Used by [SpoilerFilteringSpannable] to remove spans when being called from onDraw
*/
class SpoilerFilteringSpannableFactory(private val inOnDraw: InOnDrawProvider) : Spannable.Factory() {
override fun newSpannable(source: CharSequence): Spannable {
return wrap(super.newSpannable(source))
}
fun wrap(source: Spannable): SpoilerFilteringSpannable {
return SpoilerFilteringSpannable(source, inOnDraw)
}
}
@@ -13,7 +13,7 @@ import com.google.zxing.common.BitMatrix;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.SquareImageView;
import org.thoughtcrime.securesms.qr.QrCode;
import org.thoughtcrime.securesms.qr.QrCodeUtil;
/**
* Generates a bitmap asynchronously for the supplied {@link BitMatrix} data and displays it.
@@ -59,7 +59,7 @@ public class QrView extends SquareImageView {
}
public void setQrText(@Nullable String text) {
setQrBitmap(QrCode.create(text, foregroundColor, backgroundColor));
setQrBitmap(QrCodeUtil.create(text, foregroundColor, backgroundColor));
}
private void setQrBitmap(@Nullable Bitmap qrBitmap) {
@@ -2,6 +2,8 @@ package org.thoughtcrime.securesms.components.reminder;
import android.content.Context;
import androidx.annotation.NonNull;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.registration.RegistrationNavigationActivity;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
@@ -9,12 +11,14 @@ import org.thoughtcrime.securesms.util.TextSecurePreferences;
public class UnauthorizedReminder extends Reminder {
public UnauthorizedReminder(final Context context) {
super(context.getString(R.string.UnauthorizedReminder_device_no_longer_registered),
super(null,
context.getString(R.string.UnauthorizedReminder_this_is_likely_because_you_registered_your_phone_number_with_Signal_on_a_different_device));
setOkListener(v -> {
context.startActivity(RegistrationNavigationActivity.newIntentForReRegistration(context));
});
addAction(new Action(context.getString(R.string.UnauthorizedReminder_reregister_action), R.id.reminder_action_re_register));
}
@Override
@@ -22,6 +26,11 @@ public class UnauthorizedReminder extends Reminder {
return false;
}
@Override
public @NonNull Importance getImportance() {
return Importance.ERROR;
}
public static boolean isEligible(Context context) {
return TextSecurePreferences.isUnauthorizedReceived(context);
}
@@ -64,6 +64,7 @@ abstract class PreferenceViewHolder<T : PreferenceModel<T>>(itemView: View) : Ma
val icon = model.icon?.resolve(context)
iconView.setImageDrawable(icon)
iconView.visible = icon != null
iconView.alpha = if (model.isEnabled) 1f else 0.5f
val iconEnd = model.iconEnd?.resolve(context)
iconEndView?.setImageDrawable(iconEnd)
@@ -211,22 +212,21 @@ class SwitchPreferenceViewHolder(itemView: View) : PreferenceViewHolder<SwitchPr
switchWidget.setOnCheckedChangeListener(null)
switchWidget.isChecked = model.isChecked
switchWidget.isEnabled = model.isEnabled
switchWidget.setOnCheckedChangeListener { _, _ ->
model.onClick()
}
itemView.setOnClickListener {
model.onClick()
}
if (payload.contains(SwitchPreference.PAYLOAD_CHECKED)) {
return
}
super.bind(model)
switchWidget.isEnabled = model.isEnabled
itemView.setOnClickListener {
model.onClick()
}
}
}
@@ -1,13 +1,23 @@
package org.thoughtcrime.securesms.components.settings.app
import android.os.Bundle
import android.view.View
import android.widget.TextView
import android.widget.Toast
import androidx.annotation.IdRes
import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController
import org.greenrobot.eventbus.EventBus
import org.greenrobot.eventbus.Subscribe
import org.greenrobot.eventbus.ThreadMode
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.badges.BadgeImageView
import org.thoughtcrime.securesms.components.AvatarImageView
import org.thoughtcrime.securesms.components.emoji.EmojiTextView
import org.thoughtcrime.securesms.components.reminder.ExpiredBuildReminder
import org.thoughtcrime.securesms.components.reminder.Reminder
import org.thoughtcrime.securesms.components.reminder.ReminderView
import org.thoughtcrime.securesms.components.reminder.UnauthorizedReminder
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
import org.thoughtcrime.securesms.components.settings.DSLSettingsIcon
@@ -15,20 +25,37 @@ import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.PreferenceModel
import org.thoughtcrime.securesms.components.settings.PreferenceViewHolder
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.events.ReminderUpdateEvent
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.registration.RegistrationNavigationActivity
import org.thoughtcrime.securesms.util.FeatureFlags
import org.thoughtcrime.securesms.util.PlayStoreUtil
import org.thoughtcrime.securesms.util.Util
import org.thoughtcrime.securesms.util.ViewUtil
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder
import org.thoughtcrime.securesms.util.navigation.safeNavigate
import org.thoughtcrime.securesms.util.views.Stub
class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__menu_settings) {
class AppSettingsFragment : DSLSettingsFragment(
titleId = R.string.text_secure_normal__menu_settings,
layoutId = R.layout.dsl_settings_fragment_with_reminder
) {
private val viewModel: AppSettingsViewModel by viewModels()
private lateinit var reminderView: Stub<ReminderView>
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
reminderView = ViewUtil.findStubById(view, R.id.reminder_stub)
updateReminders()
}
override fun bindAdapter(adapter: MappingAdapter) {
adapter.registerFactory(BioPreference::class.java, LayoutFactory(::BioPreferenceViewHolder, R.layout.bio_preference_item))
adapter.registerFactory(PaymentsPreference::class.java, LayoutFactory(::PaymentsPreferenceViewHolder, R.layout.dsl_payments_preference))
@@ -39,17 +66,78 @@ class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__men
}
}
@Subscribe(threadMode = ThreadMode.MAIN)
fun onEvent(event: ReminderUpdateEvent?) {
updateReminders()
}
private fun updateReminders() {
if (ExpiredBuildReminder.isEligible()) {
showReminder(ExpiredBuildReminder(context))
} else if (UnauthorizedReminder.isEligible(context)) {
showReminder(UnauthorizedReminder(context))
} else {
hideReminders()
}
viewModel.refreshDeprecatedOrUnregistered()
}
private fun showReminder(reminder: Reminder) {
if (!reminderView.resolved()) {
reminderView.get().addOnLayoutChangeListener { _, _, top, _, bottom, _, _, _, _ ->
recyclerView?.setPadding(0, bottom - top, 0, 0)
}
recyclerView?.clipToPadding = false
}
reminderView.get().showReminder(reminder)
reminderView.get().setOnActionClickListener { reminderActionId: Int -> this.handleReminderAction(reminderActionId) }
}
private fun hideReminders() {
if (reminderView.resolved()) {
reminderView.get().hide()
recyclerView?.clipToPadding = true
}
}
private fun handleReminderAction(@IdRes reminderActionId: Int) {
when (reminderActionId) {
R.id.reminder_action_update_now -> {
PlayStoreUtil.openPlayStoreOrOurApkDownloadPage(requireContext())
}
R.id.reminder_action_re_register -> {
startActivity(RegistrationNavigationActivity.newIntentForReRegistration(requireContext()))
}
}
}
override fun onResume() {
super.onResume()
viewModel.refreshExpiredGiftBadge()
EventBus.getDefault().register(this)
}
override fun onPause() {
super.onPause()
EventBus.getDefault().unregister(this)
}
private fun getConfiguration(state: AppSettingsState): DSLConfiguration {
return configure {
customPref(
BioPreference(state.self) {
findNavController().safeNavigate(R.id.action_appSettingsFragment_to_manageProfileActivity)
}
BioPreference(
recipient = state.self,
onRowClicked = {
findNavController().safeNavigate(R.id.action_appSettingsFragment_to_manageProfileActivity)
},
onQrButtonClicked = {
if (Recipient.self().getUsername().isPresent()) {
findNavController().safeNavigate(R.id.action_appSettingsFragment_to_usernameLinkSettingsFragment)
} else {
findNavController().safeNavigate(R.id.action_appSettingsFragment_to_usernameEducationFragment)
}
}
)
)
clickPref(
@@ -65,7 +153,8 @@ class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__men
icon = DSLSettingsIcon.from(R.drawable.symbol_devices_24),
onClick = {
findNavController().safeNavigate(R.id.action_appSettingsFragment_to_deviceActivity)
}
},
isEnabled = state.isDeprecatedOrUnregistered()
)
if (state.allowUserToGoToDonationManagementScreen) {
@@ -101,7 +190,8 @@ class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__men
icon = DSLSettingsIcon.from(R.drawable.symbol_chat_24),
onClick = {
findNavController().safeNavigate(R.id.action_appSettingsFragment_to_chatsSettingsFragment)
}
},
isEnabled = state.isDeprecatedOrUnregistered()
)
clickPref(
@@ -109,7 +199,8 @@ class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__men
icon = DSLSettingsIcon.from(R.drawable.symbol_stories_24),
onClick = {
findNavController().safeNavigate(AppSettingsFragmentDirections.actionAppSettingsFragmentToStoryPrivacySettings(R.string.preferences__stories))
}
},
isEnabled = state.isDeprecatedOrUnregistered()
)
clickPref(
@@ -117,7 +208,8 @@ class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__men
icon = DSLSettingsIcon.from(R.drawable.symbol_bell_24),
onClick = {
findNavController().safeNavigate(R.id.action_appSettingsFragment_to_notificationsSettingsFragment)
}
},
isEnabled = state.isDeprecatedOrUnregistered()
)
clickPref(
@@ -125,7 +217,8 @@ class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__men
icon = DSLSettingsIcon.from(R.drawable.symbol_lock_24),
onClick = {
findNavController().safeNavigate(R.id.action_appSettingsFragment_to_privacySettingsFragment)
}
},
isEnabled = state.isDeprecatedOrUnregistered()
)
clickPref(
@@ -216,7 +309,7 @@ class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__men
}
}
private class BioPreference(val recipient: Recipient, val onClick: () -> Unit) : PreferenceModel<BioPreference>() {
private class BioPreference(val recipient: Recipient, val onRowClicked: () -> Unit, val onQrButtonClicked: () -> Unit) : PreferenceModel<BioPreference>() {
override fun areContentsTheSame(newItem: BioPreference): Boolean {
return super.areContentsTheSame(newItem) && recipient.hasSameContent(newItem.recipient)
}
@@ -229,13 +322,18 @@ class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__men
private class BioPreferenceViewHolder(itemView: View) : PreferenceViewHolder<BioPreference>(itemView) {
private val avatarView: AvatarImageView = itemView.findViewById(R.id.icon)
private val aboutView: TextView = itemView.findViewById(R.id.about)
private val aboutView: EmojiTextView = itemView.findViewById(R.id.about)
private val badgeView: BadgeImageView = itemView.findViewById(R.id.badge)
private val qrButton: View = itemView.findViewById(R.id.qr_button)
init {
aboutView.setOverflowText(" ")
}
override fun bind(model: BioPreference) {
super.bind(model)
itemView.setOnClickListener { model.onClick() }
itemView.setOnClickListener { model.onRowClicked() }
titleView.text = model.recipient.profileName.toString()
summaryView.text = PhoneNumberFormatter.prettyPrint(model.recipient.requireE164())
@@ -246,6 +344,14 @@ class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__men
summaryView.visibility = View.VISIBLE
avatarView.visibility = View.VISIBLE
if (FeatureFlags.usernames()) {
qrButton.visibility = View.VISIBLE
qrButton.isClickable = true
qrButton.setOnClickListener { model.onQrButtonClicked() }
} else {
qrButton.visibility = View.GONE
}
if (model.recipient.combinedAboutAndEmoji != null) {
aboutView.text = model.recipient.combinedAboutAndEmoji
aboutView.visibility = View.VISIBLE
@@ -6,5 +6,11 @@ data class AppSettingsState(
val self: Recipient,
val unreadPaymentsCount: Int,
val hasExpiredGiftBadge: Boolean,
val allowUserToGoToDonationManagementScreen: Boolean
)
val allowUserToGoToDonationManagementScreen: Boolean,
val userUnregistered: Boolean,
val clientDeprecated: Boolean
) {
fun isDeprecatedOrUnregistered(): Boolean {
return !(userUnregistered || clientDeprecated)
}
}
@@ -11,6 +11,7 @@ import org.thoughtcrime.securesms.conversationlist.model.UnreadPaymentsLiveData
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.TextSecurePreferences
import org.thoughtcrime.securesms.util.livedata.Store
class AppSettingsViewModel(
@@ -22,7 +23,9 @@ class AppSettingsViewModel(
Recipient.self(),
0,
SignalStore.donationsValues().getExpiredGiftBadge() != null,
SignalStore.donationsValues().isLikelyASustainer() || InAppDonations.hasAtLeastOnePaymentMethodAvailable()
SignalStore.donationsValues().isLikelyASustainer() || InAppDonations.hasAtLeastOnePaymentMethodAvailable(),
TextSecurePreferences.isUnauthorizedReceived(ApplicationDependencies.getApplication()),
SignalStore.misc().isClientDeprecated
)
)
@@ -50,6 +53,10 @@ class AppSettingsViewModel(
disposables.clear()
}
fun refreshDeprecatedOrUnregistered() {
store.update { it.copy(clientDeprecated = SignalStore.misc().isClientDeprecated, userUnregistered = TextSecurePreferences.isUnauthorizedReceived(ApplicationDependencies.getApplication())) }
}
fun refreshExpiredGiftBadge() {
store.update { it.copy(hasExpiredGiftBadge = SignalStore.donationsValues().getExpiredGiftBadge() != null) }
}
@@ -1,6 +1,7 @@
package org.thoughtcrime.securesms.components.settings.app.account
import android.content.Context
import android.content.DialogInterface
import android.content.Intent
import android.graphics.Typeface
import android.text.InputType
@@ -9,6 +10,7 @@ import android.view.ViewGroup
import android.widget.Button
import android.widget.EditText
import android.widget.TextView
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.autofill.HintConstants
import androidx.core.app.DialogCompat
@@ -24,15 +26,19 @@ import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.contactshare.SimpleTextWatcher
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.lock.PinHashing
import org.thoughtcrime.securesms.lock.v2.CreateKbsPinActivity
import org.thoughtcrime.securesms.lock.v2.KbsConstants
import org.thoughtcrime.securesms.lock.v2.PinKeyboardType
import org.thoughtcrime.securesms.pin.RegistrationLockV2Dialog
import org.thoughtcrime.securesms.registration.RegistrationNavigationActivity
import org.thoughtcrime.securesms.util.PlayStoreUtil
import org.thoughtcrime.securesms.util.ServiceUtil
import org.thoughtcrime.securesms.util.ViewUtil
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import org.thoughtcrime.securesms.util.navigation.safeNavigate
import org.whispersystems.signalservice.api.kbs.PinHashUtil
class AccountSettingsFragment : DSLSettingsFragment(R.string.AccountSettingsFragment__account) {
@@ -64,6 +70,7 @@ class AccountSettingsFragment : DSLSettingsFragment(R.string.AccountSettingsFrag
@Suppress("DEPRECATION")
clickPref(
title = DSLSettingsText.from(if (state.hasPin) R.string.preferences_app_protection__change_your_pin else R.string.preferences_app_protection__create_a_pin),
isEnabled = state.isDeprecatedOrUnregistered(),
onClick = {
if (state.hasPin) {
startActivityForResult(CreateKbsPinActivity.getIntentForPinChangeFromSettings(requireContext()), CreateKbsPinActivity.REQUEST_NEW_PIN)
@@ -77,7 +84,7 @@ class AccountSettingsFragment : DSLSettingsFragment(R.string.AccountSettingsFrag
title = DSLSettingsText.from(R.string.preferences_app_protection__pin_reminders),
summary = DSLSettingsText.from(R.string.AccountSettingsFragment__youll_be_asked_less_frequently),
isChecked = state.hasPin && state.pinRemindersEnabled,
isEnabled = state.hasPin,
isEnabled = state.hasPin && state.isDeprecatedOrUnregistered(),
onClick = {
setPinRemindersEnabled(!state.pinRemindersEnabled)
}
@@ -87,7 +94,7 @@ class AccountSettingsFragment : DSLSettingsFragment(R.string.AccountSettingsFrag
title = DSLSettingsText.from(R.string.preferences_app_protection__registration_lock),
summary = DSLSettingsText.from(R.string.AccountSettingsFragment__require_your_signal_pin),
isChecked = state.registrationLockEnabled,
isEnabled = state.hasPin,
isEnabled = state.hasPin && state.isDeprecatedOrUnregistered(),
onClick = {
setRegistrationLockEnabled(!state.registrationLockEnabled)
}
@@ -95,6 +102,7 @@ class AccountSettingsFragment : DSLSettingsFragment(R.string.AccountSettingsFrag
clickPref(
title = DSLSettingsText.from(R.string.preferences__advanced_pin_settings),
isEnabled = state.isDeprecatedOrUnregistered(),
onClick = {
Navigation.findNavController(requireView()).safeNavigate(R.id.action_accountSettingsFragment_to_advancedPinSettingsActivity)
}
@@ -107,6 +115,7 @@ class AccountSettingsFragment : DSLSettingsFragment(R.string.AccountSettingsFrag
if (SignalStore.account().isRegistered) {
clickPref(
title = DSLSettingsText.from(R.string.AccountSettingsFragment__change_phone_number),
isEnabled = state.isDeprecatedOrUnregistered(),
onClick = {
Navigation.findNavController(requireView()).safeNavigate(R.id.action_accountSettingsFragment_to_changePhoneNumberFragment)
}
@@ -116,6 +125,7 @@ class AccountSettingsFragment : DSLSettingsFragment(R.string.AccountSettingsFrag
clickPref(
title = DSLSettingsText.from(R.string.preferences_chats__transfer_account),
summary = DSLSettingsText.from(R.string.preferences_chats__transfer_account_to_a_new_android_device),
isEnabled = state.isDeprecatedOrUnregistered(),
onClick = {
Navigation.findNavController(requireView()).safeNavigate(R.id.action_accountSettingsFragment_to_oldDeviceTransferActivity)
}
@@ -123,13 +133,49 @@ class AccountSettingsFragment : DSLSettingsFragment(R.string.AccountSettingsFrag
clickPref(
title = DSLSettingsText.from(R.string.AccountSettingsFragment__request_account_data),
isEnabled = state.isDeprecatedOrUnregistered(),
onClick = {
Navigation.findNavController(requireView()).safeNavigate(R.id.action_accountSettingsFragment_to_exportAccountFragment)
}
)
if (!state.isDeprecatedOrUnregistered()) {
if (state.clientDeprecated) {
clickPref(
title = DSLSettingsText.from(R.string.preferences_account_update_signal),
onClick = {
PlayStoreUtil.openPlayStoreOrOurApkDownloadPage(requireContext())
}
)
} else if (state.userUnregistered) {
clickPref(
title = DSLSettingsText.from(R.string.preferences_account_reregister),
onClick = {
startActivity(RegistrationNavigationActivity.newIntentForReRegistration(requireContext()))
}
)
}
clickPref(
title = DSLSettingsText.from(R.string.preferences_account_delete_all_data, ContextCompat.getColor(requireContext(), R.color.signal_alert_primary)),
onClick = {
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.preferences_account_delete_all_data_confirmation_title)
.setMessage(R.string.preferences_account_delete_all_data_confirmation_message)
.setPositiveButton(R.string.preferences_account_delete_all_data_confirmation_proceed) { _: DialogInterface, _: Int ->
if (!ServiceUtil.getActivityManager(ApplicationDependencies.getApplication()).clearApplicationUserData()) {
Toast.makeText(requireContext(), R.string.preferences_account_delete_all_data_failed, Toast.LENGTH_LONG).show()
}
}
.setNegativeButton(R.string.preferences_account_delete_all_data_confirmation_cancel, null)
.show()
}
)
}
clickPref(
title = DSLSettingsText.from(R.string.preferences__delete_account, ContextCompat.getColor(requireContext(), R.color.signal_alert_primary)),
title = DSLSettingsText.from(R.string.preferences__delete_account, ContextCompat.getColor(requireContext(), if (state.isDeprecatedOrUnregistered()) R.color.signal_alert_primary else R.color.signal_alert_primary_50)),
isEnabled = state.isDeprecatedOrUnregistered(),
onClick = {
Navigation.findNavController(requireView()).safeNavigate(R.id.action_accountSettingsFragment_to_deleteAccountFragment)
}
@@ -201,7 +247,7 @@ class AccountSettingsFragment : DSLSettingsFragment(R.string.AccountSettingsFrag
pinEditText.typeface = Typeface.DEFAULT
turnOffButton.setOnClickListener {
val pin = pinEditText.text.toString()
val correct = PinHashing.verifyLocalPinHash(SignalStore.kbsValues().localPinHash!!, pin)
val correct = PinHashUtil.verifyLocalPinHash(SignalStore.kbsValues().localPinHash!!, pin)
if (correct) {
SignalStore.pinValues().setPinRemindersEnabled(false)
viewModel.refreshState()
@@ -3,5 +3,11 @@ package org.thoughtcrime.securesms.components.settings.app.account
data class AccountSettingsState(
val hasPin: Boolean,
val pinRemindersEnabled: Boolean,
val registrationLockEnabled: Boolean
)
val registrationLockEnabled: Boolean,
val userUnregistered: Boolean,
val clientDeprecated: Boolean
) {
fun isDeprecatedOrUnregistered(): Boolean {
return !(userUnregistered || clientDeprecated)
}
}
@@ -2,7 +2,9 @@ package org.thoughtcrime.securesms.components.settings.app.account
import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.TextSecurePreferences
import org.thoughtcrime.securesms.util.livedata.Store
class AccountSettingsViewModel : ViewModel() {
@@ -18,7 +20,9 @@ class AccountSettingsViewModel : ViewModel() {
return AccountSettingsState(
hasPin = SignalStore.kbsValues().hasPin() && !SignalStore.kbsValues().hasOptedOut(),
pinRemindersEnabled = SignalStore.pinValues().arePinRemindersEnabled(),
registrationLockEnabled = SignalStore.kbsValues().isV2RegistrationLockEnabled
registrationLockEnabled = SignalStore.kbsValues().isV2RegistrationLockEnabled,
userUnregistered = TextSecurePreferences.isUnauthorizedReceived(ApplicationDependencies.getApplication()),
clientDeprecated = SignalStore.misc().isClientDeprecated
)
}
}
@@ -1,5 +1,6 @@
package org.thoughtcrime.securesms.components.settings.app.appearance
import android.os.Build
import androidx.lifecycle.ViewModelProvider
import androidx.navigation.Navigation
import org.signal.core.util.concurrent.observe
@@ -67,6 +68,15 @@ class AppearanceSettingsFragment : DSLSettingsFragment(R.string.preferences__app
}
)
if (Build.VERSION.SDK_INT >= 26) {
clickPref(
title = DSLSettingsText.from(R.string.preferences__app_icon),
onClick = {
Navigation.findNavController(requireView()).safeNavigate(R.id.action_appearanceSettings_to_appIconActivity)
}
)
}
radioListPref(
title = DSLSettingsText.from(R.string.preferences_chats__message_text_size),
listItems = messageFontSizeLabels,
@@ -0,0 +1,325 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.appearance.appicon
import android.content.Context
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Image
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.ClickableText
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.navigation.fragment.findNavController
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import org.signal.core.ui.Scaffolds
import org.signal.core.ui.theme.SignalTheme
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.app.appearance.appicon.util.AppIconPreset
import org.thoughtcrime.securesms.components.settings.app.appearance.appicon.util.AppIconUtility
import org.thoughtcrime.securesms.compose.ComposeFragment
import org.thoughtcrime.securesms.util.navigation.safeNavigate
class AppIconSelectionFragment : ComposeFragment() {
private lateinit var appIconUtility: AppIconUtility
override fun onAttach(context: Context) {
super.onAttach(context)
appIconUtility = AppIconUtility(context)
}
@Composable
override fun FragmentContent() {
Scaffolds.Settings(
title = stringResource(id = R.string.preferences__app_icon),
onNavigationClick = {
findNavController().popBackStack()
},
navigationIconPainter = painterResource(id = R.drawable.ic_arrow_left_24),
navigationContentDescription = stringResource(id = R.string.Material3SearchToolbar__close)
) { contentPadding: PaddingValues ->
IconSelectionScreen(appIconUtility.currentAppIcon, ::updateAppIcon, ::openLearnMore, Modifier.padding(contentPadding))
}
}
private fun updateAppIcon(preset: AppIconPreset) {
if (!appIconUtility.isCurrentlySelected(preset)) {
appIconUtility.setNewAppIcon(preset)
}
}
private fun openLearnMore() {
findNavController().safeNavigate(R.id.action_appIconSelectionFragment_to_appIconTutorialFragment)
}
companion object {
val TAG = Log.tag(AppIconSelectionFragment::class.java)
}
}
private const val LEARN_MORE_TAG = "learn_more"
private const val URL_TAG = "URL"
private const val COLUMN_COUNT = 4
/**
* Screen allowing the user to view all the possible icon and select a new one to use.
*/
@Composable
fun IconSelectionScreen(activeIcon: AppIconPreset, onItemConfirmed: (AppIconPreset) -> Unit, onWarningClick: () -> Unit, modifier: Modifier = Modifier) {
var showDialog: Boolean by remember { mutableStateOf(false) }
var pendingIcon: AppIconPreset by remember {
mutableStateOf(activeIcon)
}
if (showDialog) {
ChangeIconDialog(
pendingIcon = pendingIcon,
onConfirm = {
onItemConfirmed(pendingIcon)
showDialog = false
},
onDismiss = {
pendingIcon = activeIcon
showDialog = false
}
)
}
Column(modifier = modifier.verticalScroll(rememberScrollState())) {
Spacer(modifier = Modifier.size(12.dp))
CaveatWarning(
onClick = onWarningClick,
modifier = Modifier.padding(horizontal = 24.dp)
)
Spacer(modifier = Modifier.size(12.dp))
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 18.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
enumValues<AppIconPreset>().toList().chunked(COLUMN_COUNT).map { it.toImmutableList() }.forEach { items ->
IconRow(
presets = items,
isSelected = { it == pendingIcon },
onItemClick = {
pendingIcon = it
showDialog = true
}
)
}
}
}
}
@Composable
fun ChangeIconDialog(pendingIcon: AppIconPreset, onConfirm: () -> Unit, onDismiss: () -> Unit, modifier: Modifier = Modifier) {
AlertDialog(
modifier = modifier,
onDismissRequest = onDismiss,
confirmButton = {
TextButton(
onClick = onConfirm
) {
Text(text = stringResource(id = R.string.preferences__app_icon_dialog_ok))
}
},
dismissButton = {
TextButton(
onClick = onDismiss
) {
Text(text = stringResource(id = R.string.preferences__app_icon_dialog_cancel))
}
},
icon = {
AppIcon(preset = pendingIcon, isSelected = false, onClick = {})
},
title = {
Text(
text = stringResource(id = R.string.preferences__app_icon_dialog_title, stringResource(id = pendingIcon.labelResId)),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.headlineSmall
)
},
text = {
Text(
text = stringResource(id = R.string.preferences__app_icon_dialog_description),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodyMedium
)
}
)
}
/**
* Composable rendering the one row of icons that the user may choose from.
*/
@Composable
fun IconRow(presets: ImmutableList<AppIconPreset>, isSelected: (AppIconPreset) -> Boolean, onItemClick: (AppIconPreset) -> Unit, modifier: Modifier = Modifier) {
Row(modifier = modifier.fillMaxWidth()) {
presets.forEach { preset ->
val currentlySelected = isSelected(preset)
IconGridElement(
preset = preset,
isSelected = currentlySelected,
onClickHandler = {
if (!currentlySelected) {
onItemClick(preset)
}
},
modifier = Modifier
.padding(vertical = 18.dp)
.weight(1f)
)
}
}
}
/**
* Composable rendering an individual icon inside that grid, including the black border of the selected icon.
*/
@Composable
fun IconGridElement(preset: AppIconPreset, isSelected: Boolean, onClickHandler: () -> Unit, modifier: Modifier = Modifier) {
Column(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally
) {
val boxModifier = Modifier.size(64.dp)
Box(
modifier = if (isSelected) boxModifier.border(3.dp, MaterialTheme.colorScheme.onBackground, CircleShape) else boxModifier
) {
AppIcon(preset = preset, isSelected = isSelected, onClickHandler, modifier = Modifier.align(Alignment.Center))
}
Spacer(modifier = Modifier.size(8.dp))
Text(
text = stringResource(id = preset.labelResId),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
/**
* Composable rendering the icon and optionally a border, to indicate selection.
*/
@Composable
fun AppIcon(preset: AppIconPreset, isSelected: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) {
val bitmapSize by animateFloatAsState(
targetValue = if (isSelected) 48f else 64f,
label = "Icon Size",
animationSpec = tween(durationMillis = 150, easing = CubicBezierEasing(0.17f, 0.17f, 0f, 1f))
)
val imageModifier = modifier
.size(bitmapSize.dp)
.graphicsLayer(
shape = CircleShape,
shadowElevation = if (isSelected) 4f else 8f,
clip = true
)
.clickable(onClick = onClick)
Image(
painterResource(id = preset.iconPreviewResId),
contentDescription = stringResource(id = preset.labelResId),
modifier = imageModifier
)
}
/**
* A clickable "learn more" block of text.
*/
@Composable
fun CaveatWarning(onClick: () -> Unit, modifier: Modifier = Modifier) {
val learnMoreString = stringResource(R.string.preferences__app_icon_learn_more)
val completeString = stringResource(R.string.preferences__app_icon_warning_learn_more)
val learnMoreStartIndex = completeString.indexOf(learnMoreString).coerceAtLeast(0)
val learnMoreEndIndex = learnMoreStartIndex + learnMoreString.length
val doesStringEndWithLearnMore = learnMoreEndIndex >= completeString.lastIndex
val annotatedText = buildAnnotatedString {
withStyle(
style = SpanStyle(
color = MaterialTheme.colorScheme.onSurfaceVariant
)
) {
append(completeString.substring(0, learnMoreStartIndex))
}
pushStringAnnotation(
tag = URL_TAG,
annotation = LEARN_MORE_TAG
)
withStyle(
style = SpanStyle(
color = MaterialTheme.colorScheme.primary
)
) {
append(learnMoreString)
}
pop()
if (!doesStringEndWithLearnMore) {
append(completeString.substring(learnMoreEndIndex, completeString.lastIndex))
}
}
ClickableText(
text = annotatedText,
onClick = { onClick() },
style = MaterialTheme.typography.bodyMedium,
modifier = modifier
)
}
@Preview(name = "Light Theme")
@Composable
private fun MainScreenPreviewLight() {
SignalTheme(isDarkMode = false) {
Surface {
IconSelectionScreen(AppIconPreset.DEFAULT, onItemConfirmed = {}, onWarningClick = {})
}
}
}
@Preview(name = "Dark Theme")
@Composable
private fun MainScreenPreviewDark() {
SignalTheme(isDarkMode = true) {
Surface {
IconSelectionScreen(AppIconPreset.DEFAULT, onItemConfirmed = {}, onWarningClick = {})
}
}
}
@@ -0,0 +1,126 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.appearance.appicon
import androidx.compose.foundation.Image
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.navigation.fragment.findNavController
import org.signal.core.ui.Scaffolds
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.compose.ComposeFragment
class AppIconTutorialFragment : ComposeFragment() {
@Composable
override fun FragmentContent() {
Scaffolds.Settings(
title = "",
onNavigationClick = {
findNavController().popBackStack()
},
navigationIconPainter = painterResource(id = R.drawable.ic_arrow_left_24),
navigationContentDescription = stringResource(id = R.string.Material3SearchToolbar__close)
) { contentPadding: PaddingValues ->
TutorialScreen(Modifier.padding(contentPadding))
}
}
@Composable
fun TutorialScreen(modifier: Modifier = Modifier) {
Box(modifier = modifier) {
Column(
modifier = Modifier
.padding(horizontal = 24.dp)
.align(Alignment.Center)
.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally
) {
val borderShape = RoundedCornerShape(12.dp)
Text(
text = stringResource(R.string.preferences__app_icon_warning),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Start,
modifier = Modifier
.padding(vertical = 20.dp)
.fillMaxWidth()
)
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.fillMaxWidth()
.clip(borderShape)
.border(1.dp, MaterialTheme.colorScheme.outline, shape = borderShape)
) {
Image(
painter = painterResource(R.drawable.app_icon_tutorial_apps_homescreen),
contentDescription = stringResource(R.string.preferences__graphic_illustrating_where_the_replacement_app_icon_will_be_visible),
contentScale = ContentScale.FillWidth,
modifier = Modifier
.widthIn(max = 328.dp)
)
}
Text(
text = stringResource(id = R.string.preferences__app_icon_notification_warning),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Start,
modifier = Modifier
.padding(vertical = 20.dp)
.fillMaxWidth()
)
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.fillMaxWidth()
.clip(borderShape)
.border(1.dp, MaterialTheme.colorScheme.outline, shape = borderShape)
) {
Image(
painter = painterResource(R.drawable.app_icon_tutorial_notification),
contentDescription = stringResource(R.string.preferences__graphic_illustrating_where_the_replacement_app_icon_will_be_visible),
contentScale = ContentScale.FillWidth,
modifier = Modifier
.widthIn(max = 328.dp)
)
}
}
}
}
@Preview
@Composable
private fun TutorialScreenPreview() {
TutorialScreen()
}
companion object {
val TAG = Log.tag(AppIconTutorialFragment::class.java)
}
}
@@ -0,0 +1,32 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.appearance.appicon.util
import android.content.ComponentName
import android.content.Context
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import org.thoughtcrime.securesms.R
enum class AppIconPreset(private val componentName: String, @DrawableRes val iconPreviewResId: Int, @StringRes val labelResId: Int) {
DEFAULT(".RoutingActivity", R.drawable.ic_app_icon_default_top_preview, R.string.app_name),
WHITE(".RoutingActivityAltWhite", R.drawable.ic_app_icon_signal_white_top_preview, R.string.app_name),
COLOR(".RoutingActivityAltColor", R.drawable.ic_app_icon_signal_color_top_preview, R.string.app_name),
DARK(".RoutingActivityAltDark", R.drawable.ic_app_icon_signal_dark_top_preview, R.string.app_name),
DARK_VARIANT(".RoutingActivityAltDarkVariant", R.drawable.ic_app_icon_signal_dark_variant_top_preview, R.string.app_name),
CHAT(".RoutingActivityAltChat", R.drawable.ic_app_icon_chat_top_preview, R.string.app_name),
BUBBLES(".RoutingActivityAltBubbles", R.drawable.ic_app_icon_bubbles_top_preview, R.string.app_name),
YELLOW(".RoutingActivityAltYellow", R.drawable.ic_app_icon_yellow_top_preview, R.string.app_name),
NEWS(".RoutingActivityAltNews", R.drawable.ic_app_icon_news_top_preview, R.string.app_icon_label_news),
NOTES(".RoutingActivityAltNotes", R.drawable.ic_app_icon_notes_top_preview, R.string.app_icon_label_notes),
WEATHER(".RoutingActivityAltWeather", R.drawable.ic_app_icon_weather_top_preview, R.string.app_icon_label_weather),
WAVES(".RoutingActivityAltWaves", R.drawable.ic_app_icon_waves_top_preview, R.string.app_icon_label_waves);
fun getComponentName(context: Context): ComponentName {
val applicationContext = context.applicationContext
return ComponentName(applicationContext, "org.thoughtcrime.securesms" + componentName)
}
}
@@ -0,0 +1,64 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.appearance.appicon.util
import android.content.ComponentName
import android.content.Context
import android.content.pm.PackageManager
import org.signal.core.util.logging.Log
class AppIconUtility(context: Context) {
private val applicationContext: Context = context.applicationContext
private val pm = applicationContext.packageManager
val currentAppIcon by lazy { readCurrentAppIconFromPackageManager() }
fun isCurrentlySelected(preset: AppIconPreset): Boolean {
return preset == currentAppIcon
}
fun currentAppIconComponentName(): ComponentName {
return currentAppIcon.getComponentName(applicationContext)
}
fun setNewAppIcon(desiredAppIcon: AppIconPreset) {
Log.d(TAG, "Setting new app icon.")
pm.setComponentEnabledSetting(desiredAppIcon.getComponentName(applicationContext), PackageManager.COMPONENT_ENABLED_STATE_ENABLED, PackageManager.DONT_KILL_APP)
Log.d(TAG, "${desiredAppIcon.name} enabled.")
val previousAppIcon = currentAppIcon
pm.setComponentEnabledSetting(previousAppIcon.getComponentName(applicationContext), PackageManager.COMPONENT_ENABLED_STATE_DISABLED, PackageManager.DONT_KILL_APP)
Log.d(TAG, "${previousAppIcon.name} disabled.")
enumValues<AppIconPreset>().filterNot { it == desiredAppIcon || it == previousAppIcon }.forEach {
pm.setComponentEnabledSetting(it.getComponentName(applicationContext), PackageManager.COMPONENT_ENABLED_STATE_DISABLED, PackageManager.DONT_KILL_APP)
Log.d(TAG, "${it.name} disabled.")
}
}
private fun readCurrentAppIconFromPackageManager(): AppIconPreset {
val activeIcon = enumValues<AppIconPreset>().firstOrNull {
val componentName = it.getComponentName(applicationContext)
val componentEnabledSetting = pm.getComponentEnabledSetting(componentName)
Log.d(TAG, "Found $componentName with state of $componentEnabledSetting")
if (it == AppIconPreset.DEFAULT && componentEnabledSetting == PackageManager.COMPONENT_ENABLED_STATE_DEFAULT) {
return it
}
componentEnabledSetting == PackageManager.COMPONENT_ENABLED_STATE_ENABLED
}
return if (activeIcon == null) {
setNewAppIcon(AppIconPreset.DEFAULT)
AppIconPreset.DEFAULT
} else {
activeIcon
}
}
companion object {
private const val TAG = "AppIconUtility"
}
}
@@ -13,7 +13,6 @@ import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.ViewBinderDelegate
import org.thoughtcrime.securesms.databinding.ChooseNavigationBarStyleFragmentBinding
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.FeatureFlags
/**
* Allows the user to choose between a compact and full-sized navigation bar.
@@ -75,11 +74,7 @@ class ChooseNavigationBarStyleFragment : DialogFragment(R.layout.choose_navigati
companion object {
@DrawableRes
fun getImageResourceId(isCompact: Boolean): Int {
return if (FeatureFlags.callsTab()) {
ThreeButtons.getImageResource(isCompact)
} else {
TwoButtons.getImageResource(isCompact)
}
return ThreeButtons.getImageResource(isCompact)
}
}
}
@@ -8,12 +8,12 @@ import androidx.navigation.fragment.findNavController
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.app.changenumber.ChangeNumberUtil.changeNumberSuccess
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.lock.PinHashing
import org.thoughtcrime.securesms.registration.fragments.BaseRegistrationLockFragment
import org.thoughtcrime.securesms.registration.viewmodel.BaseRegistrationViewModel
import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.SupportEmailUtil
import org.thoughtcrime.securesms.util.navigation.safeNavigate
import org.whispersystems.signalservice.api.kbs.PinHashUtil
class ChangeNumberRegistrationLockFragment : BaseRegistrationLockFragment(R.layout.fragment_change_number_registration_lock) {
@@ -42,7 +42,7 @@ class ChangeNumberRegistrationLockFragment : BaseRegistrationLockFragment(R.layo
}
override fun handleSuccessfulPinEntry(pin: String) {
val pinsDiffer: Boolean = SignalStore.kbsValues().localPinHash?.let { !PinHashing.verifyLocalPinHash(it, pin) } ?: false
val pinsDiffer: Boolean = SignalStore.kbsValues().localPinHash?.let { !PinHashUtil.verifyLocalPinHash(it, pin) } ?: false
pinButton.cancelSpinning()
@@ -1,11 +1,15 @@
package org.thoughtcrime.securesms.components.settings.app.changenumber
import android.content.Context
import android.content.DialogInterface.OnClickListener
import android.os.Bundle
import android.view.View
import android.widget.TextView
import android.widget.Toast
import androidx.annotation.StringRes
import androidx.appcompat.widget.Toolbar
import androidx.navigation.fragment.findNavController
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Single
import org.signal.core.util.concurrent.LifecycleDisposable
@@ -46,6 +50,7 @@ class ChangeNumberVerifyFragment : LoggingFragment(R.layout.fragment_change_phon
if (!requestingCaptcha || viewModel.hasCaptchaToken()) {
requestCode()
} else {
Log.d(TAG, "Captcha required.")
Toast.makeText(requireContext(), R.string.ChangeNumberVerifyFragment__captcha_required, Toast.LENGTH_SHORT).show()
findNavController().navigateUp()
}
@@ -60,6 +65,7 @@ class ChangeNumberVerifyFragment : LoggingFragment(R.layout.fragment_change_phon
.andThen(viewModel.changeNumberWithRecoveryPassword())
.flatMap { changed ->
if (changed) {
Log.d(TAG, "Successfully changed number using recovery password.")
Single.just(RequestCodeResult.RecoveryPasswordWorked)
} else {
viewModel.requestVerificationCode(mode, mccMncProducer.mcc, mccMncProducer.mnc)
@@ -83,16 +89,18 @@ class ChangeNumberVerifyFragment : LoggingFragment(R.layout.fragment_change_phon
requestingCaptcha = true
} else if (processor.rateLimit()) {
Log.i(TAG, "Unable to request sms code due to rate limit")
Toast.makeText(requireContext(), R.string.RegistrationActivity_rate_limited_to_service, Toast.LENGTH_LONG).show()
findNavController().navigateUp()
showErrorDialog(requireContext(), R.string.RegistrationActivity_rate_limited_to_service) { _, _ -> findNavController().navigateUp() }
} else {
Log.w(TAG, "Unable to request sms code", processor.error)
Toast.makeText(requireContext(), R.string.RegistrationActivity_unable_to_connect_to_service, Toast.LENGTH_LONG).show()
findNavController().navigateUp()
showErrorDialog(requireContext(), R.string.RegistrationActivity_unable_to_request_verification_code) { _, _ -> findNavController().navigateUp() }
}
}
}
private fun showErrorDialog(context: Context, @StringRes message: Int, onPositiveButtonClickListener: OnClickListener?) {
MaterialAlertDialogBuilder(context).setMessage(message).setPositiveButton(R.string.ok, onPositiveButtonClickListener).show()
}
private sealed interface RequestCodeResult {
object RecoveryPasswordWorked : RequestCodeResult
class RequestedVerificationCode(val processor: RegistrationSessionProcessor) : RequestCodeResult
@@ -44,6 +44,13 @@ class HelpSettingsFragment : DSLSettingsFragment(R.string.preferences__help) {
}
)
clickPref(
title = DSLSettingsText.from(R.string.HelpSettingsFragment__licenses),
onClick = {
Navigation.findNavController(requireView()).safeNavigate(R.id.action_helpSettingsFragment_to_licenseFragment)
}
)
externalLinkPref(
title = DSLSettingsText.from(R.string.HelpSettingsFragment__terms_amp_privacy_policy),
linkId = R.string.terms_and_privacy_policy_url
@@ -54,7 +61,7 @@ class HelpSettingsFragment : DSLSettingsFragment(R.string.preferences__help) {
StringBuilder().apply {
append(getString(R.string.HelpFragment__copyright_signal_messenger))
append("\n")
append(getString(R.string.HelpFragment__licenced_under_the_gplv3))
append(getString(R.string.HelpFragment__licenced_under_the_agplv3))
}
)
)
@@ -0,0 +1,72 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.help
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.rxjava3.subscribeAsState
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.navigation.fragment.findNavController
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.schedulers.Schedulers
import org.signal.core.ui.Scaffolds
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.compose.ComposeFragment
class LicenseFragment : ComposeFragment() {
private val TAG = Log.tag(LicenseFragment::class.java)
@Composable
override fun FragmentContent() {
val textState: State<String> = Single.fromCallable {
requireContext().resources.openRawResource(R.raw.third_party_licenses).bufferedReader().use { it.readText() }
}
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribeAsState(initial = "")
Scaffolds.Settings(
title = stringResource(id = R.string.HelpSettingsFragment__licenses),
onNavigationClick = findNavController()::popBackStack,
navigationIconPainter = painterResource(id = R.drawable.ic_arrow_left_24),
navigationContentDescription = stringResource(id = R.string.Material3SearchToolbar__close)
) {
LicenseScreen(licenseText = textState.value, modifier = Modifier.padding(it))
}
}
}
@Composable
fun LicenseScreen(licenseText: String, modifier: Modifier = Modifier) {
Column(
modifier = modifier
.padding(horizontal = 24.dp)
.verticalScroll(rememberScrollState())
) {
Text(
text = licenseText,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(vertical = 16.dp)
)
}
}
@Preview
@Composable
fun LicenseFragmentPreview() {
LicenseScreen("Lorem ipsum")
}
@@ -0,0 +1,164 @@
package org.thoughtcrime.securesms.components.settings.app.usernamelinks
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.res.imageResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import org.thoughtcrime.securesms.R
/**
* Shows a QRCode that represents the provided data. Includes a Signal logo in the middle.
*/
@Composable
fun QrCode(
data: QrCodeData,
modifier: Modifier = Modifier,
foregroundColor: Color = Color.Black,
backgroundColor: Color = Color.White,
deadzonePercent: Float = 0.4f
) {
val logo = ImageBitmap.imageResource(R.drawable.qrcode_logo)
Column(
modifier = modifier
.drawBehind {
drawQr(
data = data,
foregroundColor = foregroundColor,
backgroundColor = backgroundColor,
deadzonePercent = deadzonePercent,
logo = logo
)
}
) {
}
}
private fun DrawScope.drawQr(
data: QrCodeData,
foregroundColor: Color,
backgroundColor: Color,
deadzonePercent: Float,
logo: ImageBitmap
) {
// We want an even number of dots on either side of the deadzone
val candidateDeadzoneWidth: Int = (data.width * deadzonePercent).toInt()
val deadzoneWidth: Int = if ((data.width - candidateDeadzoneWidth) % 2 == 0) {
candidateDeadzoneWidth
} else {
candidateDeadzoneWidth + 1
}
val candidateDeadzoneHeight: Int = (data.height * deadzonePercent).toInt()
val deadzoneHeight: Int = if ((data.height - candidateDeadzoneHeight) % 2 == 0) {
candidateDeadzoneHeight
} else {
candidateDeadzoneHeight + 1
}
val deadzoneStartX: Int = (data.width - deadzoneWidth) / 2
val deadzoneEndX: Int = deadzoneStartX + deadzoneWidth
val deadzoneStartY: Int = (data.height - deadzoneHeight) / 2
val deadzoneEndY: Int = deadzoneStartY + deadzoneHeight
val cellWidthPx: Float = size.width / data.width
val cellRadiusPx = cellWidthPx / 2
for (x in 0 until data.width) {
for (y in 0 until data.height) {
if (x < deadzoneStartX || x >= deadzoneEndX || y < deadzoneStartY || y >= deadzoneEndY) {
drawCircle(
color = if (data.get(x, y)) foregroundColor else backgroundColor,
radius = cellRadiusPx,
center = Offset(x * cellWidthPx + cellRadiusPx, y * cellWidthPx + cellRadiusPx)
)
}
}
}
// Logo border
val deadzonePaddingPercent = 0.03f
val logoBorderRadiusPx = ((deadzonePercent - deadzonePaddingPercent) * size.width) / 2
drawCircle(
color = foregroundColor,
radius = logoBorderRadiusPx,
style = Stroke(width = 4.dp.toPx()),
center = this.center
)
// Logo
val logoWidthPx = ((deadzonePercent / 2) * size.width).toInt()
val logoOffsetPx = ((size.width - logoWidthPx) / 2).toInt()
drawImage(
image = logo,
dstOffset = IntOffset(logoOffsetPx, logoOffsetPx),
dstSize = IntSize(logoWidthPx, logoWidthPx),
colorFilter = ColorFilter.tint(foregroundColor)
)
for (eye in data.eyes()) {
val strokeWidth = cellWidthPx
// Clear the already-drawn dots
drawRect(
color = backgroundColor,
topLeft = Offset(
x = eye.position.first * cellWidthPx,
y = eye.position.second * cellWidthPx
),
size = Size(eye.size * cellWidthPx + cellRadiusPx, eye.size * cellWidthPx)
)
// Outer square
drawRoundRect(
color = foregroundColor,
topLeft = Offset(
x = eye.position.first * cellWidthPx + strokeWidth / 2,
y = eye.position.second * cellWidthPx + strokeWidth / 2
),
size = Size((eye.size - 1) * cellWidthPx, (eye.size - 1) * cellWidthPx),
cornerRadius = CornerRadius(cellRadiusPx * 2, cellRadiusPx * 2),
style = Stroke(width = strokeWidth)
)
// Inner square
drawRoundRect(
color = foregroundColor,
topLeft = Offset(
x = (eye.position.first + 2) * cellWidthPx,
y = (eye.position.second + 2) * cellWidthPx
),
size = Size((eye.size - 4) * cellWidthPx, (eye.size - 4) * cellWidthPx),
cornerRadius = CornerRadius(cellRadiusPx, cellRadiusPx)
)
}
}
@Preview
@Composable
private fun Preview() {
Surface {
QrCode(
data = QrCodeData.forData("https://signal.org", 64),
modifier = Modifier.size(200.dp),
deadzonePercent = 0.3f
)
}
}
@@ -0,0 +1,127 @@
package org.thoughtcrime.securesms.components.settings.app.usernamelinks
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import org.signal.core.ui.theme.SignalTheme
/**
* Renders a QR code and username as a badge.
*/
@Composable
fun QrCodeBadge(data: QrCodeData?, colorScheme: UsernameQrCodeColorScheme, username: String, modifier: Modifier = Modifier) {
val borderColor by animateColorAsState(targetValue = colorScheme.borderColor)
val foregroundColor by animateColorAsState(targetValue = colorScheme.foregroundColor)
val elevation by animateFloatAsState(targetValue = if (colorScheme == UsernameQrCodeColorScheme.White) 10f else 0f)
val textColor by animateColorAsState(targetValue = if (colorScheme == UsernameQrCodeColorScheme.White) Color.Black else Color.White)
Surface(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 59.dp, vertical = 24.dp),
color = borderColor,
shape = RoundedCornerShape(24.dp),
shadowElevation = elevation.dp
) {
Column {
Surface(
modifier = Modifier
.padding(
top = 32.dp,
start = 40.dp,
end = 40.dp,
bottom = 16.dp
)
.aspectRatio(1f)
.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
color = Color.White
) {
if (data != null) {
QrCode(
data = data,
modifier = Modifier.padding(20.dp),
foregroundColor = foregroundColor,
backgroundColor = Color.White
)
} else {
Box(
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(
color = colorScheme.borderColor,
modifier = Modifier.size(56.dp)
)
}
}
}
Text(
text = username,
color = textColor,
fontSize = 20.sp,
lineHeight = 26.sp,
fontWeight = FontWeight.W600,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.padding(
start = 40.dp,
end = 40.dp,
bottom = 32.dp
)
)
}
}
}
@Preview
@Composable
private fun PreviewWithCode() {
SignalTheme(isDarkMode = false) {
Surface {
QrCodeBadge(
data = QrCodeData.forData("https://signal.org", 64),
colorScheme = UsernameQrCodeColorScheme.Blue,
username = "parker.42"
)
}
}
}
@Preview
@Composable
private fun PreviewWithoutCode() {
SignalTheme(isDarkMode = false) {
Surface {
QrCodeBadge(
data = null,
colorScheme = UsernameQrCodeColorScheme.Blue,
username = "parker.42"
)
}
}
}
@@ -0,0 +1,138 @@
package org.thoughtcrime.securesms.components.settings.app.usernamelinks
import androidx.annotation.WorkerThread
import com.google.zxing.BarcodeFormat
import com.google.zxing.EncodeHintType
import com.google.zxing.qrcode.QRCodeWriter
import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel
import java.util.BitSet
/**
* Efficient representation of raw QR code data. Stored as an X/Y grid of points, where (0, 0) is the top left corner.
* X increases as you move right, and Y increases as you go down.
*/
class QrCodeData(
val width: Int,
val height: Int,
private val bits: BitSet
) {
fun get(x: Int, y: Int): Boolean {
return bits.get(y * width + x)
}
/**
* Returns the position of the "eyes" of the QR code -- the big squares in the three corners.
*/
fun eyes(): List<Eye> {
val eyes: MutableList<Eye> = mutableListOf()
val size: Int = getPossibleEyeSize()
// Top left
if (
horizontalLineExists(0, 0, size) &&
horizontalLineExists(0, size - 1, size) &&
verticalLineExists(0, 0, size) &&
verticalLineExists(size - 1, 0, size)
) {
eyes += Eye(
position = 0 to 0,
size = size
)
}
// Bottom left
if (
horizontalLineExists(0, height - size, size) &&
horizontalLineExists(0, size - 1, size) &&
verticalLineExists(0, height - size, size) &&
verticalLineExists(size - 1, height - size, size)
) {
eyes += Eye(
position = 0 to height - size,
size = size
)
}
// Top right
if (
horizontalLineExists(width - size, 0, size) &&
horizontalLineExists(width - size, size - 1, size) &&
verticalLineExists(width - size, 0, size) &&
verticalLineExists(width - 1, 0, size)
) {
eyes += Eye(
position = width - size to 0,
size = size
)
}
return eyes
}
private fun getPossibleEyeSize(): Int {
var x = 0
while (get(x, 0)) {
x++
}
return x
}
private fun horizontalLineExists(x: Int, y: Int, length: Int): Boolean {
for (p in x until x + length) {
if (!get(p, y)) {
return false
}
}
return true
}
private fun verticalLineExists(x: Int, y: Int, length: Int): Boolean {
for (p in y until y + length) {
if (!get(x, p)) {
return false
}
}
return true
}
data class Eye(
val position: Pair<Int, Int>,
val size: Int
)
companion object {
/**
* Converts the provided string data into a QR representation.
*/
@WorkerThread
fun forData(data: String, size: Int): QrCodeData {
val qrCodeWriter = QRCodeWriter()
val hints = mapOf(EncodeHintType.ERROR_CORRECTION to ErrorCorrectionLevel.H.toString())
val padded = qrCodeWriter.encode(data, BarcodeFormat.QR_CODE, size, size, hints)
val dimens = padded.enclosingRectangle
val xStart = dimens[0]
val yStart = dimens[1]
val width = dimens[2]
val height = dimens[3]
val bitSet = BitSet(width * height)
for (x in xStart until xStart + width) {
for (y in yStart until yStart + height) {
if (padded.get(x, y)) {
val destX = x - xStart
val destY = y - yStart
bitSet.set(destY * width + destX)
}
}
}
return QrCodeData(width, height, bitSet)
}
}
}

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