Compare commits

..

413 Commits

Author SHA1 Message Date
Alex Hart
643d96a896 Bump version to 6.14.1 2023-03-08 15:53:23 -04:00
Alex Hart
04664d34e4 Updated language translations. 2023-03-08 15:51:32 -04:00
Alex Hart
7fd5b72204 Allow nullability of Intent parameter in converted services. 2023-03-08 15:44:47 -04:00
Alex Hart
fd3a509231 Bump version to 6.14.0 2023-03-08 15:16:41 -04:00
Alex Hart
9b672a520a Updated language translations. 2023-03-08 15:12:21 -04:00
Alex Hart
2d3e8ef31c Replace CardView usages with MaterialCardView. 2023-03-08 15:06:50 -04:00
Cody Henthorne
f1c2ee9b32 Stabilize message processing tests and add inline decryption timings. 2023-03-08 15:06:50 -04:00
Alex Hart
68a0cb40a6 Update several AndroidX libraries.
activity -> 1.6.1
appcompat -> 1.6.1
fragment -> 1.5.5
navigation -> 2.5.3
core-ktx -> 1.9.0
safe-args -> 2.5.3
2023-03-08 15:06:50 -04:00
Alex Hart
68a50798f2 Update phone number listing rules. 2023-03-08 15:06:50 -04:00
Alex Hart
73151e8ff6 Upgrade core-ktx to 1.8.0 2023-03-08 15:06:50 -04:00
Alex Hart
b7da4b93db Upgrade CameraX to 1.2.1 2023-03-08 15:06:50 -04:00
Nicholas
bd373a3045 Improve registration network reliability. 2023-03-08 15:06:50 -04:00
Alex Hart
7c94d570cb Update copy of GiftMessageView. 2023-03-08 15:06:50 -04:00
Greyson Parrelli
8d8f5fb9e4 Update icons for nightly and nightlyStaging. 2023-03-08 15:06:50 -04:00
Greyson Parrelli
1b2cb2637f Perform decryptions inline. 2023-03-08 15:06:50 -04:00
Alex Hart
e222f96310 Add username sync job to be run after new registrations. 2023-03-08 15:06:50 -04:00
Nicholas
877a62b809 Convert VersionTracker to Kotlin and add RefreshAttributesJob. 2023-03-08 15:06:50 -04:00
Greyson Parrelli
81fc99724d Move the Job#onSubmit call to be outside of the JobController lock. 2023-03-06 10:48:18 -05:00
Cody Henthorne
6e8f3d1e71 Fix scheduled message changing disappearing messages bug. 2023-03-06 09:47:12 -05:00
Alex Hart
33ab25a557 Add global formatter to gradle build files. 2023-03-06 10:46:51 -04:00
Cody Henthorne
c30e3664b8 Improve performance of message processing.
Rearranging code allows us to skip expensive calls or duplicating work
already spent to resolve a recipient.
2023-03-04 10:52:21 -05:00
Nicholas
bb8c7bab20 Finish registration activity upon phone number entry cancellation. 2023-03-04 10:52:21 -05:00
Nicholas Tinsley
194681abb7 Change reply icon color. 2023-03-04 10:52:21 -05:00
Alex Hart
c56564014b Add ktlint checking to :build-logic:plugins and split buildQa out into its own task for readability. 2023-03-04 10:52:21 -05:00
Cody Henthorne
c0aff46e31 Add message processing performance test. 2023-03-04 10:52:21 -05:00
Jim Gustafson
f719dcca6d Update to RingRTC v2.25.1 2023-03-04 10:52:21 -05:00
Alex Hart
abd1582422 Fix kdoc in MediaPreviewRepository. 2023-03-04 10:52:21 -05:00
Greyson Parrelli
ec2565263e Initial refactor of the message decryption flow. 2023-03-04 10:52:21 -05:00
Alex Hart
c1a94be9cd Set media preview background to correct color. 2023-03-04 10:52:21 -05:00
Cody Henthorne
e303e80f17 Fix instrumentation tests and group member regression. 2023-03-04 10:52:21 -05:00
Alex Hart
018f6ac7aa Update compose BOM to 2023.01.00 2023-03-04 10:52:21 -05:00
Alex Hart
6f9d3f02f1 Privatize Scaffold preview. 2023-03-04 10:52:21 -05:00
Alex Hart
9b2ccd43c8 Make radio-row preview interactive. 2023-03-04 10:52:21 -05:00
Alex Hart
bd078274b5 Fix RadioRow vertical alignment. 2023-03-04 10:52:21 -05:00
Ehren Kret
9cfb95fee7 Update Github CI to build with more cores and limit metaspace size. 2023-03-04 10:52:03 -05:00
Alex Hart
0f18fa329d Fix crash when inserting group member remaps. 2023-03-04 10:52:03 -05:00
Nicholas
428ef554a3 Add bottom sheet reminder for linked devices on re-register. 2023-03-04 10:52:03 -05:00
Alex Hart
8ca8e5d8f9 Add scaffold preview. 2023-03-04 10:52:03 -05:00
bijaykumarpun
5634e9834d Fix incorrect underlines rendering for empty lines in image editor.
Fixes #12807
Closes #12808
2023-03-04 10:52:03 -05:00
David
b437cb0344 Fix parameter order in getAccessMapFor.
Closes #12812
2023-03-04 10:52:03 -05:00
Alex Hart
3695d7a5f1 Fix marquee scroll behavior in VoiceNotePlayerView. 2023-03-04 10:52:03 -05:00
Alex Hart
7010b19fea Remove username creation state from passphrase required activity. 2023-03-04 10:52:03 -05:00
Alex Hart
3f62221182 Add unit test for build-logic static ip tool. 2023-03-04 10:52:03 -05:00
Alex Hart
43aad90ee4 Fix code formatting. 2023-03-04 10:51:41 -05:00
Alex Hart
aa28668315 Cannot add build-logic tasks to QA. 2023-03-04 10:51:41 -05:00
Alex Hart
9ea392fb4e Utilize non-default arg. 2023-03-04 10:51:41 -05:00
Alex Hart
d0c858221e Add a couple unit tests for StaticIpResolver and string into qa. 2023-03-04 10:51:41 -05:00
Alex Hart
a95e695a97 Start StaticIpResolver testing. 2023-03-04 10:51:41 -05:00
Nicholas Tinsley
8910eac6e0 Fix crash in RegistrationCompleteFragment. 2023-03-04 10:51:41 -05:00
Cody Henthorne
e635c3030e Fix scheduled backup jobs cancelling itself. 2023-03-04 10:51:41 -05:00
Nicholas Tinsley
8cbad2c3a6 Update SMS export string. 2023-03-04 10:51:41 -05:00
Alex Hart
45a04423b0 Add PNP settings. 2023-03-04 10:51:41 -05:00
Clark
f3693c966a Improve conversation list cold start performance. 2023-03-04 10:51:41 -05:00
Cody Henthorne
10e8c6d795 Skip attachments with unrecoverable errors during sms export. 2023-03-04 10:51:41 -05:00
Greyson Parrelli
57e8684bb3 Always use a foreground service when processing high-priority FCM pushes. 2023-03-04 10:51:41 -05:00
Greyson Parrelli
f91c400f6c Convert build-logic build.gradle to kotlin. 2023-03-04 10:51:41 -05:00
Ehren Kret
40f86ed2be Enable gradle caching on the Github CI system. 2023-03-04 10:51:20 -05:00
Alex Hart
ca8add87c6 Update design for who can find me fragment. 2023-03-03 10:40:55 -05:00
Alex Hart
a9c4fcf894 Refresh onboarding cards. 2023-03-03 10:40:55 -05:00
Nicholas
6bc5b19b1e Convert RegistrationCompleteFragment to Kotlin. 2023-03-03 10:40:55 -05:00
Nicholas
4990243a91 Ask for profile name on re-register if none present for number. 2023-03-03 10:40:55 -05:00
Greyson Parrelli
6922886395 Fix a bunch of random lint warnings. 2023-03-03 10:40:55 -05:00
Greyson Parrelli
ce4b7c2d7f Convert StaticIpResolver to kotlin. 2023-03-03 10:40:55 -05:00
Greyson Parrelli
21deb6803c Delete the buildSrc directory.
Moved all of the stuff we were using it for into build-logic.
2023-03-03 10:40:55 -05:00
Greyson Parrelli
873552436a Remove some unused RecipientTable code. 2023-03-03 10:40:55 -05:00
Greyson Parrelli
8334db5273 Validate E164's in ContactRecords. 2023-03-03 10:40:55 -05:00
Greyson Parrelli
33828439fb Use the websocket for FCM fetches. 2023-03-03 10:40:55 -05:00
Clark
4f31dc36ba Improve cold start by postponing voice note service creation. 2023-03-03 10:40:55 -05:00
Cody Henthorne
0a971569d9 Bump version to 6.13.6 2023-03-03 10:28:27 -05:00
Cody Henthorne
06476c80f8 Updated language translations. 2023-03-03 10:23:22 -05:00
Nicholas
d1d73fef30 Support multiple sequential captcha challenges. 2023-03-03 09:51:27 -05:00
Nicholas
89ad213994 Bump version to 6.13.5 2023-03-01 10:47:05 -05:00
Nicholas
6969c6d6ee Updated language translations. 2023-03-01 10:45:56 -05:00
Cody Henthorne
b82f6f83ec Fix network on main thread crash. 2023-03-01 10:17:12 -05:00
Nicholas
d3572f92f5 Bump version to 6.13.4 2023-02-28 15:44:20 -05:00
Nicholas
41126ba913 Updated language translations. 2023-02-28 15:40:43 -05:00
Nicholas Tinsley
0a00413228 Clear unauthorized banner on registration. 2023-02-28 11:42:28 -05:00
Nicholas Tinsley
e810eeec58 Add debugging logs for PIN re-register flow. 2023-02-28 11:24:06 -05:00
Nicholas
5f0035b2d0 Sort country codes as strings rather than ints. 2023-02-28 09:07:20 -05:00
Cody Henthorne
a20d5fd6cf Fix crash when trying to cancel alarm without permission. 2023-02-27 16:57:51 -05:00
Cody Henthorne
191b2076c3 Bump version to 6.13.3 2023-02-24 19:14:41 -05:00
Cody Henthorne
35779e8df3 Updated language translations. 2023-02-24 19:03:50 -05:00
Cody Henthorne
06bec76371 Use recovery flow for change number when possible. 2023-02-24 16:22:43 -05:00
Cody Henthorne
ff76c4cdef Fix keyboard not always auto-showing in registration screens. 2023-02-24 15:59:58 -05:00
Cody Henthorne
42da07b763 Fix incorrect fcm status when reregistering with recovery. 2023-02-24 15:51:30 -05:00
Cody Henthorne
3e69ef8acc Attempt to auto-resolve after being locked out if local data is available. 2023-02-24 11:24:34 -05:00
Nicholas Tinsley
ab48aa5766 Do not force +1 country code when restoring registration state. 2023-02-24 11:05:45 -05:00
Cody Henthorne
e37d3be73a Bump version to 6.13.2 2023-02-23 20:20:50 -05:00
Cody Henthorne
16c2609dab Updated language translations. 2023-02-23 20:01:05 -05:00
Nicholas
e4d4a5d9e0 Adapt change number flow to use V2 API. 2023-02-23 19:56:32 -05:00
Greyson Parrelli
a552a5a5bc Updated in-app language selector to always use native language name. 2023-02-23 19:11:46 -05:00
Greyson Parrelli
13d48b880b Add language support for Cantonese. 2023-02-23 18:58:39 -05:00
Cody Henthorne
6cb8c7a8a9 Ensure attributes are updated with latest properties. 2023-02-23 16:51:25 -05:00
Chris Eager
ae3ff21689 Update spam reporting token JSON field name. 2023-02-23 16:27:14 -05:00
Cody Henthorne
d3c3986100 Bump version to 6.13.1 2023-02-23 12:38:57 -05:00
Cody Henthorne
6f3c095a95 Updated language translations. 2023-02-23 12:30:21 -05:00
Cody Henthorne
6ee04f6574 Fix crash after entering incorrect pin for registration lock. 2023-02-23 12:24:39 -05:00
Nicholas
f3922c4156 Fix bottom sheet behavior and design. 2023-02-23 12:24:39 -05:00
Cody Henthorne
2ffc576387 Fix verification file for windows builders. 2023-02-23 12:24:39 -05:00
Cody Henthorne
583f7db554 Clear old group rings on startup. 2023-02-23 12:24:39 -05:00
Cody Henthorne
1cffd88af2 Fix crashes during skip SMS flow. 2023-02-23 12:24:39 -05:00
Cody Henthorne
01351125f1 Fix reporting token data bug. 2023-02-23 08:08:21 -05:00
Greyson Parrelli
19d67d1111 Bump version to 6.13.0 2023-02-22 22:35:00 -05:00
Greyson Parrelli
8cec6a8b0c Updated language translations. 2023-02-22 22:34:30 -05:00
Greyson Parrelli
e8b3d2c7aa Improve logging around message processing. 2023-02-22 22:26:14 -05:00
Cody Henthorne
62414e72b5 Add support for pin entry sad paths. 2023-02-22 22:26:14 -05:00
Nicholas
afb9b76208 Bug fixes for the new registration flow. 2023-02-22 22:26:14 -05:00
Cody Henthorne
4f458a022f Add skip SMS flow. 2023-02-22 22:26:14 -05:00
Nicholas
a47e3900c1 Implement session-based account registration API. 2023-02-22 22:11:58 -05:00
Jim Gustafson
3de17fa2d0 Update to RingRTC v2.25.0 2023-02-22 19:09:55 -05:00
Clark
7abf358ac4 Pre-cache conversation_list_item_view to speed up cold start. 2023-02-22 16:50:08 -05:00
Greyson Parrelli
64d5cbce3d Fix bug where you could choose to add someone already in a group. 2023-02-22 16:08:39 -05:00
Greyson Parrelli
691ab353da Fix possible dlist sync crash, improved debugging.
Fixes #12795
2023-02-22 14:18:41 -05:00
Greyson Parrelli
b689ea62a6 Fix using system emoji in condensed message mode. 2023-02-22 13:14:53 -05:00
Greyson Parrelli
17aa0365d6 Handle keepMutedChatsArchived more generically, also apply it to sends.
Fixes #12788
2023-02-22 13:14:44 -05:00
Greyson Parrelli
3f93d4b9fc Change ReportSpamJob lifespan to 1 day. 2023-02-22 10:59:09 -05:00
Greyson Parrelli
263fb9fc04 Use null when submitting empty reporting tokens.
Cleaned up a few things too, just spacing and stuff.
2023-02-22 10:59:04 -05:00
Greyson Parrelli
3ebafca297 Validate that reporting token is non-null and non-empty. 2023-02-22 10:33:56 -05:00
Greyson Parrelli
316df00287 Save username when applying update to AccountRecord. 2023-02-22 09:23:49 -05:00
Greyson Parrelli
b92346d4ae Make our FABs rounded rects again. 2023-02-21 14:42:04 -05:00
Greyson Parrelli
7bdb5fd76c Update material library to 1.8.0
Fixes #12792
2023-02-21 11:32:24 -05:00
Greyson Parrelli
dad9980a80 Add Observable for LiveRecipient. 2023-02-21 11:32:24 -05:00
Greyson Parrelli
21df032b04 Mark Recipient.self() as needing sync after change PNP setting. 2023-02-21 11:32:24 -05:00
Alex Hart
7edebe9fa1 Clean up a couple warnings in MediaPreviewV2Fragment. 2023-02-21 11:32:24 -05:00
Alex Hart
a398745740 Implement username is out of sync banner. 2023-02-21 11:32:24 -05:00
Greyson Parrelli
4954be109c Bump version to 6.12.5 2023-02-21 09:59:42 -05:00
Greyson Parrelli
7380d4b11e Updated language translations. 2023-02-21 09:59:19 -05:00
Greyson Parrelli
d8a6f9c324 Fix possible crash when finishing animation. 2023-02-21 09:45:55 -05:00
Greyson Parrelli
ab9057cb25 Fix possible crash during backup restore. 2023-02-21 09:45:44 -05:00
Greyson Parrelli
ba8ea3b54b Bump version to 6.12.4 2023-02-17 15:33:51 -05:00
Greyson Parrelli
d9aca34eee Updated language translations. 2023-02-17 15:33:09 -05:00
Greyson Parrelli
05d232beec Fix possible crash in conversation fragment startup. 2023-02-17 15:25:45 -05:00
Greyson Parrelli
fde0726500 Fix bug where you couldn't add new raw phone numbers to groups. 2023-02-17 15:17:18 -05:00
Greyson Parrelli
bb8b987833 Ignore lastProfileFetchTime when determining Recipient equality.
Was resulting in some unnecessary Recipient re-renders during
conversation open.
2023-02-17 10:12:42 -05:00
Alex Hart
f066fb8ea2 Tweak media transition fade. 2023-02-16 17:26:53 -04:00
Greyson Parrelli
7738c286c2 Bump version to 6.12.3 2023-02-16 16:08:47 -05:00
Greyson Parrelli
697670b334 Updated language translations. 2023-02-16 16:08:47 -05:00
Greyson Parrelli
4cfba86cb1 Fix bug where username wasn't synced to ContactRecord. 2023-02-16 16:08:47 -05:00
Greyson Parrelli
ce4e84aadc Remove crash for group remap updates. 2023-02-16 16:08:47 -05:00
Cody Henthorne
23d0152767 Use newer APIs for wave form generation. 2023-02-16 16:08:47 -05:00
Greyson Parrelli
d714590d3f Address bioauth issues on API 28. 2023-02-16 16:08:47 -05:00
Alex Hart
65bc1263f3 Add final copy for username deletion snackbar. 2023-02-16 16:08:47 -05:00
Alex Hart
730065fc76 Add support URL for usernames. 2023-02-16 16:08:47 -05:00
Alex Hart
1e10b82769 Fix gif sizing in conversation. 2023-02-16 16:08:47 -05:00
Cody Henthorne
01f477a587 Fix voice note UX issues. 2023-02-16 16:08:47 -05:00
Alex Hart
76383fe1bc Fix RTL corners in AlbumThumbnailView. 2023-02-16 16:08:47 -05:00
Greyson Parrelli
c61f45b88b Remove old private certificate authority. 2023-02-16 16:08:47 -05:00
Greyson Parrelli
e559198495 Fix crash when creating backup. 2023-02-16 16:08:47 -05:00
Greyson Parrelli
8676cb27ae Don't show add contact button if the user has no e164.
Fixes #12570
2023-02-16 16:08:46 -05:00
Alex Hart
7215ca6a28 Fix issue where view padding would not properly update on rotation. 2023-02-16 16:08:46 -05:00
Alex Hart
ef11a8d98d Fix navbar flashing on transform. 2023-02-16 16:08:46 -05:00
Greyson Parrelli
6efd501f1c Don't send MR accept for unblocking.
That's handled by storage service now.
2023-02-15 18:07:40 -05:00
Greyson Parrelli
66c650e859 Bump version to 6.12.2 2023-02-15 17:35:23 -05:00
Greyson Parrelli
8141f7a5cf Updated language translations. 2023-02-15 17:35:23 -05:00
Greyson Parrelli
0fcdf61e76 Revert "Don't run FTS optimize job (for now)."
This reverts commit f26b2c0b2a.
2023-02-15 17:35:23 -05:00
Greyson Parrelli
fa571f14e6 Update SQLCipher to 4.5.3-FTS-S2 2023-02-15 17:35:23 -05:00
Cody Henthorne
583860053b Cancel scheduled message alarm if no messages are scheduled. 2023-02-15 17:35:23 -05:00
Alex Hart
ad70baf557 Add documentation to DisplaySecondaryInformation. 2023-02-15 17:35:23 -05:00
Cody Henthorne
2b0e9783a7 Fix invalid attachment data during sms export. 2023-02-15 17:35:23 -05:00
Alex Hart
c75a9b577d Prefer about over phone number. 2023-02-15 17:35:23 -05:00
Cody Henthorne
e8ff1a04ed Fix scrolling issue in transfer lock dialog for small displays. 2023-02-15 17:35:22 -05:00
Cody Henthorne
a22a696722 Fix missing padding in schedule message time picker. 2023-02-15 17:35:22 -05:00
Alex Hart
66494fa418 Fix transition for very tall images. 2023-02-15 17:35:22 -05:00
Greyson Parrelli
9fd763fe83 Bump version to 6.12.1 2023-02-15 13:32:37 -05:00
Greyson Parrelli
1d508ad5cc Updated language translations. 2023-02-15 13:31:58 -05:00
Alex Hart
c1c7f57ec0 Fix sticker scaling. 2023-02-15 13:31:58 -05:00
Cody Henthorne
6100160e18 Address various issues with dark theme registration flow. 2023-02-15 13:24:15 -05:00
Alex Hart
e2c3db3eda Fix miscalculation of groups in common. 2023-02-15 13:24:15 -05:00
Alex Hart
6759b59507 Fix toolbar height and fade in. 2023-02-15 13:24:15 -05:00
Greyson Parrelli
e36844fe78 Improve logging around unblocking. 2023-02-15 13:24:15 -05:00
Alex Hart
5cf937215a Fix stub of TransferControlsView. 2023-02-15 13:24:15 -05:00
Alex Hart
1b49b9bffb Hide system UI until the shared element transition completes. 2023-02-15 13:24:15 -05:00
Alex Hart
a3a29d5cb2 Prevent shared element animation when we're not on the initial media. 2023-02-15 13:24:15 -05:00
Nicholas
6fbfb87bd6 Restore entered phone number post-captcha. 2023-02-15 13:24:15 -05:00
Alex Hart
2bff2d3a30 Disable shared element transitions from bubble. 2023-02-15 13:24:15 -05:00
Cody Henthorne
a88410faaf Fix incorrect quick react emojis for story replies. 2023-02-15 13:24:15 -05:00
Greyson Parrelli
f26b2c0b2a Don't run FTS optimize job (for now). 2023-02-15 13:24:15 -05:00
Alex Hart
6f1b03eac6 Utilize fade instead of just setting alpha to 0. 2023-02-15 10:57:54 -04:00
Cody Henthorne
9610339f38 Improve UX around seeing audio wave forms.
- Attempts to generate the wave form on download instead on display
- Allows multi-threaded generation of wave forms instead of serial
  executor
2023-02-15 09:43:16 -05:00
Alex Hart
d4ce8458a4 Ensure e164s are pretty-printed. 2023-02-15 10:10:25 -04:00
Greyson Parrelli
384cdf8610 Bump version to 6.12.0 2023-02-14 22:47:48 -05:00
Greyson Parrelli
3ee30808de Updated language translations. 2023-02-14 22:47:20 -05:00
Greyson Parrelli
78c64880f7 Fix instance where PNI may be accessed too early. 2023-02-14 14:51:29 -05:00
Greyson Parrelli
b99ce9cc1d Fix typo in string. 2023-02-14 14:30:25 -05:00
Greyson Parrelli
41f796d809 Update CDSI_MRENCLAVE. 2023-02-14 14:28:22 -05:00
Alex Hart
ec504af593 Improve conversation open speed. 2023-02-14 15:10:08 -04:00
Greyson Parrelli
60874ba57b Fix contact name syncing to storage service. 2023-02-14 14:03:09 -05:00
Greyson Parrelli
4397b5af25 Add support for storing systemNickname from storage service. 2023-02-14 14:03:09 -05:00
Rashad Sookram
07234443c6 Update verification metadata for MacOS. 2023-02-14 14:03:09 -05:00
Alex Hart
c027203e8c Polish thumbnail animation. 2023-02-14 14:03:09 -05:00
Alex Hart
417db2341b Utilize drawable instead of bitmap for transition. 2023-02-14 14:03:09 -05:00
Bernie Dolan
6aa4ef95b5 Update payments to 4.0.0.1 2023-02-14 14:03:09 -05:00
Greyson Parrelli
6145fa213e Move common gradle config into convention plugins. 2023-02-14 14:03:09 -05:00
Greyson Parrelli
9fa4741e49 Update ContactRecord.hidden field to value 20. 2023-02-14 14:03:09 -05:00
Greyson Parrelli
b9d5fb54c3 Allow using the location picker with approximate location. 2023-02-14 14:03:09 -05:00
Greyson Parrelli
c0fe156897 Do not fail message inserts on bad quote attachments.
Fixes #12721
2023-02-14 14:03:09 -05:00
Alex Hart
22cad64089 Clean up ThumbnailView warnings. 2023-02-14 14:03:09 -05:00
Alex Hart
702cf6ef71 Remove unused layout class. 2023-02-14 14:03:09 -05:00
Alex Hart
d7c3112602 Speed up thumbnail transition. 2023-02-14 14:03:09 -05:00
Greyson Parrelli
d9c31a6cd6 Update AGP to 7.4.0 2023-02-14 14:03:09 -05:00
Greyson Parrelli
408c288936 Convert MediaTable to kotlin. 2023-02-14 14:03:09 -05:00
Greyson Parrelli
af6f16bdb6 Move Backups.proto to Wire. 2023-02-14 14:03:09 -05:00
Cody Henthorne
055ceba398 Add 'AnyAddressPorts' calling field trial flag. 2023-02-14 14:03:08 -05:00
Greyson Parrelli
3f81a94176 Fix case where we were performing remote inserts. 2023-02-14 14:02:23 -05:00
Greyson Parrelli
a02d2e467b Revert "Remove the unknown insert validation."
This reverts commit 320669c54e.
2023-02-14 14:02:23 -05:00
Greyson Parrelli
414550861e Prevent recursive early content processing. 2023-02-14 14:02:23 -05:00
Greyson Parrelli
afbce6f800 Re-enable FTS optimization after deletes. 2023-02-14 14:02:23 -05:00
Alex Hart
dda5037429 Add stubbing to ConversationThumbnailView and caching to a typeface. 2023-02-14 14:02:23 -05:00
Greyson Parrelli
ffbebe0670 Update SQLCipher to 4.5.3-FTS-S1 2023-02-14 14:02:23 -05:00
Alex Hart
cf250b4b32 Add catch for candidate generation error to be treated the same as username unavailable. 2023-02-14 14:02:23 -05:00
Nicholas Tinsley
b14aea0922 Support dark mode in verification code keyboard. 2023-02-14 14:02:23 -05:00
Alex Hart
d0de43a6b2 Add thumbnail shared element animation. 2023-02-14 14:02:23 -05:00
Alex Hart
2c48d40375 Update API endpoints and integration for usernames. 2023-02-14 14:02:23 -05:00
Greyson Parrelli
803154c544 Add a new PNP build flavor. 2023-02-14 14:02:23 -05:00
Greyson Parrelli
684150dc1e Handle split contacts in storage service when in PNP mode. 2023-02-14 14:02:22 -05:00
Greyson Parrelli
fdcf0a76e8 Split unregistered contacts when in PNP mode. 2023-02-14 14:02:22 -05:00
Greyson Parrelli
9e056e5dd0 Add support for rendering session switchover events. 2023-02-14 14:02:22 -05:00
Cody Henthorne
03c68375db Fix bad group state when requesting to rejoin a group. 2023-02-14 14:02:22 -05:00
Alex Hart
5d328857aa Upgrade libsignal to 0.22.0 2023-02-14 14:02:22 -05:00
Cody Henthorne
3a0dbe6e67 Use alarm clock for scheduling message sends. 2023-02-14 14:02:22 -05:00
Cody Henthorne
56b35f3767 Fix quoted links from rendering as clickable. 2023-02-14 14:02:22 -05:00
Greyson Parrelli
7f0221c5c6 Prefer MessageRecord mismatches when updating SN's. 2023-02-14 14:02:22 -05:00
Cody Henthorne
23050152de Replace time duration picker dialog for screen lock timeout. 2023-02-14 14:02:22 -05:00
Alex Hart
db65edb7df Mark DSL api discouraged. 2023-02-14 14:02:22 -05:00
Alex Hart
605289aca4 Upgrade ktlint and add twitter compose rules. 2023-02-14 14:02:22 -05:00
Jim Gustafson
52e9b31554 Update to RingRTC v2.24.0 2023-02-14 14:02:22 -05:00
Alex Hart
c8e6ccc0c0 Add extended colors to SignalTheme. 2023-02-14 14:02:22 -05:00
Alex Hart
f20d929292 Add Buttons object for properly themed compose buttons. 2023-02-14 14:02:22 -05:00
Greyson Parrelli
a9accfb074 Bump version to 6.11.7 2023-02-14 14:01:40 -05:00
Greyson Parrelli
8f2d1a2d12 Updated language translations. 2023-02-14 14:01:40 -05:00
Greyson Parrelli
ca8755c6ad Inline the scheduled message feature flag. 2023-02-14 14:01:40 -05:00
Cody Henthorne
dc4eb7911d Bump version to 6.11.6 2023-02-13 13:43:23 -05:00
Cody Henthorne
eb2e0205ae Updated language translations. 2023-02-13 13:31:23 -05:00
Cody Henthorne
7a72a9a0d7 Fix memory leak in conversation fragment. 2023-02-13 13:07:32 -05:00
Alex Hart
805ccc4f7a Bump version to 6.11.5 2023-02-10 16:13:23 -04:00
Alex Hart
499b186b68 Updated language translations. 2023-02-10 16:12:21 -04:00
Cody Henthorne
c741e32824 Fix stale thread id when a conversation is deleted. 2023-02-10 13:08:51 -05:00
Alex Hart
fba4c882cb Bump version to 6.11.4 2023-02-09 16:16:41 -04:00
Alex Hart
24ef853f24 Updated language translations. 2023-02-09 16:16:04 -04:00
Nicholas
9f22ba68ea Set PIN entry text to use dynamic theme colors. 2023-02-09 13:45:26 -05:00
Greyson Parrelli
d8eac87219 Cleanup dangling MSL rows. 2023-02-09 13:41:32 -05:00
Greyson Parrelli
cf71e2cfa8 Bump version to 6.11.3 2023-02-08 21:21:36 -05:00
Greyson Parrelli
7f16d0653c Updated language translations. 2023-02-08 21:20:43 -05:00
Greyson Parrelli
61e127fabf Fix method to find MMS group. 2023-02-08 21:13:14 -05:00
Alex Hart
7ffdf91ce5 Bump version to 6.11.2 2023-02-07 15:06:25 -04:00
Alex Hart
4c26fe432e Updated language translations. 2023-02-07 15:06:25 -04:00
Cody Henthorne
6c78a405bb Fix backup scheduling looping bug. 2023-02-07 15:06:25 -04:00
Cody Henthorne
89b0167fd2 Ensure backup job verification can be cancelled. 2023-02-07 11:19:26 -05:00
Alex Hart
e25133fa29 Bump version to 6.11.1 2023-02-06 17:15:26 -04:00
Alex Hart
4ba77c0f9f Updated language translations. 2023-02-06 17:09:45 -04:00
Nicholas Tinsley
10f376e402 Catch new audio recording error states. 2023-02-06 16:53:08 -04:00
Cody Henthorne
7bae8b6e1b Fix scheduled message sends changing thread disappearing message timer. 2023-02-06 16:53:08 -04:00
Cody Henthorne
67fb9d09d4 Fix scheduled send in note to self with no linked devices. 2023-02-06 16:53:08 -04:00
Nicholas
3b40b10a77 Try to check group mute status for keeping archived. 2023-02-06 16:53:08 -04:00
Cody Henthorne
418b486776 Fix crash when scheduling a message in an empty thread. 2023-02-06 16:53:08 -04:00
Cody Henthorne
1f31f4a50a Adjust SMS phases and show Phase 3 start date. 2023-02-06 16:53:08 -04:00
Cody Henthorne
9b08ebcc1d Update QR code and send symbols. 2023-02-06 16:53:08 -04:00
Nicholas
aec4944c56 Allow V1 groups to be deleted by clearing app data. 2023-02-06 16:53:08 -04:00
Nicholas Tinsley
9a1f8af703 Add Material3 SVG assets to Registration. 2023-02-06 16:53:08 -04:00
Greyson Parrelli
268b11c4e1 Fix bug in MSL table definition. 2023-02-06 16:53:08 -04:00
Nicholas Tinsley
2e3d73f44b Media preview design tweaks. 2023-02-06 16:53:08 -04:00
Alex Hart
f477a4dae9 Add compose bottom-sheet handle. 2023-02-06 16:53:08 -04:00
Greyson Parrelli
a41aed20e1 Fix issue where group stories weren't syncing to linked devices. 2023-02-03 12:08:21 -05:00
Alex Hart
1ed3dbb147 Add svg asset for username megaphone. 2023-02-03 09:46:42 -04:00
Alex Hart
fcfb9fad01 Add svg assets to username education screen. 2023-02-03 09:45:13 -04:00
Alex Hart
25c96a6be6 Resolve crashing when trying to get the header letters for the contacts section of search. 2023-02-03 09:33:18 -04:00
Nicholas Tinsley
90695182f3 Bump version to 6.11.0 2023-02-02 17:55:33 -05:00
Nicholas Tinsley
1c38ab18b8 Updated language translations. 2023-02-02 17:55:12 -05:00
Alex Hart
7c716e5525 Fix slow kotlin build. 2023-02-02 17:22:40 -05:00
Cody Henthorne
56a44ae65c Enforce expected ordering when scheduling text and media messages. 2023-02-02 17:22:40 -05:00
Nicholas
d33aa247db Fix composer voice memo cancellation due to focus loss. 2023-02-02 17:22:40 -05:00
Alex Hart
63a153571d Add generic Compose fragment. 2023-02-02 17:22:40 -05:00
Alex Hart
fb07e897d0 Mark username megaphone completion after hitting continue. 2023-02-02 17:22:40 -05:00
Alex Hart
93387ec79a Add deletion snackbar for usernames with temporary copy. 2023-02-02 17:22:40 -05:00
Alex Hart
cd79dbbb82 Add Username UI updates. 2023-02-02 17:22:40 -05:00
Alex Hart
7fbfc09a89 Refactor ContactSelectionListFragment to use ContactSearch infrastructure. 2023-02-02 17:22:40 -05:00
Alex Hart
0f6bc0471c Add core-ui module and Jetpack Compose. 2023-02-02 17:22:40 -05:00
Alex Hart
ba919d4ecc Add proper styling for text inputs. 2023-02-02 17:22:40 -05:00
Alex Hart
73722297cf Fix membership query to account for active state and mms state. 2023-02-02 17:22:40 -05:00
Greyson Parrelli
6050a9f585 Update feature flag constant. 2023-02-02 17:22:40 -05:00
Cody Henthorne
b243eee4ce Fix incorrect unread count after sending scheduled messages. 2023-02-02 17:22:40 -05:00
Greyson Parrelli
a91a13cead Introduce Wire for proto codegen. 2023-02-02 17:22:40 -05:00
Nicholas
72449fd73e Store & submit spam reporting token from server. 2023-02-02 17:22:40 -05:00
Cody Henthorne
6a8e82ef91 Prevent scheduling of sends when alarm permission is denied. 2023-02-02 17:22:40 -05:00
Greyson Parrelli
987fafff92 Remove is_mms field from MSL tables. 2023-02-02 17:22:40 -05:00
Cody Henthorne
35ff977df9 Fix position calculation for conversations with scheduled messages. 2023-02-01 19:23:12 -05:00
Cody Henthorne
fe2d71fca0 Delete pending scheduled messages when leaving a group. 2023-02-01 19:04:29 -05:00
Greyson Parrelli
a12a246e87 Add foreign key constraint details to Spinner. 2023-02-01 17:41:28 -05:00
Alex Hart
4f387cf8d9 Add username education screen. 2023-02-01 17:41:28 -05:00
Cody Henthorne
dae69744c2 Prevent schedule send UI from showing in story send flow. 2023-02-01 17:41:28 -05:00
Cody Henthorne
4ad233c6d1 Show will send immediately warning if scheduled send is in the past. 2023-02-01 17:41:28 -05:00
Cody Henthorne
b4c572678c Fix incorrect ripple effect in search box.
Fixes #12641
2023-02-01 17:41:28 -05:00
Cody Henthorne
5024998a6f Improve clarity of screen lock timeout duration. 2023-02-01 17:41:28 -05:00
Jim Gustafson
43cde19071 Remove redundant logging.
And move the backup stun server to the end of the list.
2023-02-01 17:41:28 -05:00
Greyson Parrelli
62a2f3d8ba Fix bug when highlighting search results. 2023-02-01 17:41:28 -05:00
Greyson Parrelli
5bc44fa586 Improve network reliability. 2023-02-01 17:41:28 -05:00
Greyson Parrelli
f0b3aa66f7 Revert "Enable gradle configuration cache."
This reverts commit 6e5b4bbc15.
2023-02-01 17:41:28 -05:00
Clark
ef9cd2515e Add new story reaction bar. 2023-02-01 17:41:28 -05:00
Greyson Parrelli
4677f207e7 Rotate scheduled message feature flag. 2023-02-01 17:41:28 -05:00
Bernie Dolan
4c26f3258d Update Mobilecoin testnet enclave values. 2023-02-01 17:41:28 -05:00
Cody Henthorne
77a3037614 Update icons in popup/context menus. 2023-02-01 17:41:28 -05:00
Alex Hart
9600d6f6a9 Add different menu copy for clearing the enabled chat filter. 2023-02-01 17:41:28 -05:00
Alex Hart
36dfa19aec Add "contacts without threads" section to Conversation List Search. 2023-02-01 17:41:28 -05:00
Alex Hart
09902e5d11 Add "Group Members" section to ConversationList search results. 2023-02-01 17:41:27 -05:00
Nicholas Tinsley
e84c6187b9 Bump version to 6.10.5 2023-02-01 16:59:54 -05:00
Nicholas Tinsley
b5d52db57c Add logging for audio recorder exceptions. 2023-02-01 11:41:30 -05:00
Alex Hart
f320cf8833 Add factory for story privacy view model. 2023-02-01 12:22:55 -04:00
Cody Henthorne
c76ca957e1 Prevent keyboard from closing immediately after opening. 2023-02-01 10:35:42 -05:00
Cody Henthorne
56354f6aae Fix memory leak of schedule message observers. 2023-02-01 10:22:17 -05:00
Nicholas Tinsley
2bf84a5f77 Fix country code dropdown during registration. 2023-02-01 09:54:44 -05:00
Nicholas Tinsley
8b23d9a6c4 Bump version to 6.10.4 2023-01-31 17:20:05 -05:00
Nicholas Tinsley
2cae3ddf04 Updated language translations. 2023-01-31 17:20:05 -05:00
Greyson Parrelli
670b6c4c56 Revert "Upgrade to Glide 4.14.2"
This reverts commit 3ee889cb79.
2023-01-31 16:15:33 -05:00
Alex Hart
eceed641bf Fix leak in recording session. 2023-01-31 16:15:33 -05:00
Nicholas
5febe6490c Null check Media URI in save task. 2023-01-31 16:15:33 -05:00
Nicholas
dca47e4cb5 Strip mention Spans out of media captions. 2023-01-31 16:15:33 -05:00
Nicholas
c3bcba6380 Design improvements for registration flow. 2023-01-31 16:15:33 -05:00
Alex Hart
cb01692a50 Fix localization of note to self string in search. 2023-01-31 10:07:50 -04:00
Alex Hart
e90074ffef Fix issue with bottom sheets. 2023-01-31 10:00:25 -04:00
Alex Hart
691520bc75 Clean out dead code from contact search. 2023-01-30 12:52:27 -04:00
Greyson Parrelli
e6de06be6f Bump version to 6.10.3 2023-01-30 10:40:13 -05:00
Greyson Parrelli
a77079ac81 Updated language translations. 2023-01-30 10:39:52 -05:00
Greyson Parrelli
30b58fe5f4 Don't run FTS optimize job (for now). 2023-01-30 10:39:41 -05:00
Greyson Parrelli
7275b95b58 Bump version to 6.10.2 2023-01-27 17:42:22 -05:00
Greyson Parrelli
f04d46b4ed Updated language translations. 2023-01-27 17:42:22 -05:00
Greyson Parrelli
e12bbe943b Restore the 3-dot menu when creating a PIN. 2023-01-27 17:42:22 -05:00
Greyson Parrelli
7348224dc2 Prevent thread trimming from gumming up the database. 2023-01-27 17:42:22 -05:00
Cody Henthorne
30c33fdd77 Fix issues with scheduled messages and quotes.
- Tapping quote in schedule view will jump to message in chat
- Scheduling a quote will not make the quoted message render as "isQuoted"
- Scheduled quotes will not appear in the quoted message's sheet of replies
- Fixes an off-by-N where N = # of scheduled messages when calculating location for jumping to a message
2023-01-27 17:42:22 -05:00
Alex Hart
e7339af119 Add fix for group membership query. 2023-01-27 17:42:22 -05:00
Cody Henthorne
661fff7a0e Fix scheduled messages being sent out of order. 2023-01-27 17:42:22 -05:00
Alex Hart
c37bad0f7a Fix opening filter when swiping from within collapsingtoolbar. 2023-01-27 17:42:22 -05:00
Alex Hart
7f228fc0fd Do not display add to story if stories are disabled. 2023-01-27 17:42:22 -05:00
Cody Henthorne
14cd216668 Fix crash when delete scheduled message dialog is open and message sends. 2023-01-27 17:42:22 -05:00
Cody Henthorne
0cb0ef977c Add calendar icon to pick date and time item.
Co-authored-by: Sgn-32 <49990901+Sgn-32@users.noreply.github.com>
2023-01-27 17:42:22 -05:00
Cody Henthorne
1761529ce9 Disable schedule message for SMS. 2023-01-27 17:42:22 -05:00
Clark
a14fc82e83 Fix scheduled view once message looking weird. 2023-01-27 17:42:22 -05:00
Clark
b94f5501d9 Disable scheduling of voice note messages. 2023-01-27 17:42:21 -05:00
Clark
834283ba9b Hide scheduled messages bar with input panel. 2023-01-27 17:42:21 -05:00
Nicholas
23190a2f6e Fix NumericKeyboardView in RTL. 2023-01-27 11:06:04 -05:00
Alex Hart
04f4cd8edc Fix V172 Migration. 2023-01-27 10:20:44 -04:00
Greyson Parrelli
8f02e4e1f5 Bump version to 6.10.1 2023-01-26 20:25:28 -05:00
Greyson Parrelli
db81a5be04 Updated language translations. 2023-01-26 20:25:28 -05:00
Greyson Parrelli
fe40e37da4 Fix full text search migration after table name change. 2023-01-26 20:25:28 -05:00
Alex Hart
22a4271dfb Rotate paypal recurring donations flag. 2023-01-26 20:25:28 -05:00
Greyson Parrelli
1263b51e03 Patch random place where we forgot to update minSdk. 2023-01-26 20:25:28 -05:00
Nicholas
ca468047ef Adjust media caption height, fix rail visibility. 2023-01-26 20:25:28 -05:00
Clark
958c52a5b8 Force indexes for scheduled message queries. 2023-01-26 20:25:28 -05:00
Greyson Parrelli
9b28585c59 Add foreign key dependency between reactions and messages. 2023-01-26 20:25:27 -05:00
Clark
c5c60b7214 Add permissions dialogs for scheduled messages. 2023-01-26 20:25:27 -05:00
Nicholas
31bcc2e2eb Finish MediaPreviewV2Activity when jumping to a message. 2023-01-26 20:25:27 -05:00
Cody Henthorne
71ecba17fc Fix crash when saving empty formatted text drafts. 2023-01-26 20:25:27 -05:00
Greyson Parrelli
afa5c68312 Periodically optimize the FTS index. 2023-01-26 20:25:27 -05:00
Clark
f3e715e069 Add support for scheduled message sends. 2023-01-26 20:25:27 -05:00
Alex Hart
df695f7611 Fix crash when trying to create new group story.
Adds INNER JOIN to threads table to allow access to date in ORDER BY
2023-01-26 20:25:27 -05:00
Greyson Parrelli
27e1bc0854 Bump version to 6.10.0 2023-01-25 17:11:34 -05:00
Greyson Parrelli
f4371b9e96 Updated language translations. 2023-01-25 17:08:53 -05:00
Cody Henthorne
e0633180ef Fix crash when trying to update a group call without an era id. 2023-01-25 17:02:41 -05:00
Alex Hart
32dd227ab6 Utilize left join instead of inner join when querying groups. 2023-01-25 17:02:41 -05:00
Cody Henthorne
0deed9d4d2 Fix notification sound not respecting notification volume.
Fix is immediate for general messages channel and for future custom channel creation
2023-01-25 17:02:41 -05:00
Cody Henthorne
cc490f4b73 Add text formatting send and receive support for conversations. 2023-01-25 17:02:41 -05:00
Cody Henthorne
aa2075c78f Attempt to fix view jitter when switching keyboards. 2023-01-25 17:02:41 -05:00
Alex Hart
b4a34599d7 Add support for message and thread results. 2023-01-25 17:02:41 -05:00
Nicholas
8dd1d3bdeb Allow user-selected backup time. 2023-01-25 17:02:41 -05:00
Greyson Parrelli
a7d9bd944b Insert session switchover events when appropriate. 2023-01-25 17:02:41 -05:00
Clark
7745ae62ea Add logging for voice note recording events. 2023-01-25 17:02:41 -05:00
Greyson Parrelli
6e5b4bbc15 Enable gradle configuration cache.
Android Studio told me to do this and that it would save me over 7
seconds. We'll see if it breaks anything :p
2023-01-25 17:02:41 -05:00
Clark
e3b38e6d38 Fix some thumbnail images not showing up in MediaGallery. 2023-01-25 17:02:41 -05:00
Clark
25aa4f39a3 Close input streams on failed resource decryption. 2023-01-25 17:02:41 -05:00
Cody Henthorne
17849e20bd Update group receipt table when sync'ing story sends to distribution lists. 2023-01-25 17:02:41 -05:00
Alex Hart
c022172ace Add group member results to contact search. 2023-01-25 17:02:41 -05:00
Nicholas
eaeeb08987 Display message text in Media Preview. 2023-01-25 17:02:41 -05:00
Alex Hart
1b7e4e047c Introduce ManyToMany table for group membership. 2023-01-24 14:18:28 -05:00
Clark
d635683303 Fix share intent not being cleared from recents. 2023-01-24 14:18:28 -05:00
Clark
4dcbbfdd63 Fix voice note draft not being generated on audio focus loss. 2023-01-24 14:18:28 -05:00
Nicholas
150bbf181d Redesign FTUX to use Material Design 3. 2023-01-24 14:18:28 -05:00
Alex Hart
0303467c91 Add PayPal decline code errors. 2023-01-24 14:18:28 -05:00
Nicholas
88da382a6f For calling purposes, categorize hearing aids as Bluetooth headsets. 2023-01-24 14:18:28 -05:00
Alex Hart
5d14166a27 Add support for arbitrary rows in contact search. 2023-01-24 14:18:28 -05:00
Jim Gustafson
d76d13f76c Update to RingRTC v2.23.1 2023-01-24 14:18:28 -05:00
Greyson Parrelli
ad4ec23875 Bump version to 6.9.2 2023-01-24 14:15:52 -05:00
Greyson Parrelli
61df2afc32 Updated language translations. 2023-01-24 14:15:52 -05:00
Nicholas Tinsley
1c6d2f7198 If enabled, don't unarchive muted group chats.
Fixes #12732
2023-01-24 14:13:42 -05:00
Cody Henthorne
df8f9761b2 Fix incorrect total sms export count. 2023-01-24 13:49:25 -05:00
Greyson Parrelli
657c5d2bce Bump version to 6.9.1 2023-01-20 18:12:37 -05:00
Greyson Parrelli
81324c6923 Updated language translations. 2023-01-20 18:12:17 -05:00
Greyson Parrelli
269a2e2990 Improve timer event generation from GV2 sync messages. 2023-01-20 17:58:17 -05:00
clark-signal
73b453b0d4 Fix re-used share intent when restarting task from recent activities. 2023-01-20 13:42:13 -05:00
Cody Henthorne
97604dc4c5 Bump version to 6.9.0 2023-01-19 13:44:21 -05:00
Cody Henthorne
8e1c05ed64 Updated language translations. 2023-01-19 13:38:09 -05:00
Nicholas
231b55a956 Don't show media controls on new pages. 2023-01-19 13:33:07 -05:00
Alex Hart
4fcdee9fa5 Rotate PayPal recurring donations feature flag. 2023-01-19 13:33:07 -05:00
Cody Henthorne
6e2e5e21cc Update copy and behavior of SMS phased removal flow. 2023-01-19 13:33:07 -05:00
Alex Hart
8f49323648 Extract adapter creation from ContactSearchMediator. 2023-01-19 13:33:07 -05:00
Greyson Parrelli
13f969b622 Fix an app migration.
Fixes #12730
2023-01-19 13:33:07 -05:00
Greyson Parrelli
518d9b3984 Update SQLCipher to 4.5.1-S1
This reverts commit d1894caea6.
2023-01-19 13:33:07 -05:00
Alex Hart
8e313f8387 Collapse KnownRecipient / Story into single model. 2023-01-19 13:33:07 -05:00
Nicholas
70c6e9e60f Store additional data that will allow us to reduce the number of verification SMSs. 2023-01-19 13:32:35 -05:00
Cody Henthorne
dcf8a82c37 Fix no snippet being shown for threads.
Snippet query wasn't updated to exclude SMS types after the conjuction
of the tables.
2023-01-19 13:32:35 -05:00
Alex Hart
f368e5b133 Suppress deselection error when opening gallery from chat. 2023-01-19 13:32:35 -05:00
Alex Hart
3ee889cb79 Upgrade to Glide 4.14.2 2023-01-17 14:30:48 -05:00
Greyson Parrelli
3e7dc79fe8 Remove unnecessary code now that minSdk is 21. 2023-01-17 14:30:48 -05:00
Greyson Parrelli
8cfd02aff2 Bump minSdk to 21. 2023-01-17 14:30:48 -05:00
Alex Hart
f36efc562e Fix improper filtering of unread conversations. 2023-01-17 14:30:48 -05:00
Sgn-32
67b6b109de Use ic_save_24_tinted instead of ic_download_24_tinted. 2023-01-17 14:30:48 -05:00
Alex Hart
8fd378db4e Fix issue where links do not render in stories if previews are off. 2023-01-17 14:30:48 -05:00
Cody Henthorne
760ace93d4 Enable PNI group invite processing. 2023-01-17 14:30:48 -05:00
Nicholas
125fd83afa Programmatically dismiss logged out notification on registration. 2023-01-17 14:30:48 -05:00
Alex Hart
7dcb598b66 Inline gift badge flag. 2023-01-17 14:30:48 -05:00
Alex Hart
4917e93d9f Update CameraX to 1.2.0 2023-01-17 14:30:48 -05:00
Alex Hart
1e9115a917 Increment compileSdkVersion to 33. 2023-01-17 14:30:48 -05:00
clark-signal
7af94f60ae Fix story linking from private story reply. 2023-01-17 14:30:48 -05:00
clark-signal
c3c8f8e7e6 Update icons across home screen and settings screen. 2023-01-17 14:30:48 -05:00
clark-signal
011c85c75b Fix "My subscription" dark mode overlapping background bubbles. 2023-01-17 14:30:47 -05:00
1365 changed files with 54028 additions and 23721 deletions

View File

@@ -2,3 +2,4 @@ root = true
[*.kt]
indent_size = 2
twitter_compose_allowed_composition_locals=LocalExtendedColors

View File

@@ -14,7 +14,7 @@ permissions:
jobs:
build:
runs-on: ubuntu-latest
runs-on: ubuntu-latest-8-cores
steps:
- uses: actions/checkout@v3
@@ -24,15 +24,13 @@ jobs:
with:
distribution: temurin
java-version: 11
cache: gradle
- name: Validate Gradle Wrapper
uses: gradle/wrapper-validation-action@v1
- name: Remove Android 31 (S)
run: $ANDROID_HOME/tools/bin/sdkmanager --uninstall "platforms;android-31"
- name: Build with Gradle
run: ./gradlew qa
run: ./gradlew qa --parallel
- name: Archive reports for failed build
if: ${{ failure() }}

View File

@@ -43,10 +43,7 @@
</JavaCodeStyleSettings>
<JetCodeStyleSettings>
<option name="PACKAGES_TO_USE_STAR_IMPORTS">
<value>
<package name="kotlinx.android.synthetic" alias="false" withSubpackages="true" />
<package name="io.ktor" alias="false" withSubpackages="true" />
</value>
<value />
</option>
<option name="NAME_COUNT_TO_USE_STAR_IMPORT" value="2147483647" />
<option name="NAME_COUNT_TO_USE_STAR_IMPORT_FOR_MEMBERS" value="2147483647" />

View File

@@ -1,37 +1,21 @@
import com.android.build.api.dsl.ManagedVirtualDevice
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'com.google.protobuf'
apply plugin: 'androidx.navigation.safeargs'
apply plugin: 'org.jlleitschuh.gradle.ktlint'
apply from: 'translations.gradle'
apply plugin: 'org.jetbrains.kotlin.android'
apply plugin: 'app.cash.exhaustive'
apply plugin: 'kotlin-parcelize'
apply from: 'static-ips.gradle'
repositories {
maven {
url "https://raw.githubusercontent.com/signalapp/maven/master/sqlcipher/release/"
content {
includeGroupByRegex "org\\.signal.*"
}
}
google()
mavenCentral()
mavenLocal()
maven {
url "https://dl.cloudsmith.io/qxAgwaeEE1vN8aLU/mobilecoin/mobilecoin/maven/"
}
jcenter {
content {
includeVersion "mobi.upod", "time-duration-picker", "1.1.3"
}
}
plugins {
id 'com.android.application'
id 'kotlin-android'
id 'com.google.protobuf'
id 'androidx.navigation.safeargs'
id 'org.jlleitschuh.gradle.ktlint'
id 'org.jetbrains.kotlin.android'
id 'app.cash.exhaustive'
id 'kotlin-parcelize'
id 'com.squareup.wire'
id 'android-constants'
id 'translations'
}
apply from: 'static-ips.gradle'
protobuf {
protoc {
artifact = 'com.google.protobuf:protoc:3.18.0'
@@ -47,13 +31,23 @@ protobuf {
}
}
ktlint {
// Use a newer version to resolve https://github.com/JLLeitschuh/ktlint-gradle/issues/507
version = "0.43.2"
wire {
kotlin {
javaInterop = true
}
sourcePath {
srcDir 'src/main/protowire'
}
}
def canonicalVersionCode = 1196
def canonicalVersionName = "6.8.3"
ktlint {
// Use a newer version to resolve https://github.com/JLLeitschuh/ktlint-gradle/issues/507
version = "0.47.1"
}
def canonicalVersionCode = 1228
def canonicalVersionName = "6.14.1"
def postFixSize = 100
def abiPostFix = ['universal' : 0,
@@ -68,6 +62,9 @@ def selectableVariants = [
'nightlyProdSpinner',
'nightlyProdPerf',
'nightlyProdRelease',
'nightlyStagingRelease',
'nightlyPnpPerf',
'nightlyPnpRelease',
'playProdDebug',
'playProdSpinner',
'playProdPerf',
@@ -77,21 +74,25 @@ def selectableVariants = [
'playStagingSpinner',
'playStagingPerf',
'playStagingInstrumentation',
'playPnpDebug',
'playPnpSpinner',
'playStagingRelease',
'websiteProdSpinner',
'websiteProdRelease',
]
android {
buildToolsVersion BUILD_TOOL_VERSION
compileSdkVersion COMPILE_SDK
namespace 'org.thoughtcrime.securesms'
buildToolsVersion = signalBuildToolsVersion
compileSdkVersion = signalCompileSdkVersion
flavorDimensions 'distribution', 'environment'
useLibrary 'org.apache.http.legacy'
testBuildType 'instrumentation'
kotlinOptions {
jvmTarget = "1.8"
jvmTarget = "11"
freeCompilerArgs = ["-Xallow-result-return-type"]
}
@@ -125,12 +126,6 @@ android {
}
}
lintOptions {
checkReleaseBuilds false
abortOnError true
baseline file("lint-baseline.xml")
disable "LintError"
}
sourceSets {
test {
@@ -144,32 +139,32 @@ android {
compileOptions {
coreLibraryDesugaringEnabled true
sourceCompatibility JAVA_VERSION
targetCompatibility JAVA_VERSION
sourceCompatibility signalJavaVersion
targetCompatibility signalJavaVersion
}
packagingOptions {
exclude 'LICENSE.txt'
exclude 'LICENSE'
exclude 'NOTICE'
exclude 'asm-license.txt'
exclude 'META-INF/LICENSE'
exclude 'META-INF/NOTICE'
exclude 'META-INF/proguard/androidx-annotations.pro'
exclude 'libsignal_jni.dylib'
exclude 'signal_jni.dll'
resources {
excludes += ['LICENSE.txt', 'LICENSE', 'NOTICE', 'asm-license.txt', 'META-INF/LICENSE', 'META-INF/LICENSE.md', 'META-INF/NOTICE', 'META-INF/LICENSE-notice.md', 'META-INF/proguard/androidx-annotations.pro', 'libsignal_jni.dylib', 'signal_jni.dll']
}
}
buildFeatures {
viewBinding true
compose true
}
composeOptions {
kotlinCompilerExtensionVersion = '1.3.2'
}
defaultConfig {
versionCode canonicalVersionCode * postFixSize
versionName canonicalVersionName
minSdkVersion MINIMUM_SDK
targetSdkVersion TARGET_SDK
minSdkVersion signalMinSdkVersion
targetSdkVersion signalTargetSdkVersion
multiDexEnabled true
@@ -203,8 +198,7 @@ android {
buildConfigField "String[]", "SIGNAL_SFU_IPS", sfu_ips
buildConfigField "String[]", "SIGNAL_CONTENT_PROXY_IPS", content_proxy_ips
buildConfigField "String", "SIGNAL_AGENT", "\"OWA\""
buildConfigField "String", "CDS_MRENCLAVE", "\"74778bb0f93ae1f78c26e67152bab0bbeb693cd56d1bb9b4e9244157acc58081\""
buildConfigField "String", "CDSI_MRENCLAVE", "\"ef4787a56a154ac6d009138cac17155acd23cfe4329281252365dd7c252e7fbf\""
buildConfigField "String", "CDSI_MRENCLAVE", "\"0f6fd79cdfdaa5b2e6337f534d3baf999318b0c462a7ac1f41297a3e4b424a57\""
buildConfigField "org.thoughtcrime.securesms.KbsEnclave", "KBS_ENCLAVE", "new org.thoughtcrime.securesms.KbsEnclave(\"e18376436159cda3ad7a45d9320e382e4a497f26b0dca34d8eab0bd0139483b5\", " +
"\"3a485adb56e2058ef7737764c738c4069dd62bc457637eafb6bbce1ce29ddb89\", " +
"\"45627094b2ea4a66f4cf0b182858a8dcf4b8479122c3820fe7fd0551a6d4cf5c\")"
@@ -296,11 +290,13 @@ android {
matchingFallbacks = ['debug']
buildConfigField "String", "BUILD_VARIANT_TYPE", "\"Spinner\""
}
release {
minifyEnabled true
proguardFiles = buildTypes.debug.proguardFiles
buildConfigField "String", "BUILD_VARIANT_TYPE", "\"Release\""
}
perf {
initWith debug
isDefault false
@@ -359,7 +355,6 @@ android {
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 "String", "CDS_MRENCLAVE", "\"74778bb0f93ae1f78c26e67152bab0bbeb693cd56d1bb9b4e9244157acc58081\""
buildConfigField "org.thoughtcrime.securesms.KbsEnclave", "KBS_ENCLAVE", "new org.thoughtcrime.securesms.KbsEnclave(\"39963b736823d5780be96ab174869a9499d56d66497aa8f9b2244f777ebc366b\", " +
"\"9dbc6855c198e04f21b5cc35df839fdcd51b53658454dfa3f817afefaffc95ef\", " +
"\"45627094b2ea4a66f4cf0b182858a8dcf4b8479122c3820fe7fd0551a6d4cf5c\")"
@@ -375,6 +370,22 @@ android {
buildConfigField "String", "BUILD_ENVIRONMENT_TYPE", "\"Staging\""
buildConfigField "String", "STRIPE_PUBLISHABLE_KEY", "\"pk_test_sngOd8FnXNkpce9nPXawKrJD00kIDngZkD\""
}
pnp {
dimension 'environment'
initWith staging
applicationIdSuffix ".pnp"
buildConfigField "String", "BUILD_ENVIRONMENT_TYPE", "\"Pnp\""
}
}
lint {
abortOnError true
baseline file('lint-baseline.xml')
checkReleaseBuilds false
disable 'LintError'
}
android.applicationVariants.all { variant ->
@@ -421,7 +432,7 @@ dependencies {
implementation (libs.androidx.appcompat) {
version {
strictly '1.5.1'
strictly '1.6.1'
}
}
implementation libs.androidx.window.window
@@ -429,7 +440,6 @@ dependencies {
implementation libs.androidx.recyclerview
implementation libs.material.material
implementation libs.androidx.legacy.support
implementation libs.androidx.cardview
implementation libs.androidx.preference
implementation libs.androidx.legacy.preference
implementation libs.androidx.gridlayout
@@ -506,7 +516,6 @@ dependencies {
implementation libs.greenrobot.eventbus
implementation libs.waitingdots
implementation libs.google.zxing.android.integration
implementation libs.time.duration.picker
implementation libs.google.zxing.core
implementation libs.google.flexbox
implementation (libs.subsampling.scale.image.view) {
@@ -560,6 +569,7 @@ dependencies {
androidTestImplementation testLibs.androidx.test.ext.junit.ktx
androidTestImplementation testLibs.mockito.android
androidTestImplementation testLibs.mockito.kotlin
androidTestImplementation testLibs.mockk.android
androidTestImplementation testLibs.square.okhttp.mockserver
instrumentationImplementation (libs.androidx.fragment.testing) {
@@ -577,6 +587,9 @@ dependencies {
implementation libs.rxdogtag
androidTestUtil testLibs.androidx.test.orchestrator
implementation project(':core-ui')
ktlintRuleset libs.ktlint.twitter.compose
}
def getLastCommitTimestamp() {

View File

@@ -1,15 +1,40 @@
package org.thoughtcrime.securesms
import org.signal.core.util.concurrent.SignalExecutors
import org.signal.core.util.logging.AndroidLogger
import org.signal.core.util.logging.Log
import org.signal.libsignal.protocol.logging.SignalProtocolLoggerProvider
import org.thoughtcrime.securesms.database.LogDatabase
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.dependencies.ApplicationDependencyProvider
import org.thoughtcrime.securesms.dependencies.InstrumentationApplicationDependencyProvider
import org.thoughtcrime.securesms.logging.CustomSignalProtocolLogger
import org.thoughtcrime.securesms.logging.PersistentLogger
import org.thoughtcrime.securesms.testing.InMemoryLogger
/**
* Application context for running instrumentation tests (aka androidTests).
*/
class SignalInstrumentationApplicationContext : ApplicationContext() {
val inMemoryLogger: InMemoryLogger = InMemoryLogger()
override fun initializeAppDependencies() {
val default = ApplicationDependencyProvider(this)
ApplicationDependencies.init(this, InstrumentationApplicationDependencyProvider(this, default))
ApplicationDependencies.getDeadlockDetector().start()
}
override fun initializeLogging() {
persistentLogger = PersistentLogger(this)
Log.initialize({ true }, AndroidLogger(), persistentLogger, inMemoryLogger)
SignalProtocolLoggerProvider.setProvider(CustomSignalProtocolLogger())
SignalExecutors.UNBOUNDED.execute {
Log.blockUntilAllWritesFinished()
LogDatabase.getInstance(this).trimToSize()
}
}
}

View File

@@ -22,6 +22,7 @@ import org.thoughtcrime.securesms.registration.VerifyAccountRepository
import org.thoughtcrime.securesms.registration.VerifyResponseProcessor
import org.thoughtcrime.securesms.testing.Get
import org.thoughtcrime.securesms.testing.MockProvider
import org.thoughtcrime.securesms.testing.Post
import org.thoughtcrime.securesms.testing.Put
import org.thoughtcrime.securesms.testing.SignalActivityRule
import org.thoughtcrime.securesms.testing.assertIs
@@ -82,8 +83,10 @@ class ChangeNumberViewModelTest {
lateinit var setPreKeysRequest: PreKeyState
InstrumentationApplicationDependencyProvider.addMockWebRequestHandlers(
Post("/v1/verification/session") { MockResponse().success(MockProvider.sessionMetadataJson.copy(verified = false)) },
Put("/v1/verification/session/${MockProvider.sessionMetadataJson.id}/code") { MockResponse().success(MockProvider.sessionMetadataJson) },
Get("/v1/devices") { MockResponse().success(MockProvider.primaryOnlyDeviceList) },
Put("/v1/accounts/number") { r ->
Put("/v2/accounts/number") { r ->
changeNumberRequest = r.parsedRequestBody()
MockResponse().success(MockProvider.createVerifyAccountResponse(aci, newPni))
},
@@ -95,6 +98,7 @@ class ChangeNumberViewModelTest {
)
// WHEN
viewModel.requestVerificationCode(VerifyAccountRepository.Mode.SMS_WITHOUT_LISTENER, null, null).blockingGet().resultOrThrow
viewModel.verifyCodeWithoutRegistrationLock("123456").blockingGet().resultOrThrow
// THEN
@@ -112,11 +116,14 @@ class ChangeNumberViewModelTest {
val oldE164 = Recipient.self().requireE164()
InstrumentationApplicationDependencyProvider.addMockWebRequestHandlers(
Post("/v1/verification/session") { MockResponse().success(MockProvider.sessionMetadataJson.copy(verified = false)) },
Put("/v1/verification/session/${MockProvider.sessionMetadataJson.id}/code") { MockResponse().success(MockProvider.sessionMetadataJson) },
Get("/v1/devices") { MockResponse().success(MockProvider.primaryOnlyDeviceList) },
Put("/v1/accounts/number") { MockResponse().failure(500) },
Put("/v2/accounts/number") { MockResponse().failure(500) }
)
// WHEN
viewModel.requestVerificationCode(VerifyAccountRepository.Mode.SMS_WITHOUT_LISTENER, null, null).blockingGet().resultOrThrow
val processor: VerifyResponseProcessor = viewModel.verifyCodeWithoutRegistrationLock("123456").blockingGet()
// THEN
@@ -142,12 +149,15 @@ class ChangeNumberViewModelTest {
val oldE164 = Recipient.self().requireE164()
InstrumentationApplicationDependencyProvider.addMockWebRequestHandlers(
Post("/v1/verification/session") { MockResponse().success(MockProvider.sessionMetadataJson.copy(verified = false)) },
Put("/v1/verification/session/${MockProvider.sessionMetadataJson.id}/code") { MockResponse().success(MockProvider.sessionMetadataJson) },
Get("/v1/devices") { MockResponse().success(MockProvider.primaryOnlyDeviceList) },
Put("/v1/accounts/number") { MockResponse().connectionFailure() },
Put("/v2/accounts/number") { MockResponse().connectionFailure() },
Get("/v1/accounts/whoami") { MockResponse().success(MockProvider.createWhoAmIResponse(aci, oldPni, oldE164)) }
)
// WHEN
viewModel.requestVerificationCode(VerifyAccountRepository.Mode.SMS_WITHOUT_LISTENER, null, null).blockingGet().resultOrThrow
val processor: VerifyResponseProcessor = viewModel.verifyCodeWithoutRegistrationLock("123456").blockingGet()
// THEN
@@ -181,8 +191,10 @@ class ChangeNumberViewModelTest {
lateinit var setPreKeysRequest: PreKeyState
InstrumentationApplicationDependencyProvider.addMockWebRequestHandlers(
Post("/v1/verification/session") { MockResponse().success(MockProvider.sessionMetadataJson.copy(verified = false)) },
Put("/v1/verification/session/${MockProvider.sessionMetadataJson.id}/code") { MockResponse().success(MockProvider.sessionMetadataJson) },
Get("/v1/devices") { MockResponse().success(MockProvider.primaryOnlyDeviceList) },
Put("/v1/accounts/number") { r ->
Put("/v2/accounts/number") { r ->
changeNumberRequest = r.parsedRequestBody()
MockResponse().timeout()
},
@@ -195,6 +207,7 @@ class ChangeNumberViewModelTest {
)
// WHEN
viewModel.requestVerificationCode(VerifyAccountRepository.Mode.SMS_WITHOUT_LISTENER, null, null).blockingGet().resultOrThrow
val processor: VerifyResponseProcessor = viewModel.verifyCodeWithoutRegistrationLock("123456").blockingGet()
// THEN
@@ -225,8 +238,10 @@ class ChangeNumberViewModelTest {
MockProvider.mockGetRegistrationLockStringFlow(kbsRepository)
InstrumentationApplicationDependencyProvider.addMockWebRequestHandlers(
Post("/v1/verification/session") { MockResponse().success(MockProvider.sessionMetadataJson.copy(verified = false)) },
Put("/v1/verification/session/${MockProvider.sessionMetadataJson.id}/code") { MockResponse().success(MockProvider.sessionMetadataJson) },
Get("/v1/devices") { MockResponse().success(MockProvider.primaryOnlyDeviceList) },
Put("/v1/accounts/number") { r ->
Put("/v2/accounts/number") { r ->
changeNumberRequest = r.parsedRequestBody()
if (changeNumberRequest.registrationLock.isNullOrEmpty()) {
MockResponse().failure(423, MockProvider.lockedFailure)
@@ -242,6 +257,7 @@ class ChangeNumberViewModelTest {
)
// WHEN
viewModel.requestVerificationCode(VerifyAccountRepository.Mode.SMS_WITHOUT_LISTENER, null, null).blockingGet().resultOrThrow
viewModel.verifyCodeWithoutRegistrationLock("123456").blockingGet().also { processor ->
processor.registrationLock() assertIs true
Recipient.self().requirePni() assertIsNot newPni
@@ -263,8 +279,10 @@ class ChangeNumberViewModelTest {
lateinit var setPreKeysRequest: PreKeyState
InstrumentationApplicationDependencyProvider.addMockWebRequestHandlers(
Post("/v1/verification/session") { MockResponse().success(MockProvider.sessionMetadataJson.copy(verified = false)) },
Put("/v1/verification/session/${MockProvider.sessionMetadataJson.id}/code") { MockResponse().success(MockProvider.sessionMetadataJson) },
Get("/v1/devices") { MockResponse().success(MockProvider.primaryOnlyDeviceList) },
Put("/v1/accounts/number") { r ->
Put("/v2/accounts/number") { r ->
changeNumberRequest = r.parsedRequestBody()
if (changeNumberRequest.deviceMessages.isEmpty()) {
MockResponse().failure(
@@ -289,6 +307,7 @@ class ChangeNumberViewModelTest {
)
// WHEN
viewModel.requestVerificationCode(VerifyAccountRepository.Mode.SMS_WITHOUT_LISTENER, null, null).blockingGet().resultOrThrow
viewModel.verifyCodeWithoutRegistrationLock("123456").blockingGet().resultOrThrow
// THEN
@@ -307,7 +326,9 @@ class ChangeNumberViewModelTest {
MockProvider.mockGetRegistrationLockStringFlow(kbsRepository)
InstrumentationApplicationDependencyProvider.addMockWebRequestHandlers(
Put("/v1/accounts/number") { r ->
Post("/v1/verification/session") { MockResponse().success(MockProvider.sessionMetadataJson.copy(verified = false)) },
Put("/v1/verification/session/${MockProvider.sessionMetadataJson.id}/code") { MockResponse().success(MockProvider.sessionMetadataJson) },
Put("/v2/accounts/number") { r ->
changeNumberRequest = r.parsedRequestBody()
if (changeNumberRequest.registrationLock.isNullOrEmpty()) {
MockResponse().failure(423, MockProvider.lockedFailure)
@@ -345,6 +366,7 @@ class ChangeNumberViewModelTest {
)
// WHEN
viewModel.requestVerificationCode(VerifyAccountRepository.Mode.SMS_WITHOUT_LISTENER, null, null).blockingGet().resultOrThrow
viewModel.verifyCodeWithoutRegistrationLock("123456").blockingGet().also { processor ->
processor.registrationLock() assertIs true
Recipient.self().requirePni() assertIsNot newPni

View File

@@ -69,7 +69,7 @@ class ConversationItemPreviewer {
sentTimeMillis = System.currentTimeMillis(),
serverTimeMillis = System.currentTimeMillis(),
receivedTimeMillis = System.currentTimeMillis(),
attachments = PointerAttachment.forPointers(Optional.of(attachments)),
attachments = PointerAttachment.forPointers(Optional.of(attachments))
)
SignalDatabase.messages.insertSecureDecryptedMessageInbox(message, SignalDatabase.threads.getOrCreateThreadIdFor(other)).get()
@@ -88,7 +88,7 @@ class ConversationItemPreviewer {
sentTimeMillis = System.currentTimeMillis(),
serverTimeMillis = System.currentTimeMillis(),
receivedTimeMillis = System.currentTimeMillis(),
attachments = PointerAttachment.forPointers(Optional.of(attachments)),
attachments = PointerAttachment.forPointers(Optional.of(attachments))
)
val insert = SignalDatabase.messages.insertSecureDecryptedMessageInbox(message, SignalDatabase.threads.getOrCreateThreadIdFor(other)).get()

View File

@@ -64,7 +64,7 @@ class SafetyNumberChangeDialogPreviewer {
SafetyNumberBottomSheet
.forIdentityRecordsAndDestinations(
identityRecords = ApplicationDependencies.getProtocolStore().aci().identities().getIdentityRecords(othersRecipients).identityRecords,
destinations = listOf(ContactSearchKey.RecipientSearchKey.Story(myStoryRecipientId))
destinations = listOf(ContactSearchKey.RecipientSearchKey(myStoryRecipientId, true))
)
.show(conversationActivity.supportFragmentManager)
}

View File

@@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.database
import android.net.Uri
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.FlakyTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Before
@@ -34,6 +35,7 @@ class AttachmentTableTest {
assertEquals(attachment2.fileName, attachment.fileName)
}
@FlakyTest
@Test
fun givenABlobAndDifferentTransformQuality_whenIInsert2AttachmentsForPreUpload_thenIExpectDifferentFileInfos() {
val blob = BlobProvider.getInstance().forData(byteArrayOf(1, 2, 3, 4, 5)).createForSingleSessionInMemory()
@@ -61,6 +63,7 @@ class AttachmentTableTest {
assertNotEquals(attachment1Info, attachment2Info)
}
@FlakyTest
@Test
fun givenIdenticalAttachmentsInsertedForPreUpload_whenIUpdateAttachmentDataAndSpecifyOnlyModifyThisAttachment_thenIExpectDifferentFileInfos() {
val blob = BlobProvider.getInstance().forData(byteArrayOf(1, 2, 3, 4, 5)).createForSingleSessionInMemory()

View File

@@ -0,0 +1,285 @@
package org.thoughtcrime.securesms.database
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.signal.core.util.delete
import org.signal.core.util.readToList
import org.signal.core.util.requireLong
import org.signal.core.util.withinTransaction
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.groups.GroupId
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.testing.SignalActivityRule
import java.security.SecureRandom
import kotlin.random.Random
class GroupTableTest {
@get:Rule
val harness = SignalActivityRule()
private lateinit var groupTable: GroupTable
@Before
fun setUp() {
groupTable = SignalDatabase.groups
groupTable.writableDatabase.delete(GroupTable.TABLE_NAME).run()
groupTable.writableDatabase.delete(GroupTable.MembershipTable.TABLE_NAME).run()
}
@Test
fun whenICreateGroupV2_thenIExpectMemberRowsPopulated() {
val groupId = insertPushGroup()
//language=sql
val members: List<RecipientId> = groupTable.writableDatabase.query(
"""
SELECT ${GroupTable.MembershipTable.RECIPIENT_ID}
FROM ${GroupTable.MembershipTable.TABLE_NAME}
WHERE ${GroupTable.MembershipTable.GROUP_ID} = "${groupId.serialize()}"
""".trimIndent()
).readToList {
RecipientId.from(it.requireLong(GroupTable.RECIPIENT_ID))
}
assertEquals(2, members.size)
}
@Test
fun givenAGroupV2_whenIGetGroupsContainingMember_thenIExpectGroup() {
val groupId = insertPushGroup()
insertThread(groupId)
val groups = groupTable.getGroupsContainingMember(harness.others[0], false)
assertEquals(1, groups.size)
assertEquals(groupId, groups[0].id)
}
@Test
fun givenAnMmsGroup_whenIGetMembers_thenIExpectAllMembers() {
val groupId = insertMmsGroup()
val groups = groupTable.getGroupMemberIds(groupId, GroupTable.MemberSet.FULL_MEMBERS_INCLUDING_SELF)
assertEquals(2, groups.size)
}
@Test
fun givenGroups_whenIQueryGroupsByMembership_thenIExpectBothGroups() {
insertPushGroup()
insertMmsGroup(members = listOf(harness.others[1]))
val groups = groupTable.queryGroupsByMembership(
setOf(harness.self.id, harness.others[1]),
includeInactive = false,
excludeV1 = false,
excludeMms = false
)
assertEquals(2, groups.cursor?.count)
}
@Test
fun givenGroups_whenIGetGroups_thenIExpectBothGroups() {
insertPushGroup()
insertMmsGroup(members = listOf(harness.others[1]))
val groups = groupTable.getGroups()
assertEquals(2, groups.cursor?.count)
}
@Test
fun givenAGroup_whenIGetGroup_thenIExpectGroup() {
val v2Group = insertPushGroup()
insertThread(v2Group)
val groupRecord = groupTable.getGroup(v2Group).get()
assertEquals(setOf(harness.self.id, harness.others[0]), groupRecord.members.toSet())
}
@Test
fun givenAGroupAndARemap_whenIGetGroup_thenIExpectRemap() {
val v2Group = insertPushGroup()
insertThread(v2Group)
groupTable.writableDatabase.withinTransaction {
RemappedRecords.getInstance().addRecipient(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 givenAGroupAndMember_whenIIsCurrentMember_thenIExpectTrue() {
val v2Group = insertPushGroup()
val actual = groupTable.isCurrentMember(v2Group.requirePush(), harness.others[0])
assertTrue(actual)
}
@Test
fun givenAGroupAndMember_whenIRemove_thenIExpectNotAMember() {
val v2Group = insertPushGroup()
groupTable.remove(v2Group, harness.others[0])
val actual = groupTable.isCurrentMember(v2Group.requirePush(), harness.others[0])
assertFalse(actual)
}
@Test
fun givenAGroupAndNonMember_whenIIsCurrentMember_thenIExpectFalse() {
val v2Group = insertPushGroup()
val actual = groupTable.isCurrentMember(v2Group.requirePush(), harness.others[1])
assertFalse(actual)
}
@Test
fun givenAGroup_whenIUpdateMembers_thenIExpectUpdatedMembers() {
val v2Group = insertPushGroup()
groupTable.updateMembers(v2Group, listOf(harness.self.id, harness.others[1]))
val groupRecord = groupTable.getGroup(v2Group)
assertEquals(setOf(harness.self.id, harness.others[1]), groupRecord.get().members.toSet())
}
@Test
fun givenAnMmsGroup_whenIGetOrCreateMmsGroup_thenIExpectMyMmsGroup() {
val members: List<RecipientId> = listOf(harness.self.id, harness.others[0])
val other = insertMmsGroup(members + listOf(harness.others[1]))
val mmsGroup = insertMmsGroup(members)
val actual = groupTable.getOrCreateMmsGroupForMembers(members.toSet())
assertNotEquals(other, actual)
assertEquals(mmsGroup, actual)
}
@Test
fun givenMultipleMmsGroups_whenIGetOrCreateMmsGroup_thenIExpectMyMmsGroup() {
val group1Members: List<RecipientId> = listOf(harness.self.id, harness.others[0], harness.others[1])
val group2Members: List<RecipientId> = listOf(harness.self.id, harness.others[0], harness.others[2])
val group1: GroupId = insertMmsGroup(group1Members)
val group2: GroupId = insertMmsGroup(group2Members)
val group1Result: GroupId = groupTable.getOrCreateMmsGroupForMembers(group1Members.toSet())
val group2Result: GroupId = groupTable.getOrCreateMmsGroupForMembers(group2Members.toSet())
assertEquals(group1, group1Result)
assertEquals(group2, group2Result)
assertNotEquals(group1Result, group2Result)
}
@Test
fun givenMultipleMmsGroupsWithDifferentMemberOrders_whenIGetOrCreateMmsGroup_thenIExpectMyMmsGroup() {
val group1Members: List<RecipientId> = listOf(harness.self.id, harness.others[0], harness.others[1], harness.others[2]).shuffled()
val group2Members: List<RecipientId> = listOf(harness.self.id, harness.others[0], harness.others[2], harness.others[3]).shuffled()
val group1: GroupId = insertMmsGroup(group1Members)
val group2: GroupId = insertMmsGroup(group2Members)
val group1Result: GroupId = groupTable.getOrCreateMmsGroupForMembers(group1Members.shuffled().toSet())
val group2Result: GroupId = groupTable.getOrCreateMmsGroupForMembers(group2Members.shuffled().toSet())
assertEquals(group1, group1Result)
assertEquals(group2, group2Result)
assertNotEquals(group1Result, group2Result)
}
@Test
fun givenMmsGroupWithOneMember_whenIGetOrCreateMmsGroup_thenIExpectMyMmsGroup() {
val groupMembers: List<RecipientId> = listOf(harness.self.id)
val group: GroupId = insertMmsGroup(groupMembers)
val groupResult: GroupId = groupTable.getOrCreateMmsGroupForMembers(groupMembers.toSet())
assertEquals(group, groupResult)
}
@Test
fun givenTwoGroupsWithoutMembers_whenIQueryThem_thenIExpectEach() {
val g1 = insertPushGroup(listOf())
val g2 = insertPushGroup(listOf())
val gr1 = groupTable.getGroup(g1)
val gr2 = groupTable.getGroup(g2)
assertEquals(g1, gr1.get().id)
assertEquals(g2, gr2.get().id)
}
@Test
fun givenASharedActiveGroupWithoutAThread_whenISearchForRecipientsWithGroupsInCommon_thenIExpectThatGroup() {
val groupInCommon = insertPushGroup()
val expected = Recipient.resolved(harness.others[0])
SignalDatabase.recipients.setProfileSharing(expected.id, false)
SignalDatabase.recipients.queryGroupMemberContacts("Buddy")!!.use {
assertTrue(it.moveToFirst())
assertEquals(1, it.count)
assertEquals(expected.id.toLong(), it.requireLong(RecipientTable.ID))
}
val groups = groupTable.getPushGroupsContainingMember(expected.id)
assertEquals(1, groups.size)
assertEquals(groups[0].id, groupInCommon)
}
private fun insertThread(groupId: GroupId): Long {
val groupRecipient = SignalDatabase.recipients.getByGroupId(groupId).get()
return SignalDatabase.threads.getOrCreateThreadIdFor(Recipient.resolved(groupRecipient))
}
private fun insertMmsGroup(members: List<RecipientId> = listOf(harness.self.id, harness.others[0])): GroupId {
val id = GroupId.createMms(SecureRandom())
groupTable.create(
id,
null,
members.apply {
println("Creating a group with ${members.size} members")
}
)
return id
}
private fun insertPushGroup(
members: List<DecryptedMember> = listOf(
DecryptedMember.newBuilder()
.setUuid(harness.self.requireServiceId().toByteString())
.setJoinedAtRevision(0)
.setRole(Member.Role.DEFAULT)
.build(),
DecryptedMember.newBuilder()
.setUuid(Recipient.resolved(harness.others[0]).requireServiceId().toByteString())
.setJoinedAtRevision(0)
.setRole(Member.Role.DEFAULT)
.build()
)
): GroupId {
val groupMasterKey = GroupMasterKey(Random.nextBytes(GroupMasterKey.SIZE))
val decryptedGroupState = DecryptedGroup.newBuilder()
.addAllMembers(members)
.setRevision(0)
.build()
return groupTable.create(groupMasterKey, decryptedGroupState)
}
}

View File

@@ -128,7 +128,7 @@ class MmsTableTest_stories {
sentTimeMillis = 2,
serverTimeMillis = 2,
receivedTimeMillis = 2,
storyType = StoryType.STORY_WITH_REPLIES,
storyType = StoryType.STORY_WITH_REPLIES
),
-1L
).get().messageId
@@ -160,7 +160,7 @@ class MmsTableTest_stories {
sentTimeMillis = System.currentTimeMillis(),
serverTimeMillis = 2,
receivedTimeMillis = 2,
storyType = StoryType.STORY_WITH_REPLIES,
storyType = StoryType.STORY_WITH_REPLIES
),
-1L
).get().messageId
@@ -174,7 +174,7 @@ class MmsTableTest_stories {
sentTimeMillis = System.currentTimeMillis(),
serverTimeMillis = 2,
receivedTimeMillis = 2,
storyType = StoryType.STORY_WITH_REPLIES,
storyType = StoryType.STORY_WITH_REPLIES
),
-1L
).get().messageId
@@ -219,7 +219,7 @@ class MmsTableTest_stories {
sentTimeMillis = 200,
serverTimeMillis = 2,
receivedTimeMillis = 2,
storyType = StoryType.STORY_WITH_REPLIES,
storyType = StoryType.STORY_WITH_REPLIES
),
-1L
)

View File

@@ -11,6 +11,11 @@ import org.signal.core.util.CursorUtil
import org.thoughtcrime.securesms.profiles.ProfileName
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.testing.SignalActivityRule
import org.thoughtcrime.securesms.util.FeatureFlags
import org.thoughtcrime.securesms.util.FeatureFlagsAccessor
import org.whispersystems.signalservice.api.push.ACI
import org.whispersystems.signalservice.api.push.PNI
import java.util.UUID
@RunWith(AndroidJUnit4::class)
class RecipientTableTest {
@@ -159,4 +164,47 @@ class RecipientTableTest {
assertNotEquals(0, results.size)
assertFalse(blockedRecipient in results)
}
@Test
fun givenARecipientWithPniAndAci_whenIMarkItUnregistered_thenIExpectItToBeSplit() {
FeatureFlagsAccessor.forceValue(FeatureFlags.PHONE_NUMBER_PRIVACY, true)
val mainId = SignalDatabase.recipients.getAndPossiblyMerge(ACI_A, PNI_A, E164_A)
SignalDatabase.recipients.markUnregistered(mainId)
val byAci: RecipientId = SignalDatabase.recipients.getByServiceId(ACI_A).get()
val byE164: RecipientId = SignalDatabase.recipients.getByE164(E164_A).get()
val byPni: RecipientId = SignalDatabase.recipients.getByServiceId(PNI_A).get()
assertEquals(mainId, byAci)
assertEquals(byE164, byPni)
assertNotEquals(byAci, byE164)
}
@Test
fun givenARecipientWithPniAndAci_whenISplitItForStorageSync_thenIExpectItToBeSplit() {
FeatureFlagsAccessor.forceValue(FeatureFlags.PHONE_NUMBER_PRIVACY, true)
val mainId = SignalDatabase.recipients.getAndPossiblyMerge(ACI_A, PNI_A, E164_A)
val mainRecord = SignalDatabase.recipients.getRecord(mainId)
SignalDatabase.recipients.splitForStorageSync(mainRecord.storageId!!)
val byAci: RecipientId = SignalDatabase.recipients.getByServiceId(ACI_A).get()
val byE164: RecipientId = SignalDatabase.recipients.getByE164(E164_A).get()
val byPni: RecipientId = SignalDatabase.recipients.getByServiceId(PNI_A).get()
assertEquals(mainId, byAci)
assertEquals(byE164, byPni)
assertNotEquals(byAci, byE164)
}
companion object {
val ACI_A = ACI.from(UUID.fromString("aaaa0000-5a76-47fa-a98a-7e72c948a82e"))
val PNI_A = PNI.from(UUID.fromString("aaaa1111-c960-4f6c-8385-671ad2ffb999"))
const val E164_A = "+12222222222"
}
}

View File

@@ -1,17 +1,21 @@
package org.thoughtcrime.securesms.database
import android.database.Cursor
import androidx.core.content.contentValuesOf
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.hamcrest.MatcherAssert
import org.hamcrest.Matchers
import org.junit.Assert
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.signal.core.util.CursorUtil
import org.signal.core.util.SqlUtil
import org.signal.core.util.requireLong
import org.signal.core.util.requireNonNullString
import org.signal.core.util.select
import org.signal.libsignal.protocol.IdentityKey
import org.signal.libsignal.protocol.SignalProtocolAddress
@@ -23,6 +27,8 @@ import org.thoughtcrime.securesms.database.model.Mention
import org.thoughtcrime.securesms.database.model.MessageId
import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.database.model.ReactionRecord
import org.thoughtcrime.securesms.database.model.databaseprotos.SessionSwitchoverEvent
import org.thoughtcrime.securesms.database.model.databaseprotos.ThreadMergeEvent
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.keyvalue.SignalStore
@@ -32,6 +38,9 @@ import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.sms.IncomingEncryptedMessage
import org.thoughtcrime.securesms.sms.IncomingTextMessage
import org.thoughtcrime.securesms.util.Base64
import org.thoughtcrime.securesms.util.FeatureFlags
import org.thoughtcrime.securesms.util.FeatureFlagsAccessor
import org.whispersystems.signalservice.api.push.ACI
import org.whispersystems.signalservice.api.push.PNI
import org.whispersystems.signalservice.api.push.ServiceId
@@ -46,6 +55,34 @@ class RecipientTableTest_getAndPossiblyMerge {
SignalStore.account().setE164(E164_SELF)
SignalStore.account().setAci(ACI_SELF)
SignalStore.account().setPni(PNI_SELF)
FeatureFlagsAccessor.forceValue(FeatureFlags.PHONE_NUMBER_PRIVACY, true)
}
@Test
fun allNonMergeTests() {
test("e164-only insert") {
val id = process(E164_A, null, null)
expect(E164_A, null, null)
val record = SignalDatabase.recipients.getRecord(id)
assertEquals(RecipientTable.RegisteredState.UNKNOWN, record.registered)
}
test("pni-only insert") {
val id = process(null, PNI_A, null)
expect(null, PNI_A, null)
val record = SignalDatabase.recipients.getRecord(id)
assertEquals(RecipientTable.RegisteredState.REGISTERED, record.registered)
}
test("aci-only insert") {
val id = process(null, null, ACI_A)
expect(null, null, ACI_A)
val record = SignalDatabase.recipients.getRecord(id)
assertEquals(RecipientTable.RegisteredState.REGISTERED, record.registered)
}
}
@Test
@@ -115,24 +152,40 @@ class RecipientTableTest_getAndPossiblyMerge {
expect(E164_A, null, ACI_B)
}
test("e164 and pni matches, all provided, new aci") {
test("e164 and pni matches, all provided, new aci, no pni session") {
given(E164_A, PNI_A, null)
process(E164_A, PNI_A, ACI_A)
expect(E164_A, PNI_A, ACI_A)
}
test("e164 and pni matches, all provided, new aci, existing pni session") {
given(E164_A, PNI_A, null, pniSession = true)
process(E164_A, PNI_A, ACI_A)
expect(E164_A, PNI_A, ACI_A)
expectSessionSwitchoverEvent(E164_A)
}
test("e164 and aci matches, all provided, new pni") {
given(E164_A, null, ACI_A)
process(E164_A, PNI_A, ACI_A)
expect(E164_A, PNI_A, ACI_A)
}
test("pni matches, all provided, new e164 and aci") {
test("pni matches, all provided, new e164 and aci, no pni session") {
given(null, PNI_A, null)
process(E164_A, PNI_A, ACI_A)
expect(E164_A, PNI_A, ACI_A)
}
test("pni matches, all provided, new e164 and aci, existing pni session") {
given(null, PNI_A, null, pniSession = true)
process(E164_A, PNI_A, ACI_A)
expect(E164_A, PNI_A, ACI_A)
expectSessionSwitchoverEvent(E164_A)
}
test("pni and aci matches, all provided, new e164") {
given(null, PNI_A, ACI_A)
process(E164_A, PNI_A, ACI_A)
@@ -172,20 +225,42 @@ class RecipientTableTest_getAndPossiblyMerge {
expect(E164_A, PNI_A, null)
}
test("e164 and pni matches, all provided, no existing session") {
test("e164 matches, e164 and pni provided, pni changes, existing pni session") {
given(E164_A, PNI_B, null, pniSession = true)
process(E164_A, PNI_A, null)
expect(E164_A, PNI_A, null)
expectSessionSwitchoverEvent(E164_A)
}
test("e164 and pni matches, all provided, no pni session") {
given(E164_A, PNI_A, null)
process(E164_A, PNI_A, ACI_A)
expect(E164_A, PNI_A, ACI_A)
}
test("pni matches, all provided, no existing session") {
test("e164 and pni matches, all provided, existing pni session") {
given(E164_A, PNI_A, null, pniSession = true)
process(E164_A, PNI_A, ACI_A)
expect(E164_A, PNI_A, ACI_A)
expectSessionSwitchoverEvent(E164_A)
}
test("pni matches, all provided, no pni session") {
given(null, PNI_A, null)
process(E164_A, PNI_A, ACI_A)
expect(E164_A, PNI_A, ACI_A)
}
// This test, I could go either way. We decide to change the E164 on the existing row rather than create a new one.
// But it's an "unstable E164->PNI mapping" case, which we don't expect, so as long as there's a user-visible impact that should be fine.
test("pni matches, all provided, existing pni session") {
given(null, PNI_A, null, pniSession = true)
process(E164_A, PNI_A, ACI_A)
expect(E164_A, PNI_A, ACI_A)
expectSessionSwitchoverEvent(E164_A)
}
test("pni matches, no existing pni session, changes number") {
given(E164_B, PNI_A, null)
process(E164_A, PNI_A, ACI_A)
@@ -194,8 +269,15 @@ class RecipientTableTest_getAndPossiblyMerge {
expectChangeNumberEvent()
}
// This test, I could go either way. We decide to change the E164 on the existing row rather than create a new one.
// But it's an "unstable E164->PNI mapping" case, which we don't expect, so as long as there's a user-visible impact that should be fine.
test("pni matches, existing pni session, changes number") {
given(E164_B, PNI_A, null, pniSession = true)
process(E164_A, PNI_A, ACI_A)
expect(E164_A, PNI_A, ACI_A)
expectSessionSwitchoverEvent(E164_B)
expectChangeNumberEvent()
}
test("pni and aci matches, change number") {
given(E164_B, PNI_A, ACI_A)
process(E164_A, PNI_A, ACI_A)
@@ -220,7 +302,7 @@ class RecipientTableTest_getAndPossiblyMerge {
expectChangeNumberEvent()
}
test("steal, e164+pni & e164+pni, no aci provided, no sessions") {
test("steal, e164+pni & e164+pni, no aci provided, no pni session") {
given(E164_A, PNI_B, null)
given(E164_B, PNI_A, null)
@@ -230,6 +312,18 @@ class RecipientTableTest_getAndPossiblyMerge {
expect(E164_B, null, null)
}
test("steal, e164+pni & e164+pni, no aci provided, existing pni session") {
given(E164_A, PNI_B, null, pniSession = true)
given(E164_B, PNI_A, null) // TODO How to handle if this user had a session? They just end up losing the PNI, meaning it would become unregistered, but it could register again later with a different PNI?
process(E164_A, PNI_A, null)
expect(E164_A, PNI_A, null)
expect(E164_B, null, null)
expectSessionSwitchoverEvent(E164_A)
}
test("steal, e164+pni & aci, e164 record has separate e164") {
given(E164_B, PNI_A, null)
given(null, null, ACI_A)
@@ -252,6 +346,19 @@ class RecipientTableTest_getAndPossiblyMerge {
expectChangeNumberEvent()
}
test("steal, e164 & pni+e164, no aci provided") {
val id1 = given(E164_A, null, null)
val id2 = given(E164_B, PNI_A, null, pniSession = true)
process(E164_A, PNI_A, null)
expect(E164_A, PNI_A, null)
expect(E164_B, null, null)
expectSessionSwitchoverEvent(id1, E164_A)
expectSessionSwitchoverEvent(id2, E164_B)
}
test("merge, e164 & pni & aci, all provided") {
given(E164_A, null, null)
given(null, PNI_A, null)
@@ -262,6 +369,34 @@ class RecipientTableTest_getAndPossiblyMerge {
expectDeleted()
expectDeleted()
expect(E164_A, PNI_A, ACI_A)
expectThreadMergeEvent(E164_A)
}
test("merge, e164 & pni & aci, all provided, no threads") {
given(E164_A, null, null, createThread = false)
given(null, PNI_A, null, createThread = false)
given(null, null, ACI_A, createThread = false)
process(E164_A, PNI_A, ACI_A)
expectDeleted()
expectDeleted()
expect(E164_A, PNI_A, ACI_A)
}
test("merge, e164 & pni & aci, all provided, pni session no threads") {
given(E164_A, null, null, createThread = false)
given(null, PNI_A, null, createThread = true, pniSession = true)
given(null, null, ACI_A, createThread = false)
process(E164_A, PNI_A, ACI_A)
expectDeleted()
expectDeleted()
expect(E164_A, PNI_A, ACI_A)
expectSessionSwitchoverEvent(E164_A)
}
test("merge, e164 & pni, no aci provided") {
@@ -272,9 +407,11 @@ class RecipientTableTest_getAndPossiblyMerge {
expect(E164_A, PNI_A, null)
expectDeleted()
expectThreadMergeEvent("")
}
test("merge, e164 & pni, aci provided but no aci record") {
test("merge, e164 & pni, aci provided, no pni session") {
given(E164_A, null, null)
given(null, PNI_A, null)
@@ -282,16 +419,44 @@ class RecipientTableTest_getAndPossiblyMerge {
expect(E164_A, PNI_A, ACI_A)
expectDeleted()
expectThreadMergeEvent("")
}
test("merge, e164 & pni+e164, no aci provided") {
test("merge, e164 & pni, aci provided, no pni session") {
given(E164_A, null, null)
given(E164_B, PNI_A, null)
given(null, PNI_A, null)
process(E164_A, PNI_A, null)
process(E164_A, PNI_A, ACI_A)
expect(E164_A, PNI_A, null)
expect(E164_B, null, null)
expect(E164_A, PNI_A, ACI_A)
expectDeleted()
expectThreadMergeEvent("")
}
test("merge, e164 & pni, aci provided, existing pni session, thread merge shadows") {
given(E164_A, null, null)
given(null, PNI_A, null, pniSession = true)
process(E164_A, PNI_A, ACI_A)
expect(E164_A, PNI_A, ACI_A)
expectDeleted()
expectThreadMergeEvent("")
}
test("merge, e164 & pni, aci provided, existing pni session, no thread merge") {
given(E164_A, null, null, createThread = true)
given(null, PNI_A, null, createThread = false, pniSession = true)
process(E164_A, PNI_A, ACI_A)
expect(E164_A, PNI_A, ACI_A)
expectDeleted()
expectSessionSwitchoverEvent(E164_A)
}
test("merge, e164+pni & pni, no aci provided") {
@@ -302,9 +467,11 @@ class RecipientTableTest_getAndPossiblyMerge {
expect(E164_A, PNI_A, null)
expectDeleted()
expectThreadMergeEvent("")
}
test("merge, e164+pni & aci") {
test("merge, e164+pni & aci, no pni session") {
given(E164_A, PNI_A, null)
given(null, null, ACI_A)
@@ -312,6 +479,54 @@ class RecipientTableTest_getAndPossiblyMerge {
expectDeleted()
expect(E164_A, PNI_A, ACI_A)
expectThreadMergeEvent(E164_A)
}
test("merge, e164+pni & aci, pni session, thread merge shadows") {
given(E164_A, PNI_A, null, pniSession = true)
given(null, null, ACI_A)
process(E164_A, PNI_A, ACI_A)
expectDeleted()
expect(E164_A, PNI_A, ACI_A)
expectThreadMergeEvent(E164_A)
}
test("merge, e164+pni & aci, pni session, no thread merge") {
given(E164_A, PNI_A, null, createThread = true, pniSession = true)
given(null, null, ACI_A, createThread = false)
process(E164_A, PNI_A, ACI_A)
expectDeleted()
expect(E164_A, PNI_A, ACI_A)
expectSessionSwitchoverEvent(E164_A)
}
test("merge, e164+pni & aci, pni session, no thread merge, pni verified") {
given(E164_A, PNI_A, null, createThread = true, pniSession = true)
given(null, null, ACI_A, createThread = false)
process(E164_A, PNI_A, ACI_A, pniVerified = true)
expectDeleted()
expect(E164_A, PNI_A, ACI_A)
}
test("merge, e164+pni & aci, pni session, pni verified") {
given(E164_A, PNI_A, null, pniSession = true)
given(null, null, ACI_A)
process(E164_A, PNI_A, ACI_A, pniVerified = true)
expectDeleted()
expect(E164_A, PNI_A, ACI_A)
expectThreadMergeEvent(E164_A)
}
test("merge, e164+pni & e164+pni+aci, change number") {
@@ -324,6 +539,7 @@ class RecipientTableTest_getAndPossiblyMerge {
expect(E164_A, PNI_A, ACI_A)
expectChangeNumberEvent()
expectThreadMergeEvent(E164_A)
}
test("merge, e164+pni & e164+aci, change number") {
@@ -336,6 +552,7 @@ class RecipientTableTest_getAndPossiblyMerge {
expect(E164_A, PNI_A, ACI_A)
expectChangeNumberEvent()
expectThreadMergeEvent(E164_A)
}
test("merge, e164 & aci") {
@@ -346,6 +563,8 @@ class RecipientTableTest_getAndPossiblyMerge {
expectDeleted()
expect(E164_A, null, ACI_A)
expectThreadMergeEvent(E164_A)
}
test("merge, e164 & e164+aci, change number") {
@@ -358,6 +577,8 @@ class RecipientTableTest_getAndPossiblyMerge {
expect(E164_A, null, ACI_A)
expectChangeNumberEvent()
expectThreadMergeEvent(E164_A)
}
test("local user, local e164 and aci provided, changeSelf=false, leave e164 alone") {
@@ -539,11 +760,11 @@ class RecipientTableTest_getAndPossiblyMerge {
}
private fun getMention(messageId: Long): MentionModel {
SignalDatabase.rawDatabase.rawQuery("SELECT * FROM ${MentionTable.TABLE_NAME} WHERE ${MentionTable.MESSAGE_ID} = $messageId").use { cursor ->
return SignalDatabase.rawDatabase.rawQuery("SELECT * FROM ${MentionTable.TABLE_NAME} WHERE ${MentionTable.MESSAGE_ID} = $messageId").use { cursor ->
cursor.moveToFirst()
return MentionModel(
recipientId = RecipientId.from(CursorUtil.requireLong(cursor, MentionTable.RECIPIENT_ID)),
threadId = CursorUtil.requireLong(cursor, MentionTable.THREAD_ID)
MentionModel(
recipientId = RecipientId.from(cursor.requireLong(MentionTable.RECIPIENT_ID)),
threadId = cursor.requireLong(MentionTable.THREAD_ID)
)
}
}
@@ -577,6 +798,14 @@ class RecipientTableTest_getAndPossiblyMerge {
if (!test.changeNumberExpected) {
test.expectNoChangeNumberEvent()
}
if (!test.threadMergeExpected) {
test.expectNoThreadMergeEvent()
}
if (!test.sessionSwitchoverExpected) {
test.expectNoSessionSwitchoverEvent()
}
} catch (e: Throwable) {
if (e.javaClass != exception) {
val error = java.lang.AssertionError("[$name] ${e.message}")
@@ -594,6 +823,8 @@ class RecipientTableTest_getAndPossiblyMerge {
private lateinit var outputRecipientId: RecipientId
var changeNumberExpected = false
var threadMergeExpected = false
var sessionSwitchoverExpected = false
init {
// Need to delete these first to prevent foreign key crash
@@ -616,9 +847,8 @@ class RecipientTableTest_getAndPossiblyMerge {
pni: PNI?,
aci: ACI?,
createThread: Boolean = true,
sms: List<String> = emptyList(),
mms: List<String> = emptyList()
) {
pniSession: Boolean = false
): RecipientId {
val id = insert(e164, pni, aci)
generatedIds += id
if (createThread) {
@@ -626,11 +856,22 @@ class RecipientTableTest_getAndPossiblyMerge {
SignalDatabase.threads.getOrCreateThreadIdFor(Recipient.resolved(id))
SignalDatabase.messages.insertMessageInbox(IncomingEncryptedMessage(IncomingTextMessage(id, 1, 0, 0, 0, "", Optional.empty(), 0, false, ""), ""))
}
if (pniSession) {
if (pni == null) {
throw IllegalArgumentException("pniSession = true but pni is null!")
}
SignalDatabase.sessions.store(pni, SignalProtocolAddress(pni.toString(), 1), SessionRecord())
}
return id
}
fun process(e164: String?, pni: PNI?, aci: ACI?, changeSelf: Boolean = false) {
outputRecipientId = SignalDatabase.recipients.getAndPossiblyMerge(serviceId = aci ?: pni, pni = pni, e164 = e164, pniVerified = false, changeSelf = changeSelf)
fun process(e164: String?, pni: PNI?, aci: ACI?, changeSelf: Boolean = false, pniVerified: Boolean = false): RecipientId {
outputRecipientId = SignalDatabase.recipients.getAndPossiblyMerge(serviceId = aci ?: pni, pni = pni, e164 = e164, pniVerified = pniVerified, changeSelf = changeSelf)
generatedIds += outputRecipientId
return outputRecipientId
}
fun expect(e164: String?, pni: PNI?, aci: ACI?) {
@@ -676,6 +917,32 @@ class RecipientTableTest_getAndPossiblyMerge {
changeNumberExpected = false
}
fun expectSessionSwitchoverEvent(e164: String) {
expectSessionSwitchoverEvent(outputRecipientId, e164)
}
fun expectSessionSwitchoverEvent(recipientId: RecipientId, e164: String) {
val event: SessionSwitchoverEvent? = getLatestSessionSwitchoverEvent(recipientId)
assertNotNull(event)
assertEquals(e164, event!!.e164)
sessionSwitchoverExpected = true
}
fun expectNoSessionSwitchoverEvent() {
assertNull(getLatestSessionSwitchoverEvent(outputRecipientId))
}
fun expectThreadMergeEvent(previousE164: String) {
val event: ThreadMergeEvent? = getLatestThreadMergeEvent(outputRecipientId)
assertNotNull(event)
assertEquals(previousE164, event!!.previousE164)
threadMergeExpected = true
}
fun expectNoThreadMergeEvent() {
assertNull(getLatestThreadMergeEvent(outputRecipientId))
}
private fun insert(e164: String?, pni: PNI?, aci: ACI?): RecipientId {
val serviceIdString: String? = (aci ?: pni)?.toString()
val pniString: String? = pni?.toString()
@@ -746,6 +1013,42 @@ class RecipientTableTest_getAndPossiblyMerge {
}
}
private fun getLatestThreadMergeEvent(recipientId: RecipientId): ThreadMergeEvent? {
return SignalDatabase.rawDatabase
.select(MessageTable.BODY)
.from(MessageTable.TABLE_NAME)
.where("${MessageTable.RECIPIENT_ID} = ? AND ${MessageTable.TYPE} = ?", recipientId, MessageTypes.THREAD_MERGE_TYPE)
.orderBy("${MessageTable.DATE_RECEIVED} DESC")
.limit(1)
.run()
.use { cursor: Cursor ->
if (cursor.moveToFirst()) {
val bytes = Base64.decode(cursor.requireNonNullString(MessageTable.BODY))
ThreadMergeEvent.parseFrom(bytes)
} else {
null
}
}
}
private fun getLatestSessionSwitchoverEvent(recipientId: RecipientId): SessionSwitchoverEvent? {
return SignalDatabase.rawDatabase
.select(MessageTable.BODY)
.from(MessageTable.TABLE_NAME)
.where("${MessageTable.RECIPIENT_ID} = ? AND ${MessageTable.TYPE} = ?", recipientId, MessageTypes.SESSION_SWITCHOVER_TYPE)
.orderBy("${MessageTable.DATE_RECEIVED} DESC")
.limit(1)
.run()
.use { cursor: Cursor ->
if (cursor.moveToFirst()) {
val bytes = Base64.decode(cursor.requireNonNullString(MessageTable.BODY))
SessionSwitchoverEvent.parseFrom(bytes)
} else {
null
}
}
}
companion object {
val ACI_A = ACI.from(UUID.fromString("aaaa0000-5a76-47fa-a98a-7e72c948a82e"))
val ACI_B = ACI.from(UUID.fromString("bbbb0000-0b60-4a68-9cd9-ed2f8453f9ed"))

View File

@@ -1,842 +0,0 @@
package org.thoughtcrime.securesms.database
import androidx.core.content.contentValuesOf
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.testing.SignalDatabaseRule
import org.thoughtcrime.securesms.util.Util
import org.whispersystems.signalservice.api.push.ACI
import org.whispersystems.signalservice.api.push.PNI
import org.whispersystems.signalservice.api.push.ServiceId
import java.lang.AssertionError
import java.lang.IllegalStateException
import java.util.UUID
@RunWith(AndroidJUnit4::class)
class RecipientTableTest_processPnpTupleToChangeSet {
@Rule
@JvmField
val databaseRule = SignalDatabaseRule(deleteAllThreadsOnEachRun = false)
private lateinit var db: RecipientTable
@Before
fun setup() {
db = SignalDatabase.recipients
}
@Test
fun noMatch_e164Only() {
val changeSet = db.processPnpTupleToChangeSet(E164_A, null, null, pniVerified = false)
assertEquals(
PnpChangeSet(
id = PnpIdResolver.PnpInsert(E164_A, null, null)
),
changeSet
)
}
@Test
fun noMatch_e164AndPni() {
val changeSet = db.processPnpTupleToChangeSet(E164_A, PNI_A, null, pniVerified = false)
assertEquals(
PnpChangeSet(
id = PnpIdResolver.PnpInsert(E164_A, PNI_A, null)
),
changeSet
)
}
@Test
fun noMatch_aciOnly() {
val changeSet = db.processPnpTupleToChangeSet(null, null, ACI_A, pniVerified = false)
assertEquals(
PnpChangeSet(
id = PnpIdResolver.PnpInsert(null, null, ACI_A)
),
changeSet
)
}
@Test(expected = IllegalStateException::class)
fun noMatch_noData() {
db.processPnpTupleToChangeSet(null, null, null, pniVerified = false)
}
@Test
fun noMatch_allFields() {
val changeSet = db.processPnpTupleToChangeSet(E164_A, PNI_A, ACI_A, pniVerified = false)
assertEquals(
PnpChangeSet(
id = PnpIdResolver.PnpInsert(E164_A, PNI_A, ACI_A)
),
changeSet
)
}
@Test
fun fullMatch() {
val result = applyAndAssert(
Input(E164_A, PNI_A, ACI_A),
Update(E164_A, PNI_A, ACI_A),
Output(E164_A, PNI_A, ACI_A)
)
assertEquals(
PnpChangeSet(
id = PnpIdResolver.PnpNoopId(result.id)
),
result.changeSet
)
}
@Test
fun onlyE164Matches() {
val result = applyAndAssert(
Input(E164_A, null, null),
Update(E164_A, PNI_A, ACI_A),
Output(E164_A, PNI_A, ACI_A)
)
assertEquals(
PnpChangeSet(
id = PnpIdResolver.PnpNoopId(result.id),
operations = listOf(
PnpOperation.SetPni(result.id, PNI_A),
PnpOperation.SetAci(result.id, ACI_A)
)
),
result.changeSet
)
}
@Test
fun onlyE164Matches_pniChanges_noAciProvided_existingPniSession() {
val result = applyAndAssert(
Input(E164_A, PNI_B, null, pniSession = true),
Update(E164_A, PNI_A, null),
Output(E164_A, PNI_A, null)
)
assertEquals(
PnpChangeSet(
id = PnpIdResolver.PnpNoopId(result.id),
operations = listOf(
PnpOperation.SetPni(result.id, PNI_A),
PnpOperation.SessionSwitchoverInsert(result.id)
)
),
result.changeSet
)
}
@Test
fun onlyE164Matches_pniChanges_noAciProvided_noPniSession() {
val result = applyAndAssert(
Input(E164_A, PNI_B, null),
Update(E164_A, PNI_A, null),
Output(E164_A, PNI_A, null)
)
assertEquals(
PnpChangeSet(
id = PnpIdResolver.PnpNoopId(result.id),
operations = listOf(
PnpOperation.SetPni(result.id, PNI_A)
)
),
result.changeSet
)
}
@Test
fun e164AndPniMatches_noExistingSession() {
val result = applyAndAssert(
Input(E164_A, PNI_A, null),
Update(E164_A, PNI_A, ACI_A),
Output(E164_A, PNI_A, ACI_A)
)
assertEquals(
PnpChangeSet(
id = PnpIdResolver.PnpNoopId(result.id),
operations = listOf(
PnpOperation.SetAci(result.id, ACI_A)
)
),
result.changeSet
)
}
@Test
fun e164AndPniMatches_existingPniSession() {
val result = applyAndAssert(
Input(E164_A, PNI_A, null, pniSession = true),
Update(E164_A, PNI_A, ACI_A),
Output(E164_A, PNI_A, ACI_A)
)
assertEquals(
PnpChangeSet(
id = PnpIdResolver.PnpNoopId(result.id),
operations = listOf(
PnpOperation.SetAci(result.id, ACI_A),
PnpOperation.SessionSwitchoverInsert(result.id)
)
),
result.changeSet
)
}
@Test
fun e164AndAciMatches() {
val result = applyAndAssert(
Input(E164_A, null, ACI_A),
Update(E164_A, PNI_A, ACI_A),
Output(E164_A, PNI_A, ACI_A)
)
assertEquals(
PnpChangeSet(
id = PnpIdResolver.PnpNoopId(result.id),
operations = listOf(
PnpOperation.SetPni(result.id, PNI_A)
)
),
result.changeSet
)
}
@Test
fun onlyPniMatches_noExistingSession() {
val result = applyAndAssert(
Input(null, PNI_A, null),
Update(E164_A, PNI_A, ACI_A),
Output(E164_A, PNI_A, ACI_A)
)
assertEquals(
PnpChangeSet(
id = PnpIdResolver.PnpNoopId(result.id),
operations = listOf(
PnpOperation.SetE164(result.id, E164_A),
PnpOperation.SetAci(result.id, ACI_A)
)
),
result.changeSet
)
}
@Test
fun onlyPniMatches_existingPniSession() {
val result = applyAndAssert(
Input(null, PNI_A, null, pniSession = true),
Update(E164_A, PNI_A, ACI_A),
Output(E164_A, PNI_A, ACI_A)
)
assertEquals(
PnpChangeSet(
id = PnpIdResolver.PnpNoopId(result.id),
operations = listOf(
PnpOperation.SetE164(result.id, E164_A),
PnpOperation.SetAci(result.id, ACI_A),
PnpOperation.SessionSwitchoverInsert(result.id)
)
),
result.changeSet
)
}
@Test
fun onlyPniMatches_existingPniSession_changeNumber() {
val result = applyAndAssert(
Input(E164_B, PNI_A, null, pniSession = true),
Update(E164_A, PNI_A, ACI_A),
Output(E164_A, PNI_A, ACI_A)
)
assertEquals(
PnpChangeSet(
id = PnpIdResolver.PnpNoopId(result.id),
operations = listOf(
PnpOperation.SetE164(result.id, E164_A),
PnpOperation.SetAci(result.id, ACI_A),
PnpOperation.ChangeNumberInsert(
recipientId = result.id,
oldE164 = E164_B,
newE164 = E164_A
),
PnpOperation.SessionSwitchoverInsert(result.id)
)
),
result.changeSet
)
}
@Test
fun pniAndAciMatches() {
val result = applyAndAssert(
Input(null, PNI_A, ACI_A),
Update(E164_A, PNI_A, ACI_A),
Output(E164_A, PNI_A, ACI_A)
)
assertEquals(
PnpChangeSet(
id = PnpIdResolver.PnpNoopId(result.id),
operations = listOf(
PnpOperation.SetE164(result.id, E164_A),
)
),
result.changeSet
)
}
@Test
fun pniAndAciMatches_changeNumber() {
val result = applyAndAssert(
Input(E164_B, PNI_A, ACI_A),
Update(E164_A, PNI_A, ACI_A),
Output(E164_A, PNI_A, ACI_A)
)
assertEquals(
PnpChangeSet(
id = PnpIdResolver.PnpNoopId(result.id),
operations = listOf(
PnpOperation.SetE164(result.id, E164_A),
PnpOperation.ChangeNumberInsert(
recipientId = result.id,
oldE164 = E164_B,
newE164 = E164_A
)
)
),
result.changeSet
)
}
@Test
fun onlyAciMatches() {
val result = applyAndAssert(
Input(null, null, ACI_A),
Update(E164_A, PNI_A, ACI_A),
Output(E164_A, PNI_A, ACI_A)
)
assertEquals(
PnpChangeSet(
id = PnpIdResolver.PnpNoopId(result.id),
operations = listOf(
PnpOperation.SetE164(result.id, E164_A),
PnpOperation.SetPni(result.id, PNI_A)
)
),
result.changeSet
)
}
@Test
fun onlyAciMatches_changeNumber() {
val result = applyAndAssert(
Input(E164_B, null, ACI_A),
Update(E164_A, PNI_A, ACI_A),
Output(E164_A, PNI_A, ACI_A)
)
assertEquals(
PnpChangeSet(
id = PnpIdResolver.PnpNoopId(result.id),
operations = listOf(
PnpOperation.SetE164(result.id, E164_A),
PnpOperation.SetPni(result.id, PNI_A),
PnpOperation.ChangeNumberInsert(
recipientId = result.id,
oldE164 = E164_B,
newE164 = E164_A
)
)
),
result.changeSet
)
}
@Test
fun merge_e164Only_pniOnly_aciOnly() {
val result = applyAndAssert(
listOf(
Input(E164_A, null, null),
Input(null, PNI_A, null),
Input(null, null, ACI_A)
),
Update(E164_A, PNI_A, ACI_A),
Output(E164_A, PNI_A, ACI_A)
)
assertEquals(
PnpChangeSet(
id = PnpIdResolver.PnpNoopId(result.thirdId),
operations = listOf(
PnpOperation.Merge(
primaryId = result.firstId,
secondaryId = result.secondId
),
PnpOperation.Merge(
primaryId = result.thirdId,
secondaryId = result.firstId
)
)
),
result.changeSet
)
}
@Test
fun merge_e164Only_pniOnly_noAciProvided() {
val result = applyAndAssert(
listOf(
Input(E164_A, null, null),
Input(null, PNI_A, null),
),
Update(E164_A, PNI_A, null),
Output(E164_A, PNI_A, null)
)
assertEquals(
PnpChangeSet(
id = PnpIdResolver.PnpNoopId(result.firstId),
operations = listOf(
PnpOperation.Merge(
primaryId = result.firstId,
secondaryId = result.secondId
)
)
),
result.changeSet
)
}
@Test
fun merge_e164Only_pniOnly_aciProvidedButNoAciRecord() {
val result = applyAndAssert(
listOf(
Input(E164_A, null, null),
Input(null, PNI_A, null),
),
Update(E164_A, PNI_A, ACI_A),
Output(E164_A, PNI_A, ACI_A)
)
assertEquals(
PnpChangeSet(
id = PnpIdResolver.PnpNoopId(result.firstId),
operations = listOf(
PnpOperation.Merge(
primaryId = result.firstId,
secondaryId = result.secondId
),
PnpOperation.SetAci(
recipientId = result.firstId,
aci = ACI_A
)
)
),
result.changeSet
)
}
@Test
fun merge_e164Only_pniAndE164_noAciProvided() {
val result = applyAndAssert(
listOf(
Input(E164_A, null, null),
Input(E164_B, PNI_A, null),
),
Update(E164_A, PNI_A, null),
Output(E164_A, PNI_A, null)
)
assertEquals(
PnpChangeSet(
id = PnpIdResolver.PnpNoopId(result.firstId),
operations = listOf(
PnpOperation.RemovePni(result.secondId),
PnpOperation.SetPni(
recipientId = result.firstId,
pni = PNI_A
),
)
),
result.changeSet
)
}
@Test
fun merge_e164AndPni_pniOnly_noAciProvided() {
val result = applyAndAssert(
listOf(
Input(E164_A, PNI_B, null),
Input(null, PNI_A, null),
),
Update(E164_A, PNI_A, null),
Output(E164_A, PNI_A, null)
)
assertEquals(
PnpChangeSet(
id = PnpIdResolver.PnpNoopId(result.firstId),
operations = listOf(
PnpOperation.RemovePni(result.firstId),
PnpOperation.Merge(
primaryId = result.firstId,
secondaryId = result.secondId
),
)
),
result.changeSet
)
}
@Test
fun merge_e164AndPni_e164AndPni_noAciProvided_noSessions() {
val result = applyAndAssert(
listOf(
Input(E164_A, PNI_B, null),
Input(E164_B, PNI_A, null),
),
Update(E164_A, PNI_A, null),
Output(E164_A, PNI_A, null)
)
assertEquals(
PnpChangeSet(
id = PnpIdResolver.PnpNoopId(result.firstId),
operations = listOf(
PnpOperation.RemovePni(result.secondId),
PnpOperation.SetPni(result.firstId, PNI_A)
)
),
result.changeSet
)
}
@Test
fun merge_e164AndPni_e164AndPni_noAciProvided_sessionsExist() {
val result = applyAndAssert(
listOf(
Input(E164_A, PNI_B, null, pniSession = true),
Input(E164_B, PNI_A, null, pniSession = true),
),
Update(E164_A, PNI_A, null),
Output(E164_A, PNI_A, null)
)
assertEquals(
PnpChangeSet(
id = PnpIdResolver.PnpNoopId(result.firstId),
operations = listOf(
PnpOperation.RemovePni(result.secondId),
PnpOperation.SetPni(result.firstId, PNI_A),
PnpOperation.SessionSwitchoverInsert(result.secondId),
PnpOperation.SessionSwitchoverInsert(result.firstId)
)
),
result.changeSet
)
}
@Test
fun merge_e164AndPni_aciOnly() {
val result = applyAndAssert(
listOf(
Input(E164_A, PNI_A, null),
Input(null, null, ACI_A),
),
Update(E164_A, PNI_A, ACI_A),
Output(E164_A, PNI_A, ACI_A)
)
assertEquals(
PnpChangeSet(
id = PnpIdResolver.PnpNoopId(result.secondId),
operations = listOf(
PnpOperation.Merge(
primaryId = result.secondId,
secondaryId = result.firstId
),
)
),
result.changeSet
)
}
@Test
fun merge_e164AndPni_aciOnly_e164RecordHasSeparateE164() {
val result = applyAndAssert(
listOf(
Input(E164_B, PNI_A, null),
Input(null, null, ACI_A),
),
Update(E164_A, PNI_A, ACI_A),
Output(E164_A, PNI_A, ACI_A)
)
assertEquals(
PnpChangeSet(
id = PnpIdResolver.PnpNoopId(result.secondId),
operations = listOf(
PnpOperation.RemovePni(result.firstId),
PnpOperation.SetPni(
recipientId = result.secondId,
pni = PNI_A,
),
PnpOperation.SetE164(
recipientId = result.secondId,
e164 = E164_A,
)
)
),
result.changeSet
)
}
@Test
fun merge_e164AndPni_aciOnly_e164RecordHasSeparateE164_changeNumber() {
val result = applyAndAssert(
listOf(
Input(E164_B, PNI_A, null),
Input(E164_C, null, ACI_A),
),
Update(E164_A, PNI_A, ACI_A),
Output(E164_A, PNI_A, ACI_A)
)
assertEquals(
PnpChangeSet(
id = PnpIdResolver.PnpNoopId(result.secondId),
operations = listOf(
PnpOperation.RemovePni(result.firstId),
PnpOperation.SetPni(
recipientId = result.secondId,
pni = PNI_A,
),
PnpOperation.SetE164(
recipientId = result.secondId,
e164 = E164_A,
),
PnpOperation.ChangeNumberInsert(
recipientId = result.secondId,
oldE164 = E164_C,
newE164 = E164_A
)
)
),
result.changeSet
)
}
@Test
fun merge_e164AndPni_e164AndPniAndAci_changeNumber() {
val result = applyAndAssert(
listOf(
Input(E164_A, PNI_A, null),
Input(E164_B, PNI_B, ACI_A),
),
Update(E164_A, PNI_A, ACI_A),
Output(E164_A, PNI_A, ACI_A)
)
assertEquals(
PnpChangeSet(
id = PnpIdResolver.PnpNoopId(result.secondId),
operations = listOf(
PnpOperation.RemovePni(result.secondId),
PnpOperation.RemoveE164(result.secondId),
PnpOperation.Merge(
primaryId = result.secondId,
secondaryId = result.firstId
),
PnpOperation.ChangeNumberInsert(
recipientId = result.secondId,
oldE164 = E164_B,
newE164 = E164_A
)
)
),
result.changeSet
)
}
@Test
fun merge_e164AndPni_e164Aci_changeNumber() {
val result = applyAndAssert(
listOf(
Input(E164_A, PNI_A, null),
Input(E164_B, null, ACI_A),
),
Update(E164_A, PNI_A, ACI_A),
Output(E164_A, PNI_A, ACI_A)
)
assertEquals(
PnpChangeSet(
id = PnpIdResolver.PnpNoopId(result.secondId),
operations = listOf(
PnpOperation.RemoveE164(result.secondId),
PnpOperation.Merge(
primaryId = result.secondId,
secondaryId = result.firstId
),
PnpOperation.ChangeNumberInsert(
recipientId = result.secondId,
oldE164 = E164_B,
newE164 = E164_A
)
)
),
result.changeSet
)
}
private fun insert(e164: String?, pni: PNI?, aci: ACI?): RecipientId {
val id: Long = SignalDatabase.rawDatabase.insert(
RecipientTable.TABLE_NAME,
null,
contentValuesOf(
RecipientTable.PHONE to e164,
RecipientTable.SERVICE_ID to (aci ?: pni)?.toString(),
RecipientTable.PNI_COLUMN to pni?.toString(),
RecipientTable.REGISTERED to RecipientTable.RegisteredState.REGISTERED.id
)
)
return RecipientId.from(id)
}
private fun insertMockSessionFor(account: ServiceId, address: ServiceId) {
SignalDatabase.rawDatabase.insert(
SessionTable.TABLE_NAME, null,
contentValuesOf(
SessionTable.ACCOUNT_ID to account.toString(),
SessionTable.ADDRESS to address.toString(),
SessionTable.DEVICE to 1,
SessionTable.RECORD to Util.getSecretBytes(32)
)
)
}
data class Input(val e164: String?, val pni: PNI?, val aci: ACI?, val pniSession: Boolean = false, val aciSession: Boolean = false)
data class Update(val e164: String?, val pni: PNI?, val aci: ACI?, val pniVerified: Boolean = false)
data class Output(val e164: String?, val pni: PNI?, val aci: ACI?)
data class PnpMatchResult(val ids: List<RecipientId>, val changeSet: PnpChangeSet) {
val id
get() = if (ids.size == 1) {
ids[0]
} else {
throw IllegalStateException("There are multiple IDs, but you assumed 1!")
}
val firstId
get() = ids[0]
val secondId
get() = ids[1]
val thirdId
get() = ids[2]
}
private fun applyAndAssert(input: Input, update: Update, output: Output): PnpMatchResult {
return applyAndAssert(listOf(input), update, output)
}
/**
* Helper method that will call insert your recipients, call [RecipientTable.processPnpTupleToChangeSet] with your params,
* and then verify your output matches what you expect.
*
* It results the inserted ID's and changeset for additional verification.
*
* But basically this is here to make the tests more readable. It gives you a clear list of:
* - input
* - update
* - output
*
* that you can spot check easily.
*
* Important: The output will only include records that contain fields from the input. That means
* for:
*
* Input: E164_B, PNI_A, null
* Update: E164_A, PNI_A, null
*
* You will get:
* Output: E164_A, PNI_A, null
*
* Even though there was an update that will also result in the row (E164_B, null, null)
*/
private fun applyAndAssert(input: List<Input>, update: Update, output: Output): PnpMatchResult {
val ids = input.map { insert(it.e164, it.pni, it.aci) }
input
.filter { it.pniSession }
.forEach { insertMockSessionFor(databaseRule.localAci, it.pni!!) }
input
.filter { it.aciSession }
.forEach { insertMockSessionFor(databaseRule.localAci, it.aci!!) }
val byE164 = update.e164?.let { db.getByE164(it).orElse(null) }
val byPniSid = update.pni?.let { db.getByServiceId(it).orElse(null) }
val byAciSid = update.aci?.let { db.getByServiceId(it).orElse(null) }
val data = PnpDataSet(
e164 = update.e164,
pni = update.pni,
aci = update.aci,
byE164 = byE164,
byPniSid = byPniSid,
byPniOnly = update.pni?.let { db.getByPni(it).orElse(null) },
byAciSid = byAciSid,
e164Record = byE164?.let { db.getRecord(it) },
pniSidRecord = byPniSid?.let { db.getRecord(it) },
aciSidRecord = byAciSid?.let { db.getRecord(it) }
)
val changeSet = db.processPnpTupleToChangeSet(update.e164, update.pni, update.aci, pniVerified = update.pniVerified)
val finalData = data.perform(changeSet.operations)
val finalRecords = setOfNotNull(finalData.e164Record, finalData.pniSidRecord, finalData.aciSidRecord)
assertEquals("There's still multiple records in the resulting record set! $finalRecords", 1, finalRecords.size)
finalRecords.firstOrNull { record -> record.e164 == output.e164 && record.pni == output.pni && record.serviceId == (output.aci ?: output.pni) }
?: throw AssertionError("Expected output was not found in the result set! Expected: $output")
return PnpMatchResult(
ids = ids,
changeSet = changeSet
)
}
companion object {
val ACI_A = ACI.from(UUID.fromString("3436efbe-5a76-47fa-a98a-7e72c948a82e"))
val ACI_B = ACI.from(UUID.fromString("8de7f691-0b60-4a68-9cd9-ed2f8453f9ed"))
val PNI_A = PNI.from(UUID.fromString("154b8d92-c960-4f6c-8385-671ad2ffb999"))
val PNI_B = PNI.from(UUID.fromString("ba92b1fb-cd55-40bf-adda-c35a85375533"))
const val E164_A = "+12221234567"
const val E164_B = "+13331234567"
const val E164_C = "+14441234567"
}
}

View File

@@ -65,17 +65,17 @@ class StorySendTableTest {
messageId1 = MmsHelper.insert(
recipient = distributionListRecipient1,
storyType = StoryType.STORY_WITHOUT_REPLIES,
storyType = StoryType.STORY_WITHOUT_REPLIES
)
messageId2 = MmsHelper.insert(
recipient = distributionListRecipient2,
storyType = StoryType.STORY_WITH_REPLIES,
storyType = StoryType.STORY_WITH_REPLIES
)
messageId3 = MmsHelper.insert(
recipient = distributionListRecipient3,
storyType = StoryType.STORY_WITHOUT_REPLIES,
storyType = StoryType.STORY_WITHOUT_REPLIES
)
recipients6to15 = recipients1to10.takeLast(5) + recipients11to20.take(5)

View File

@@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.dependencies
import android.app.Application
import okhttp3.ConnectionSpec
import okhttp3.WebSocketListener
import okhttp3.mockwebserver.Dispatcher
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
@@ -14,15 +15,16 @@ import org.thoughtcrime.securesms.KbsEnclave
import org.thoughtcrime.securesms.push.SignalServiceNetworkAccess
import org.thoughtcrime.securesms.push.SignalServiceTrustStore
import org.thoughtcrime.securesms.recipients.LiveRecipientCache
import org.thoughtcrime.securesms.testing.Get
import org.thoughtcrime.securesms.testing.Verb
import org.thoughtcrime.securesms.testing.runSync
import org.thoughtcrime.securesms.testing.success
import org.thoughtcrime.securesms.util.Base64
import org.whispersystems.signalservice.api.KeyBackupService
import org.whispersystems.signalservice.api.SignalServiceAccountManager
import org.whispersystems.signalservice.api.push.TrustStore
import org.whispersystems.signalservice.internal.configuration.SignalCdnUrl
import org.whispersystems.signalservice.internal.configuration.SignalCdsiUrl
import org.whispersystems.signalservice.internal.configuration.SignalContactDiscoveryUrl
import org.whispersystems.signalservice.internal.configuration.SignalKeyBackupServiceUrl
import org.whispersystems.signalservice.internal.configuration.SignalServiceConfiguration
import org.whispersystems.signalservice.internal.configuration.SignalServiceUrl
@@ -48,6 +50,12 @@ class InstrumentationApplicationDependencyProvider(application: Application, def
runSync {
webServer = MockWebServer()
baseUrl = webServer.url("").toString()
addMockWebRequestHandlers(
Get("/v1/websocket/") {
MockResponse().success().withWebSocketUpgrade(object : WebSocketListener() {})
}
)
}
webServer.setDispatcher(object : Dispatcher() {
@@ -66,15 +74,13 @@ class InstrumentationApplicationDependencyProvider(application: Application, def
0 to arrayOf(SignalCdnUrl(baseUrl, "localhost", serviceTrustStore, ConnectionSpec.CLEARTEXT)),
2 to arrayOf(SignalCdnUrl(baseUrl, "localhost", serviceTrustStore, ConnectionSpec.CLEARTEXT))
),
arrayOf(SignalContactDiscoveryUrl(baseUrl, "localhost", serviceTrustStore, ConnectionSpec.CLEARTEXT)),
arrayOf(SignalKeyBackupServiceUrl(baseUrl, "localhost", serviceTrustStore, ConnectionSpec.CLEARTEXT)),
arrayOf(SignalStorageUrl(baseUrl, "localhost", serviceTrustStore, ConnectionSpec.CLEARTEXT)),
arrayOf(SignalCdsiUrl(baseUrl, "localhost", serviceTrustStore, ConnectionSpec.CLEARTEXT)),
emptyList(),
Optional.of(SignalServiceNetworkAccess.DNS),
Optional.empty(),
Base64.decode(BuildConfig.ZKGROUP_SERVER_PUBLIC_PARAMS),
true
Base64.decode(BuildConfig.ZKGROUP_SERVER_PUBLIC_PARAMS)
)
serviceNetworkAccessMock = mock {

View File

@@ -69,7 +69,7 @@ class PreKeysSyncJobTest {
Put("/v2/keys/signed?identity=pni") { r ->
pniSignedPreKey = r.parsedRequestBody()
MockResponse().success()
},
}
)
// WHEN
@@ -107,7 +107,7 @@ class PreKeysSyncJobTest {
InstrumentationApplicationDependencyProvider.addMockWebRequestHandlers(
Get("/v2/keys?identity=aci") { MockResponse().success(PreKeyStatus(100)) },
Get("/v2/keys?identity=pni") { MockResponse().success(PreKeyStatus(100)) },
Get("/v2/keys?identity=pni") { MockResponse().success(PreKeyStatus(100)) }
)
// WHEN
@@ -134,7 +134,7 @@ class PreKeysSyncJobTest {
InstrumentationApplicationDependencyProvider.addMockWebRequestHandlers(
Get("/v2/keys?identity=aci") { MockResponse().success(PreKeyStatus(100)) },
Put("/v2/keys/signed?identity=pni") { MockResponse().success() },
Put("/v2/keys/signed?identity=pni") { MockResponse().success() }
)
// WHEN
@@ -173,7 +173,7 @@ class PreKeysSyncJobTest {
Put("/v2/keys/?identity=pni") { r ->
pniPreKeyStateRequest = r.parsedRequestBody()
MockResponse().success()
},
}
)
// WHEN

View File

@@ -0,0 +1,175 @@
package org.thoughtcrime.securesms.jobs
import androidx.test.ext.junit.runners.AndroidJUnit4
import okhttp3.mockwebserver.MockResponse
import org.junit.After
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.signal.libsignal.usernames.Username
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.dependencies.InstrumentationApplicationDependencyProvider
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.testing.Get
import org.thoughtcrime.securesms.testing.Put
import org.thoughtcrime.securesms.testing.SignalActivityRule
import org.thoughtcrime.securesms.testing.failure
import org.thoughtcrime.securesms.testing.success
import org.whispersystems.signalservice.internal.push.ReserveUsernameResponse
import org.whispersystems.signalservice.internal.push.WhoAmIResponse
import org.whispersystems.util.Base64UrlSafe
@Suppress("ClassName")
@RunWith(AndroidJUnit4::class)
class RefreshOwnProfileJob__checkUsernameIsInSyncTest {
@get:Rule
val harness = SignalActivityRule()
@After
fun tearDown() {
InstrumentationApplicationDependencyProvider.clearHandlers()
SignalStore.phoneNumberPrivacy().clearUsernameOutOfSync()
}
@Test
fun givenNoLocalUsername_whenICheckUsernameIsInSync_thenIExpectNoFailures() {
// WHEN
RefreshOwnProfileJob.checkUsernameIsInSync()
}
@Test
fun givenLocalUsernameDoesNotMatchServerUsername_whenICheckUsernameIsInSync_thenIExpectRetry() {
// GIVEN
var didReserve = false
var didConfirm = false
val username = "hello.32"
val serverUsername = "hello.3232"
SignalDatabase.recipients.setUsername(harness.self.id, username)
InstrumentationApplicationDependencyProvider.addMockWebRequestHandlers(
Get("/v1/accounts/whoami") { r ->
MockResponse().success(
WhoAmIResponse().apply {
usernameHash = Base64UrlSafe.encodeBytesWithoutPadding(Username.hash(serverUsername))
}
)
},
Put("/v1/accounts/username_hash/reserve") { r ->
didReserve = true
MockResponse().success(ReserveUsernameResponse(Base64UrlSafe.encodeBytesWithoutPadding(Username.hash(username))))
},
Put("/v1/accounts/username_hash/confirm") { r ->
didConfirm = true
MockResponse().success()
}
)
// WHEN
RefreshOwnProfileJob.checkUsernameIsInSync()
// THEN
assertTrue(didReserve)
assertTrue(didConfirm)
assertFalse(SignalStore.phoneNumberPrivacy().isUsernameOutOfSync)
}
@Test
fun givenLocalAndNoServer_whenICheckUsernameIsInSync_thenIExpectRetry() {
// GIVEN
var didReserve = false
var didConfirm = false
val username = "hello.32"
SignalDatabase.recipients.setUsername(harness.self.id, username)
InstrumentationApplicationDependencyProvider.addMockWebRequestHandlers(
Get("/v1/accounts/whoami") { r ->
MockResponse().success(WhoAmIResponse())
},
Put("/v1/accounts/username_hash/reserve") { r ->
didReserve = true
MockResponse().success(ReserveUsernameResponse(Base64UrlSafe.encodeBytesWithoutPadding(Username.hash(username))))
},
Put("/v1/accounts/username_hash/confirm") { r ->
didConfirm = true
MockResponse().success()
}
)
// WHEN
RefreshOwnProfileJob.checkUsernameIsInSync()
// THEN
assertTrue(didReserve)
assertTrue(didConfirm)
assertFalse(SignalStore.phoneNumberPrivacy().isUsernameOutOfSync)
}
@Test
fun givenLocalAndServerMatch_whenICheckUsernameIsInSync_thenIExpectNoRetry() {
// GIVEN
var didReserve = false
var didConfirm = false
val username = "hello.32"
SignalDatabase.recipients.setUsername(harness.self.id, username)
InstrumentationApplicationDependencyProvider.addMockWebRequestHandlers(
Get("/v1/accounts/whoami") { r ->
MockResponse().success(
WhoAmIResponse().apply {
usernameHash = Base64UrlSafe.encodeBytesWithoutPadding(Username.hash(username))
}
)
},
Put("/v1/accounts/username_hash/reserve") { r ->
didReserve = true
MockResponse().success(ReserveUsernameResponse(Base64UrlSafe.encodeBytesWithoutPadding(Username.hash(username))))
},
Put("/v1/accounts/username_hash/confirm") { r ->
didConfirm = true
MockResponse().success()
}
)
// WHEN
RefreshOwnProfileJob.checkUsernameIsInSync()
// THEN
assertFalse(didReserve)
assertFalse(didConfirm)
assertFalse(SignalStore.phoneNumberPrivacy().isUsernameOutOfSync)
}
@Test
fun givenMismatchAndReservationFails_whenICheckUsernameIsInSync_thenIExpectNoConfirm() {
// GIVEN
var didReserve = false
var didConfirm = false
val username = "hello.32"
SignalDatabase.recipients.setUsername(harness.self.id, username)
InstrumentationApplicationDependencyProvider.addMockWebRequestHandlers(
Get("/v1/accounts/whoami") { r ->
MockResponse().success(
WhoAmIResponse().apply {
usernameHash = Base64UrlSafe.encodeBytesWithoutPadding(Username.hash("${username}23"))
}
)
},
Put("/v1/accounts/username_hash/reserve") { r ->
didReserve = true
MockResponse().failure(418)
},
Put("/v1/accounts/username_hash/confirm") { r ->
didConfirm = true
MockResponse().success()
}
)
// WHEN
RefreshOwnProfileJob.checkUsernameIsInSync()
// THEN
assertTrue(didReserve)
assertFalse(didConfirm)
assertTrue(SignalStore.phoneNumberPrivacy().isUsernameOutOfSync)
}
}

View File

@@ -30,7 +30,7 @@ abstract class MessageContentProcessorTest {
protected fun createNormalContentTestSubject(): MessageContentProcessor {
val context = ApplicationProvider.getApplicationContext<Application>()
return MessageContentProcessor.forNormalContent(context)
return MessageContentProcessor.create(context)
}
/**

View File

@@ -0,0 +1,163 @@
package org.thoughtcrime.securesms.messages
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.mockk.every
import io.mockk.mockkStatic
import io.mockk.unmockkStatic
import org.junit.After
import org.junit.Before
import org.junit.Ignore
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.signal.core.util.logging.Log
import org.signal.libsignal.protocol.ecc.Curve
import org.signal.libsignal.protocol.ecc.ECKeyPair
import org.signal.libsignal.zkgroup.profiles.ProfileKey
import org.thoughtcrime.securesms.crypto.UnidentifiedAccessUtil
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.testing.AliceClient
import org.thoughtcrime.securesms.testing.BobClient
import org.thoughtcrime.securesms.testing.Entry
import org.thoughtcrime.securesms.testing.FakeClientHelpers
import org.thoughtcrime.securesms.testing.SignalActivityRule
import org.thoughtcrime.securesms.testing.awaitFor
import org.whispersystems.signalservice.internal.push.SignalServiceProtos.Envelope
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
import android.util.Log as AndroidLog
/**
* Sends N messages from Bob to Alice to track performance of Alice's processing of messages.
*/
@Ignore("Ignore test in normal testing as it's a performance test with no assertions")
@RunWith(AndroidJUnit4::class)
class MessageProcessingPerformanceTest {
companion object {
private val TAG = Log.tag(MessageProcessingPerformanceTest::class.java)
private val TIMING_TAG = "TIMING_$TAG".substring(0..23)
}
@get:Rule
val harness = SignalActivityRule()
private val trustRoot: ECKeyPair = Curve.generateKeyPair()
@Before
fun setup() {
mockkStatic(UnidentifiedAccessUtil::class)
every { UnidentifiedAccessUtil.getCertificateValidator() } returns FakeClientHelpers.noOpCertificateValidator
mockkStatic(MessageContentProcessor::class)
every { MessageContentProcessor.create(harness.application) } returns TimingMessageContentProcessor(harness.application)
}
@After
fun after() {
unmockkStatic(UnidentifiedAccessUtil::class)
unmockkStatic(MessageContentProcessor::class)
}
@Test
fun testPerformance() {
val aliceClient = AliceClient(
serviceId = harness.self.requireServiceId(),
e164 = harness.self.requireE164(),
trustRoot = trustRoot
)
val bob = Recipient.resolved(harness.others[0])
val bobClient = BobClient(
serviceId = bob.requireServiceId(),
e164 = bob.requireE164(),
identityKeyPair = harness.othersKeys[0],
trustRoot = trustRoot,
profileKey = ProfileKey(bob.profileKey)
)
// Send message from Bob to Alice (self)
val firstPreKeyMessageTimestamp = System.currentTimeMillis()
val encryptedEnvelope = bobClient.encrypt(firstPreKeyMessageTimestamp)
val aliceProcessFirstMessageLatch = harness
.inMemoryLogger
.getLockForUntil(TimingMessageContentProcessor.endTagPredicate(firstPreKeyMessageTimestamp))
Thread { aliceClient.process(encryptedEnvelope, System.currentTimeMillis()) }.start()
aliceProcessFirstMessageLatch.awaitFor(15.seconds)
// Send message from Alice to Bob
val aliceNow = System.currentTimeMillis()
bobClient.decrypt(aliceClient.encrypt(aliceNow, bob), aliceNow)
// Build N messages from Bob to Alice
val messageCount = 100
val envelopes = ArrayList<Envelope>(messageCount)
var now = System.currentTimeMillis()
for (i in 0..messageCount) {
envelopes += bobClient.encrypt(now)
now += 3
}
val firstTimestamp = envelopes.first().timestamp
val lastTimestamp = envelopes.last().timestamp
// Alice processes N messages
val aliceProcessLastMessageLatch = harness
.inMemoryLogger
.getLockForUntil(TimingMessageContentProcessor.endTagPredicate(lastTimestamp))
Thread {
for (envelope in envelopes) {
Log.i(TIMING_TAG, "Retrieved envelope! ${envelope.timestamp}")
aliceClient.process(envelope, envelope.timestamp)
}
}.start()
// Wait for Alice to finish processing messages
aliceProcessLastMessageLatch.awaitFor(1.minutes)
harness.inMemoryLogger.flush()
// Process logs for timing data
val entries = harness.inMemoryLogger.entries()
// Calculate decryption average
val decrypts = entries
.filter { it.tag == AliceClient.TAG }
.drop(1)
val totalDecryptDuration = decrypts.sumOf { it.message!!.toLong() }
AndroidLog.w(TAG, "Decryption: Average runtime: ${totalDecryptDuration.toFloat() / decrypts.size.toFloat()}ms")
// Calculate MessageContentProcessor
val takeLast: List<Entry> = entries.filter { it.tag == TimingMessageContentProcessor.TAG }.drop(2)
val iterator = takeLast.iterator()
var processCount = 0L
var processDuration = 0L
while (iterator.hasNext()) {
val start = iterator.next()
val end = iterator.next()
processCount++
processDuration += end.timestamp - start.timestamp
}
AndroidLog.w(TAG, "MessageContentProcessor.process: Average runtime: ${processDuration.toFloat() / processCount.toFloat()}ms")
// Calculate messages per second from "retrieving" first message post session initialization to processing last message
val start = entries.first { it.message == "Retrieved envelope! $firstTimestamp" }
val end = entries.first { it.message == TimingMessageContentProcessor.endTag(lastTimestamp) }
val duration = (end.timestamp - start.timestamp).toFloat() / 1000f
val messagePerSecond = messageCount.toFloat() / duration
AndroidLog.w(TAG, "Processing $messageCount messages took ${duration}s or ${messagePerSecond}m/s")
}
}

View File

@@ -0,0 +1,25 @@
package org.thoughtcrime.securesms.messages
import android.content.Context
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.testing.LogPredicate
import org.whispersystems.signalservice.api.messages.SignalServiceContent
class TimingMessageContentProcessor(context: Context) : MessageContentProcessor(context) {
companion object {
val TAG = Log.tag(TimingMessageContentProcessor::class.java)
fun endTagPredicate(timestamp: Long): LogPredicate = { entry ->
entry.tag == TAG && entry.message == endTag(timestamp)
}
private fun startTag(timestamp: Long) = "$timestamp start"
fun endTag(timestamp: Long) = "$timestamp end"
}
override fun process(messageState: MessageState?, content: SignalServiceContent?, exceptionMetadata: ExceptionMetadata?, envelopeTimestamp: Long, smsMessageId: Long) {
Log.d(TAG, startTag(envelopeTimestamp))
super.process(messageState, content, exceptionMetadata, envelopeTimestamp, smsMessageId)
Log.d(TAG, endTag(envelopeTimestamp))
}
}

View File

@@ -100,7 +100,7 @@ class UsernameEditFragmentTest {
InstrumentationApplicationDependencyProvider.addMockWebRequestHandlers(
Put("/v1/accounts/username/reserved") {
MockResponse().success(ReserveUsernameResponse(username, "reservationToken"))
MockResponse().success(ReserveUsernameResponse(username))
},
Put("/v1/accounts/username/confirm") {
MockResponse().success()

View File

@@ -27,7 +27,7 @@ class SafetyNumberBottomSheetRepositoryTest {
@Test
fun givenIOnlyHave1to1Destinations_whenIGetBuckets_thenIOnlyHaveContactsBucketContainingAllRecipients() {
val recipients = harness.others
val destinations = harness.others.map { ContactSearchKey.RecipientSearchKey.KnownRecipient(it) }
val destinations = harness.others.map { ContactSearchKey.RecipientSearchKey(it, false) }
val result = subjectUnderTest.getBuckets(recipients, destinations).test()
@@ -42,7 +42,7 @@ class SafetyNumberBottomSheetRepositoryTest {
fun givenIOnlyHaveASingle1to1Destination_whenIGetBuckets_thenIOnlyHaveContactsBucketContainingAllRecipients() {
// GIVEN
val recipients = harness.others
val destination = harness.others.take(1).map { ContactSearchKey.RecipientSearchKey.KnownRecipient(it) }
val destination = harness.others.take(1).map { ContactSearchKey.RecipientSearchKey(it, false) }
// WHEN
val result = subjectUnderTest.getBuckets(recipients, destination).test(1)
@@ -59,7 +59,7 @@ class SafetyNumberBottomSheetRepositoryTest {
// GIVEN
val distributionListMembers = harness.others.take(5)
val distributionList = SignalDatabase.distributionLists.createList("ListA", distributionListMembers)!!
val destinationKey = ContactSearchKey.RecipientSearchKey.Story(SignalDatabase.distributionLists.getRecipientId(distributionList)!!)
val destinationKey = ContactSearchKey.RecipientSearchKey(SignalDatabase.distributionLists.getRecipientId(distributionList)!!, true)
// WHEN
val result = subjectUnderTest.getBuckets(harness.others, listOf(destinationKey)).test(1)
@@ -82,7 +82,7 @@ class SafetyNumberBottomSheetRepositoryTest {
val distributionListMembers = harness.others.take(5)
val toRemove = distributionListMembers.last()
val distributionList = SignalDatabase.distributionLists.createList("ListA", distributionListMembers)!!
val destinationKey = ContactSearchKey.RecipientSearchKey.Story(SignalDatabase.distributionLists.getRecipientId(distributionList)!!)
val destinationKey = ContactSearchKey.RecipientSearchKey(SignalDatabase.distributionLists.getRecipientId(distributionList)!!, true)
val testSubscriber = subjectUnderTest.getBuckets(distributionListMembers, listOf(destinationKey)).test(2)
testScheduler.triggerActions()
@@ -108,7 +108,7 @@ class SafetyNumberBottomSheetRepositoryTest {
// GIVEN
val distributionListMembers = harness.others.take(5)
val distributionList = SignalDatabase.distributionLists.createList("ListA", distributionListMembers)!!
val destinationKey = ContactSearchKey.RecipientSearchKey.Story(SignalDatabase.distributionLists.getRecipientId(distributionList)!!)
val destinationKey = ContactSearchKey.RecipientSearchKey(SignalDatabase.distributionLists.getRecipientId(distributionList)!!, true)
val testSubscriber = subjectUnderTest.getBuckets(distributionListMembers, listOf(destinationKey)).test(2)
testScheduler.triggerActions()

View File

@@ -0,0 +1,127 @@
package org.thoughtcrime.securesms.storage
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.signal.core.util.update
import org.thoughtcrime.securesms.database.RecipientTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.Base64
import org.thoughtcrime.securesms.util.FeatureFlags
import org.thoughtcrime.securesms.util.FeatureFlagsAccessor
import org.whispersystems.signalservice.api.push.ACI
import org.whispersystems.signalservice.api.push.PNI
import org.whispersystems.signalservice.api.storage.SignalContactRecord
import org.whispersystems.signalservice.api.storage.StorageId
import org.whispersystems.signalservice.internal.storage.protos.ContactRecord
import java.util.UUID
@RunWith(AndroidJUnit4::class)
class ContactRecordProcessorTest {
@Before
fun setup() {
SignalStore.account().setE164(E164_SELF)
SignalStore.account().setAci(ACI_SELF)
SignalStore.account().setPni(PNI_SELF)
FeatureFlagsAccessor.forceValue(FeatureFlags.PHONE_NUMBER_PRIVACY, true)
}
@Test
fun process_splitContact_normalSplit() {
// GIVEN
val originalId = SignalDatabase.recipients.getAndPossiblyMerge(ACI_A, PNI_A, E164_A)
setStorageId(originalId, STORAGE_ID_A)
val remote1 = buildRecord(STORAGE_ID_B) {
setServiceId(ACI_A.toString())
setUnregisteredAtTimestamp(100)
}
val remote2 = buildRecord(STORAGE_ID_C) {
setServiceId(PNI_A.toString())
setServicePni(PNI_A.toString())
setServiceE164(E164_A)
}
// WHEN
val subject = ContactRecordProcessor()
subject.process(listOf(remote1, remote2), StorageSyncHelper.KEY_GENERATOR)
// THEN
val byAci: RecipientId = SignalDatabase.recipients.getByServiceId(ACI_A).get()
val byE164: RecipientId = SignalDatabase.recipients.getByE164(E164_A).get()
val byPni: RecipientId = SignalDatabase.recipients.getByServiceId(PNI_A).get()
assertEquals(originalId, byAci)
assertEquals(byE164, byPni)
assertNotEquals(byAci, byE164)
}
@Test
fun process_splitContact_doNotSplitIfAciRecordIsRegistered() {
// GIVEN
val originalId = SignalDatabase.recipients.getAndPossiblyMerge(ACI_A, PNI_A, E164_A)
setStorageId(originalId, STORAGE_ID_A)
val remote1 = buildRecord(STORAGE_ID_B) {
setServiceId(ACI_A.toString())
setUnregisteredAtTimestamp(0)
}
val remote2 = buildRecord(STORAGE_ID_C) {
setServiceId(PNI_A.toString())
setServicePni(PNI_A.toString())
setServiceE164(E164_A)
}
// WHEN
val subject = ContactRecordProcessor()
subject.process(listOf(remote1, remote2), StorageSyncHelper.KEY_GENERATOR)
// THEN
val byAci: RecipientId = SignalDatabase.recipients.getByServiceId(ACI_A).get()
val byE164: RecipientId = SignalDatabase.recipients.getByE164(E164_A).get()
val byPni: RecipientId = SignalDatabase.recipients.getByPni(PNI_A).get()
assertEquals(originalId, byAci)
assertEquals(byE164, byPni)
assertEquals(byAci, byE164)
}
private fun buildRecord(id: StorageId, applyParams: ContactRecord.Builder.() -> ContactRecord.Builder): SignalContactRecord {
return SignalContactRecord(id, ContactRecord.getDefaultInstance().toBuilder().applyParams().build())
}
private fun setStorageId(recipientId: RecipientId, storageId: StorageId) {
SignalDatabase.rawDatabase
.update(RecipientTable.TABLE_NAME)
.values(RecipientTable.STORAGE_SERVICE_ID to Base64.encodeBytes(storageId.raw))
.where("${RecipientTable.ID} = ?", recipientId)
.run()
}
companion object {
val ACI_A = ACI.from(UUID.fromString("aaaa0000-5a76-47fa-a98a-7e72c948a82e"))
val ACI_B = ACI.from(UUID.fromString("bbbb0000-0b60-4a68-9cd9-ed2f8453f9ed"))
val ACI_SELF = ACI.from(UUID.fromString("77770000-b477-4f35-a824-d92987a63641"))
val PNI_A = PNI.from(UUID.fromString("aaaa1111-c960-4f6c-8385-671ad2ffb999"))
val PNI_B = PNI.from(UUID.fromString("bbbb1111-cd55-40bf-adda-c35a85375533"))
val PNI_SELF = PNI.from(UUID.fromString("77771111-b014-41fb-bf73-05cb2ec52910"))
const val E164_A = "+12222222222"
const val E164_B = "+13333333333"
const val E164_SELF = "+10000000000"
val STORAGE_ID_A: StorageId = StorageId.forContact(byteArrayOf(1, 2, 3, 4))
val STORAGE_ID_B: StorageId = StorageId.forContact(byteArrayOf(5, 6, 7, 8))
val STORAGE_ID_C: StorageId = StorageId.forContact(byteArrayOf(9, 10, 11, 12))
}
}

View File

@@ -0,0 +1,52 @@
package org.thoughtcrime.securesms.testing
import org.signal.core.util.logging.Log
import org.signal.libsignal.protocol.ecc.ECKeyPair
import org.signal.libsignal.zkgroup.profiles.ProfileKey
import org.thoughtcrime.securesms.crypto.ProfileKeyUtil
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.testing.FakeClientHelpers.toEnvelope
import org.whispersystems.signalservice.api.push.ServiceId
import org.whispersystems.signalservice.api.push.SignalServiceAddress
import org.whispersystems.signalservice.internal.push.SignalServiceProtos.Envelope
/**
* Welcome to Alice's Client.
*
* Alice represent the Android instrumentation test user. Unlike [BobClient] much less is needed here
* as it can make use of the standard Signal Android App infrastructure.
*/
class AliceClient(val serviceId: ServiceId, val e164: String, val trustRoot: ECKeyPair) {
companion object {
val TAG = Log.tag(AliceClient::class.java)
}
private val aliceSenderCertificate = FakeClientHelpers.createCertificateFor(
trustRoot = trustRoot,
uuid = serviceId.uuid(),
e164 = e164,
deviceId = 1,
identityKey = SignalStore.account().aciIdentityKey.publicKey.publicKey,
expires = 31337
)
fun process(envelope: Envelope, serverDeliveredTimestamp: Long) {
val start = System.currentTimeMillis()
ApplicationDependencies.getIncomingMessageObserver().processEnvelope(envelope, serverDeliveredTimestamp)
val end = System.currentTimeMillis()
Log.d(TAG, "${end - start}")
}
fun encrypt(now: Long, destination: Recipient): Envelope {
return ApplicationDependencies.getSignalServiceMessageSender().getEncryptedMessage(
SignalServiceAddress(destination.requireServiceId(), destination.requireE164()),
FakeClientHelpers.getTargetUnidentifiedAccess(ProfileKeyUtil.getSelfProfileKey(), ProfileKey(destination.profileKey), aliceSenderCertificate),
1,
FakeClientHelpers.encryptedTextMessage(now),
false
).toEnvelope(now, destination.requireServiceId())
}
}

View File

@@ -0,0 +1,167 @@
package org.thoughtcrime.securesms.testing
import org.signal.core.util.readToSingleInt
import org.signal.core.util.select
import org.signal.libsignal.protocol.IdentityKey
import org.signal.libsignal.protocol.IdentityKeyPair
import org.signal.libsignal.protocol.SessionBuilder
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.PreKeyBundle
import org.signal.libsignal.protocol.state.PreKeyRecord
import org.signal.libsignal.protocol.state.SessionRecord
import org.signal.libsignal.protocol.state.SignedPreKeyRecord
import org.signal.libsignal.protocol.util.KeyHelper
import org.signal.libsignal.zkgroup.profiles.ProfileKey
import org.thoughtcrime.securesms.crypto.ProfileKeyUtil
import org.thoughtcrime.securesms.crypto.UnidentifiedAccessUtil
import org.thoughtcrime.securesms.database.OneTimePreKeyTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.SignedPreKeyTable
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.testing.FakeClientHelpers.toEnvelope
import org.whispersystems.signalservice.api.SignalServiceAccountDataStore
import org.whispersystems.signalservice.api.SignalSessionLock
import org.whispersystems.signalservice.api.crypto.SignalServiceCipher
import org.whispersystems.signalservice.api.crypto.SignalSessionBuilder
import org.whispersystems.signalservice.api.crypto.UnidentifiedAccess
import org.whispersystems.signalservice.api.push.DistributionId
import org.whispersystems.signalservice.api.push.ServiceId
import org.whispersystems.signalservice.api.push.SignalServiceAddress
import org.whispersystems.signalservice.internal.push.SignalServiceProtos
import java.util.Optional
import java.util.UUID
import java.util.concurrent.locks.ReentrantLock
/**
* Welcome to Bob's Client.
*
* Bob is a "fake" client that can start a session with the Android instrumentation test user (Alice).
*
* Bob can create a new session using a prekey bundle created from Alice's prekeys, send a message, decrypt
* a return message from Alice, and that'll start a standard Signal session with normal keys/ratcheting.
*/
class BobClient(val serviceId: ServiceId, val e164: String, val identityKeyPair: IdentityKeyPair, val trustRoot: ECKeyPair, val profileKey: ProfileKey) {
private val serviceAddress = SignalServiceAddress(serviceId, e164)
private val registrationId = KeyHelper.generateRegistrationId(false)
private val aciStore = BobSignalServiceAccountDataStore(registrationId, identityKeyPair)
private val senderCertificate = FakeClientHelpers.createCertificateFor(trustRoot, serviceId.uuid(), e164, 1, identityKeyPair.publicKey.publicKey, 31337)
private val sessionLock = object : SignalSessionLock {
private val lock = ReentrantLock()
override fun acquire(): SignalSessionLock.Lock {
lock.lock()
return SignalSessionLock.Lock { lock.unlock() }
}
}
/** Inspired by SignalServiceMessageSender#getEncryptedMessage */
fun encrypt(now: Long): SignalServiceProtos.Envelope {
val envelopeContent = FakeClientHelpers.encryptedTextMessage(now)
val cipher = SignalServiceCipher(serviceAddress, 1, aciStore, sessionLock, null)
if (!aciStore.containsSession(getAliceProtocolAddress())) {
val sessionBuilder = SignalSessionBuilder(sessionLock, SessionBuilder(aciStore, getAliceProtocolAddress()))
sessionBuilder.process(getAlicePreKeyBundle())
}
return cipher.encrypt(getAliceProtocolAddress(), getAliceUnidentifiedAccess(), envelopeContent)
.toEnvelope(envelopeContent.content.get().dataMessage.timestamp, getAliceServiceId())
}
fun decrypt(envelope: SignalServiceProtos.Envelope, serverDeliveredTimestamp: Long) {
val cipher = SignalServiceCipher(serviceAddress, 1, aciStore, sessionLock, UnidentifiedAccessUtil.getCertificateValidator())
cipher.decrypt(envelope, serverDeliveredTimestamp)
}
private fun getAliceServiceId(): ServiceId {
return SignalStore.account().requireAci()
}
private fun getAlicePreKeyBundle(): PreKeyBundle {
val selfPreKeyId = SignalDatabase.rawDatabase
.select(OneTimePreKeyTable.KEY_ID)
.from(OneTimePreKeyTable.TABLE_NAME)
.where("${OneTimePreKeyTable.ACCOUNT_ID} = ?", getAliceServiceId().toString())
.run()
.readToSingleInt(-1)
val selfPreKeyRecord = SignalDatabase.oneTimePreKeys.get(getAliceServiceId(), selfPreKeyId)!!
val selfSignedPreKeyId = SignalDatabase.rawDatabase
.select(SignedPreKeyTable.KEY_ID)
.from(SignedPreKeyTable.TABLE_NAME)
.where("${SignedPreKeyTable.ACCOUNT_ID} = ?", getAliceServiceId().toString())
.run()
.readToSingleInt(-1)
val selfSignedPreKeyRecord = SignalDatabase.signedPreKeys.get(getAliceServiceId(), selfSignedPreKeyId)!!
return PreKeyBundle(
SignalStore.account().registrationId,
1,
selfPreKeyId,
selfPreKeyRecord.keyPair.publicKey,
selfSignedPreKeyId,
selfSignedPreKeyRecord.keyPair.publicKey,
selfSignedPreKeyRecord.signature,
getAlicePublicKey()
)
}
private fun getAliceProtocolAddress(): SignalProtocolAddress {
return SignalProtocolAddress(SignalStore.account().requireAci().toString(), 1)
}
private fun getAlicePublicKey(): IdentityKey {
return SignalStore.account().aciIdentityKey.publicKey
}
private fun getAliceProfileKey(): ProfileKey {
return ProfileKeyUtil.getSelfProfileKey()
}
private fun getAliceUnidentifiedAccess(): Optional<UnidentifiedAccess> {
return FakeClientHelpers.getTargetUnidentifiedAccess(profileKey, getAliceProfileKey(), senderCertificate)
}
private class BobSignalServiceAccountDataStore(private val registrationId: Int, private val identityKeyPair: IdentityKeyPair) : SignalServiceAccountDataStore {
private var aliceSessionRecord: SessionRecord? = null
override fun getIdentityKeyPair(): IdentityKeyPair = identityKeyPair
override fun getLocalRegistrationId(): Int = registrationId
override fun isTrustedIdentity(address: SignalProtocolAddress?, identityKey: IdentityKey?, direction: IdentityKeyStore.Direction?): Boolean = true
override fun loadSession(address: SignalProtocolAddress?): SessionRecord = aliceSessionRecord ?: SessionRecord()
override fun saveIdentity(address: SignalProtocolAddress?, identityKey: IdentityKey?): Boolean = false
override fun storeSession(address: SignalProtocolAddress?, record: SessionRecord?) { aliceSessionRecord = record }
override fun getSubDeviceSessions(name: String?): List<Int> = emptyList()
override fun containsSession(address: SignalProtocolAddress?): Boolean = aliceSessionRecord != null
override fun getIdentity(address: SignalProtocolAddress?): IdentityKey = SignalStore.account().aciIdentityKey.publicKey
override fun loadPreKey(preKeyId: Int): PreKeyRecord = throw UnsupportedOperationException()
override fun storePreKey(preKeyId: Int, record: PreKeyRecord?) = throw UnsupportedOperationException()
override fun containsPreKey(preKeyId: Int): Boolean = throw UnsupportedOperationException()
override fun removePreKey(preKeyId: Int) = throw UnsupportedOperationException()
override fun loadExistingSessions(addresses: MutableList<SignalProtocolAddress>?): MutableList<SessionRecord> = throw UnsupportedOperationException()
override fun deleteSession(address: SignalProtocolAddress?) = throw UnsupportedOperationException()
override fun deleteAllSessions(name: String?) = throw UnsupportedOperationException()
override fun loadSignedPreKey(signedPreKeyId: Int): SignedPreKeyRecord = throw UnsupportedOperationException()
override fun loadSignedPreKeys(): MutableList<SignedPreKeyRecord> = throw UnsupportedOperationException()
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 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()
override fun getAllAddressesWithActiveSessions(addressNames: MutableList<String>?): MutableSet<SignalProtocolAddress> = throw UnsupportedOperationException()
override fun getSenderKeySharedWith(distributionId: DistributionId?): MutableSet<SignalProtocolAddress> = throw UnsupportedOperationException()
override fun markSenderKeySharedWith(distributionId: DistributionId?, addresses: MutableCollection<SignalProtocolAddress>?) = throw UnsupportedOperationException()
override fun clearSenderKeySharedWith(addresses: MutableCollection<SignalProtocolAddress>?) = throw UnsupportedOperationException()
override fun isMultiDevice(): Boolean = throw UnsupportedOperationException()
}
}

View File

@@ -0,0 +1,79 @@
package org.thoughtcrime.securesms.testing
import org.signal.libsignal.internal.Native
import org.signal.libsignal.internal.NativeHandleGuard
import org.signal.libsignal.metadata.certificate.CertificateValidator
import org.signal.libsignal.metadata.certificate.SenderCertificate
import org.signal.libsignal.metadata.certificate.ServerCertificate
import org.signal.libsignal.protocol.ecc.Curve
import org.signal.libsignal.protocol.ecc.ECKeyPair
import org.signal.libsignal.protocol.ecc.ECPublicKey
import org.signal.libsignal.zkgroup.profiles.ProfileKey
import org.thoughtcrime.securesms.database.model.toProtoByteString
import org.whispersystems.signalservice.api.crypto.ContentHint
import org.whispersystems.signalservice.api.crypto.EnvelopeContent
import org.whispersystems.signalservice.api.crypto.UnidentifiedAccess
import org.whispersystems.signalservice.api.crypto.UnidentifiedAccessPair
import org.whispersystems.signalservice.api.push.ServiceId
import org.whispersystems.signalservice.internal.push.OutgoingPushMessage
import org.whispersystems.signalservice.internal.push.SignalServiceProtos
import org.whispersystems.signalservice.internal.push.SignalServiceProtos.Envelope
import org.whispersystems.util.Base64
import java.util.Optional
import java.util.UUID
object FakeClientHelpers {
val noOpCertificateValidator = object : CertificateValidator(null) {
override fun validate(certificate: SenderCertificate, validationTime: Long) = Unit
}
fun createCertificateFor(trustRoot: ECKeyPair, uuid: UUID, e164: String, deviceId: Int, identityKey: ECPublicKey, expires: Long): SenderCertificate {
val serverKey: ECKeyPair = Curve.generateKeyPair()
NativeHandleGuard(serverKey.publicKey).use { serverPublicGuard ->
NativeHandleGuard(trustRoot.privateKey).use { trustRootPrivateGuard ->
val serverCertificate = ServerCertificate(Native.ServerCertificate_New(1, serverPublicGuard.nativeHandle(), trustRootPrivateGuard.nativeHandle()))
NativeHandleGuard(identityKey).use { identityGuard ->
NativeHandleGuard(serverCertificate).use { serverCertificateGuard ->
NativeHandleGuard(serverKey.privateKey).use { serverPrivateGuard ->
return SenderCertificate(Native.SenderCertificate_New(uuid.toString(), e164, deviceId, identityGuard.nativeHandle(), expires, serverCertificateGuard.nativeHandle(), serverPrivateGuard.nativeHandle()))
}
}
}
}
}
}
fun getTargetUnidentifiedAccess(myProfileKey: ProfileKey, theirProfileKey: ProfileKey, senderCertificate: SenderCertificate): Optional<UnidentifiedAccess> {
val selfUnidentifiedAccessKey = UnidentifiedAccess.deriveAccessKeyFrom(myProfileKey)
val themUnidentifiedAccessKey = UnidentifiedAccess.deriveAccessKeyFrom(theirProfileKey)
return UnidentifiedAccessPair(UnidentifiedAccess(selfUnidentifiedAccessKey, senderCertificate.serialized), UnidentifiedAccess(themUnidentifiedAccessKey, senderCertificate.serialized)).targetUnidentifiedAccess
}
fun encryptedTextMessage(now: Long, message: String = "Test body message"): EnvelopeContent {
val content = SignalServiceProtos.Content.newBuilder().apply {
setDataMessage(
SignalServiceProtos.DataMessage.newBuilder().apply {
body = message
timestamp = now
}
)
}
return EnvelopeContent.encrypted(content.build(), ContentHint.RESENDABLE, Optional.empty())
}
fun OutgoingPushMessage.toEnvelope(timestamp: Long, destination: ServiceId): Envelope {
return Envelope.newBuilder()
.setType(Envelope.Type.valueOf(this.type))
.setSourceDevice(1)
.setTimestamp(timestamp)
.setServerTimestamp(timestamp + 1)
.setDestinationUuid(destination.toString())
.setServerGuid(UUID.randomUUID().toString())
.setContent(Base64.decode(this.content).toProtoByteString())
.setUrgent(true)
.setStory(false)
.build()
}
}

View File

@@ -0,0 +1,86 @@
package org.thoughtcrime.securesms.testing
import org.signal.core.util.concurrent.SignalExecutors
import org.signal.core.util.logging.Log
import java.util.concurrent.CountDownLatch
typealias LogPredicate = (Entry) -> Boolean
/**
* Logging implementation that holds logs in memory as they are added to be retrieve at a later time by a test.
* Can also be used for multithreaded synchronization and waiting until certain logs are emitted before continuing
* a test.
*/
class InMemoryLogger : Log.Logger() {
private val executor = SignalExecutors.newCachedSingleThreadExecutor("inmemory-logger")
private val predicates = mutableListOf<LogPredicate>()
private val logEntries = mutableListOf<Entry>()
override fun v(tag: String, message: String?, t: Throwable?, keepLonger: Boolean) = add(Verbose(tag, message, t, System.currentTimeMillis()))
override fun d(tag: String, message: String?, t: Throwable?, keepLonger: Boolean) = add(Debug(tag, message, t, System.currentTimeMillis()))
override fun i(tag: String, message: String?, t: Throwable?, keepLonger: Boolean) = add(Info(tag, message, t, System.currentTimeMillis()))
override fun w(tag: String, message: String?, t: Throwable?, keepLonger: Boolean) = add(Warn(tag, message, t, System.currentTimeMillis()))
override fun e(tag: String, message: String?, t: Throwable?, keepLonger: Boolean) = add(Error(tag, message, t, System.currentTimeMillis()))
override fun flush() {
val latch = CountDownLatch(1)
executor.execute { latch.countDown() }
latch.await()
}
private fun add(entry: Entry) {
executor.execute {
logEntries += entry
val iterator = predicates.iterator()
while (iterator.hasNext()) {
val predicate = iterator.next()
if (predicate(entry)) {
iterator.remove()
}
}
}
}
/** Blocks until a snapshot of all log entries can be taken in a thread-safe way. */
fun entries(): List<Entry> {
val latch = CountDownLatch(1)
var entries: List<Entry> = emptyList()
executor.execute {
entries = logEntries.toList()
latch.countDown()
}
latch.await()
return entries
}
/** Returns a countdown latch that'll fire at a future point when an [Entry] is received that matches the predicate. */
fun getLockForUntil(predicate: LogPredicate): CountDownLatch {
val latch = CountDownLatch(1)
executor.execute {
predicates += { entry ->
if (predicate(entry)) {
latch.countDown()
true
} else {
false
}
}
}
return latch
}
}
sealed interface Entry {
val tag: String
val message: String?
val throwable: Throwable?
val timestamp: Long
}
data class Verbose(override val tag: String, override val message: String?, override val throwable: Throwable?, override val timestamp: Long) : Entry
data class Debug(override val tag: String, override val message: String?, override val throwable: Throwable?, override val timestamp: Long) : Entry
data class Info(override val tag: String, override val message: String?, override val throwable: Throwable?, override val timestamp: Long) : Entry
data class Warn(override val tag: String, override val message: String?, override val throwable: Throwable?, override val timestamp: Long) : Entry
data class Error(override val tag: String, override val message: String?, override val throwable: Throwable?, override val timestamp: Long) : Entry

View File

@@ -32,6 +32,7 @@ import org.whispersystems.signalservice.internal.push.PreKeyEntity
import org.whispersystems.signalservice.internal.push.PreKeyResponse
import org.whispersystems.signalservice.internal.push.PreKeyResponseItem
import org.whispersystems.signalservice.internal.push.PushServiceSocket
import org.whispersystems.signalservice.internal.push.RegistrationSessionMetadataJson
import org.whispersystems.signalservice.internal.push.SenderCertificate
import org.whispersystems.signalservice.internal.push.VerifyAccountResponse
import org.whispersystems.signalservice.internal.push.WhoAmIResponse
@@ -56,6 +57,16 @@ object MockProvider {
)
}
val sessionMetadataJson = RegistrationSessionMetadataJson(
id = "asdfasdfasdfasdf",
nextCall = null,
nextSms = null,
nextVerificationAttempt = null,
allowedToRequestCode = true,
requestedInformation = emptyList(),
verified = true
)
fun createVerifyAccountResponse(aci: ServiceId, newPni: ServiceId): VerifyAccountResponse {
return VerifyAccountResponse().apply {
uuid = aci.toString()

View File

@@ -17,6 +17,8 @@ class Get(path: String, responseFactory: ResponseFactory) : Verb("GET", path, re
class Put(path: String, responseFactory: ResponseFactory) : Verb("PUT", path, responseFactory)
class Post(path: String, responseFactory: ResponseFactory) : Verb("POST", path, responseFactory)
fun MockResponse.success(response: Any? = null): MockResponse {
return setResponseCode(200).apply {
if (response != null) {

View File

@@ -13,7 +13,7 @@ class RxTestSchedulerRule(
val ioTestScheduler: TestScheduler = defaultTestScheduler,
val computationTestScheduler: TestScheduler = defaultTestScheduler,
val singleTestScheduler: TestScheduler = defaultTestScheduler,
val newThreadTestScheduler: TestScheduler = defaultTestScheduler,
val newThreadTestScheduler: TestScheduler = defaultTestScheduler
) : ExternalResource() {
override fun before() {

View File

@@ -11,7 +11,9 @@ import androidx.test.platform.app.InstrumentationRegistry
import okhttp3.mockwebserver.MockResponse
import org.junit.rules.ExternalResource
import org.signal.libsignal.protocol.IdentityKey
import org.signal.libsignal.protocol.IdentityKeyPair
import org.signal.libsignal.protocol.SignalProtocolAddress
import org.thoughtcrime.securesms.SignalInstrumentationApplicationContext
import org.thoughtcrime.securesms.crypto.IdentityKeyUtil
import org.thoughtcrime.securesms.crypto.MasterSecretUtil
import org.thoughtcrime.securesms.crypto.ProfileKeyUtil
@@ -20,7 +22,6 @@ import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.dependencies.InstrumentationApplicationDependencyProvider
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.net.DeviceTransferBlockingInterceptor
import org.thoughtcrime.securesms.profiles.ProfileName
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
@@ -54,18 +55,23 @@ class SignalActivityRule(private val othersCount: Int = 4) : ExternalResource()
private set
lateinit var others: List<RecipientId>
private set
lateinit var othersKeys: List<IdentityKeyPair>
val inMemoryLogger: InMemoryLogger
get() = (application as SignalInstrumentationApplicationContext).inMemoryLogger
override fun before() {
context = InstrumentationRegistry.getInstrumentation().targetContext
self = setupSelf()
others = setupOthers()
val setupOthers = setupOthers()
others = setupOthers.first
othersKeys = setupOthers.second
InstrumentationApplicationDependencyProvider.clearHandlers()
}
private fun setupSelf(): Recipient {
DeviceTransferBlockingInterceptor.getInstance().blockNetwork()
PreferenceManager.getDefaultSharedPreferences(application).edit().putBoolean("pref_prompted_push_registration", true).commit()
val masterSecret = MasterSecretUtil.generateMasterSecret(application, MasterSecretUtil.UNENCRYPTED_PASSPHRASE)
MasterSecretUtil.generateAsymmetricMasterSecret(application, masterSecret)
@@ -83,22 +89,25 @@ class SignalActivityRule(private val othersCount: Int = 4) : ExternalResource()
registrationId = registrationRepository.registrationId,
profileKey = registrationRepository.getProfileKey("+15555550101"),
fcmToken = null,
pniRegistrationId = registrationRepository.pniRegistrationId
pniRegistrationId = registrationRepository.pniRegistrationId,
recoveryPassword = "asdfasdfasdfasdf"
),
VerifyResponse(VerifyAccountResponse(UUID.randomUUID().toString(), UUID.randomUUID().toString(), false), null, null)
VerifyResponse(VerifyAccountResponse(UUID.randomUUID().toString(), UUID.randomUUID().toString(), false), null, null),
false
).blockingGet()
ServiceResponseProcessor.DefaultProcessor(response).resultOrThrow
SignalStore.kbsValues().optOut()
RegistrationUtil.maybeMarkRegistrationComplete(application)
RegistrationUtil.maybeMarkRegistrationComplete()
SignalDatabase.recipients.setProfileName(Recipient.self().id, ProfileName.fromParts("Tester", "McTesterson"))
return Recipient.self()
}
private fun setupOthers(): List<RecipientId> {
private fun setupOthers(): Pair<List<RecipientId>, List<IdentityKeyPair>> {
val others = mutableListOf<RecipientId>()
val othersKeys = mutableListOf<IdentityKeyPair>()
if (othersCount !in 0 until 1000) {
throw IllegalArgumentException("$othersCount must be between 0 and 1000")
@@ -112,11 +121,13 @@ class SignalActivityRule(private val othersCount: Int = 4) : ExternalResource()
SignalDatabase.recipients.setCapabilities(recipientId, SignalServiceProfile.Capabilities(true, true, true, true, true, true, true, true, true))
SignalDatabase.recipients.setProfileSharing(recipientId, true)
SignalDatabase.recipients.markRegistered(recipientId, aci)
ApplicationDependencies.getProtocolStore().aci().saveIdentity(SignalProtocolAddress(aci.toString(), 0), IdentityKeyUtil.generateIdentityKeyPair().publicKey)
val otherIdentity = IdentityKeyUtil.generateIdentityKeyPair()
ApplicationDependencies.getProtocolStore().aci().saveIdentity(SignalProtocolAddress(aci.toString(), 0), otherIdentity.publicKey)
others += recipientId
othersKeys += otherIdentity
}
return others
return others to othersKeys
}
inline fun <reified T : Activity> launchActivity(initIntent: Intent.() -> Unit = {}): ActivityScenario<T> {

View File

@@ -21,7 +21,7 @@ class TestProtos private constructor() {
}
fun metadata(
address: AddressProto = address().build(),
address: AddressProto = address().build()
): MetadataProto.Builder {
return MetadataProto.newBuilder()
.setAddress(address)

View File

@@ -7,6 +7,9 @@ import org.hamcrest.Matchers.not
import org.hamcrest.Matchers.notNullValue
import org.hamcrest.Matchers.nullValue
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.TimeoutException
import kotlin.time.Duration
/**
* Run the given [runnable] on a new thread and wait for it to finish.
@@ -44,3 +47,9 @@ infix fun <T : Any> T.assertIsNot(expected: T) {
infix fun <E, T : Collection<E>> T.assertIsSize(expected: Int) {
assertThat(this, hasSize(expected))
}
fun CountDownLatch.awaitFor(duration: Duration) {
if (!await(duration.inWholeMilliseconds, TimeUnit.MILLISECONDS)) {
throw TimeoutException("Latch await took longer than ${duration.inWholeMilliseconds}ms")
}
}

View File

@@ -0,0 +1,11 @@
package org.thoughtcrime.securesms.util;
/**
* A class that allows us to inject feature flags during tests.
*/
public final class FeatureFlagsAccessor {
public static void forceValue(String key, Object value) {
FeatureFlags.FORCED_VALUES.put(FeatureFlags.PHONE_NUMBER_PRIVACY, true);
}
}

View File

@@ -1,7 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
package="org.thoughtcrime.securesms">
xmlns:tools="http://schemas.android.com/tools">
<application
android:usesCleartextTraffic="true"

View File

@@ -1,7 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
package="org.thoughtcrime.securesms">
xmlns:tools="http://schemas.android.com/tools">
<uses-sdk tools:overrideLibrary="androidx.camera.core,androidx.camera.camera2,androidx.camera.lifecycle,androidx.camera.view" />
@@ -98,9 +97,10 @@
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
tools:replace="android:allowBackup"
android:resizeableActivity="true"
android:allowBackup="false"
android:fullBackupOnly="false"
android:allowBackup="true"
android:backupAgent=".absbackup.SignalBackupAgent"
android:theme="@style/TextSecure.LightTheme"
android:largeHeap="true">
@@ -464,7 +464,7 @@
<activity android:name=".registration.RegistrationNavigationActivity"
android:launchMode="singleTask"
android:theme="@style/TextSecure.LightRegistrationTheme"
android:theme="@style/Theme.Signal.DayNight.NoActionBar"
android:windowSoftInputMode="stateHidden"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
@@ -699,6 +699,7 @@
<service android:enabled="true" android:name=".service.webrtc.WebRtcCallService" android:foregroundServiceType="camera|microphone"/>
<service android:enabled="true" android:exported="false" android:name=".service.KeyCachingService"/>
<service android:enabled="true" android:name=".messages.IncomingMessageObserver$ForegroundService"/>
<service android:enabled="true" android:name=".messages.IncomingMessageObserver$BackgroundService"/>
<service android:name=".service.webrtc.AndroidCallConnectionService"
android:permission="android.permission.BIND_TELECOM_CONNECTION_SERVICE"
android:exported="true">
@@ -814,6 +815,8 @@
<receiver android:name=".revealable.ViewOnceMessageManager$ViewOnceAlarm" />
<receiver android:name=".service.ScheduledMessageManager$ScheduledMessagesAlarm" />
<receiver android:name=".service.PendingRetryReceiptManager$PendingRetryReceiptAlarm" />
<receiver android:name=".service.TrimThreadsByDateManager$TrimThreadsByDateAlarm" />

View File

@@ -4,8 +4,6 @@ import android.content.Context;
import android.net.Uri;
import android.provider.DocumentsContract;
import androidx.annotation.RequiresApi;
import org.signal.core.util.logging.Log;
/**
@@ -22,7 +20,6 @@ public class DocumentFileHelper {
*
* @return true if rename successful
*/
@RequiresApi(21)
public static boolean renameTo(Context context, DocumentFile documentFile, String displayName) {
if (documentFile instanceof TreeDocumentFile) {
Log.d(TAG, "Renaming document directly");

View File

@@ -24,7 +24,6 @@ import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import androidx.annotation.WorkerThread;
import androidx.appcompat.app.AppCompatDelegate;
import androidx.multidex.MultiDexApplication;
import com.google.android.gms.security.ProviderInstaller;
@@ -63,6 +62,7 @@ import org.thoughtcrime.securesms.jobs.PnpInitializeDevicesJob;
import org.thoughtcrime.securesms.jobs.PreKeysSyncJob;
import org.thoughtcrime.securesms.jobs.ProfileUploadJob;
import org.thoughtcrime.securesms.jobs.PushNotificationReceiveJob;
import org.thoughtcrime.securesms.jobs.RefreshKbsCredentialsJob;
import org.thoughtcrime.securesms.jobs.RetrieveProfileJob;
import org.thoughtcrime.securesms.jobs.RetrieveRemoteAnnouncementsJob;
import org.thoughtcrime.securesms.jobs.StoryOnboardingDownloadJob;
@@ -73,9 +73,9 @@ import org.thoughtcrime.securesms.logging.CustomSignalProtocolLogger;
import org.thoughtcrime.securesms.logging.PersistentLogger;
import org.thoughtcrime.securesms.messageprocessingalarm.MessageProcessReceiver;
import org.thoughtcrime.securesms.migrations.ApplicationMigrations;
import org.thoughtcrime.securesms.mms.GlideApp;
import org.thoughtcrime.securesms.mms.SignalGlideComponents;
import org.thoughtcrime.securesms.mms.SignalGlideModule;
import org.thoughtcrime.securesms.notifications.NotificationChannels;
import org.thoughtcrime.securesms.providers.BlobProvider;
import org.thoughtcrime.securesms.ratelimit.RateLimitUtil;
import org.thoughtcrime.securesms.recipients.Recipient;
@@ -104,10 +104,10 @@ import org.thoughtcrime.securesms.util.dynamiclanguage.DynamicLanguageContextWra
import java.net.SocketException;
import java.net.SocketTimeoutException;
import java.security.Security;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import io.reactivex.rxjava3.core.CompletableObserver;
import io.reactivex.rxjava3.disposables.Disposable;
import io.reactivex.rxjava3.exceptions.OnErrorNotImplementedException;
import io.reactivex.rxjava3.exceptions.UndeliverableException;
import io.reactivex.rxjava3.plugins.RxJavaPlugins;
@@ -126,7 +126,8 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
private static final String TAG = Log.tag(ApplicationContext.class);
private PersistentLogger persistentLogger;
@VisibleForTesting
protected PersistentLogger persistentLogger;
public static ApplicationContext getInstance(Context context) {
return (ApplicationContext)context.getApplicationContext();
@@ -163,16 +164,10 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
.addBlocking("app-dependencies", this::initializeAppDependencies)
.addBlocking("first-launch", this::initializeFirstEverAppLaunch)
.addBlocking("app-migrations", this::initializeApplicationMigrations)
.addBlocking("ring-rtc", this::initializeRingRtc)
.addBlocking("mark-registration", () -> RegistrationUtil.maybeMarkRegistrationComplete(this))
.addBlocking("mark-registration", () -> RegistrationUtil.maybeMarkRegistrationComplete())
.addBlocking("lifecycle-observer", () -> ApplicationDependencies.getAppForegroundObserver().addListener(this))
.addBlocking("message-retriever", this::initializeMessageRetrieval)
.addBlocking("dynamic-theme", () -> DynamicTheme.setDefaultDayNightMode(this))
.addBlocking("vector-compat", () -> {
if (Build.VERSION.SDK_INT < 21) {
AppCompatDelegate.setCompatVectorFromResourcesEnabled(true);
}
})
.addBlocking("proxy-init", () -> {
if (SignalStore.proxy().isProxyEnabled()) {
Log.w(TAG, "Proxy detected. Enabling Conscrypt.setUseEngineSocketByDefault()");
@@ -181,11 +176,14 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
})
.addBlocking("blob-provider", this::initializeBlobProvider)
.addBlocking("feature-flags", FeatureFlags::init)
.addBlocking("ring-rtc", this::initializeRingRtc)
.addBlocking("glide", () -> SignalGlideModule.setRegisterGlideComponents(new SignalGlideComponents()))
.addNonBlocking(() -> GlideApp.get(this))
.addNonBlocking(this::checkIsGooglePayReady)
.addNonBlocking(this::cleanAvatarStorage)
.addNonBlocking(this::initializeRevealableMessageManager)
.addNonBlocking(this::initializePendingRetryReceiptManager)
.addNonBlocking(this::initializeScheduledMessageManager)
.addNonBlocking(this::initializeFcmCheck)
.addNonBlocking(PreKeysSyncJob::enqueueIfNeeded)
.addNonBlocking(this::initializePeriodicTasks)
@@ -203,6 +201,7 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
.addPostRender(this::initializeExpiringMessageManager)
.addPostRender(() -> SignalStore.settings().setDefaultSms(Util.isDefaultSmsProvider(this)))
.addPostRender(this::initializeTrimThreadsByDateManager)
.addPostRender(RefreshKbsCredentialsJob::enqueueIfNecessary)
.addPostRender(() -> DownloadLatestEmojiDataJob.scheduleIfNecessary(this))
.addPostRender(EmojiSearchIndexDownloadJob::scheduleIfNecessary)
.addPostRender(() -> SignalDatabase.messageLog().trimOldMessages(System.currentTimeMillis(), FeatureFlags.retryRespondMaxAge()))
@@ -215,6 +214,8 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
.addPostRender(StoryOnboardingDownloadJob.Companion::enqueueIfNeeded)
.addPostRender(PnpInitializeDevicesJob::enqueueIfNecessary)
.addPostRender(() -> ApplicationDependencies.getExoPlayerPool().getPoolStats().getMaxUnreserved())
.addPostRender(() -> SignalDatabase.groupCallRings().removeOldRings())
.addPostRender(() -> ApplicationDependencies.getRecipientCache().warmUp())
.execute();
Log.d(TAG, "onCreate() took " + (System.currentTimeMillis() - startTime) + " ms");
@@ -234,7 +235,6 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
SignalExecutors.BOUNDED.execute(() -> {
FeatureFlags.refreshIfNecessary();
ApplicationDependencies.getRecipientCache().warmUp();
RetrieveProfileJob.enqueueRoutineFetchIfNecessary(this);
executePendingContactSync();
KeyCachingService.onAppForegrounded(this);
@@ -300,7 +300,8 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
}
}
private void initializeLogging() {
@VisibleForTesting
protected void initializeLogging() {
persistentLogger = new PersistentLogger(this);
org.signal.core.util.logging.Log.initialize(FeatureFlags::internalUser, new AndroidLogger(), persistentLogger);
@@ -394,6 +395,10 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
ApplicationDependencies.getPendingRetryReceiptManager().scheduleIfNecessary();
}
private void initializeScheduledMessageManager() {
ApplicationDependencies.getScheduledMessageManager().scheduleIfNecessary();
}
private void initializeTrimThreadsByDateManager() {
KeepMessagesDuration keepMessagesDuration = SignalStore.settings().getKeepMessagesDuration();
if (keepMessagesDuration != KeepMessagesDuration.FOREVER) {
@@ -415,7 +420,11 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
private void initializeRingRtc() {
try {
CallManager.initialize(this, new RingRtcLogger());
Map<String, String> fieldTrials = new HashMap<>();
if (FeatureFlags.callingFieldTrialAnyAddressPortsKillSwitch()) {
fieldTrials.put("RingRTC-AnyAddressPortsKillSwitch", "Enabled");
}
CallManager.initialize(this, new RingRtcLogger(), fieldTrials);
} catch (UnsatisfiedLinkError e) {
throw new AssertionError("Unable to load ringrtc library", e);
}

View File

@@ -72,12 +72,10 @@ public final class AvatarPreviewActivity extends PassphraseRequiredActivity {
setTheme(R.style.TextSecure_MediaPreview);
setContentView(R.layout.contact_photo_preview_activity);
if (Build.VERSION.SDK_INT >= 21) {
postponeEnterTransition();
TransitionInflater inflater = TransitionInflater.from(this);
getWindow().setSharedElementEnterTransition(inflater.inflateTransition(R.transition.full_screen_avatar_image_enter_transition_set));
getWindow().setSharedElementReturnTransition(inflater.inflateTransition(R.transition.full_screen_avatar_image_return_transition_set));
}
postponeEnterTransition();
TransitionInflater inflater = TransitionInflater.from(this);
getWindow().setSharedElementEnterTransition(inflater.inflateTransition(R.transition.full_screen_avatar_image_enter_transition_set));
getWindow().setSharedElementReturnTransition(inflater.inflateTransition(R.transition.full_screen_avatar_image_return_transition_set));
Toolbar toolbar = findViewById(R.id.toolbar);
EmojiTextView title = findViewById(R.id.title);
@@ -122,9 +120,7 @@ public final class AvatarPreviewActivity extends PassphraseRequiredActivity {
@Override
public void onResourceReady(@NonNull Bitmap resource, @Nullable Transition<? super Bitmap> transition) {
avatar.setImageDrawable(RoundedBitmapDrawableFactory.create(resources, resource));
if (Build.VERSION.SDK_INT >= 21) {
startPostponedEnterTransition();
}
startPostponedEnterTransition();
}
@Override

View File

@@ -19,6 +19,7 @@ import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.util.AppStartup;
import org.thoughtcrime.securesms.util.ConfigurationUtil;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.WindowUtil;
import org.thoughtcrime.securesms.util.dynamiclanguage.DynamicLanguageContextWrapper;
import java.util.Objects;
@@ -42,7 +43,7 @@ public abstract class BaseActivity extends AppCompatActivity {
@Override
protected void onResume() {
super.onResume();
initializeScreenshotSecurity();
WindowUtil.initializeScreenshotSecurity(this, getWindow());
}
@Override
@@ -64,14 +65,6 @@ public abstract class BaseActivity extends AppCompatActivity {
super.onDestroy();
}
private void initializeScreenshotSecurity() {
if (TextSecurePreferences.isScreenSecurityEnabled(this)) {
getWindow().addFlags(WindowManager.LayoutParams.FLAG_SECURE);
} else {
getWindow().clearFlags(WindowManager.LayoutParams.FLAG_SECURE);
}
}
protected void startActivitySceneTransition(Intent intent, View sharedView, String transitionName) {
Bundle bundle = ActivityOptionsCompat.makeSceneTransitionAnimation(this, sharedView, transitionName)
.toBundle();

View File

@@ -10,10 +10,11 @@ import androidx.lifecycle.Observer;
import org.thoughtcrime.securesms.components.voice.VoiceNotePlaybackState;
import org.thoughtcrime.securesms.contactshare.Contact;
import org.thoughtcrime.securesms.conversation.ConversationItem;
import org.thoughtcrime.securesms.conversation.ConversationItemDisplayMode;
import org.thoughtcrime.securesms.conversation.ConversationMessage;
import org.thoughtcrime.securesms.conversation.colors.Colorizable;
import org.thoughtcrime.securesms.conversation.colors.Colorizer;
import org.thoughtcrime.securesms.conversation.ConversationItemDisplayMode;
import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectPart;
import org.thoughtcrime.securesms.conversation.mutiselect.Multiselectable;
import org.thoughtcrime.securesms.database.model.InMemoryMessageRecord;
@@ -23,6 +24,7 @@ import org.thoughtcrime.securesms.giph.mp4.GiphyMp4Playable;
import org.thoughtcrime.securesms.groups.GroupId;
import org.thoughtcrime.securesms.groups.GroupMigrationMembershipChange;
import org.thoughtcrime.securesms.linkpreview.LinkPreview;
import org.thoughtcrime.securesms.mediapreview.MediaIntentFactory;
import org.thoughtcrime.securesms.mms.GlideRequests;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
@@ -106,11 +108,14 @@ public interface BindableConversationItem extends Unbindable, GiphyMp4Playable,
void onInviteToSignalClicked();
void onActivatePaymentsClicked();
void onSendPaymentClicked(@NonNull RecipientId recipientId);
void onScheduledIndicatorClicked(@NonNull View view, @NonNull MessageRecord messageRecord);
/** @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);
}
}

View File

@@ -5,7 +5,6 @@ import android.content.Context
import android.content.Intent
import android.os.Build
import androidx.activity.result.contract.ActivityResultContract
import androidx.annotation.RequiresApi
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt
import androidx.biometric.BiometricPrompt.PromptInfo
@@ -26,6 +25,14 @@ class BiometricDeviceAuthentication(
const val TAG: String = "BiometricDeviceAuth"
const val BIOMETRIC_AUTHENTICATORS = BiometricManager.Authenticators.BIOMETRIC_STRONG or BiometricManager.Authenticators.BIOMETRIC_WEAK
const val ALLOWED_AUTHENTICATORS = BIOMETRIC_AUTHENTICATORS or BiometricManager.Authenticators.DEVICE_CREDENTIAL
/**
* From the docs on [BiometricManager.canAuthenticate]
*
* > Note that not all combinations of authenticator types are supported prior to Android 11 (API 30). Specifically, DEVICE_CREDENTIAL alone is unsupported
* > prior to API 30, and BIOMETRIC_STRONG | DEVICE_CREDENTIAL is unsupported on API 28-29.
*/
private val DISALLOWED_BIOMETRIC_VERSIONS = setOf(28, 29)
}
fun authenticate(context: Context, force: Boolean, showConfirmDeviceCredentialIntent: () -> Unit): Boolean {
@@ -36,7 +43,7 @@ class BiometricDeviceAuthentication(
return false
}
return if (Build.VERSION.SDK_INT != 29 && biometricManager.canAuthenticate(ALLOWED_AUTHENTICATORS) == BiometricManager.BIOMETRIC_SUCCESS) {
return if (!DISALLOWED_BIOMETRIC_VERSIONS.contains(Build.VERSION.SDK_INT) && biometricManager.canAuthenticate(ALLOWED_AUTHENTICATORS) == BiometricManager.BIOMETRIC_SUCCESS) {
if (force) {
Log.i(TAG, "Listening for biometric authentication...")
biometricPrompt.authenticate(biometricPromptInfo)
@@ -44,7 +51,7 @@ class BiometricDeviceAuthentication(
Log.i(TAG, "Skipping show system biometric or device lock dialog unless forced")
}
true
} else if (Build.VERSION.SDK_INT >= 21) {
} else {
if (force) {
Log.i(TAG, "firing intent...")
showConfirmDeviceCredentialIntent()
@@ -52,9 +59,6 @@ class BiometricDeviceAuthentication(
Log.i(TAG, "Skipping firing intent unless forced")
}
true
} else {
Log.w(TAG, "Not compatible...")
false
}
}
@@ -65,7 +69,6 @@ class BiometricDeviceAuthentication(
class BiometricDeviceLockContract : ActivityResultContract<String, Int>() {
@RequiresApi(api = 21)
override fun createIntent(context: Context, input: String): Intent {
val keyguardManager = ServiceUtil.getKeyguardManager(context)
return keyguardManager.createConfirmDeviceCredentialIntent(input, "")

View File

@@ -26,7 +26,7 @@ import androidx.swiperefreshlayout.widget.SwipeRefreshLayout;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.components.ContactFilterView;
import org.thoughtcrime.securesms.contacts.ContactsCursorLoader.DisplayMode;
import org.thoughtcrime.securesms.contacts.ContactSelectionDisplayMode;
import org.thoughtcrime.securesms.contacts.sync.ContactDiscovery;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.recipients.RecipientId;
@@ -71,7 +71,7 @@ public abstract class ContactSelectionActivity extends PassphraseRequiredActivit
protected void onCreate(Bundle icicle, boolean ready) {
if (!getIntent().hasExtra(ContactSelectionListFragment.DISPLAY_MODE)) {
boolean includeSms = Util.isDefaultSmsProvider(this) && SignalStore.misc().getSmsExportPhase().allowSmsFeatures();
int displayMode = includeSms ? DisplayMode.FLAG_ALL : DisplayMode.FLAG_PUSH | DisplayMode.FLAG_ACTIVE_GROUPS | DisplayMode.FLAG_INACTIVE_GROUPS | DisplayMode.FLAG_SELF;
int displayMode = includeSms ? ContactSelectionDisplayMode.FLAG_ALL : ContactSelectionDisplayMode.FLAG_PUSH | ContactSelectionDisplayMode.FLAG_ACTIVE_GROUPS | ContactSelectionDisplayMode.FLAG_INACTIVE_GROUPS | ContactSelectionDisplayMode.FLAG_SELF;
getIntent().putExtra(ContactSelectionListFragment.DISPLAY_MODE, displayMode);
}

View File

@@ -0,0 +1,88 @@
package org.thoughtcrime.securesms
import android.content.Context
import android.view.View
import org.thoughtcrime.securesms.contacts.paged.ContactSearchAdapter
import org.thoughtcrime.securesms.contacts.paged.ContactSearchConfiguration
import org.thoughtcrime.securesms.contacts.paged.ContactSearchData
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel
import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder
class ContactSelectionListAdapter(
context: Context,
fixedContacts: Set<ContactSearchKey>,
displayCheckBox: Boolean,
displaySmsTag: DisplaySmsTag,
displaySecondaryInformation: DisplaySecondaryInformation,
onClickCallbacks: OnContactSelectionClick,
longClickCallbacks: LongClickCallbacks,
storyContextMenuCallbacks: StoryContextMenuCallbacks
) : ContactSearchAdapter(context, fixedContacts, displayCheckBox, displaySmsTag, displaySecondaryInformation, onClickCallbacks, longClickCallbacks, storyContextMenuCallbacks) {
init {
registerFactory(NewGroupModel::class.java, LayoutFactory({ NewGroupViewHolder(it, onClickCallbacks::onNewGroupClicked) }, R.layout.contact_selection_new_group_item))
registerFactory(InviteToSignalModel::class.java, LayoutFactory({ InviteToSignalViewHolder(it, onClickCallbacks::onInviteToSignalClicked) }, R.layout.contact_selection_invite_action_item))
}
class NewGroupModel : MappingModel<NewGroupModel> {
override fun areItemsTheSame(newItem: NewGroupModel): Boolean = true
override fun areContentsTheSame(newItem: NewGroupModel): Boolean = true
}
class InviteToSignalModel : MappingModel<InviteToSignalModel> {
override fun areItemsTheSame(newItem: InviteToSignalModel): Boolean = true
override fun areContentsTheSame(newItem: InviteToSignalModel): Boolean = true
}
private class InviteToSignalViewHolder(itemView: View, onClickListener: () -> Unit) : MappingViewHolder<InviteToSignalModel>(itemView) {
init {
itemView.setOnClickListener { onClickListener() }
}
override fun bind(model: InviteToSignalModel) = Unit
}
private class NewGroupViewHolder(itemView: View, onClickListener: () -> Unit) : MappingViewHolder<NewGroupModel>(itemView) {
init {
itemView.setOnClickListener { onClickListener() }
}
override fun bind(model: NewGroupModel) = Unit
}
class ArbitraryRepository : org.thoughtcrime.securesms.contacts.paged.ArbitraryRepository {
enum class ArbitraryRow(val code: String) {
NEW_GROUP("new-group"),
INVITE_TO_SIGNAL("invite-to-signal");
companion object {
fun fromCode(code: String) = values().first { it.code == code }
}
}
override fun getSize(section: ContactSearchConfiguration.Section.Arbitrary, query: String?): Int {
return if (query.isNullOrEmpty()) section.types.size else 0
}
override fun getData(section: ContactSearchConfiguration.Section.Arbitrary, query: String?, startIndex: Int, endIndex: Int, totalSearchSize: Int): List<ContactSearchData.Arbitrary> {
check(section.types.size == 1)
return listOf(ContactSearchData.Arbitrary(section.types.first()))
}
override fun getMappingModel(arbitrary: ContactSearchData.Arbitrary): MappingModel<*> {
val code = ArbitraryRow.fromCode(arbitrary.type)
return when (code) {
ArbitraryRow.NEW_GROUP -> NewGroupModel()
ArbitraryRow.INVITE_TO_SIGNAL -> InviteToSignalModel()
}
}
}
interface OnContactSelectionClick : ClickCallbacks {
fun onNewGroupClicked()
fun onInviteToSignalClicked()
}
}

View File

@@ -21,7 +21,6 @@ import android.Manifest;
import android.annotation.SuppressLint;
import android.content.Context;
import android.content.Intent;
import android.database.Cursor;
import android.graphics.Rect;
import android.os.AsyncTask;
import android.os.Bundle;
@@ -40,10 +39,7 @@ import androidx.annotation.Px;
import androidx.appcompat.app.AlertDialog;
import androidx.constraintlayout.widget.ConstraintLayout;
import androidx.constraintlayout.widget.ConstraintSet;
import androidx.fragment.app.FragmentActivity;
import androidx.lifecycle.ViewModelProvider;
import androidx.loader.app.LoaderManager;
import androidx.loader.content.Loader;
import androidx.recyclerview.widget.DefaultItemAnimator;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
@@ -59,38 +55,36 @@ import com.pnikosis.materialishprogress.ProgressWheel;
import org.signal.core.util.concurrent.SimpleTask;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.components.RecyclerViewFastScroller;
import org.thoughtcrime.securesms.contacts.AbstractContactsCursorLoader;
import org.thoughtcrime.securesms.contacts.ContactChipViewModel;
import org.thoughtcrime.securesms.contacts.ContactSelectionListAdapter;
import org.thoughtcrime.securesms.contacts.ContactSelectionListItem;
import org.thoughtcrime.securesms.contacts.ContactsCursorLoader;
import org.thoughtcrime.securesms.contacts.ContactsCursorLoader.DisplayMode;
import org.thoughtcrime.securesms.contacts.ContactSelectionDisplayMode;
import org.thoughtcrime.securesms.contacts.HeaderAction;
import org.thoughtcrime.securesms.contacts.LetterHeaderDecoration;
import org.thoughtcrime.securesms.contacts.SelectedContact;
import org.thoughtcrime.securesms.contacts.SelectedContacts;
import org.thoughtcrime.securesms.contacts.selection.ContactSelectionArguments;
import org.thoughtcrime.securesms.contacts.paged.ContactSearchAdapter;
import org.thoughtcrime.securesms.contacts.paged.ContactSearchConfiguration;
import org.thoughtcrime.securesms.contacts.paged.ContactSearchData;
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey;
import org.thoughtcrime.securesms.contacts.paged.ContactSearchMediator;
import org.thoughtcrime.securesms.contacts.paged.ContactSearchSortOrder;
import org.thoughtcrime.securesms.contacts.paged.ContactSearchState;
import org.thoughtcrime.securesms.contacts.sync.ContactDiscovery;
import org.thoughtcrime.securesms.groups.SelectionLimits;
import org.thoughtcrime.securesms.groups.ui.GroupLimitDialog;
import org.thoughtcrime.securesms.mms.GlideApp;
import org.thoughtcrime.securesms.mms.GlideRequests;
import org.thoughtcrime.securesms.permissions.Permissions;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.sharing.ShareContact;
import org.thoughtcrime.securesms.util.LifecycleDisposable;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.UsernameUtil;
import org.thoughtcrime.securesms.util.ViewUtil;
import org.thoughtcrime.securesms.util.adapter.FixedViewsAdapter;
import org.thoughtcrime.securesms.util.adapter.RecyclerViewConcatenateAdapterStickyHeader;
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter;
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModelList;
import org.thoughtcrime.securesms.util.views.SimpleProgressDialog;
import java.io.IOException;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;
@@ -105,7 +99,6 @@ import kotlin.Unit;
* @author Moxie Marlinspike
*/
public final class ContactSelectionListFragment extends LoggingFragment
implements LoaderManager.LoaderCallbacks<Cursor>
{
@SuppressWarnings("unused")
private static final String TAG = Log.tag(ContactSelectionListFragment.class);
@@ -137,27 +130,23 @@ public final class ContactSelectionListFragment extends LoggingFragment
private String cursorFilter;
private RecyclerView recyclerView;
private RecyclerViewFastScroller fastScroller;
private ContactSelectionListAdapter cursorRecyclerViewAdapter;
private RecyclerView chipRecycler;
private OnSelectionLimitReachedListener onSelectionLimitReachedListener;
private AbstractContactsCursorLoaderFactoryProvider cursorFactoryProvider;
private MappingAdapter contactChipAdapter;
private ContactChipViewModel contactChipViewModel;
private LifecycleDisposable lifecycleDisposable;
private HeaderActionProvider headerActionProvider;
private TextView headerActionView;
private ContactSearchMediator contactSearchMediator;
@Nullable private FixedViewsAdapter headerAdapter;
@Nullable private FixedViewsAdapter footerAdapter;
@Nullable private ListCallback listCallback;
@Nullable private ScrollCallback scrollCallback;
@Nullable private OnItemLongClickListener onItemLongClickListener;
private GlideRequests glideRequests;
private SelectionLimits selectionLimit = SelectionLimits.NO_LIMITS;
private Set<RecipientId> currentSelection;
private boolean isMulti;
private boolean hideCount;
private boolean canSelectSelf;
private ListClickListener listClickListener = new ListClickListener();
@Override
public void onAttach(@NonNull Context context) {
@@ -191,14 +180,6 @@ public final class ContactSelectionListFragment extends LoggingFragment
onSelectionLimitReachedListener = (OnSelectionLimitReachedListener) getParentFragment();
}
if (context instanceof AbstractContactsCursorLoaderFactoryProvider) {
cursorFactoryProvider = (AbstractContactsCursorLoaderFactoryProvider) context;
}
if (getParentFragment() instanceof AbstractContactsCursorLoaderFactoryProvider) {
cursorFactoryProvider = (AbstractContactsCursorLoaderFactoryProvider) getParentFragment();
}
if (context instanceof HeaderActionProvider) {
headerActionProvider = (HeaderActionProvider) context;
}
@@ -234,16 +215,14 @@ public final class ContactSelectionListFragment extends LoggingFragment
if (!TextSecurePreferences.hasSuccessfullyRetrievedDirectory(getActivity())) {
handleContactPermissionGranted();
} else {
LoaderManager.getInstance(this).initLoader(0, null, this);
contactSearchMediator.refresh();
}
})
.onAnyDenied(() -> {
FragmentActivity activity = requireActivity();
requireActivity().getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_HIDDEN);
activity.getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_HIDDEN);
if (safeArguments().getBoolean(RECENTS, activity.getIntent().getBooleanExtra(RECENTS, false))) {
LoaderManager.getInstance(this).initLoader(0, null, ContactSelectionListFragment.this);
if (safeArguments().getBoolean(RECENTS, requireActivity().getIntent().getBooleanExtra(RECENTS, false))) {
contactSearchMediator.refresh();
} else {
initializeNoContactsPermission();
}
@@ -305,7 +284,6 @@ public final class ContactSelectionListFragment extends LoggingFragment
swipeRefresh.setNestedScrollingEnabled(isRefreshable);
swipeRefresh.setEnabled(isRefreshable);
hideCount = arguments.getBoolean(HIDE_COUNT, intent.getBooleanExtra(HIDE_COUNT, false));
selectionLimit = arguments.getParcelable(SELECTION_LIMITS);
if (selectionLimit == null) {
selectionLimit = intent.getParcelableExtra(SELECTION_LIMITS);
@@ -353,6 +331,66 @@ public final class ContactSelectionListFragment extends LoggingFragment
headerActionView.setEnabled(false);
}
contactSearchMediator = new ContactSearchMediator(
this,
currentSelection.stream()
.map(r -> new ContactSearchKey.RecipientSearchKey(r, false))
.collect(java.util.stream.Collectors.toSet()),
selectionLimit,
isMulti,
ContactSearchAdapter.DisplaySmsTag.DEFAULT,
ContactSearchAdapter.DisplaySecondaryInformation.ALWAYS,
this::mapStateToConfiguration,
new ContactSearchMediator.SimpleCallbacks() {
@Override
public void onAdapterListCommitted(int size) {
onLoadFinished(size);
}
},
false,
(context, fixedContacts, displayCheckBox, displaySmsTag, displaySecondaryInformation, callbacks, longClickCallbacks, storyContextMenuCallbacks) -> new ContactSelectionListAdapter(
context,
fixedContacts,
displayCheckBox,
displaySmsTag,
displaySecondaryInformation,
new ContactSelectionListAdapter.OnContactSelectionClick() {
@Override
public void onNewGroupClicked() {
listCallback.onNewGroup(false);
}
@Override
public void onInviteToSignalClicked() {
listCallback.onInvite();
}
@Override
public void onStoryClicked(@NonNull View view1, @NonNull ContactSearchData.Story story, boolean isSelected) {
throw new UnsupportedOperationException();
}
@Override
public void onKnownRecipientClicked(@NonNull View view1, @NonNull ContactSearchData.KnownRecipient knownRecipient, boolean isSelected) {
listClickListener.onItemClick(knownRecipient.getContactSearchKey());
}
@Override
public void onExpandClicked(@NonNull ContactSearchData.Expand expand) {
callbacks.onExpandClicked(expand);
}
@Override
public void onUnknownRecipientClicked(@NonNull View view, @NonNull ContactSearchData.UnknownRecipient unknownRecipient, boolean isSelected) {
listClickListener.onItemClick(unknownRecipient.getContactSearchKey());
}
},
(anchorView, data) -> listClickListener.onItemLongClick(anchorView, data.getContactSearchKey()),
storyContextMenuCallbacks
),
new ContactSelectionListAdapter.ArbitraryRepository()
);
return view;
}
@@ -372,27 +410,30 @@ public final class ContactSelectionListFragment extends LoggingFragment
}
public @NonNull List<SelectedContact> getSelectedContacts() {
if (cursorRecyclerViewAdapter == null) {
if (contactSearchMediator == null) {
return Collections.emptyList();
}
return cursorRecyclerViewAdapter.getSelectedContacts();
return contactSearchMediator.getSelectedContacts()
.stream()
.map(ContactSearchKey::requireSelectedContact)
.collect(java.util.stream.Collectors.toList());
}
public int getSelectedContactsCount() {
if (cursorRecyclerViewAdapter == null) {
if (contactSearchMediator == null) {
return 0;
}
return cursorRecyclerViewAdapter.getSelectedContactsCount();
return contactSearchMediator.getSelectedContacts().size();
}
public int getTotalMemberCount() {
if (cursorRecyclerViewAdapter == null) {
if (contactSearchMediator == null) {
return 0;
}
return cursorRecyclerViewAdapter.getSelectedContactsCount() + cursorRecyclerViewAdapter.getCurrentContactsCount();
return getSelectedContactsCount() + contactSearchMediator.getFixedContactsSize();
}
private Set<RecipientId> getCurrentSelection() {
@@ -402,7 +443,7 @@ public final class ContactSelectionListFragment extends LoggingFragment
}
return currentSelection == null ? Collections.emptySet()
: Collections.unmodifiableSet(Stream.of(currentSelection).collect(Collectors.toSet()));
: Collections.unmodifiableSet(new HashSet<>(currentSelection));
}
public boolean isMulti() {
@@ -410,34 +451,8 @@ public final class ContactSelectionListFragment extends LoggingFragment
}
private void initializeCursor() {
glideRequests = GlideApp.with(this);
cursorRecyclerViewAdapter = new ContactSelectionListAdapter(requireContext(),
glideRequests,
null,
new ListClickListener(),
isMulti,
currentSelection,
safeArguments().getInt(ContactSelectionArguments.CHECKBOX_RESOURCE, R.drawable.contact_selection_checkbox));
RecyclerViewConcatenateAdapterStickyHeader concatenateAdapter = new RecyclerViewConcatenateAdapterStickyHeader();
if (listCallback != null) {
headerAdapter = new FixedViewsAdapter(createNewGroupItem(listCallback));
headerAdapter.hide();
concatenateAdapter.addAdapter(headerAdapter);
}
concatenateAdapter.addAdapter(cursorRecyclerViewAdapter);
if (listCallback != null) {
footerAdapter = new FixedViewsAdapter(createInviteActionView(listCallback));
footerAdapter.hide();
concatenateAdapter.addAdapter(footerAdapter);
}
recyclerView.addItemDecoration(new LetterHeaderDecoration(requireContext(), this::hideLetterHeaders));
recyclerView.setAdapter(concatenateAdapter);
recyclerView.setAdapter(contactSearchMediator.getAdapter());
recyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() {
@Override
public void onScrollStateChanged(@NonNull RecyclerView recyclerView, int newState) {
@@ -458,20 +473,6 @@ public final class ContactSelectionListFragment extends LoggingFragment
return hasQueryFilter() || shouldDisplayRecents();
}
private View createInviteActionView(@NonNull ListCallback listCallback) {
View view = LayoutInflater.from(requireContext())
.inflate(R.layout.contact_selection_invite_action_item, (ViewGroup) requireView(), false);
view.setOnClickListener(v -> listCallback.onInvite());
return view;
}
private View createNewGroupItem(@NonNull ListCallback listCallback) {
View view = LayoutInflater.from(requireContext())
.inflate(R.layout.contact_selection_new_group_item, (ViewGroup) requireView(), false);
view.setOnClickListener(v -> listCallback.onNewGroup(false));
return view;
}
private void initializeNoContactsPermission() {
swipeRefresh.setVisibility(View.GONE);
@@ -496,7 +497,7 @@ public final class ContactSelectionListFragment extends LoggingFragment
public void setQueryFilter(String filter) {
this.cursorFilter = filter;
LoaderManager.getInstance(this).restartLoader(0, null, this);
contactSearchMediator.onFilterChanged(filter);
}
public void resetQueryFilter() {
@@ -513,51 +514,21 @@ public final class ContactSelectionListFragment extends LoggingFragment
}
public void reset() {
cursorRecyclerViewAdapter.clearSelectedContacts();
if (!isDetached() && !isRemoving() && getActivity() != null && !getActivity().isFinishing()) {
LoaderManager.getInstance(this).restartLoader(0, null, this);
}
contactSearchMediator.clearSelection();
fastScroller.setVisibility(View.GONE);
headerActionView.setVisibility(View.GONE);
}
public void setRecyclerViewPaddingBottom(@Px int paddingBottom) {
ViewUtil.setPaddingBottom(recyclerView, paddingBottom);
}
@Override
public @NonNull Loader<Cursor> onCreateLoader(int id, Bundle args) {
FragmentActivity activity = requireActivity();
int displayMode = safeArguments().getInt(DISPLAY_MODE, activity.getIntent().getIntExtra(DISPLAY_MODE, DisplayMode.FLAG_ALL));
boolean displayRecents = shouldDisplayRecents();
if (cursorFactoryProvider != null) {
return cursorFactoryProvider.get().create();
} else {
return new ContactsCursorLoader.Factory(activity, displayMode, cursorFilter, displayRecents).create();
}
}
@Override
public void onLoadFinished(@NonNull Loader<Cursor> loader, @Nullable Cursor data) {
private void onLoadFinished(int count) {
swipeRefresh.setVisibility(View.VISIBLE);
showContactsLayout.setVisibility(View.GONE);
cursorRecyclerViewAdapter.changeCursor(data);
if (footerAdapter != null) {
footerAdapter.show();
}
if (headerAdapter != null) {
if (TextUtils.isEmpty(cursorFilter)) {
headerAdapter.show();
} else {
headerAdapter.hide();
}
}
emptyText.setText(R.string.contact_selection_group_activity__no_contacts);
boolean useFastScroller = data != null && data.getCount() > 20;
boolean useFastScroller = count > 20;
recyclerView.setVerticalScrollBarEnabled(!useFastScroller);
if (useFastScroller) {
fastScroller.setVisibility(View.VISIBLE);
@@ -574,13 +545,6 @@ public final class ContactSelectionListFragment extends LoggingFragment
}
}
@Override
public void onLoaderReset(@NonNull Loader<Cursor> loader) {
cursorRecyclerViewAdapter.changeCursor(null);
fastScroller.setVisibility(View.GONE);
headerActionView.setVisibility(View.GONE);
}
private boolean shouldDisplayRecents() {
return safeArguments().getBoolean(RECENTS, requireActivity().getIntent().getBooleanExtra(RECENTS, false));
}
@@ -634,20 +598,15 @@ public final class ContactSelectionListFragment extends LoggingFragment
*
* @param contacts List of the contacts to select. This will not overwrite the current selection, but append to it.
*/
public void markSelected(@NonNull Set<ShareContact> contacts) {
public void markSelected(@NonNull Set<RecipientId> contacts) {
if (contacts.isEmpty()) {
return;
}
Set<SelectedContact> toMarkSelected = contacts.stream()
.map(contact -> {
if (contact.getRecipientId().isPresent()) {
return SelectedContact.forRecipientId(contact.getRecipientId().get());
} else {
return SelectedContact.forPhone(null, contact.getNumber());
}
})
.filter(c -> !cursorRecyclerViewAdapter.isSelectedContact(c))
.filter(r -> !contactSearchMediator.getSelectedContacts()
.contains(new ContactSearchKey.RecipientSearchKey(r, false)))
.map(SelectedContact::forRecipientId)
.collect(java.util.stream.Collectors.toSet());
if (toMarkSelected.isEmpty()) {
@@ -657,22 +616,18 @@ public final class ContactSelectionListFragment extends LoggingFragment
for (final SelectedContact selectedContact : toMarkSelected) {
markContactSelected(selectedContact);
}
cursorRecyclerViewAdapter.notifyItemRangeChanged(0, cursorRecyclerViewAdapter.getItemCount());
}
private class ListClickListener implements ContactSelectionListAdapter.ItemClickListener {
@Override
public void onItemClick(ContactSelectionListItem contact) {
SelectedContact selectedContact = contact.isUsernameType() ? SelectedContact.forUsername(contact.getRecipientId().orElse(null), contact.getNumber())
: SelectedContact.forPhone(contact.getRecipientId().orElse(null), contact.getNumber());
private class ListClickListener {
public void onItemClick(ContactSearchKey contact) {
SelectedContact selectedContact = contact.requireSelectedContact();
if (!canSelectSelf && !selectedContact.hasUsername() && Recipient.self().getId().equals(selectedContact.getOrCreateRecipientId(requireContext()))) {
Toast.makeText(requireContext(), R.string.ContactSelectionListFragment_you_do_not_need_to_add_yourself_to_the_group, Toast.LENGTH_SHORT).show();
return;
}
if (!isMulti || !cursorRecyclerViewAdapter.isSelectedContact(selectedContact)) {
if (!isMulti || !contactSearchMediator.getSelectedContacts().contains(selectedContact.toContactSearchKey())) {
if (selectionHardLimitReached()) {
if (onSelectionLimitReachedListener != null) {
onSelectionLimitReachedListener.onHardLimitReached(selectionLimit.getHardLimit());
@@ -682,63 +637,58 @@ public final class ContactSelectionListFragment extends LoggingFragment
return;
}
if (contact.isUsernameType()) {
if (contact instanceof ContactSearchKey.UnknownRecipientKey && ((ContactSearchKey.UnknownRecipientKey) contact).getSectionKey() == ContactSearchConfiguration.SectionKey.USERNAME) {
String username = ((ContactSearchKey.UnknownRecipientKey) contact).getQuery();
AlertDialog loadingDialog = SimpleProgressDialog.show(requireContext());
SimpleTask.run(getViewLifecycleOwner().getLifecycle(), () -> {
return UsernameUtil.fetchAciForUsername(contact.getNumber());
return UsernameUtil.fetchAciForUsername(username);
}, uuid -> {
loadingDialog.dismiss();
if (uuid.isPresent()) {
Recipient recipient = Recipient.externalUsername(uuid.get(), contact.getNumber());
SelectedContact selected = SelectedContact.forUsername(recipient.getId(), contact.getNumber());
Recipient recipient = Recipient.externalUsername(uuid.get(), username);
SelectedContact selected = SelectedContact.forUsername(recipient.getId(), username);
if (onContactSelectedListener != null) {
onContactSelectedListener.onBeforeContactSelected(Optional.of(recipient.getId()), null, allowed -> {
if (allowed) {
markContactSelected(selected);
cursorRecyclerViewAdapter.notifyItemRangeChanged(0, cursorRecyclerViewAdapter.getItemCount(), ContactSelectionListAdapter.PAYLOAD_SELECTION_CHANGE);
}
});
} else {
markContactSelected(selected);
cursorRecyclerViewAdapter.notifyItemRangeChanged(0, cursorRecyclerViewAdapter.getItemCount(), ContactSelectionListAdapter.PAYLOAD_SELECTION_CHANGE);
}
} else {
new MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.ContactSelectionListFragment_username_not_found)
.setMessage(getString(R.string.ContactSelectionListFragment_s_is_not_a_signal_user, contact.getNumber()))
.setMessage(getString(R.string.ContactSelectionListFragment_s_is_not_a_signal_user, username))
.setPositiveButton(android.R.string.ok, (dialog, which) -> dialog.dismiss())
.show();
}
});
} else {
if (onContactSelectedListener != null) {
onContactSelectedListener.onBeforeContactSelected(contact.getRecipientId(), contact.getNumber(), allowed -> {
onContactSelectedListener.onBeforeContactSelected(Optional.ofNullable(selectedContact.getRecipientId()), selectedContact.getNumber(), allowed -> {
if (allowed) {
markContactSelected(selectedContact);
cursorRecyclerViewAdapter.notifyItemRangeChanged(0, cursorRecyclerViewAdapter.getItemCount(), ContactSelectionListAdapter.PAYLOAD_SELECTION_CHANGE);
}
});
} else {
markContactSelected(selectedContact);
cursorRecyclerViewAdapter.notifyItemRangeChanged(0, cursorRecyclerViewAdapter.getItemCount(), ContactSelectionListAdapter.PAYLOAD_SELECTION_CHANGE);
}
}
} else {
markContactUnselected(selectedContact);
cursorRecyclerViewAdapter.notifyItemRangeChanged(0, cursorRecyclerViewAdapter.getItemCount(), ContactSelectionListAdapter.PAYLOAD_SELECTION_CHANGE);
if (onContactSelectedListener != null) {
onContactSelectedListener.onContactDeselected(contact.getRecipientId(), contact.getNumber());
onContactSelectedListener.onContactDeselected(Optional.ofNullable(selectedContact.getRecipientId()), selectedContact.getNumber());
}
}
}
@Override
public boolean onItemLongClick(ContactSelectionListItem item) {
public boolean onItemLongClick(View anchorView, ContactSearchKey item) {
if (onItemLongClickListener != null) {
return onItemLongClickListener.onLongClick(item, recyclerView);
return onItemLongClickListener.onLongClick(anchorView, item, recyclerView);
} else {
return false;
}
@@ -758,7 +708,7 @@ public final class ContactSelectionListFragment extends LoggingFragment
}
private void markContactSelected(@NonNull SelectedContact selectedContact) {
cursorRecyclerViewAdapter.addSelectedContact(selectedContact);
contactSearchMediator.setKeysSelected(Collections.singleton(selectedContact.toContactSearchKey()));
if (isMulti) {
addChipForSelectedContact(selectedContact);
}
@@ -768,8 +718,7 @@ public final class ContactSelectionListFragment extends LoggingFragment
}
private void markContactUnselected(@NonNull SelectedContact selectedContact) {
cursorRecyclerViewAdapter.removeFromSelectedContacts(selectedContact);
cursorRecyclerViewAdapter.notifyItemRangeChanged(0, cursorRecyclerViewAdapter.getItemCount(), ContactSelectionListAdapter.PAYLOAD_SELECTION_CHANGE);
contactSearchMediator.setKeysNotSelected(Collections.singleton(selectedContact.toContactSearchKey()));
contactChipViewModel.remove(selectedContact);
if (onContactSelectedListener != null) {
@@ -842,6 +791,116 @@ public final class ContactSelectionListFragment extends LoggingFragment
chipRecycler.smoothScrollBy(x, 0);
}
private @NonNull ContactSearchConfiguration mapStateToConfiguration(@NonNull ContactSearchState contactSearchState) {
int displayMode = safeArguments().getInt(DISPLAY_MODE, requireActivity().getIntent().getIntExtra(DISPLAY_MODE, ContactSelectionDisplayMode.FLAG_ALL));
boolean includeRecents = safeArguments().getBoolean(RECENTS, requireActivity().getIntent().getBooleanExtra(RECENTS, false));
boolean includePushContacts = flagSet(displayMode, ContactSelectionDisplayMode.FLAG_PUSH);
boolean includeSmsContacts = flagSet(displayMode, ContactSelectionDisplayMode.FLAG_SMS);
boolean includeActiveGroups = flagSet(displayMode, ContactSelectionDisplayMode.FLAG_ACTIVE_GROUPS);
boolean includeInactiveGroups = flagSet(displayMode, ContactSelectionDisplayMode.FLAG_INACTIVE_GROUPS);
boolean includeSelf = flagSet(displayMode, ContactSelectionDisplayMode.FLAG_SELF);
boolean includeV1Groups = !flagSet(displayMode, ContactSelectionDisplayMode.FLAG_HIDE_GROUPS_V1);
boolean includeNew = !flagSet(displayMode, ContactSelectionDisplayMode.FLAG_HIDE_NEW);
boolean includeRecentsHeader = !flagSet(displayMode, ContactSelectionDisplayMode.FLAG_HIDE_RECENT_HEADER);
boolean includeGroupsAfterContacts = flagSet(displayMode, ContactSelectionDisplayMode.FLAG_GROUPS_AFTER_CONTACTS);
boolean blocked = flagSet(displayMode, ContactSelectionDisplayMode.FLAG_BLOCK);
ContactSearchConfiguration.TransportType transportType = resolveTransportType(includePushContacts, includeSmsContacts);
ContactSearchConfiguration.Section.Recents.Mode mode = resolveRecentsMode(transportType, includeActiveGroups);
ContactSearchConfiguration.NewRowMode newRowMode = resolveNewRowMode(blocked, includeActiveGroups);
return ContactSearchConfiguration.build(builder -> {
builder.setQuery(contactSearchState.getQuery());
if (listCallback != null) {
builder.arbitrary(ContactSelectionListAdapter.ArbitraryRepository.ArbitraryRow.NEW_GROUP.getCode());
}
if (transportType != null) {
if (TextUtils.isEmpty(contactSearchState.getQuery()) && includeRecents) {
builder.addSection(new ContactSearchConfiguration.Section.Recents(
25,
mode,
includeInactiveGroups,
includeV1Groups,
includeSmsContacts,
includeSelf,
includeRecentsHeader,
null
));
}
builder.addSection(new ContactSearchConfiguration.Section.Individuals(
includeSelf,
transportType,
true,
null,
!hideLetterHeaders()
));
}
if ((includeGroupsAfterContacts || !TextUtils.isEmpty(contactSearchState.getQuery())) && includeActiveGroups) {
builder.addSection(new ContactSearchConfiguration.Section.Groups(
includeSmsContacts,
includeV1Groups,
includeInactiveGroups,
false,
ContactSearchSortOrder.NATURAL,
false,
true,
null
));
}
if (listCallback != null) {
builder.arbitrary(ContactSelectionListAdapter.ArbitraryRepository.ArbitraryRow.INVITE_TO_SIGNAL.getCode());
}
if (includeNew) {
builder.phone(newRowMode);
builder.username(newRowMode);
}
return Unit.INSTANCE;
});
}
private static @Nullable ContactSearchConfiguration.TransportType resolveTransportType(boolean includePushContacts, boolean includeSmsContacts) {
if (includePushContacts && includeSmsContacts) {
return ContactSearchConfiguration.TransportType.ALL;
} else if (includePushContacts) {
return ContactSearchConfiguration.TransportType.PUSH;
} else if (includeSmsContacts) {
return ContactSearchConfiguration.TransportType.SMS;
} else {
return null;
}
}
private static @NonNull ContactSearchConfiguration.Section.Recents.Mode resolveRecentsMode(ContactSearchConfiguration.TransportType transportType, boolean includeGroupContacts) {
if (transportType != null && includeGroupContacts) {
return ContactSearchConfiguration.Section.Recents.Mode.ALL;
} else if (includeGroupContacts) {
return ContactSearchConfiguration.Section.Recents.Mode.GROUPS;
} else {
return ContactSearchConfiguration.Section.Recents.Mode.INDIVIDUALS;
}
}
private static @NonNull ContactSearchConfiguration.NewRowMode resolveNewRowMode(boolean isBlocked, boolean isActiveGroups) {
if (isBlocked) {
return ContactSearchConfiguration.NewRowMode.BLOCK;
} else if (isActiveGroups) {
return ContactSearchConfiguration.NewRowMode.NEW_CONVERSATION;
} else {
return ContactSearchConfiguration.NewRowMode.ADD_TO_GROUP;
}
}
private static boolean flagSet(int mode, int flag) {
return (mode & flag) > 0;
}
public interface OnContactSelectedListener {
/**
* Provides an opportunity to disallow selecting an item. Call the callback with false to disallow, or true to allow it.
@@ -874,10 +933,6 @@ public final class ContactSelectionListFragment extends LoggingFragment
}
public interface OnItemLongClickListener {
boolean onLongClick(ContactSelectionListItem contactSelectionListItem, RecyclerView recyclerView);
}
public interface AbstractContactsCursorLoaderFactoryProvider {
@NonNull AbstractContactsCursorLoader.Factory get();
boolean onLongClick(View anchorView, ContactSearchKey contactSearchKey, RecyclerView recyclerView);
}
}

View File

@@ -140,27 +140,18 @@ public class DeviceActivity extends PassphraseRequiredActivity
Uri uri = Uri.parse(data);
deviceLinkFragment.setLinkClickedListener(uri, DeviceActivity.this);
if (Build.VERSION.SDK_INT >= 21) {
deviceAddFragment.setSharedElementReturnTransition(TransitionInflater.from(DeviceActivity.this).inflateTransition(R.transition.fragment_shared));
deviceAddFragment.setExitTransition(TransitionInflater.from(DeviceActivity.this).inflateTransition(android.R.transition.fade));
deviceAddFragment.setSharedElementReturnTransition(TransitionInflater.from(DeviceActivity.this).inflateTransition(R.transition.fragment_shared));
deviceAddFragment.setExitTransition(TransitionInflater.from(DeviceActivity.this).inflateTransition(android.R.transition.fade));
deviceLinkFragment.setSharedElementEnterTransition(TransitionInflater.from(DeviceActivity.this).inflateTransition(R.transition.fragment_shared));
deviceLinkFragment.setEnterTransition(TransitionInflater.from(DeviceActivity.this).inflateTransition(android.R.transition.fade));
deviceLinkFragment.setSharedElementEnterTransition(TransitionInflater.from(DeviceActivity.this).inflateTransition(R.transition.fragment_shared));
deviceLinkFragment.setEnterTransition(TransitionInflater.from(DeviceActivity.this).inflateTransition(android.R.transition.fade));
getSupportFragmentManager().beginTransaction()
.addToBackStack(null)
.addSharedElement(deviceAddFragment.getDevicesImage(), "devices")
.replace(R.id.fragment_container, deviceLinkFragment)
.commit();
getSupportFragmentManager().beginTransaction()
.addToBackStack(null)
.addSharedElement(deviceAddFragment.getDevicesImage(), "devices")
.replace(R.id.fragment_container, deviceLinkFragment)
.commit();
} else {
getSupportFragmentManager().beginTransaction()
.setCustomAnimations(R.anim.slide_from_bottom, R.anim.slide_to_bottom,
R.anim.slide_from_bottom, R.anim.slide_to_bottom)
.replace(R.id.fragment_container, deviceLinkFragment)
.addToBackStack(null)
.commit();
}
});
}

View File

@@ -41,22 +41,19 @@ public class DeviceAddFragment extends LoggingFragment {
this.devicesImage = container.findViewById(R.id.devices);
ViewCompat.setTransitionName(devicesImage, "devices");
if (Build.VERSION.SDK_INT >= 21) {
container.addOnLayoutChangeListener(new View.OnLayoutChangeListener() {
@TargetApi(21)
@Override
public void onLayoutChange(View v, int left, int top, int right, int bottom,
int oldLeft, int oldTop, int oldRight, int oldBottom)
{
v.removeOnLayoutChangeListener(this);
container.addOnLayoutChangeListener(new View.OnLayoutChangeListener() {
@Override
public void onLayoutChange(View v, int left, int top, int right, int bottom,
int oldLeft, int oldTop, int oldRight, int oldBottom)
{
v.removeOnLayoutChangeListener(this);
Animator reveal = ViewAnimationUtils.createCircularReveal(v, right, bottom, 0, (int) Math.hypot(right, bottom));
reveal.setInterpolator(new DecelerateInterpolator(2f));
reveal.setDuration(800);
reveal.start();
}
});
}
Animator reveal = ViewAnimationUtils.createCircularReveal(v, right, bottom, 0, (int) Math.hypot(right, bottom));
reveal.setInterpolator(new DecelerateInterpolator(2f));
reveal.setDuration(800);
reveal.start();
}
});
scannerView.start(getViewLifecycleOwner(), CameraXModelBlocklist.isBlocklisted());

View File

@@ -23,7 +23,7 @@ import androidx.interpolator.view.animation.FastOutSlowInInterpolator;
import org.thoughtcrime.securesms.components.ContactFilterView;
import org.thoughtcrime.securesms.components.ContactFilterView.OnFilterChangedListener;
import org.thoughtcrime.securesms.contacts.ContactsCursorLoader.DisplayMode;
import org.thoughtcrime.securesms.contacts.ContactSelectionDisplayMode;
import org.thoughtcrime.securesms.contacts.SelectedContact;
import org.thoughtcrime.securesms.database.SignalDatabase;
import org.thoughtcrime.securesms.groups.SelectionLimits;
@@ -62,7 +62,7 @@ public class InviteActivity extends PassphraseRequiredActivity implements Contac
@Override
protected void onCreate(Bundle savedInstanceState, boolean ready) {
getIntent().putExtra(ContactSelectionListFragment.DISPLAY_MODE, DisplayMode.FLAG_SMS);
getIntent().putExtra(ContactSelectionListFragment.DISPLAY_MODE, ContactSelectionDisplayMode.FLAG_SMS);
getIntent().putExtra(ContactSelectionListFragment.SELECTION_LIMITS, SelectionLimits.NO_LIMITS);
getIntent().putExtra(ContactSelectionListFragment.HIDE_COUNT, true);
getIntent().putExtra(ContactSelectionListFragment.REFRESHABLE, false);

View File

@@ -6,6 +6,7 @@ import android.content.Intent;
import android.net.Uri;
import android.os.Bundle;
import android.view.View;
import android.view.ViewTreeObserver;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
@@ -14,6 +15,7 @@ import androidx.lifecycle.ViewModelProvider;
import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaController;
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;
@@ -37,6 +39,8 @@ public class MainActivity extends PassphraseRequiredActivity implements VoiceNot
private VoiceNoteMediaController mediaController;
private ConversationListTabsViewModel conversationListTabsViewModel;
private boolean onFirstRender = false;
public static @NonNull Intent clearTop(@NonNull Context context) {
Intent intent = new Intent(context, MainActivity.class);
@@ -53,8 +57,23 @@ public class MainActivity extends PassphraseRequiredActivity implements VoiceNot
super.onCreate(savedInstanceState, ready);
setContentView(R.layout.main_activity);
final View content = findViewById(android.R.id.content);
content.getViewTreeObserver().addOnPreDrawListener(
new ViewTreeObserver.OnPreDrawListener() {
@Override
public boolean onPreDraw() {
// Use pre draw listener to delay drawing frames till conversation list is ready
if (onFirstRender) {
content.getViewTreeObserver().removeOnPreDrawListener(this);
return true;
} else {
return false;
}
}
});
mediaController = new VoiceNoteMediaController(this);
mediaController = new VoiceNoteMediaController(this, true);
ConversationListTabRepository repository = new ConversationListTabRepository();
ConversationListTabsViewModel.Factory factory = new ConversationListTabsViewModel.Factory(repository);
@@ -98,6 +117,11 @@ public class MainActivity extends PassphraseRequiredActivity implements VoiceNot
OldDeviceTransferLockedDialog.show(getSupportFragmentManager());
}
if (SignalStore.misc().getShouldShowLinkedDevicesReminder()) {
SignalStore.misc().setShouldShowLinkedDevicesReminder(false);
RelinkDevicesReminderBottomSheetFragment.show(getSupportFragmentManager());
}
updateTabVisibility();
}
@@ -158,6 +182,10 @@ public class MainActivity extends PassphraseRequiredActivity implements VoiceNot
}
}
public void onFirstRender() {
onFirstRender = true;
}
@Override
public @NonNull VoiceNoteMediaController getVoiceNoteMediaController() {
return mediaController;

View File

@@ -20,6 +20,7 @@ import android.content.Intent;
import android.os.Bundle;
import android.view.Menu;
import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup;
import androidx.activity.result.ActivityResultLauncher;
@@ -39,26 +40,22 @@ import org.signal.core.util.concurrent.SimpleTask;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.components.menu.ActionItem;
import org.thoughtcrime.securesms.components.menu.SignalContextMenu;
import org.thoughtcrime.securesms.contacts.ContactSelectionListItem;
import org.thoughtcrime.securesms.contacts.management.ContactsManagementRepository;
import org.thoughtcrime.securesms.contacts.management.ContactsManagementViewModel;
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey;
import org.thoughtcrime.securesms.contacts.sync.ContactDiscovery;
import org.thoughtcrime.securesms.conversation.ConversationIntents;
import org.thoughtcrime.securesms.database.SignalDatabase;
import org.thoughtcrime.securesms.groups.ui.creategroup.CreateGroupActivity;
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.util.CommunicationActions;
import org.thoughtcrime.securesms.util.FeatureFlags;
import org.thoughtcrime.securesms.util.LifecycleDisposable;
import org.thoughtcrime.securesms.util.Util;
import org.thoughtcrime.securesms.util.views.SimpleProgressDialog;
import java.io.IOException;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
@@ -180,22 +177,23 @@ public class NewConversationActivity extends ContactSelectionActivity
public boolean onOptionsItemSelected(MenuItem item) {
super.onOptionsItemSelected(item);
switch (item.getItemId()) {
case android.R.id.home:
super.onBackPressed();
return true;
case R.id.menu_refresh:
handleManualRefresh();
return true;
case R.id.menu_new_group:
handleCreateGroup();
return true;
case R.id.menu_invite:
handleInvite();
return true;
}
int itemId = item.getItemId();
return false;
if (itemId == android.R.id.home) {
super.onBackPressed();
return true;
} else if (itemId == R.id.menu_refresh) {
handleManualRefresh();
return true;
} else if (itemId == R.id.menu_new_group) {
handleCreateGroup();
return true;
} else if (itemId == R.id.menu_invite) {
handleInvite();
return true;
} else {
return false;
}
}
private void handleManualRefresh() {
@@ -233,18 +231,14 @@ public class NewConversationActivity extends ContactSelectionActivity
}
@Override
public boolean onLongClick(ContactSelectionListItem contactSelectionListItem, RecyclerView recyclerView) {
RecipientId recipientId = contactSelectionListItem.getRecipientId().orElse(null);
if (recipientId == null) {
return false;
}
public boolean onLongClick(View anchorView, ContactSearchKey contactSearchKey, RecyclerView recyclerView) {
RecipientId recipientId = contactSearchKey.requireRecipientSearchKey().getRecipientId();
List<ActionItem> actions = generateContextualActionsForRecipient(recipientId);
if (actions.isEmpty()) {
return false;
}
new SignalContextMenu.Builder(contactSelectionListItem, (ViewGroup) contactSelectionListItem.getRootView())
new SignalContextMenu.Builder(anchorView, (ViewGroup) anchorView.getRootView())
.preferredVerticalPosition(SignalContextMenu.VerticalPosition.BELOW)
.preferredHorizontalPosition(SignalContextMenu.HorizontalPosition.START)
.offsetX((int) DimensionUnit.DP.toPixels(12))

View File

@@ -307,10 +307,8 @@ public class PassphrasePromptActivity extends PassphraseActivity {
public Unit showConfirmDeviceCredentialIntent() {
KeyguardManager keyguardManager = (KeyguardManager) getSystemService(Context.KEYGUARD_SERVICE);
Intent intent = null;
if (Build.VERSION.SDK_INT >= 21) {
intent = keyguardManager.createConfirmDeviceCredentialIntent(getString(R.string.PassphrasePromptActivity_unlock_signal), "");
}
Intent intent = keyguardManager.createConfirmDeviceCredentialIntent(getString(R.string.PassphrasePromptActivity_unlock_signal), "");
startActivityForResult(intent, AUTHENTICATE_REQUEST_CODE);
return Unit.INSTANCE;
}

View File

@@ -20,20 +20,17 @@ import org.thoughtcrime.securesms.crypto.MasterSecretUtil;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.devicetransfer.olddevice.OldDeviceTransferActivity;
import org.thoughtcrime.securesms.jobs.PushNotificationReceiveJob;
import org.thoughtcrime.securesms.keyvalue.PhoneNumberPrivacyValues;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.lock.v2.CreateKbsPinActivity;
import org.thoughtcrime.securesms.migrations.ApplicationMigrationActivity;
import org.thoughtcrime.securesms.migrations.ApplicationMigrations;
import org.thoughtcrime.securesms.pin.PinRestoreActivity;
import org.thoughtcrime.securesms.profiles.edit.EditProfileActivity;
import org.thoughtcrime.securesms.profiles.username.AddAUsernameActivity;
import org.thoughtcrime.securesms.push.SignalServiceNetworkAccess;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.registration.RegistrationNavigationActivity;
import org.thoughtcrime.securesms.service.KeyCachingService;
import org.thoughtcrime.securesms.util.AppStartup;
import org.thoughtcrime.securesms.util.FeatureFlags;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import java.util.Locale;
@@ -55,7 +52,6 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
private static final int STATE_TRANSFER_ONGOING = 8;
private static final int STATE_TRANSFER_LOCKED = 9;
private static final int STATE_CHANGE_NUMBER_LOCK = 10;
private static final int STATE_CREATE_USERNAME = 11;
private SignalServiceNetworkAccess networkAccess;
private BroadcastReceiver clearKeyReceiver;
@@ -160,7 +156,6 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
case STATE_TRANSFER_ONGOING: return getOldDeviceTransferIntent();
case STATE_TRANSFER_LOCKED: return getOldDeviceTransferLockedIntent();
case STATE_CHANGE_NUMBER_LOCK: return getChangeNumberLockIntent();
case STATE_CREATE_USERNAME: return getCreateUsernameIntent();
default: return null;
}
}
@@ -180,8 +175,6 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
return STATE_CREATE_SIGNAL_PIN;
} else if (userMustSetProfileName()) {
return STATE_CREATE_PROFILE_NAME;
} else if (shouldAskUserToCreateUsername()) {
return STATE_CREATE_USERNAME;
} else if (userMustCreateSignalPin()) {
return STATE_CREATE_SIGNAL_PIN;
} else if (EventBus.getDefault().getStickyEvent(TransferStatus.class) != null && getClass() != OldDeviceTransferActivity.class) {
@@ -207,13 +200,6 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
return !SignalStore.registrationValues().isRegistrationComplete() && Recipient.self().getProfileName().isEmpty();
}
private boolean shouldAskUserToCreateUsername() {
return FeatureFlags.usernames() &&
FeatureFlags.phoneNumberPrivacy() &&
!SignalStore.uiHints().hasSetOrSkippedUsernameCreation() &&
SignalStore.phoneNumberPrivacy().getPhoneNumberListingMode() == PhoneNumberPrivacyValues.PhoneNumberListingMode.UNLISTED;
}
private Intent getCreatePassphraseIntent() {
return getRoutedIntent(PassphraseCreateActivity.class, getIntent());
}
@@ -273,10 +259,6 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
return ChangeNumberLockActivity.createIntent(this);
}
private Intent getCreateUsernameIntent() {
return getRoutedIntent(AddAUsernameActivity.class, getIntent());
}
private Intent getRoutedIntent(Intent destination, @Nullable Intent nextIntent) {
if (nextIntent != null) destination.putExtra("next_intent", nextIntent);
return destination;

View File

@@ -0,0 +1,11 @@
package org.thoughtcrime.securesms.absbackup
/**
* Abstracts away the implementation of pieces of data we want to hand off to various backup services.
* Here we can control precisely which data gets backed up and more importantly, what does not.
*/
interface AndroidBackupItem {
fun getKey(): String
fun getDataForBackup(): ByteArray
fun restoreData(data: ByteArray)
}

View File

@@ -0,0 +1,66 @@
package org.thoughtcrime.securesms.absbackup
import android.app.backup.BackupAgent
import android.app.backup.BackupDataInput
import android.app.backup.BackupDataOutput
import android.os.ParcelFileDescriptor
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.absbackup.backupables.KbsAuthTokens
import java.io.DataInputStream
import java.io.DataOutputStream
import java.io.FileInputStream
import java.io.FileOutputStream
import java.io.IOException
/**
* Uses the [Android Backup Service](https://developer.android.com/guide/topics/data/keyvaluebackup) and backs up everything in [items]
*/
class SignalBackupAgent : BackupAgent() {
private val items: List<AndroidBackupItem> = listOf(
KbsAuthTokens
)
override fun onBackup(oldState: ParcelFileDescriptor?, data: BackupDataOutput, newState: ParcelFileDescriptor) {
val contentsHash = cumulativeHashCode()
if (oldState == null) {
performBackup(data)
} else {
val hash = try {
DataInputStream(FileInputStream(oldState.fileDescriptor)).use { it.readInt() }
} catch (e: IOException) {
Log.w(TAG, "No old state, may be first backup request or bug with not writing to newState at end.", e)
}
if (hash != contentsHash) {
performBackup(data)
}
}
DataOutputStream(FileOutputStream(newState.fileDescriptor)).use { it.writeInt(contentsHash) }
}
private fun performBackup(data: BackupDataOutput) {
items.forEach {
val backupData = it.getDataForBackup()
data.writeEntityHeader(it.getKey(), backupData.size)
data.writeEntityData(backupData, backupData.size)
}
}
override fun onRestore(dataInput: BackupDataInput, appVersionCode: Int, newState: ParcelFileDescriptor) {
Log.i(TAG, "Restoring from Android Backup Service.")
while (dataInput.readNextHeader()) {
val buffer = ByteArray(dataInput.dataSize)
dataInput.readEntityData(buffer, 0, dataInput.dataSize)
items.find { dataInput.key == it.getKey() }?.restoreData(buffer)
}
DataOutputStream(FileOutputStream(newState.fileDescriptor)).use { it.writeInt(cumulativeHashCode()) }
}
private fun cumulativeHashCode(): Int {
return items.fold("") { acc: String, androidBackupItem: AndroidBackupItem -> acc + androidBackupItem.getDataForBackup().decodeToString() }.hashCode()
}
companion object {
private const val TAG = "SignalBackupAgent"
}
}

View File

@@ -0,0 +1,37 @@
package org.thoughtcrime.securesms.absbackup.backupables
import com.google.protobuf.InvalidProtocolBufferException
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.absbackup.AndroidBackupItem
import org.thoughtcrime.securesms.absbackup.protos.KbsAuthToken
import org.thoughtcrime.securesms.keyvalue.SignalStore
/**
* This backs up the not-secret KBS Auth tokens, which can be combined with a PIN to prove ownership of a phone number in order to complete the registration process.
*/
object KbsAuthTokens : AndroidBackupItem {
private const val TAG = "KbsAuthTokens"
override fun getKey(): String {
return TAG
}
override fun getDataForBackup(): ByteArray {
val proto = KbsAuthToken(tokens = SignalStore.kbsValues().kbsAuthTokenList)
return proto.encode()
}
override fun restoreData(data: ByteArray) {
if (SignalStore.kbsValues().kbsAuthTokenList.isNotEmpty()) {
return
}
try {
val proto = KbsAuthToken.ADAPTER.decode(data)
SignalStore.kbsValues().putAuthTokenList(proto.tokens)
} catch (e: InvalidProtocolBufferException) {
Log.w(TAG, "Cannot restore KbsAuthToken from backup service.")
}
}
}

View File

@@ -11,7 +11,7 @@ public abstract class AnimationCompleteListener implements Animator.AnimatorList
public abstract void onAnimationEnd(Animator animation);
@Override
public final void onAnimationCancel(Animator animation) {}
public void onAnimationCancel(Animator animation) {}
@Override
public final void onAnimationRepeat(Animator animation) {}
}

View File

@@ -0,0 +1,9 @@
package org.thoughtcrime.securesms.animation
import android.animation.Animator
abstract class AnimationStartListener : Animator.AnimatorListener {
override fun onAnimationEnd(animation: Animator) = Unit
override fun onAnimationCancel(animation: Animator) = Unit
override fun onAnimationRepeat(animation: Animator) = Unit
}

View File

@@ -13,7 +13,6 @@ import android.view.ViewGroup
import android.view.animation.AccelerateInterpolator
import android.view.animation.DecelerateInterpolator
import android.view.animation.Interpolator
import androidx.annotation.RequiresApi
private const val POSITION_ON_SCREEN = "signal.circleavatartransition.positiononscreen"
private const val WIDTH = "signal.circleavatartransition.width"
@@ -22,7 +21,6 @@ private const val HEIGHT = "signal.circleavatartransition.height"
/**
* Custom transition for Circular avatars, because once you have multiple things animating stuff was getting broken and weird.
*/
@RequiresApi(21)
class CircleAvatarTransition(context: Context, attrs: AttributeSet?) : Transition(context, attrs) {
override fun captureStartValues(transitionValues: TransitionValues) {
captureValues(transitionValues)

View File

@@ -13,7 +13,6 @@ import android.widget.ImageView;
import androidx.core.graphics.drawable.RoundedBitmapDrawable;
@TargetApi(21)
abstract class CircleSquareImageViewTransition extends Transition {
private static final String CIRCLE_RATIO = "CIRCLE_RATIO";

View File

@@ -7,7 +7,6 @@ import android.util.AttributeSet;
/**
* Will only transition {@link android.widget.ImageView}s that contain a {@link androidx.core.graphics.drawable.RoundedBitmapDrawable}.
*/
@TargetApi(21)
public final class CircleToSquareImageViewTransition extends CircleSquareImageViewTransition {
public CircleToSquareImageViewTransition(Context context, AttributeSet attrs) {
super(false);

View File

@@ -7,11 +7,9 @@ import android.transition.Transition
import android.transition.TransitionValues
import android.util.AttributeSet
import android.view.ViewGroup
import androidx.annotation.RequiresApi
import androidx.core.animation.doOnEnd
import androidx.core.animation.doOnStart
@RequiresApi(21)
class CrossfaderTransition(context: Context, attrs: AttributeSet?) : Transition(context, attrs) {
companion object {

View File

@@ -7,7 +7,6 @@ import android.util.AttributeSet;
/**
* Will only transition {@link android.widget.ImageView}s that contain a {@link androidx.core.graphics.drawable.RoundedBitmapDrawable}.
*/
@TargetApi(21)
public final class SquareToCircleImageViewTransition extends CircleSquareImageViewTransition {
public SquareToCircleImageViewTransition(Context context, AttributeSet attrs) {
super(true);

View File

@@ -10,7 +10,6 @@ import android.transition.TransitionValues
import android.util.AttributeSet
import android.view.View
import android.view.ViewGroup
import androidx.annotation.RequiresApi
import androidx.core.animation.addListener
import androidx.fragment.app.FragmentContainerView
@@ -19,7 +18,6 @@ private const val BOUNDS = "signal.wipedowntransition.bottom"
/**
* WipeDownTransition will animate the bottom position of a view such that it "wipes" down the screen to a final position.
*/
@RequiresApi(21)
class WipeDownTransition(context: Context, attrs: AttributeSet?) : Transition(context, attrs) {
override fun captureStartValues(transitionValues: TransitionValues) {
captureValues(transitionValues)

View File

@@ -0,0 +1,45 @@
package org.thoughtcrime.securesms.audio;
import androidx.annotation.NonNull;
import com.google.protobuf.ByteString;
import org.thoughtcrime.securesms.database.model.databaseprotos.AudioWaveFormData;
import java.util.concurrent.TimeUnit;
public class AudioFileInfo {
private final long durationUs;
private final byte[] waveFormBytes;
private final float[] waveForm;
public static @NonNull AudioFileInfo fromDatabaseProtobuf(@NonNull AudioWaveFormData audioWaveForm) {
return new AudioFileInfo(audioWaveForm.getDurationUs(), audioWaveForm.getWaveForm().toByteArray());
}
AudioFileInfo(long durationUs, byte[] waveFormBytes) {
this.durationUs = durationUs;
this.waveFormBytes = waveFormBytes;
this.waveForm = new float[waveFormBytes.length];
for (int i = 0; i < waveFormBytes.length; i++) {
int unsigned = waveFormBytes[i] & 0xff;
this.waveForm[i] = unsigned / 255f;
}
}
public long getDuration(@NonNull TimeUnit timeUnit) {
return timeUnit.convert(durationUs, TimeUnit.MICROSECONDS);
}
public float[] getWaveForm() {
return waveForm;
}
public @NonNull AudioWaveFormData toDatabaseProtobuf() {
return AudioWaveFormData.newBuilder()
.setDurationUs(durationUs)
.setWaveForm(ByteString.copyFrom(waveFormBytes))
.build();
}
}

View File

@@ -7,19 +7,20 @@ import android.os.Build;
import android.os.ParcelFileDescriptor;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.signal.core.util.ThreadUtil;
import org.signal.core.util.concurrent.SignalExecutors;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.components.voice.VoiceNoteDraft;
import org.thoughtcrime.securesms.providers.BlobProvider;
import org.thoughtcrime.securesms.util.MediaUtil;
import org.thoughtcrime.securesms.util.concurrent.ListenableFuture;
import org.thoughtcrime.securesms.util.concurrent.SettableFuture;
import java.io.IOException;
import java.util.concurrent.ExecutorService;
import io.reactivex.rxjava3.core.Single;
import io.reactivex.rxjava3.subjects.SingleSubject;
public class AudioRecorder {
private static final String TAG = Log.tag(AudioRecorder.class);
@@ -27,19 +28,42 @@ public class AudioRecorder {
private static final ExecutorService executor = SignalExecutors.newCachedSingleThreadExecutor("signal-AudioRecorder");
private final Context context;
private final AudioRecordingHandler uiHandler;
private final AudioRecorderFocusManager audioFocusManager;
private Recorder recorder;
private Uri captureUri;
public AudioRecorder(@NonNull Context context) {
this.context = context;
audioFocusManager = AudioRecorderFocusManager.create(context, focusChange -> stopRecording());
private SingleSubject<VoiceNoteDraft> recordingSubject;
public AudioRecorder(@NonNull Context context, @Nullable AudioRecordingHandler uiHandler) {
this.context = context;
this.uiHandler = uiHandler;
AudioManager.OnAudioFocusChangeListener onAudioFocusChangeListener;
if (this.uiHandler != null) {
onAudioFocusChangeListener = focusChange -> {
if (focusChange == AudioManager.AUDIOFOCUS_LOSS) {
Log.i(TAG, "Audio focus change " + focusChange + " stopping recording");
this.uiHandler.onRecordCanceled(false);
}
};
} else {
onAudioFocusChangeListener = focusChange -> {
if (focusChange == AudioManager.AUDIOFOCUS_LOSS) {
Log.i(TAG, "Audio focus change " + focusChange + " stopping recording");
stopRecording();
}
};
}
audioFocusManager = AudioRecorderFocusManager.create(context, onAudioFocusChangeListener);
}
public void startRecording() {
public @NonNull Single<VoiceNoteDraft> startRecording() {
Log.i(TAG, "startRecording()");
final SingleSubject<VoiceNoteDraft> recordingSingle = SingleSubject.create();
executor.execute(() -> {
Log.i(TAG, "Running startRecording() + " + Thread.currentThread().getId());
try {
@@ -53,27 +77,29 @@ public class AudioRecorder {
.forData(new ParcelFileDescriptor.AutoCloseInputStream(fds[0]), 0)
.withMimeType(MediaUtil.AUDIO_AAC)
.createForDraftAttachmentAsync(context, () -> Log.i(TAG, "Write successful."), e -> Log.w(TAG, "Error during recording", e));
recorder = Build.VERSION.SDK_INT >= 26 ? new MediaRecorderWrapper() : new AudioCodec();
recorder = Build.VERSION.SDK_INT >= 26 ? new MediaRecorderWrapper() : new AudioCodec();
int focusResult = audioFocusManager.requestAudioFocus();
if (focusResult != AudioManager.AUDIOFOCUS_REQUEST_GRANTED) {
Log.w(TAG, "Could not gain audio focus. Received result code " + focusResult);
}
recorder.start(fds[1]);
this.recordingSubject = recordingSingle;
} catch (IOException e) {
recordingSingle.onError(e);
recorder = null;
Log.w(TAG, e);
}
});
return recordingSingle;
}
public @NonNull ListenableFuture<VoiceNoteDraft> stopRecording() {
public void stopRecording() {
Log.i(TAG, "stopRecording()");
final SettableFuture<VoiceNoteDraft> future = new SettableFuture<>();
executor.execute(() -> {
if (recorder == null) {
sendToFuture(future, new IOException("MediaRecorder was never initialized successfully!"));
Log.e(TAG, "MediaRecorder was never initialized successfully!");
return;
}
@@ -82,24 +108,15 @@ public class AudioRecorder {
try {
long size = MediaUtil.getMediaSize(context, captureUri);
sendToFuture(future, new VoiceNoteDraft(captureUri, size));
recordingSubject.onSuccess(new VoiceNoteDraft(captureUri, size));
} catch (IOException ioe) {
Log.w(TAG, ioe);
sendToFuture(future, ioe);
recordingSubject.onError(ioe);
}
recorder = null;
captureUri = null;
recordingSubject = null;
recorder = null;
captureUri = null;
});
return future;
}
private <T> void sendToFuture(final SettableFuture<T> future, final Exception exception) {
ThreadUtil.runOnMain(() -> future.setException(exception));
}
private <T> void sendToFuture(final SettableFuture<T> future, final T result) {
ThreadUtil.runOnMain(() -> future.set(result));
}
}

View File

@@ -0,0 +1,10 @@
package org.thoughtcrime.securesms.audio
interface AudioRecordingHandler {
fun onRecordPressed()
fun onRecordReleased()
fun onRecordCanceled(byUser: Boolean)
fun onRecordLocked()
fun onRecordMoved(offsetX: Float, absoluteX: Float)
fun onRecordPermissionRequired()
}

View File

@@ -1,325 +0,0 @@
package org.thoughtcrime.securesms.audio;
import android.content.Context;
import android.media.MediaCodec;
import android.media.MediaExtractor;
import android.media.MediaFormat;
import android.net.Uri;
import android.os.Build;
import android.util.LruCache;
import androidx.annotation.AnyThread;
import androidx.annotation.NonNull;
import androidx.annotation.RequiresApi;
import androidx.annotation.WorkerThread;
import androidx.core.util.Consumer;
import com.google.protobuf.ByteString;
import org.signal.core.util.ThreadUtil;
import org.signal.core.util.concurrent.SignalExecutors;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.attachments.Attachment;
import org.thoughtcrime.securesms.attachments.DatabaseAttachment;
import org.thoughtcrime.securesms.database.AttachmentTable;
import org.thoughtcrime.securesms.database.SignalDatabase;
import org.thoughtcrime.securesms.database.model.databaseprotos.AudioWaveFormData;
import org.thoughtcrime.securesms.media.DecryptableUriMediaInput;
import org.thoughtcrime.securesms.media.MediaInput;
import org.thoughtcrime.securesms.mms.AudioSlide;
import org.thoughtcrime.securesms.util.concurrent.SerialExecutor;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.util.Locale;
import java.util.concurrent.Executor;
import java.util.concurrent.TimeUnit;
@RequiresApi(api = Build.VERSION_CODES.M)
public final class AudioWaveForm {
private static final String TAG = Log.tag(AudioWaveForm.class);
private static final int BAR_COUNT = 46;
private static final int SAMPLES_PER_BAR = 4;
private final Context context;
private final AudioSlide slide;
public AudioWaveForm(@NonNull Context context, @NonNull AudioSlide slide) {
this.context = context.getApplicationContext();
this.slide = slide;
}
private static final LruCache<String, AudioFileInfo> WAVE_FORM_CACHE = new LruCache<>(200);
private static final Executor AUDIO_DECODER_EXECUTOR = new SerialExecutor(SignalExecutors.BOUNDED);
@AnyThread
public void getWaveForm(@NonNull Consumer<AudioFileInfo> onSuccess, @NonNull Runnable onFailure) {
Uri uri = slide.getUri();
Attachment attachment = slide.asAttachment();
if (uri == null) {
Log.w(TAG, "No uri");
ThreadUtil.runOnMain(onFailure);
return;
}
String cacheKey = uri.toString();
AudioFileInfo cached = WAVE_FORM_CACHE.get(cacheKey);
if (cached != null) {
Log.i(TAG, "Loaded wave form from cache " + cacheKey);
ThreadUtil.runOnMain(() -> onSuccess.accept(cached));
return;
}
AUDIO_DECODER_EXECUTOR.execute(() -> {
AudioFileInfo cachedInExecutor = WAVE_FORM_CACHE.get(cacheKey);
if (cachedInExecutor != null) {
Log.i(TAG, "Loaded wave form from cache inside executor" + cacheKey);
ThreadUtil.runOnMain(() -> onSuccess.accept(cachedInExecutor));
return;
}
AudioHash audioHash = attachment.getAudioHash();
if (audioHash != null) {
AudioFileInfo audioFileInfo = AudioFileInfo.fromDatabaseProtobuf(audioHash.getAudioWaveForm());
if (audioFileInfo.waveForm.length == 0) {
Log.w(TAG, "Recovering from a wave form generation error " + cacheKey);
ThreadUtil.runOnMain(onFailure);
return;
} else if (audioFileInfo.waveForm.length != BAR_COUNT) {
Log.w(TAG, "Wave form from database does not match bar count, regenerating " + cacheKey);
} else {
WAVE_FORM_CACHE.put(cacheKey, audioFileInfo);
Log.i(TAG, "Loaded wave form from DB " + cacheKey);
ThreadUtil.runOnMain(() -> onSuccess.accept(audioFileInfo));
return;
}
}
if (attachment instanceof DatabaseAttachment) {
try {
AttachmentTable attachmentDatabase = SignalDatabase.attachments();
DatabaseAttachment dbAttachment = (DatabaseAttachment) attachment;
long startTime = System.currentTimeMillis();
attachmentDatabase.writeAudioHash(dbAttachment.getAttachmentId(), AudioWaveFormData.getDefaultInstance());
Log.i(TAG, String.format("Starting wave form generation (%s)", cacheKey));
AudioFileInfo fileInfo = generateWaveForm(uri);
Log.i(TAG, String.format(Locale.US, "Audio wave form generation time %d ms (%s)", System.currentTimeMillis() - startTime, cacheKey));
attachmentDatabase.writeAudioHash(dbAttachment.getAttachmentId(), fileInfo.toDatabaseProtobuf());
WAVE_FORM_CACHE.put(cacheKey, fileInfo);
ThreadUtil.runOnMain(() -> onSuccess.accept(fileInfo));
} catch (Throwable e) {
Log.w(TAG, "Failed to create audio wave form for " + cacheKey, e);
ThreadUtil.runOnMain(onFailure);
}
} else {
try {
Log.i(TAG, "Not in database and not cached. Generating wave form on-the-fly.");
long startTime = System.currentTimeMillis();
Log.i(TAG, String.format("Starting wave form generation (%s)", cacheKey));
AudioFileInfo fileInfo = generateWaveForm(uri);
Log.i(TAG, String.format(Locale.US, "Audio wave form generation time %d ms (%s)", System.currentTimeMillis() - startTime, cacheKey));
WAVE_FORM_CACHE.put(cacheKey, fileInfo);
ThreadUtil.runOnMain(() -> onSuccess.accept(fileInfo));
} catch (IOException e) {
Log.w(TAG, "Failed to create audio wave form for " + cacheKey, e);
ThreadUtil.runOnMain(onFailure);
}
}
});
}
/**
* Based on decode sample from:
* <p>
* https://android.googlesource.com/platform/cts/+/jb-mr2-release/tests/tests/media/src/android/media/cts/DecoderTest.java
*/
@WorkerThread
@RequiresApi(api = 23)
private @NonNull AudioFileInfo generateWaveForm(@NonNull Uri uri) throws IOException {
try (MediaInput dataSource = DecryptableUriMediaInput.createForUri(context, uri)) {
long[] wave = new long[BAR_COUNT];
int[] waveSamples = new int[BAR_COUNT];
MediaExtractor extractor = dataSource.createExtractor();
if (extractor.getTrackCount() == 0) {
throw new IOException("No audio track");
}
MediaFormat format = extractor.getTrackFormat(0);
if (!format.containsKey(MediaFormat.KEY_DURATION)) {
throw new IOException("Unknown duration");
}
long totalDurationUs = format.getLong(MediaFormat.KEY_DURATION);
String mime = format.getString(MediaFormat.KEY_MIME);
if (!mime.startsWith("audio/")) {
throw new IOException("Mime not audio");
}
MediaCodec codec = MediaCodec.createDecoderByType(mime);
if (totalDurationUs == 0) {
throw new IOException("Zero duration");
}
codec.configure(format, null, null, 0);
codec.start();
ByteBuffer[] codecInputBuffers = codec.getInputBuffers();
ByteBuffer[] codecOutputBuffers = codec.getOutputBuffers();
extractor.selectTrack(0);
long kTimeOutUs = 5000;
MediaCodec.BufferInfo info = new MediaCodec.BufferInfo();
boolean sawInputEOS = false;
boolean sawOutputEOS = false;
int noOutputCounter = 0;
while (!sawOutputEOS && noOutputCounter < 50) {
noOutputCounter++;
if (!sawInputEOS) {
int inputBufIndex = codec.dequeueInputBuffer(kTimeOutUs);
if (inputBufIndex >= 0) {
ByteBuffer dstBuf = codecInputBuffers[inputBufIndex];
int sampleSize = extractor.readSampleData(dstBuf, 0);
long presentationTimeUs = 0;
if (sampleSize < 0) {
sawInputEOS = true;
sampleSize = 0;
} else {
presentationTimeUs = extractor.getSampleTime();
}
codec.queueInputBuffer(
inputBufIndex,
0,
sampleSize,
presentationTimeUs,
sawInputEOS ? MediaCodec.BUFFER_FLAG_END_OF_STREAM : 0);
if (!sawInputEOS) {
int barSampleIndex = (int) (SAMPLES_PER_BAR * (wave.length * extractor.getSampleTime()) / totalDurationUs);
sawInputEOS = !extractor.advance();
int nextBarSampleIndex = (int) (SAMPLES_PER_BAR * (wave.length * extractor.getSampleTime()) / totalDurationUs);
while (!sawInputEOS && nextBarSampleIndex == barSampleIndex) {
sawInputEOS = !extractor.advance();
if (!sawInputEOS) {
nextBarSampleIndex = (int) (SAMPLES_PER_BAR * (wave.length * extractor.getSampleTime()) / totalDurationUs);
}
}
}
}
}
int outputBufferIndex;
do {
outputBufferIndex = codec.dequeueOutputBuffer(info, kTimeOutUs);
if (outputBufferIndex >= 0) {
if (info.size > 0) {
noOutputCounter = 0;
}
ByteBuffer buf = codecOutputBuffers[outputBufferIndex];
int barIndex = (int) ((wave.length * info.presentationTimeUs) / totalDurationUs);
long total = 0;
for (int i = 0; i < info.size; i += 2 * 4) {
short aShort = buf.getShort(i);
total += Math.abs(aShort);
}
if (barIndex >= 0 && barIndex < wave.length) {
wave[barIndex] += total;
waveSamples[barIndex] += info.size / 2;
}
codec.releaseOutputBuffer(outputBufferIndex, false);
if ((info.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0) {
sawOutputEOS = true;
}
} else if (outputBufferIndex == MediaCodec.INFO_OUTPUT_BUFFERS_CHANGED) {
codecOutputBuffers = codec.getOutputBuffers();
} else if (outputBufferIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {
Log.d(TAG, "output format has changed to " + codec.getOutputFormat());
}
} while (outputBufferIndex >= 0);
}
codec.stop();
codec.release();
extractor.release();
float[] floats = new float[BAR_COUNT];
byte[] bytes = new byte[BAR_COUNT];
float max = 0;
for (int i = 0; i < BAR_COUNT; i++) {
if (waveSamples[i] == 0) continue;
floats[i] = wave[i] / (float) waveSamples[i];
if (floats[i] > max) {
max = floats[i];
}
}
for (int i = 0; i < BAR_COUNT; i++) {
float normalized = floats[i] / max;
bytes[i] = (byte) (255 * normalized);
}
return new AudioFileInfo(totalDurationUs, bytes);
}
}
public static class AudioFileInfo {
private final long durationUs;
private final byte[] waveFormBytes;
private final float[] waveForm;
private static @NonNull AudioFileInfo fromDatabaseProtobuf(@NonNull AudioWaveFormData audioWaveForm) {
return new AudioFileInfo(audioWaveForm.getDurationUs(), audioWaveForm.getWaveForm().toByteArray());
}
private AudioFileInfo(long durationUs, byte[] waveFormBytes) {
this.durationUs = durationUs;
this.waveFormBytes = waveFormBytes;
this.waveForm = new float[waveFormBytes.length];
for (int i = 0; i < waveFormBytes.length; i++) {
int unsigned = waveFormBytes[i] & 0xff;
this.waveForm[i] = unsigned / 255f;
}
}
public long getDuration(@NonNull TimeUnit timeUnit) {
return timeUnit.convert(durationUs, TimeUnit.MICROSECONDS);
}
public float[] getWaveForm() {
return waveForm;
}
private @NonNull AudioWaveFormData toDatabaseProtobuf() {
return AudioWaveFormData.newBuilder()
.setDurationUs(durationUs)
.setWaveForm(ByteString.copyFrom(waveFormBytes))
.build();
}
}
}

View File

@@ -0,0 +1,168 @@
package org.thoughtcrime.securesms.audio;
import android.content.Context;
import android.media.MediaCodec;
import android.media.MediaExtractor;
import android.media.MediaFormat;
import android.net.Uri;
import androidx.annotation.NonNull;
import androidx.annotation.RequiresApi;
import androidx.annotation.WorkerThread;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.media.DecryptableUriMediaInput;
import org.thoughtcrime.securesms.media.MediaInput;
import java.io.IOException;
import java.nio.ByteBuffer;
@RequiresApi(api = 23)
public final class AudioWaveFormGenerator {
private static final String TAG = Log.tag(AudioWaveFormGenerator.class);
public static final int BAR_COUNT = 46;
private static final int SAMPLES_PER_BAR = 4;
private AudioWaveFormGenerator() {}
/**
* Based on decode sample from:
* <p>
* https://android.googlesource.com/platform/cts/+/jb-mr2-release/tests/tests/media/src/android/media/cts/DecoderTest.java
*/
@WorkerThread
public static @NonNull AudioFileInfo generateWaveForm(@NonNull Context context, @NonNull Uri uri) throws IOException {
try (MediaInput dataSource = DecryptableUriMediaInput.createForUri(context, uri)) {
long[] wave = new long[BAR_COUNT];
int[] waveSamples = new int[BAR_COUNT];
MediaExtractor extractor = dataSource.createExtractor();
if (extractor.getTrackCount() == 0) {
throw new IOException("No audio track");
}
MediaFormat format = extractor.getTrackFormat(0);
if (!format.containsKey(MediaFormat.KEY_DURATION)) {
throw new IOException("Unknown duration");
}
long totalDurationUs = format.getLong(MediaFormat.KEY_DURATION);
String mime = format.getString(MediaFormat.KEY_MIME);
if (!mime.startsWith("audio/")) {
throw new IOException("Mime not audio");
}
MediaCodec codec = MediaCodec.createDecoderByType(mime);
if (totalDurationUs == 0) {
throw new IOException("Zero duration");
}
codec.configure(format, null, null, 0);
codec.start();
extractor.selectTrack(0);
long kTimeOutUs = 5000;
MediaCodec.BufferInfo info = new MediaCodec.BufferInfo();
boolean sawInputEOS = false;
boolean sawOutputEOS = false;
int noOutputCounter = 0;
while (!sawOutputEOS && noOutputCounter < 50) {
noOutputCounter++;
if (!sawInputEOS) {
int inputBufIndex = codec.dequeueInputBuffer(kTimeOutUs);
if (inputBufIndex >= 0) {
ByteBuffer dstBuf = codec.getInputBuffer(inputBufIndex);
int sampleSize = extractor.readSampleData(dstBuf, 0);
long presentationTimeUs = 0;
if (sampleSize < 0) {
sawInputEOS = true;
sampleSize = 0;
} else {
presentationTimeUs = extractor.getSampleTime();
}
codec.queueInputBuffer(
inputBufIndex,
0,
sampleSize,
presentationTimeUs,
sawInputEOS ? MediaCodec.BUFFER_FLAG_END_OF_STREAM : 0);
if (!sawInputEOS) {
int barSampleIndex = (int) (SAMPLES_PER_BAR * (wave.length * extractor.getSampleTime()) / totalDurationUs);
sawInputEOS = !extractor.advance();
int nextBarSampleIndex = (int) (SAMPLES_PER_BAR * (wave.length * extractor.getSampleTime()) / totalDurationUs);
while (!sawInputEOS && nextBarSampleIndex == barSampleIndex) {
sawInputEOS = !extractor.advance();
if (!sawInputEOS) {
nextBarSampleIndex = (int) (SAMPLES_PER_BAR * (wave.length * extractor.getSampleTime()) / totalDurationUs);
}
}
}
}
}
int outputBufferIndex;
do {
outputBufferIndex = codec.dequeueOutputBuffer(info, kTimeOutUs);
if (outputBufferIndex >= 0) {
if (info.size > 0) {
noOutputCounter = 0;
}
ByteBuffer buf = codec.getOutputBuffer(outputBufferIndex);
int barIndex = (int) ((wave.length * info.presentationTimeUs) / totalDurationUs);
long total = 0;
for (int i = 0; i < info.size; i += 2 * 4) {
short aShort = buf.getShort(i);
total += Math.abs(aShort);
}
if (barIndex >= 0 && barIndex < wave.length) {
wave[barIndex] += total;
waveSamples[barIndex] += info.size / 2;
}
codec.releaseOutputBuffer(outputBufferIndex, false);
if ((info.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0) {
sawOutputEOS = true;
}
} else if (outputBufferIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {
Log.d(TAG, "output format has changed to " + codec.getOutputFormat());
}
} while (outputBufferIndex >= 0);
}
codec.stop();
codec.release();
extractor.release();
float[] floats = new float[BAR_COUNT];
byte[] bytes = new byte[BAR_COUNT];
float max = 0;
for (int i = 0; i < BAR_COUNT; i++) {
if (waveSamples[i] == 0) continue;
floats[i] = wave[i] / (float) waveSamples[i];
if (floats[i] > max) {
max = floats[i];
}
}
for (int i = 0; i < BAR_COUNT; i++) {
float normalized = floats[i] / max;
bytes[i] = (byte) (255 * normalized);
}
return new AudioFileInfo(totalDurationUs, bytes);
}
}
}

View File

@@ -0,0 +1,152 @@
package org.thoughtcrime.securesms.audio
import android.content.Context
import android.net.Uri
import android.util.LruCache
import androidx.annotation.AnyThread
import androidx.annotation.RequiresApi
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.schedulers.Schedulers
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.attachments.Attachment
import org.thoughtcrime.securesms.attachments.AttachmentId
import org.thoughtcrime.securesms.attachments.DatabaseAttachment
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.databaseprotos.AudioWaveFormData
import java.io.IOException
import java.util.concurrent.locks.ReentrantReadWriteLock
import kotlin.concurrent.read
import kotlin.concurrent.write
/**
* Uses [AudioWaveFormGenerator] to generate audio wave forms.
*
* Maintains an in-memory cache of recently requested wave forms.
*/
@RequiresApi(23)
object AudioWaveForms {
private val TAG = Log.tag(AudioWaveForms::class.java)
private val cache = ThreadSafeLruCache(200)
@AnyThread
@JvmStatic
fun getWaveForm(context: Context, attachment: Attachment): Single<AudioFileInfo> {
val uri = attachment.uri
if (uri == null) {
Log.i(TAG, "No uri")
return Single.error(IllegalArgumentException("No uri from attachment"))
}
val cacheKey = uri.toString()
val cachedInfo = cache.get(cacheKey)
if (cachedInfo != null) {
Log.i(TAG, "Loaded wave form from cache $cacheKey")
return Single.just(cachedInfo)
}
val databaseCache = Single.fromCallable {
val audioHash = attachment.audioHash
return@fromCallable if (audioHash != null) {
checkDatabaseCache(cacheKey, audioHash.audioWaveForm)
} else {
Miss
}
}.subscribeOn(Schedulers.io())
val generateWaveForm: Single<CacheCheckResult> = if (attachment is DatabaseAttachment) {
Single.fromCallable { generateWaveForm(context, uri, cacheKey, attachment.attachmentId) }
} else {
Single.fromCallable { generateWaveForm(context, uri, cacheKey) }
}.subscribeOn(Schedulers.io())
return databaseCache
.flatMap { r ->
if (r is Miss) {
generateWaveForm
} else {
Single.just(r)
}
}
.map { r ->
if (r is Success) {
r.audioFileInfo
} else {
throw IOException("Unable to generate wave form")
}
}
}
private fun checkDatabaseCache(cacheKey: String, audioWaveForm: AudioWaveFormData): CacheCheckResult {
val audioFileInfo = AudioFileInfo.fromDatabaseProtobuf(audioWaveForm)
if (audioFileInfo.waveForm.isEmpty()) {
Log.w(TAG, "Recovering from a wave form generation error $cacheKey")
return Failure
} else if (audioFileInfo.waveForm.size != AudioWaveFormGenerator.BAR_COUNT) {
Log.w(TAG, "Wave form from database does not match bar count, regenerating $cacheKey")
} else {
cache.put(cacheKey, audioFileInfo)
Log.i(TAG, "Loaded wave form from DB $cacheKey")
return Success(audioFileInfo)
}
return Miss
}
private fun generateWaveForm(context: Context, uri: Uri, cacheKey: String, attachmentId: AttachmentId): CacheCheckResult {
try {
val startTime = System.currentTimeMillis()
SignalDatabase.attachments.writeAudioHash(attachmentId, AudioWaveFormData.getDefaultInstance())
Log.i(TAG, "Starting wave form generation ($cacheKey)")
val fileInfo: AudioFileInfo = AudioWaveFormGenerator.generateWaveForm(context, uri)
Log.i(TAG, "Audio wave form generation time ${System.currentTimeMillis() - startTime} ms ($cacheKey)")
SignalDatabase.attachments.writeAudioHash(attachmentId, fileInfo.toDatabaseProtobuf())
cache.put(cacheKey, fileInfo)
return Success(fileInfo)
} catch (e: Throwable) {
Log.w(TAG, "Failed to create audio wave form for $cacheKey", e)
return Failure
}
}
private fun generateWaveForm(context: Context, uri: Uri, cacheKey: String): CacheCheckResult {
try {
Log.i(TAG, "Not in database and not cached. Generating wave form on-the-fly.")
val startTime = System.currentTimeMillis()
Log.i(TAG, "Starting wave form generation ($cacheKey)")
val fileInfo: AudioFileInfo = AudioWaveFormGenerator.generateWaveForm(context, uri)
Log.i(TAG, "Audio wave form generation time ${System.currentTimeMillis() - startTime} ms ($cacheKey)")
cache.put(cacheKey, fileInfo)
return Success(fileInfo)
} catch (e: Throwable) {
Log.w(TAG, "Failed to create audio wave form for $cacheKey", e)
return Failure
}
}
private class ThreadSafeLruCache(maxSize: Int) {
private val cache = LruCache<String, AudioFileInfo>(maxSize)
private val lock = ReentrantReadWriteLock()
fun put(key: String, info: AudioFileInfo) {
lock.write { cache.put(key, info) }
}
fun get(key: String): AudioFileInfo? {
return lock.read { cache.get(key) }
}
}
private sealed class CacheCheckResult
private class Success(val audioFileInfo: AudioFileInfo) : CacheCheckResult()
private object Failure : CacheCheckResult()
private object Miss : CacheCheckResult()
}

View File

@@ -21,7 +21,7 @@ sealed class Avatar(
data class Text(
val text: String,
val color: Avatars.ColorPair,
override val databaseId: DatabaseId,
override val databaseId: DatabaseId
) : Avatar(databaseId) {
override fun withDatabaseId(databaseId: DatabaseId): Avatar {
return copy(databaseId = databaseId)
@@ -35,7 +35,7 @@ sealed class Avatar(
data class Vector(
val key: String,
val color: Avatars.ColorPair,
override val databaseId: DatabaseId,
override val databaseId: DatabaseId
) : Avatar(databaseId) {
override fun withDatabaseId(databaseId: DatabaseId): Avatar {
return copy(databaseId = databaseId)

View File

@@ -1,6 +1,8 @@
package org.thoughtcrime.securesms.avatar
import android.net.Uri
import android.os.Bundle
import org.signal.core.util.getParcelableCompat
/**
* Utility class which encapsulates reading and writing Avatar objects to and from Bundles.
@@ -33,7 +35,7 @@ object AvatarBundler {
}
fun extractPhoto(bundle: Bundle): Avatar.Photo = Avatar.Photo(
uri = requireNotNull(bundle.getParcelable(URI)),
uri = requireNotNull(bundle.getParcelableCompat(URI, Uri::class.java)),
size = bundle.getLong(SIZE),
databaseId = bundle.getDatabaseId()
)

View File

@@ -6,6 +6,7 @@ import android.graphics.Canvas
import android.graphics.Typeface
import android.graphics.drawable.Drawable
import android.net.Uri
import androidx.annotation.MainThread
import androidx.appcompat.content.res.AppCompatResources
import com.airbnb.lottie.SimpleColorFilter
import org.signal.core.util.concurrent.SignalExecutors
@@ -28,8 +29,13 @@ object AvatarRenderer {
val DIMENSIONS = AvatarHelper.AVATAR_DIMENSIONS
private var typeface: Typeface? = null
@MainThread
fun getTypeface(context: Context): Typeface {
return Typeface.createFromAsset(context.assets, "fonts/Inter-Medium.otf")
val interMedium = typeface ?: Typeface.createFromAsset(context.assets, "fonts/Inter-Medium.otf")
typeface = interMedium
return interMedium
}
fun renderAvatar(context: Context, avatar: Avatar, onAvatarRendered: (Media) -> Unit, onRenderFailed: (Throwable?) -> Unit) {

View File

@@ -74,7 +74,7 @@ object Avatars {
"avatar_sunset" to DefaultAvatar(R.drawable.ic_avatar_sunset, "A120"),
"avatar_surfboard" to DefaultAvatar(R.drawable.ic_avatar_surfboard, "A110"),
"avatar_soccerball" to DefaultAvatar(R.drawable.ic_avatar_soccerball, "A130"),
"avatar_football" to DefaultAvatar(R.drawable.ic_avatar_football, "A220"),
"avatar_football" to DefaultAvatar(R.drawable.ic_avatar_football, "A220")
)
@DrawableRes

View File

@@ -16,6 +16,7 @@ import androidx.fragment.app.viewModels
import androidx.navigation.Navigation
import androidx.recyclerview.widget.RecyclerView
import org.signal.core.util.ThreadUtil
import org.signal.core.util.getParcelableExtraCompat
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.avatar.Avatar
import org.thoughtcrime.securesms.avatar.AvatarBundler
@@ -87,8 +88,9 @@ class AvatarPickerFragment : Fragment(R.layout.avatar_picker_fragment) {
val selectedPosition = items.indexOfFirst { it.isSelected }
adapter.submitList(items) {
if (selectedPosition > -1)
if (selectedPosition > -1) {
recycler.smoothScrollToPosition(selectedPosition)
}
}
}
@@ -146,10 +148,9 @@ class AvatarPickerFragment : Fragment(R.layout.avatar_picker_fragment) {
ViewUtil.hideKeyboard(requireContext(), requireView())
}
@Suppress("DEPRECATION")
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
if (requestCode == REQUEST_CODE_SELECT_IMAGE && resultCode == Activity.RESULT_OK && data != null) {
val media: Media = requireNotNull(data.getParcelableExtra(AvatarSelectionActivity.EXTRA_MEDIA))
val media: Media = requireNotNull(data.getParcelableExtraCompat(AvatarSelectionActivity.EXTRA_MEDIA, Media::class.java))
viewModel.onAvatarPhotoSelectionCompleted(media)
} else {
super.onActivityResult(requestCode, resultCode, data)

View File

@@ -5,7 +5,7 @@ import org.thoughtcrime.securesms.avatar.AvatarColorItem
import org.thoughtcrime.securesms.avatar.Avatars
data class TextAvatarCreationState(
val currentAvatar: Avatar.Text,
val currentAvatar: Avatar.Text
) {
fun colors(): List<AvatarColorItem> = Avatars.colors.map { AvatarColorItem(it, currentAvatar.color == it) }
}

View File

@@ -5,7 +5,7 @@ import org.thoughtcrime.securesms.avatar.AvatarColorItem
import org.thoughtcrime.securesms.avatar.Avatars
data class VectorAvatarCreationState(
val currentAvatar: Avatar.Vector,
val currentAvatar: Avatar.Vector
) {
fun colors(): List<AvatarColorItem> = Avatars.colors.map { AvatarColorItem(it, currentAvatar.color == it) }
}

View File

@@ -6,6 +6,8 @@ import org.signal.core.util.Conversions;
import org.signal.core.util.StreamUtil;
import org.signal.libsignal.protocol.kdf.HKDF;
import org.signal.libsignal.protocol.util.ByteUtil;
import org.thoughtcrime.securesms.backup.proto.BackupFrame;
import org.thoughtcrime.securesms.backup.proto.Header;
import java.io.IOException;
import java.io.InputStream;
@@ -45,21 +47,21 @@ class BackupRecordInputStream extends FullBackupBase.BackupStream {
byte[] headerFrame = new byte[headerLength];
StreamUtil.readFully(in, headerFrame);
BackupProtos.BackupFrame frame = BackupProtos.BackupFrame.parseFrom(headerFrame);
BackupFrame frame = BackupFrame.ADAPTER.decode(headerFrame);
if (!frame.hasHeader()) {
if (frame.header_ == null) {
throw new IOException("Backup stream does not start with header!");
}
BackupProtos.Header header = frame.getHeader();
Header header = frame.header_;
this.iv = header.getIv().toByteArray();
this.iv = header.iv.toByteArray();
if (iv.length != 16) {
throw new IOException("Invalid IV length!");
}
byte[] key = getBackupKey(passphrase, header.hasSalt() ? header.getSalt().toByteArray() : null);
byte[] key = getBackupKey(passphrase, header.salt != null ? header.salt.toByteArray() : null);
byte[] derived = HKDF.deriveSecrets(key, "Backup Export".getBytes(), 64);
byte[][] split = ByteUtil.split(derived, 32, 32);
@@ -76,7 +78,7 @@ class BackupRecordInputStream extends FullBackupBase.BackupStream {
}
}
BackupProtos.BackupFrame readFrame() throws IOException {
BackupFrame readFrame() throws IOException {
return readFrame(in);
}
@@ -128,7 +130,7 @@ class BackupRecordInputStream extends FullBackupBase.BackupStream {
}
}
private BackupProtos.BackupFrame readFrame(InputStream in) throws IOException {
private BackupFrame readFrame(InputStream in) throws IOException {
try {
byte[] length = new byte[4];
StreamUtil.readFully(in, length);
@@ -151,7 +153,7 @@ class BackupRecordInputStream extends FullBackupBase.BackupStream {
byte[] plaintext = cipher.doFinal(frame, 0, frame.length - 10);
return BackupProtos.BackupFrame.parseFrom(plaintext);
return BackupFrame.ADAPTER.decode(plaintext);
} catch (InvalidKeyException | InvalidAlgorithmParameterException | IllegalBlockSizeException | BadPaddingException e) {
throw new AssertionError(e);
}

View File

@@ -2,7 +2,10 @@ package org.thoughtcrime.securesms.backup
import org.greenrobot.eventbus.EventBus
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.backup.BackupProtos.BackupFrame
import org.thoughtcrime.securesms.backup.proto.Attachment
import org.thoughtcrime.securesms.backup.proto.Avatar
import org.thoughtcrime.securesms.backup.proto.BackupFrame
import org.thoughtcrime.securesms.backup.proto.Sticker
import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
@@ -15,38 +18,42 @@ object BackupVerifier {
private val TAG = Log.tag(BackupVerifier::class.java)
@JvmStatic
@Throws(IOException::class)
fun verifyFile(cipherStream: InputStream, passphrase: String, expectedCount: Long): Boolean {
@Throws(IOException::class, FullBackupExporter.BackupCanceledException::class)
fun verifyFile(cipherStream: InputStream, passphrase: String, expectedCount: Long, cancellationSignal: FullBackupExporter.BackupCancellationSignal): Boolean {
val inputStream = BackupRecordInputStream(cipherStream, passphrase)
var count = 0L
var frame: BackupFrame = inputStream.readFrame()
while (!frame.end) {
val verified = when {
frame.hasAttachment() -> verifyAttachment(frame.attachment, inputStream)
frame.hasSticker() -> verifySticker(frame.sticker, inputStream)
frame.hasAvatar() -> verifyAvatar(frame.avatar, inputStream)
else -> true
cipherStream.use {
while (frame.end != true && !cancellationSignal.isCanceled) {
val verified = when {
frame.attachment != null -> verifyAttachment(frame.attachment!!, inputStream)
frame.sticker != null -> verifySticker(frame.sticker!!, inputStream)
frame.avatar != null -> verifyAvatar(frame.avatar!!, inputStream)
else -> true
}
if (!verified) {
return false
}
EventBus.getDefault().post(BackupEvent(BackupEvent.Type.PROGRESS_VERIFYING, ++count, expectedCount))
frame = inputStream.readFrame()
}
if (!verified) {
return false
}
EventBus.getDefault().post(BackupEvent(BackupEvent.Type.PROGRESS_VERIFYING, ++count, expectedCount))
frame = inputStream.readFrame()
}
cipherStream.close()
if (cancellationSignal.isCanceled) {
throw FullBackupExporter.BackupCanceledException()
}
return true
}
private fun verifyAttachment(attachment: BackupProtos.Attachment, inputStream: BackupRecordInputStream): Boolean {
private fun verifyAttachment(attachment: Attachment, inputStream: BackupRecordInputStream): Boolean {
try {
inputStream.readAttachmentTo(NullOutputStream, attachment.length)
inputStream.readAttachmentTo(NullOutputStream, attachment.length ?: 0)
} catch (e: IOException) {
Log.w(TAG, "Bad attachment id: ${attachment.attachmentId} len: ${attachment.length}", e)
return false
@@ -55,9 +62,9 @@ object BackupVerifier {
return true
}
private fun verifySticker(sticker: BackupProtos.Sticker, inputStream: BackupRecordInputStream): Boolean {
private fun verifySticker(sticker: Sticker, inputStream: BackupRecordInputStream): Boolean {
try {
inputStream.readAttachmentTo(NullOutputStream, sticker.length)
inputStream.readAttachmentTo(NullOutputStream, sticker.length ?: 0)
} catch (e: IOException) {
Log.w(TAG, "Bad sticker id: ${sticker.rowId} len: ${sticker.length}", e)
return false
@@ -65,9 +72,9 @@ object BackupVerifier {
return true
}
private fun verifyAvatar(avatar: BackupProtos.Avatar, inputStream: BackupRecordInputStream): Boolean {
private fun verifyAvatar(avatar: Avatar, inputStream: BackupRecordInputStream): Boolean {
try {
inputStream.readAttachmentTo(NullOutputStream, avatar.length)
inputStream.readAttachmentTo(NullOutputStream, avatar.length ?: 0)
} catch (e: IOException) {
Log.w(TAG, "Bad avatar id: ${avatar.recipientId} len: ${avatar.length}", e)
return false

View File

@@ -12,7 +12,6 @@ import androidx.annotation.VisibleForTesting;
import androidx.documentfile.provider.DocumentFile;
import com.annimon.stream.function.Predicate;
import com.google.protobuf.ByteString;
import net.zetetic.database.sqlcipher.SQLiteDatabase;
@@ -26,6 +25,15 @@ import org.signal.core.util.logging.Log;
import org.signal.libsignal.protocol.kdf.HKDF;
import org.signal.libsignal.protocol.util.ByteUtil;
import org.thoughtcrime.securesms.attachments.AttachmentId;
import org.thoughtcrime.securesms.backup.proto.Attachment;
import org.thoughtcrime.securesms.backup.proto.Avatar;
import org.thoughtcrime.securesms.backup.proto.BackupFrame;
import org.thoughtcrime.securesms.backup.proto.DatabaseVersion;
import org.thoughtcrime.securesms.backup.proto.Header;
import org.thoughtcrime.securesms.backup.proto.KeyValue;
import org.thoughtcrime.securesms.backup.proto.SharedPreference;
import org.thoughtcrime.securesms.backup.proto.SqlStatement;
import org.thoughtcrime.securesms.backup.proto.Sticker;
import org.thoughtcrime.securesms.crypto.AttachmentSecret;
import org.thoughtcrime.securesms.crypto.ClassicDecryptingPartInputStream;
import org.thoughtcrime.securesms.crypto.ModernDecryptingPartInputStream;
@@ -80,6 +88,8 @@ import javax.crypto.NoSuchPaddingException;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import okio.ByteString;
public class FullBackupExporter extends FullBackupBase {
private static final String TAG = Log.tag(FullBackupExporter.class);
@@ -97,7 +107,7 @@ public class FullBackupExporter extends FullBackupBase {
SignedPreKeyTable.TABLE_NAME,
OneTimePreKeyTable.TABLE_NAME,
SessionTable.TABLE_NAME,
SearchTable.MMS_FTS_TABLE_NAME,
SearchTable.FTS_TABLE_NAME,
EmojiSearchTable.TABLE_NAME,
SenderKeyTable.TABLE_NAME,
SenderKeySharedTable.TABLE_NAME,
@@ -187,7 +197,7 @@ public class FullBackupExporter extends FullBackupBase {
stopwatch.split("table::" + table);
}
for (BackupProtos.SharedPreference preference : TextSecurePreferences.getPreferencesToSaveToBackup(context)) {
for (SharedPreference preference : TextSecurePreferences.getPreferencesToSaveToBackup(context)) {
throwIfCanceled(cancellationSignal);
EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, ++count, estimatedCount));
outputStream.write(preference);
@@ -287,7 +297,7 @@ public class FullBackupExporter extends FullBackupBase {
String statement = createStatementsByTable.get(table);
if (statement != null) {
outputStream.write(BackupProtos.SqlStatement.newBuilder().setStatement(statement).build());
outputStream.write(new SqlStatement.Builder().statement(statement).build());
} else {
throw new IOException("Failed to find a create statement for table: " + table);
}
@@ -299,7 +309,7 @@ public class FullBackupExporter extends FullBackupBase {
String name = cursor.getString(1);
if (isTableAllowed(name)) {
outputStream.write(BackupProtos.SqlStatement.newBuilder().setStatement(sql).build());
outputStream.write(new SqlStatement.Builder().statement(sql).build());
}
}
}
@@ -368,7 +378,7 @@ public class FullBackupExporter extends FullBackupBase {
}
boolean isReservedTable = table.startsWith("sqlite_");
boolean isMmsFtsSecretTable = !table.equals(SearchTable.MMS_FTS_TABLE_NAME) && table.startsWith(SearchTable.MMS_FTS_TABLE_NAME);
boolean isMmsFtsSecretTable = !table.equals(SearchTable.FTS_TABLE_NAME) && table.startsWith(SearchTable.FTS_TABLE_NAME);
boolean isEmojiFtsSecretTable = !table.equals(EmojiSearchTable.TABLE_NAME) && table.startsWith(EmojiSearchTable.TABLE_NAME);
return !isReservedTable &&
@@ -393,8 +403,10 @@ public class FullBackupExporter extends FullBackupBase {
throwIfCanceled(cancellationSignal);
if (predicate == null || predicate.test(cursor)) {
StringBuilder statement = new StringBuilder(template);
BackupProtos.SqlStatement.Builder statementBuilder = BackupProtos.SqlStatement.newBuilder();
StringBuilder statement = new StringBuilder(template);
SqlStatement.Builder statementBuilder = new SqlStatement.Builder();
statementBuilder.parameters = new ArrayList<>();
statement.append('(');
@@ -402,15 +414,15 @@ public class FullBackupExporter extends FullBackupBase {
statement.append('?');
if (cursor.getType(i) == Cursor.FIELD_TYPE_STRING) {
statementBuilder.addParameters(BackupProtos.SqlStatement.SqlParameter.newBuilder().setStringParamter(cursor.getString(i)));
statementBuilder.parameters.add(new SqlStatement.SqlParameter.Builder().stringParamter(cursor.getString(i)).build());
} else if (cursor.getType(i) == Cursor.FIELD_TYPE_FLOAT) {
statementBuilder.addParameters(BackupProtos.SqlStatement.SqlParameter.newBuilder().setDoubleParameter(cursor.getDouble(i)));
statementBuilder.parameters.add(new SqlStatement.SqlParameter.Builder().doubleParameter(cursor.getDouble(i)).build());
} else if (cursor.getType(i) == Cursor.FIELD_TYPE_INTEGER) {
statementBuilder.addParameters(BackupProtos.SqlStatement.SqlParameter.newBuilder().setIntegerParameter(cursor.getLong(i)));
statementBuilder.parameters.add(new SqlStatement.SqlParameter.Builder().integerParameter(cursor.getLong(i)).build());
} else if (cursor.getType(i) == Cursor.FIELD_TYPE_BLOB) {
statementBuilder.addParameters(BackupProtos.SqlStatement.SqlParameter.newBuilder().setBlobParameter(ByteString.copyFrom(cursor.getBlob(i))));
statementBuilder.parameters.add(new SqlStatement.SqlParameter.Builder().blobParameter(new ByteString(cursor.getBlob(i))).build());
} else if (cursor.getType(i) == Cursor.FIELD_TYPE_NULL) {
statementBuilder.addParameters(BackupProtos.SqlStatement.SqlParameter.newBuilder().setNullparameter(true));
statementBuilder.parameters.add(new SqlStatement.SqlParameter.Builder().nullparameter(true).build());
} else {
throw new AssertionError("unknown type?" + cursor.getType(i));
}
@@ -423,7 +435,7 @@ public class FullBackupExporter extends FullBackupBase {
statement.append(')');
EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, ++count, estimatedCount));
outputStream.write(statementBuilder.setStatement(statement.toString()).build());
outputStream.write(statementBuilder.statement(statement.toString()).build());
if (postProcess != null) {
count = postProcess.postProcess(cursor, count);
@@ -539,29 +551,30 @@ public class FullBackupExporter extends FullBackupBase {
if (!dataSet.containsKey(key)) {
continue;
}
BackupProtos.KeyValue.Builder builder = BackupProtos.KeyValue.newBuilder()
.setKey(key);
KeyValue.Builder builder = new KeyValue.Builder()
.key(key);
Class<?> type = dataSet.getType(key);
if (type == byte[].class) {
byte[] data = dataSet.getBlob(key, null);
if (data != null) {
builder.setBlobValue(ByteString.copyFrom(dataSet.getBlob(key, null)));
builder.blobValue(new ByteString(dataSet.getBlob(key, null)));
} else {
Log.w(TAG, "Skipping storing null blob for key: " + key);
}
} else if (type == Boolean.class) {
builder.setBooleanValue(dataSet.getBoolean(key, false));
builder.booleanValue(dataSet.getBoolean(key, false));
} else if (type == Float.class) {
builder.setFloatValue(dataSet.getFloat(key, 0));
builder.floatValue(dataSet.getFloat(key, 0));
} else if (type == Integer.class) {
builder.setIntegerValue(dataSet.getInteger(key, 0));
builder.integerValue(dataSet.getInteger(key, 0));
} else if (type == Long.class) {
builder.setLongValue(dataSet.getLong(key, 0));
builder.longValue(dataSet.getLong(key, 0));
} else if (type == String.class) {
String data = dataSet.getString(key, null);
if (data != null) {
builder.setStringValue(dataSet.getString(key, null));
builder.stringValue(dataSet.getString(key, null));
} else {
Log.w(TAG, "Skipping storing null string for key: " + key);
}
@@ -631,10 +644,12 @@ public class FullBackupExporter extends FullBackupBase {
mac.init(new SecretKeySpec(macKey, "HmacSHA256"));
byte[] header = BackupProtos.BackupFrame.newBuilder().setHeader(BackupProtos.Header.newBuilder()
.setIv(ByteString.copyFrom(iv))
.setSalt(ByteString.copyFrom(salt)))
.build().toByteArray();
byte[] header = new BackupFrame.Builder().header_(new Header.Builder()
.iv(new okio.ByteString(iv))
.salt(new okio.ByteString(salt))
.build())
.build()
.encode();
outputStream.write(Conversions.intToByteArray(header.length));
outputStream.write(header);
@@ -643,26 +658,26 @@ public class FullBackupExporter extends FullBackupBase {
}
}
public void write(BackupProtos.SharedPreference preference) throws IOException {
write(outputStream, BackupProtos.BackupFrame.newBuilder().setPreference(preference).build());
public void write(SharedPreference preference) throws IOException {
write(outputStream, new BackupFrame.Builder().preference(preference).build());
}
public void write(BackupProtos.KeyValue keyValue) throws IOException {
write(outputStream, BackupProtos.BackupFrame.newBuilder().setKeyValue(keyValue).build());
public void write(KeyValue keyValue) throws IOException {
write(outputStream, new BackupFrame.Builder().keyValue(keyValue).build());
}
public void write(BackupProtos.SqlStatement statement) throws IOException {
write(outputStream, BackupProtos.BackupFrame.newBuilder().setStatement(statement).build());
public void write(SqlStatement statement) throws IOException {
write(outputStream, new BackupFrame.Builder().statement(statement).build());
}
public void write(@NonNull String avatarName, @NonNull InputStream in, long size) throws IOException {
try {
write(outputStream, BackupProtos.BackupFrame.newBuilder()
.setAvatar(BackupProtos.Avatar.newBuilder()
.setRecipientId(avatarName)
.setLength(Util.toIntExact(size))
.build())
.build());
write(outputStream, new BackupFrame.Builder()
.avatar(new Avatar.Builder()
.recipientId(avatarName)
.length(Util.toIntExact(size))
.build())
.build());
} catch (ArithmeticException e) {
Log.w(TAG, "Unable to write avatar to backup", e);
throw new InvalidBackupStreamException();
@@ -675,13 +690,13 @@ public class FullBackupExporter extends FullBackupBase {
public void write(@NonNull AttachmentId attachmentId, @NonNull InputStream in, long size) throws IOException {
try {
write(outputStream, BackupProtos.BackupFrame.newBuilder()
.setAttachment(BackupProtos.Attachment.newBuilder()
.setRowId(attachmentId.getRowId())
.setAttachmentId(attachmentId.getUniqueId())
.setLength(Util.toIntExact(size))
.build())
.build());
write(outputStream, new BackupFrame.Builder()
.attachment(new Attachment.Builder()
.rowId(attachmentId.getRowId())
.attachmentId(attachmentId.getUniqueId())
.length(Util.toIntExact(size))
.build())
.build());
} catch (ArithmeticException e) {
Log.w(TAG, "Unable to write " + attachmentId + " to backup", e);
throw new InvalidBackupStreamException();
@@ -694,12 +709,12 @@ public class FullBackupExporter extends FullBackupBase {
public void writeSticker(long rowId, @NonNull InputStream in, long size) throws IOException {
try {
write(outputStream, BackupProtos.BackupFrame.newBuilder()
.setSticker(BackupProtos.Sticker.newBuilder()
.setRowId(rowId)
.setLength(Util.toIntExact(size))
.build())
.build());
write(outputStream, new BackupFrame.Builder()
.sticker(new Sticker.Builder()
.rowId(rowId)
.length(Util.toIntExact(size))
.build())
.build());
} catch (ArithmeticException e) {
Log.w(TAG, "Unable to write sticker to backup", e);
throw new InvalidBackupStreamException();
@@ -711,13 +726,13 @@ public class FullBackupExporter extends FullBackupBase {
}
void writeDatabaseVersion(int version) throws IOException {
write(outputStream, BackupProtos.BackupFrame.newBuilder()
.setVersion(BackupProtos.DatabaseVersion.newBuilder().setVersion(version))
.build());
write(outputStream, new BackupFrame.Builder()
.version(new DatabaseVersion.Builder().version(version).build())
.build());
}
void writeEnd() throws IOException {
write(outputStream, BackupProtos.BackupFrame.newBuilder().setEnd(true).build());
write(outputStream, new BackupFrame.Builder().end(true).build());
}
/**
@@ -758,12 +773,12 @@ public class FullBackupExporter extends FullBackupBase {
}
}
private void write(@NonNull OutputStream out, @NonNull BackupProtos.BackupFrame frame) throws IOException {
private void write(@NonNull OutputStream out, @NonNull BackupFrame frame) throws IOException {
try {
Conversions.intToByteArray(iv, 0, counter++);
cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(cipherKey, "AES"), new IvParameterSpec(iv));
byte[] frameCiphertext = cipher.doFinal(frame.toByteArray());
byte[] frameCiphertext = cipher.doFinal(frame.encode());
byte[] frameMac = mac.doFinal(frameCiphertext);
byte[] length = Conversions.intToByteArray(frameCiphertext.length + 10);

View File

@@ -11,17 +11,20 @@ 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;
import org.signal.core.util.SqlUtil;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.backup.BackupProtos.Attachment;
import org.thoughtcrime.securesms.backup.BackupProtos.BackupFrame;
import org.thoughtcrime.securesms.backup.BackupProtos.DatabaseVersion;
import org.thoughtcrime.securesms.backup.BackupProtos.SharedPreference;
import org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement;
import org.thoughtcrime.securesms.backup.BackupProtos.Sticker;
import org.thoughtcrime.securesms.backup.proto.Attachment;
import org.thoughtcrime.securesms.backup.proto.Avatar;
import org.thoughtcrime.securesms.backup.proto.BackupFrame;
import org.thoughtcrime.securesms.backup.proto.DatabaseVersion;
import org.thoughtcrime.securesms.backup.proto.KeyValue;
import org.thoughtcrime.securesms.backup.proto.SharedPreference;
import org.thoughtcrime.securesms.backup.proto.SqlStatement;
import org.thoughtcrime.securesms.backup.proto.Sticker;
import org.thoughtcrime.securesms.crypto.AttachmentSecret;
import org.thoughtcrime.securesms.crypto.ModernEncryptingPartOutputStream;
import org.thoughtcrime.securesms.database.AttachmentTable;
@@ -86,17 +89,17 @@ public class FullBackupImporter extends FullBackupBase {
BackupFrame frame;
while (!(frame = inputStream.readFrame()).getEnd()) {
while ((frame = inputStream.readFrame()).end != Boolean.TRUE) {
if (count % 100 == 0) EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, count, 0));
count++;
if (frame.hasVersion()) processVersion(db, frame.getVersion());
else if (frame.hasStatement()) processStatement(db, frame.getStatement());
else if (frame.hasPreference()) processPreference(context, frame.getPreference());
else if (frame.hasAttachment()) processAttachment(context, attachmentSecret, db, frame.getAttachment(), inputStream);
else if (frame.hasSticker()) processSticker(context, attachmentSecret, db, frame.getSticker(), inputStream);
else if (frame.hasAvatar()) processAvatar(context, db, frame.getAvatar(), inputStream);
else if (frame.hasKeyValue()) processKeyValue(frame.getKeyValue());
if (frame.version != null) processVersion(db, frame.version);
else if (frame.statement != null) tryProcessStatement(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);
else if (frame.avatar != null) processAvatar(context, db, frame.avatar, inputStream);
else if (frame.keyValue != null) processKeyValue(frame.keyValue);
else count--;
}
@@ -119,35 +122,65 @@ public class FullBackupImporter extends FullBackupBase {
}
private static void processVersion(@NonNull SQLiteDatabase db, DatabaseVersion version) throws IOException {
if (version.getVersion() > db.getVersion()) {
throw new DatabaseDowngradeException(db.getVersion(), version.getVersion());
if (version.version == null || version.version > db.getVersion()) {
throw new DatabaseDowngradeException(db.getVersion(), version.version != null ? version.version : -1);
}
db.setVersion(version.getVersion());
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) {
boolean isForMmsFtsSecretTable = statement.getStatement().contains(SearchTable.MMS_FTS_TABLE_NAME + "_");
boolean isForEmojiSecretTable = statement.getStatement().contains(EmojiSearchTable.TABLE_NAME + "_");
boolean isForSqliteSecretTable = statement.getStatement().toLowerCase().startsWith("create table sqlite_");
if (statement.statement == null) {
Log.w(TAG, "Null statement!");
return;
}
boolean isForMmsFtsSecretTable = statement.statement.contains(SearchTable.FTS_TABLE_NAME + "_");
boolean isForEmojiSecretTable = statement.statement.contains(EmojiSearchTable.TABLE_NAME + "_");
boolean isForSqliteSecretTable = statement.statement.toLowerCase().startsWith("create table sqlite_");
if (isForMmsFtsSecretTable || isForEmojiSecretTable || isForSqliteSecretTable) {
Log.i(TAG, "Ignoring import for statement: " + statement.getStatement());
Log.i(TAG, "Ignoring import for statement: " + statement.statement);
return;
}
List<Object> parameters = new LinkedList<>();
for (SqlStatement.SqlParameter parameter : statement.getParametersList()) {
if (parameter.hasStringParamter()) parameters.add(parameter.getStringParamter());
else if (parameter.hasDoubleParameter()) parameters.add(parameter.getDoubleParameter());
else if (parameter.hasIntegerParameter()) parameters.add(parameter.getIntegerParameter());
else if (parameter.hasBlobParameter()) parameters.add(parameter.getBlobParameter().toByteArray());
else if (parameter.hasNullparameter()) parameters.add(null);
for (SqlStatement.SqlParameter parameter : statement.parameters) {
if (parameter.stringParamter != null) parameters.add(parameter.stringParamter);
else if (parameter.doubleParameter != null) parameters.add(parameter.doubleParameter);
else if (parameter.integerParameter != null) parameters.add(parameter.integerParameter);
else if (parameter.blobParameter != null) parameters.add(parameter.blobParameter.toByteArray());
else if (parameter.nullparameter != null) parameters.add(null);
}
if (parameters.size() > 0) db.execSQL(statement.getStatement(), parameters.toArray());
else db.execSQL(statement.getStatement());
if (parameters.size() > 0) db.execSQL(statement.statement, parameters.toArray());
else db.execSQL(statement.statement);
}
private static void processAttachment(@NonNull Context context, @NonNull AttachmentSecret attachmentSecret, @NonNull SQLiteDatabase db, @NonNull Attachment attachment, BackupRecordInputStream inputStream)
@@ -159,12 +192,12 @@ public class FullBackupImporter extends FullBackupBase {
ContentValues contentValues = new ContentValues();
try {
inputStream.readAttachmentTo(output.second, attachment.getLength());
inputStream.readAttachmentTo(output.second, attachment.length);
contentValues.put(AttachmentTable.DATA, dataFile.getAbsolutePath());
contentValues.put(AttachmentTable.DATA_RANDOM, output.first);
} catch (BackupRecordInputStream.BadMacException e) {
Log.w(TAG, "Bad MAC for attachment " + attachment.getAttachmentId() + "! Can't restore it.", e);
Log.w(TAG, "Bad MAC for attachment " + attachment.attachmentId + "! Can't restore it.", e);
dataFile.delete();
contentValues.put(AttachmentTable.DATA, (String) null);
contentValues.put(AttachmentTable.DATA_RANDOM, (String) null);
@@ -172,7 +205,7 @@ public class FullBackupImporter extends FullBackupBase {
db.update(AttachmentTable.TABLE_NAME, contentValues,
AttachmentTable.ROW_ID + " = ? AND " + AttachmentTable.UNIQUE_ID + " = ?",
new String[] {String.valueOf(attachment.getRowId()), String.valueOf(attachment.getAttachmentId())});
new String[] {String.valueOf(attachment.rowId), String.valueOf(attachment.attachmentId)});
}
private static void processSticker(@NonNull Context context, @NonNull AttachmentSecret attachmentSecret, @NonNull SQLiteDatabase db, @NonNull Sticker sticker, BackupRecordInputStream inputStream)
@@ -183,52 +216,57 @@ public class FullBackupImporter extends FullBackupBase {
Pair<byte[], OutputStream> output = ModernEncryptingPartOutputStream.createFor(attachmentSecret, dataFile, false);
inputStream.readAttachmentTo(output.second, sticker.getLength());
inputStream.readAttachmentTo(output.second, sticker.length);
ContentValues contentValues = new ContentValues();
contentValues.put(StickerTable.FILE_PATH, dataFile.getAbsolutePath());
contentValues.put(StickerTable.FILE_LENGTH, sticker.getLength());
contentValues.put(StickerTable.FILE_LENGTH, sticker.length);
contentValues.put(StickerTable.FILE_RANDOM, output.first);
db.update(StickerTable.TABLE_NAME, contentValues,
StickerTable._ID + " = ?",
new String[] {String.valueOf(sticker.getRowId())});
new String[] {String.valueOf(sticker.rowId)});
}
private static void processAvatar(@NonNull Context context, @NonNull SQLiteDatabase db, @NonNull BackupProtos.Avatar avatar, @NonNull BackupRecordInputStream inputStream) throws IOException {
if (avatar.hasRecipientId()) {
RecipientId recipientId = RecipientId.from(avatar.getRecipientId());
inputStream.readAttachmentTo(AvatarHelper.getOutputStream(context, recipientId, false), avatar.getLength());
private static void processAvatar(@NonNull Context context, @NonNull SQLiteDatabase db, @NonNull Avatar avatar, @NonNull BackupRecordInputStream inputStream) throws IOException {
if (avatar.recipientId != null) {
RecipientId recipientId = RecipientId.from(avatar.recipientId);
inputStream.readAttachmentTo(AvatarHelper.getOutputStream(context, recipientId, false), avatar.length);
} else {
if (avatar.hasName() && SqlUtil.tableExists(db, "recipient_preferences")) {
if (avatar.name != null && SqlUtil.tableExists(db, "recipient_preferences")) {
Log.w(TAG, "Avatar is missing a recipientId. Clearing signal_profile_avatar (legacy) so it can be fetched later.");
db.execSQL("UPDATE recipient_preferences SET signal_profile_avatar = NULL WHERE recipient_ids = ?", new String[] { avatar.getName() });
} else if (avatar.hasName() && SqlUtil.tableExists(db, "recipient")) {
db.execSQL("UPDATE recipient_preferences SET signal_profile_avatar = NULL WHERE recipient_ids = ?", new String[] { avatar.name });
} else if (avatar.name != null && SqlUtil.tableExists(db, "recipient")) {
Log.w(TAG, "Avatar is missing a recipientId. Clearing signal_profile_avatar so it can be fetched later.");
db.execSQL("UPDATE recipient SET signal_profile_avatar = NULL WHERE phone = ?", new String[] { avatar.getName() });
db.execSQL("UPDATE recipient SET signal_profile_avatar = NULL WHERE phone = ?", new String[] { avatar.name });
} else {
Log.w(TAG, "Avatar is missing a recipientId. Skipping avatar restore.");
}
inputStream.readAttachmentTo(new ByteArrayOutputStream(), avatar.getLength());
inputStream.readAttachmentTo(new ByteArrayOutputStream(), avatar.length);
}
}
private static void processKeyValue(BackupProtos.KeyValue keyValue) {
private static void processKeyValue(KeyValue keyValue) {
KeyValueDataSet dataSet = new KeyValueDataSet();
if (keyValue.hasBlobValue()) {
dataSet.putBlob(keyValue.getKey(), keyValue.getBlobValue().toByteArray());
} else if (keyValue.hasBooleanValue()) {
dataSet.putBoolean(keyValue.getKey(), keyValue.getBooleanValue());
} else if (keyValue.hasFloatValue()) {
dataSet.putFloat(keyValue.getKey(), keyValue.getFloatValue());
} else if (keyValue.hasIntegerValue()) {
dataSet.putInteger(keyValue.getKey(), keyValue.getIntegerValue());
} else if (keyValue.hasLongValue()) {
dataSet.putLong(keyValue.getKey(), keyValue.getLongValue());
} else if (keyValue.hasStringValue()) {
dataSet.putString(keyValue.getKey(), keyValue.getStringValue());
if (keyValue.key == null) {
Log.w(TAG, "Null preference key!");
return;
}
if (keyValue.blobValue != null) {
dataSet.putBlob(keyValue.key, keyValue.blobValue.toByteArray());
} else if (keyValue.booleanValue != null) {
dataSet.putBoolean(keyValue.key, keyValue.booleanValue);
} else if (keyValue.floatValue != null) {
dataSet.putFloat(keyValue.key, keyValue.floatValue);
} else if (keyValue.integerValue != null) {
dataSet.putInteger(keyValue.key, keyValue.integerValue);
} else if (keyValue.longValue != null) {
dataSet.putLong(keyValue.key, keyValue.longValue);
} else if (keyValue.stringValue != null) {
dataSet.putString(keyValue.key, keyValue.stringValue);
} else {
Log.i(TAG, "Unknown KeyValue backup value, skipping");
return;
@@ -239,25 +277,25 @@ public class FullBackupImporter extends FullBackupBase {
@SuppressLint("ApplySharedPref")
private static void processPreference(@NonNull Context context, SharedPreference preference) {
SharedPreferences preferences = context.getSharedPreferences(preference.getFile(), 0);
SharedPreferences preferences = context.getSharedPreferences(preference.file_, 0);
// Identity keys were moved from shared prefs into SignalStore. Need to handle importing backups made before the migration.
if ("SecureSMS-Preferences".equals(preference.getFile())) {
if ("pref_identity_public_v3".equals(preference.getKey()) && preference.hasValue()) {
SignalStore.account().restoreLegacyIdentityPublicKeyFromBackup(preference.getValue());
} else if ("pref_identity_private_v3".equals(preference.getKey()) && preference.hasValue()) {
SignalStore.account().restoreLegacyIdentityPrivateKeyFromBackup(preference.getValue());
if ("SecureSMS-Preferences".equals(preference.file_)) {
if ("pref_identity_public_v3".equals(preference.key) && preference.value_ != null) {
SignalStore.account().restoreLegacyIdentityPublicKeyFromBackup(preference.value_);
} else if ("pref_identity_private_v3".equals(preference.key) && preference.value_ != null) {
SignalStore.account().restoreLegacyIdentityPrivateKeyFromBackup(preference.value_);
}
return;
}
if (preference.hasValue()) {
preferences.edit().putString(preference.getKey(), preference.getValue()).commit();
} else if (preference.hasBooleanValue()) {
preferences.edit().putBoolean(preference.getKey(), preference.getBooleanValue()).commit();
} else if (preference.hasIsStringSetValue() && preference.getIsStringSetValue()) {
preferences.edit().putStringSet(preference.getKey(), new HashSet<>(preference.getStringSetValueList())).commit();
if (preference.value_ != null) {
preferences.edit().putString(preference.key, preference.value_).commit();
} else if (preference.booleanValue != null) {
preferences.edit().putBoolean(preference.key, preference.booleanValue).commit();
} else if (preference.isStringSetValue == Boolean.TRUE) {
preferences.edit().putStringSet(preference.key, new HashSet<>(preference.stringSetValue)).commit();
}
}

View File

@@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.badges.gifts
import android.os.Bundle
import androidx.fragment.app.FragmentManager
import org.signal.core.util.getParcelableCompat
import org.thoughtcrime.securesms.badges.gifts.ExpiredGiftSheetConfiguration.forExpiredBadge
import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
@@ -28,7 +29,7 @@ class ExpiredGiftSheet : DSLSettingsBottomSheetFragment() {
}
private val badge: Badge
get() = requireArguments().getParcelable(ARG_BADGE)!!
get() = requireArguments().getParcelableCompat(ARG_BADGE, Badge::class.java)!!
override fun bindAdapter(adapter: DSLSettingsAdapter) {
ExpiredGiftSheetConfiguration.register(adapter)

View File

@@ -31,7 +31,8 @@ object ExpiredGiftSheetConfiguration {
textPref(
title = DSLSettingsText.from(
stringId = R.string.ExpiredGiftSheetConfiguration__your_badge_has_expired,
DSLSettingsText.CenterModifier, DSLSettingsText.TitleLargeModifier
DSLSettingsText.CenterModifier,
DSLSettingsText.TitleLargeModifier
)
)

View File

@@ -58,7 +58,7 @@ class GiftMessageView @JvmOverloads constructor(
if (isOutgoing) {
actionView.setText(R.string.GiftMessageView__view)
titleView.text = context.getString(R.string.GiftMessageView__donation_to_s, recipient.getDisplayName(context))
titleView.text = context.getString(R.string.GiftMessageView__donation_on_behalf_of_s, recipient.getDisplayName(context))
} else {
titleView.text = context.getString(R.string.GiftMessageView__s_donated_to_signal_on, recipient.getShortDisplayName(context))
when (giftBadge.redemptionState) {

View File

@@ -9,6 +9,7 @@ import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController
import org.signal.core.util.getParcelableArrayListCompat
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.contacts.paged.ContactSearchConfiguration
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey
@@ -79,8 +80,7 @@ class GiftFlowRecipientSelectionFragment : Fragment(R.layout.gift_flow_recipient
override fun onSearchInputFocused() = Unit
override fun setResult(bundle: Bundle) {
val parcelableContacts: List<ContactSearchKey.ParcelableRecipientSearchKey> = bundle.getParcelableArrayList(MultiselectForwardFragment.RESULT_SELECTION)!!
val contacts = parcelableContacts.map { it.asRecipientSearchKey() }
val contacts: List<ContactSearchKey.RecipientSearchKey> = bundle.getParcelableArrayListCompat(MultiselectForwardFragment.RESULT_SELECTION, ContactSearchKey.RecipientSearchKey::class.java)!!
if (contacts.isNotEmpty()) {
viewModel.setSelectedContact(contacts.first())

View File

@@ -117,7 +117,7 @@ class GiftFlowViewModel(
private fun getLoadState(
oldState: GiftFlowState,
giftPrices: Map<Currency, FiatMoney>? = null,
giftBadge: Badge? = null,
giftBadge: Badge? = null
): GiftFlowState.Stage {
if (oldState.stage != GiftFlowState.Stage.INIT) {
return oldState.stage

View File

@@ -4,6 +4,7 @@ import android.os.Bundle
import androidx.fragment.app.FragmentManager
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import org.signal.core.util.DimensionUnit
import org.signal.core.util.getParcelableCompat
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.badges.models.BadgePreview
@@ -41,10 +42,10 @@ class GiftThanksSheet : DSLSettingsBottomSheetFragment() {
private val lifecycleDisposable = LifecycleDisposable()
private val recipientId: RecipientId
get() = requireArguments().getParcelable(ARGS_RECIPIENT_ID)!!
get() = requireArguments().getParcelableCompat(ARGS_RECIPIENT_ID, RecipientId::class.java)!!
private val badge: Badge
get() = requireArguments().getParcelable(ARGS_BADGE)!!
get() = requireArguments().getParcelableCompat(ARGS_BADGE, Badge::class.java)!!
override fun bindAdapter(adapter: DSLSettingsAdapter) {
BadgePreview.register(adapter)

View File

@@ -11,6 +11,7 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.kotlin.subscribeBy
import org.signal.core.util.DimensionUnit
import org.signal.core.util.getParcelableCompat
import org.signal.core.util.logging.Log
import org.signal.libsignal.zkgroup.InvalidInputException
import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialPresentation
@@ -73,7 +74,7 @@ class ViewReceivedGiftBottomSheet : DSLSettingsBottomSheetFragment() {
private val lifecycleDisposable = LifecycleDisposable()
private val sentFrom: RecipientId
get() = requireArguments().getParcelable(ARG_SENT_FROM)!!
get() = requireArguments().getParcelableCompat(ARG_SENT_FROM, RecipientId::class.java)!!
private val messageId: Long
get() = requireArguments().getLong(ARG_MESSAGE_ID)
@@ -123,7 +124,8 @@ class ViewReceivedGiftBottomSheet : DSLSettingsBottomSheetFragment() {
}
errorDialog = DonationErrorDialogs.show(
requireContext(), throwable,
requireContext(),
throwable,
object : DonationErrorDialogs.DialogCallback() {
override fun onDialogDismissed() {
findNavController().popBackStack()
@@ -158,7 +160,8 @@ class ViewReceivedGiftBottomSheet : DSLSettingsBottomSheetFragment() {
noPadTextPref(
title = DSLSettingsText.from(
charSequence = requireContext().getString(R.string.ViewReceivedGiftBottomSheet__s_made_a_donation_for_you, state.recipient.getShortDisplayName(requireContext())),
DSLSettingsText.CenterModifier, DSLSettingsText.TitleLargeModifier
DSLSettingsText.CenterModifier,
DSLSettingsText.TitleLargeModifier
)
)

View File

@@ -5,6 +5,7 @@ import androidx.fragment.app.FragmentManager
import androidx.fragment.app.viewModels
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import org.signal.core.util.DimensionUnit
import org.signal.core.util.getParcelableCompat
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.badges.gifts.viewgift.ViewGiftRepository
import org.thoughtcrime.securesms.badges.models.BadgeDisplay112
@@ -41,7 +42,7 @@ class ViewSentGiftBottomSheet : DSLSettingsBottomSheetFragment() {
}
private val sentTo: RecipientId
get() = requireArguments().getParcelable(ARG_SENT_TO)!!
get() = requireArguments().getParcelableCompat(ARG_SENT_TO, RecipientId::class.java)!!
private val giftBadge: GiftBadge
get() = GiftBadge.parseFrom(requireArguments().getByteArray(ARG_GIFT_BADGE))
@@ -66,7 +67,8 @@ class ViewSentGiftBottomSheet : DSLSettingsBottomSheetFragment() {
noPadTextPref(
title = DSLSettingsText.from(
stringId = R.string.ViewSentGiftBottomSheet__thanks_for_your_support,
DSLSettingsText.CenterModifier, DSLSettingsText.TitleLargeModifier
DSLSettingsText.CenterModifier,
DSLSettingsText.TitleLargeModifier
)
)

View File

@@ -133,7 +133,7 @@ data class Badge(
.downsample(DownsampleStrategy.NONE)
.diskCacheStrategy(DiskCacheStrategy.NONE)
.transform(
BadgeSpriteTransformation(BadgeSpriteTransformation.Size.BADGE_64, model.badge.imageDensity, ThemeUtil.isDarkTheme(context)),
BadgeSpriteTransformation(BadgeSpriteTransformation.Size.BADGE_64, model.badge.imageDensity, ThemeUtil.isDarkTheme(context))
)
.into(badge)

View File

@@ -11,6 +11,7 @@ import androidx.fragment.app.FragmentManager
import androidx.fragment.app.viewModels
import androidx.viewpager2.widget.ViewPager2
import com.google.android.material.tabs.TabLayoutMediator
import org.signal.core.util.getParcelableCompat
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.badges.BadgeRepository
import org.thoughtcrime.securesms.badges.models.Badge
@@ -122,9 +123,9 @@ class ViewBadgeBottomSheetDialogFragment : FixedRoundedCornerBottomSheetDialogFr
}
}
private fun getStartBadge(): Badge? = requireArguments().getParcelable(ARG_START_BADGE)
private fun getStartBadge(): Badge? = requireArguments().getParcelableCompat(ARG_START_BADGE, Badge::class.java)
private fun getRecipientId(): RecipientId = requireNotNull(requireArguments().getParcelable(ARG_RECIPIENT_ID))
private fun getRecipientId(): RecipientId = requireNotNull(requireArguments().getParcelableCompat(ARG_RECIPIENT_ID, RecipientId::class.java))
companion object {

View File

@@ -19,7 +19,7 @@ import org.thoughtcrime.securesms.ContactSelectionListFragment;
import org.thoughtcrime.securesms.PassphraseRequiredActivity;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.ContactFilterView;
import org.thoughtcrime.securesms.contacts.ContactsCursorLoader;
import org.thoughtcrime.securesms.contacts.ContactSelectionDisplayMode;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme;
@@ -143,11 +143,11 @@ public class BlockedUsersActivity extends PassphraseRequiredActivity implements
intent.putExtra(ContactSelectionListFragment.SELECTION_LIMITS, 1);
intent.putExtra(ContactSelectionListFragment.HIDE_COUNT, true);
intent.putExtra(ContactSelectionListFragment.DISPLAY_MODE,
ContactsCursorLoader.DisplayMode.FLAG_PUSH |
ContactsCursorLoader.DisplayMode.FLAG_SMS |
ContactsCursorLoader.DisplayMode.FLAG_ACTIVE_GROUPS |
ContactsCursorLoader.DisplayMode.FLAG_INACTIVE_GROUPS |
ContactsCursorLoader.DisplayMode.FLAG_BLOCK);
ContactSelectionDisplayMode.FLAG_PUSH |
ContactSelectionDisplayMode.FLAG_SMS |
ContactSelectionDisplayMode.FLAG_ACTIVE_GROUPS |
ContactSelectionDisplayMode.FLAG_INACTIVE_GROUPS |
ContactSelectionDisplayMode.FLAG_BLOCK);
getSupportFragmentManager().beginTransaction()
.replace(R.id.fragment_container, fragment, CONTACT_SELECTION_FRAGMENT)

View File

@@ -27,13 +27,15 @@ public class AlbumThumbnailView extends FrameLayout {
private int currentSizeClass;
private final int[] corners = new int[4];
private ViewGroup albumCellContainer;
private Stub<TransferControlView> transferControls;
private final SlideClickListener defaultThumbnailClickListener = (v, slide) -> {
if (thumbnailClickListener != null) {
thumbnailClickListener.onClick(v, slide);
}
if (thumbnailClickListener != null) {
thumbnailClickListener.onClick(v, slide);
}
};
private final OnLongClickListener defaultLongClickListener = v -> this.performLongClick();
@@ -82,6 +84,7 @@ public class AlbumThumbnailView extends FrameLayout {
}
showSlides(glideRequests, slides);
applyCorners();
}
public void setCellBackgroundColor(@ColorInt int color) {
@@ -102,6 +105,15 @@ public class AlbumThumbnailView extends FrameLayout {
downloadClickListener = listener;
}
public void setRadii(int topLeft, int topRight, int bottomRight, int bottomLeft) {
corners[0] = topLeft;
corners[1] = topRight;
corners[2] = bottomRight;
corners[3] = bottomLeft;
applyCorners();
}
private void inflateLayout(int sizeClass) {
albumCellContainer.removeAllViews();
@@ -124,6 +136,83 @@ public class AlbumThumbnailView extends FrameLayout {
}
}
private void applyCorners() {
if (currentSizeClass < 2) {
return;
}
switch (currentSizeClass) {
case 2:
applyCornersForSizeClass2();
break;
case 3:
applyCornersForSizeClass3();
break;
case 4:
applyCornersForSizeClass4();
break;
case 5:
applyCornersForSizeClass5();
break;
default:
applyCornersForManySizeClass();
}
}
private ThumbnailView[] getCells() {
ThumbnailView one = findViewById(R.id.album_cell_1);
ThumbnailView two = findViewById(R.id.album_cell_2);
ThumbnailView three = findViewById(R.id.album_cell_3);
ThumbnailView four = findViewById(R.id.album_cell_4);
ThumbnailView five = findViewById(R.id.album_cell_5);
return new ThumbnailView[] { one, two, three, four, five };
}
private void applyCornersForSizeClass2() {
ThumbnailView[] cells = getCells();
setRelativeRadii(cells[0], corners[0], 0, 0, corners[3]);
setRelativeRadii(cells[1], 0, corners[1], corners[2], 0);
}
private void applyCornersForSizeClass3() {
ThumbnailView[] cells = getCells();
setRelativeRadii(cells[0], corners[0], 0, 0, corners[3]);
setRelativeRadii(cells[1], 0, corners[1], 0, 0);
setRelativeRadii(cells[2], 0, 0, corners[2], 0);
}
private void applyCornersForSizeClass4() {
ThumbnailView[] cells = getCells();
setRelativeRadii(cells[0], corners[0], 0, 0, 0);
setRelativeRadii(cells[1], 0, corners[1], 0, 0);
setRelativeRadii(cells[2], 0, 0, 0, corners[3]);
setRelativeRadii(cells[3], 0, 0, corners[2], 0);
}
private void applyCornersForSizeClass5() {
ThumbnailView[] cells = getCells();
setRelativeRadii(cells[0], corners[0], 0, 0, 0);
setRelativeRadii(cells[1], 0, corners[1], 0, 0);
setRelativeRadii(cells[2], 0, 0, 0, corners[3]);
setRelativeRadii(cells[3], 0, 0, 0, 0);
setRelativeRadii(cells[4], 0, 0, corners[2], 0);
}
private void setRelativeRadii(@NonNull ThumbnailView cell, int topLeft, int topRight, int bottomRight, int bottomLeft) {
boolean isLTR = getRootView().getLayoutDirection() == LAYOUT_DIRECTION_LTR;
cell.setRadii(
isLTR ? topLeft : topRight,
isLTR ? topRight : topLeft,
isLTR ? bottomRight : bottomLeft,
isLTR ? bottomLeft : bottomRight
);
}
private void applyCornersForManySizeClass() {
applyCornersForSizeClass5();
}
private void showSlides(@NonNull GlideRequests glideRequests, @NonNull List<Slide> slides) {
setSlide(glideRequests, slides.get(0), R.id.album_cell_1);
setSlide(glideRequests, slides.get(1), R.id.album_cell_2);

View File

@@ -28,7 +28,6 @@ public class AlertView extends LinearLayout {
initialize(attrs);
}
@TargetApi(VERSION_CODES.HONEYCOMB)
public AlertView(final Context context, AttributeSet attrs, int defStyle) {
super(context, attrs, defStyle);
initialize(attrs);

View File

@@ -54,7 +54,7 @@ public class AnimatingToggle extends FrameLayout {
}
public void display(@Nullable View view) {
if (view == current) return;
if (view == current && current.getVisibility() == View.VISIBLE) return;
if (current != null) ViewUtil.animateOut(current, outAnimation, View.GONE);
if (view != null) ViewUtil.animateIn(view, inAnimation);
@@ -62,7 +62,7 @@ public class AnimatingToggle extends FrameLayout {
}
public void displayQuick(@Nullable View view) {
if (view == current) return;
if (view == current && current.getVisibility() == View.VISIBLE) return;
if (current != null) current.setVisibility(View.GONE);
if (view != null) view.setVisibility(View.VISIBLE);

View File

@@ -33,7 +33,7 @@ import org.greenrobot.eventbus.Subscribe;
import org.greenrobot.eventbus.ThreadMode;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.audio.AudioWaveForm;
import org.thoughtcrime.securesms.audio.AudioWaveForms;
import org.thoughtcrime.securesms.components.voice.VoiceNotePlaybackState;
import org.thoughtcrime.securesms.database.AttachmentTable;
import org.thoughtcrime.securesms.events.PartProgressEvent;
@@ -43,6 +43,9 @@ import org.thoughtcrime.securesms.mms.SlideClickListener;
import java.util.Objects;
import java.util.concurrent.TimeUnit;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.disposables.Disposable;
public final class AudioView extends FrameLayout {
private static final String TAG = Log.tag(AudioView.class);
@@ -77,6 +80,8 @@ public final class AudioView extends FrameLayout {
private AudioSlide audioSlide;
private Callbacks callbacks;
private Disposable disposable = Disposable.disposed();
private final Observer<VoiceNotePlaybackState> playbackStateObserver = this::onPlaybackState;
public AudioView(Context context) {
@@ -155,6 +160,7 @@ public final class AudioView extends FrameLayout {
protected void onDetachedFromWindow() {
super.onDetachedFromWindow();
EventBus.getDefault().unregister(this);
disposable.dispose();
}
public void setProgressAndPlayBackgroundTint(@ColorInt int color) {
@@ -170,6 +176,7 @@ public final class AudioView extends FrameLayout {
final boolean showControls,
final boolean forceHideDuration)
{
this.disposable.dispose();
this.callbacks = callbacks;
if (duration != null) {
@@ -212,16 +219,19 @@ public final class AudioView extends FrameLayout {
WaveFormSeekBarView waveFormView = (WaveFormSeekBarView) seekBar;
waveFormView.setColors(waveFormPlayedBarsColor, waveFormUnplayedBarsColor, waveFormThumbTint);
if (android.os.Build.VERSION.SDK_INT >= 23) {
new AudioWaveForm(getContext(), audio).getWaveForm(
data -> {
durationMillis = data.getDuration(TimeUnit.MILLISECONDS);
updateProgress(0, 0);
if (!forceHideDuration && duration != null) {
duration.setVisibility(VISIBLE);
}
waveFormView.setWaveData(data.getWaveForm());
},
() -> waveFormView.setWaveMode(false));
disposable = AudioWaveForms.getWaveForm(getContext(), audioSlide.asAttachment())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
data -> {
durationMillis = data.getDuration(TimeUnit.MILLISECONDS);
updateProgress(0, 0);
if (!forceHideDuration && duration != null) {
duration.setVisibility(VISIBLE);
}
waveFormView.setWaveData(data.getWaveForm());
},
t -> waveFormView.setWaveMode(false)
);
} else {
waveFormView.setWaveMode(false);
if (duration != null) {

View File

@@ -4,6 +4,7 @@ import android.content.Context;
import android.util.AttributeSet;
import android.view.View;
import android.widget.FrameLayout;
import android.widget.ImageView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
@@ -58,10 +59,10 @@ public class BorderlessImageView extends FrameLayout {
boolean showControls = slide.asAttachment().getUri() == null;
if (slide.hasSticker()) {
image.setFit(new CenterInside());
image.setScaleType(ImageView.ScaleType.FIT_CENTER);
image.setImageResource(glideRequests, slide, showControls, false);
} else {
image.setFit(new CenterCrop());
image.setScaleType(ImageView.ScaleType.CENTER_CROP);
image.setImageResource(glideRequests, slide, showControls, false, slide.asAttachment().getWidth(), slide.asAttachment().getHeight());
}

View File

@@ -6,8 +6,8 @@ import android.graphics.Path
import android.graphics.Rect
import android.graphics.RectF
import android.util.AttributeSet
import androidx.cardview.widget.CardView
import androidx.core.graphics.withClip
import com.google.android.material.card.MaterialCardView
/**
* Adds manual clipping around the card. This ensures that software rendering
@@ -16,7 +16,7 @@ import androidx.core.graphics.withClip
class ClippedCardView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null
) : CardView(context, attrs) {
) : MaterialCardView(context, attrs) {
private val bounds = Rect()
private val boundsF = RectF()

View File

@@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.components;
import android.content.Context;
import android.content.res.Configuration;
import android.graphics.Canvas;
import android.graphics.Typeface;
import android.os.Build;
import android.os.Bundle;
import android.text.Annotation;
@@ -14,8 +15,15 @@ import android.text.SpannableStringBuilder;
import android.text.Spanned;
import android.text.TextUtils;
import android.text.TextUtils.TruncateAt;
import android.text.style.CharacterStyle;
import android.text.style.RelativeSizeSpan;
import android.text.style.StrikethroughSpan;
import android.text.style.StyleSpan;
import android.text.style.TypefaceSpan;
import android.util.AttributeSet;
import android.view.ActionMode;
import android.view.Menu;
import android.view.MenuItem;
import android.view.inputmethod.EditorInfo;
import android.view.inputmethod.InputConnection;
@@ -35,18 +43,19 @@ import org.thoughtcrime.securesms.components.mention.MentionDeleter;
import org.thoughtcrime.securesms.components.mention.MentionRendererDelegate;
import org.thoughtcrime.securesms.components.mention.MentionValidatorWatcher;
import org.thoughtcrime.securesms.conversation.MessageSendType;
import org.thoughtcrime.securesms.conversation.MessageStyler;
import org.thoughtcrime.securesms.conversation.ui.inlinequery.InlineQuery;
import org.thoughtcrime.securesms.conversation.ui.inlinequery.InlineQueryChangedListener;
import org.thoughtcrime.securesms.conversation.ui.inlinequery.InlineQueryReplacement;
import org.thoughtcrime.securesms.database.model.Mention;
import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.util.FeatureFlags;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.TimeUnit;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import static org.thoughtcrime.securesms.database.MentionUtil.MENTION_STARTER;
@@ -248,10 +257,6 @@ public class ComposeText extends EmojiEditText {
editorInfo.imeOptions &= ~EditorInfo.IME_FLAG_NO_ENTER_ACTION;
}
if (Build.VERSION.SDK_INT < 21) {
return inputConnection;
}
if (mediaListener == null) {
return inputConnection;
}
@@ -280,6 +285,19 @@ public class ComposeText extends EmojiEditText {
return MentionAnnotation.getMentionsFromAnnotations(getText());
}
public boolean hasStyling() {
CharSequence trimmed = getTextTrimmed();
return FeatureFlags.textFormatting() && (trimmed instanceof Spanned) && MessageStyler.hasStyling((Spanned) trimmed);
}
public @Nullable BodyRangeList getStyling() {
if (FeatureFlags.textFormatting()) {
return MessageStyler.getStyling(getTextTrimmed());
} else {
return null;
}
}
private void initialize() {
if (TextSecurePreferences.isIncognitoKeyboardEnabled(getContext())) {
setImeOptions(getImeOptions() | 16777216);
@@ -290,6 +308,80 @@ public class ComposeText extends EmojiEditText {
addTextChangedListener(new MentionDeleter());
mentionValidatorWatcher = new MentionValidatorWatcher();
addTextChangedListener(mentionValidatorWatcher);
if (FeatureFlags.textFormatting()) {
setCustomSelectionActionModeCallback(new ActionMode.Callback() {
@Override
public boolean onCreateActionMode(ActionMode mode, Menu menu) {
MenuItem copy = menu.findItem(android.R.id.copy);
MenuItem cut = menu.findItem(android.R.id.cut);
MenuItem paste = menu.findItem(android.R.id.paste);
int copyOrder = copy != null ? copy.getOrder() : 0;
int cutOrder = cut != null ? cut.getOrder() : 0;
int pasteOrder = paste != null ? paste.getOrder() : 0;
int largestOrder = Math.max(copyOrder, Math.max(cutOrder, pasteOrder));
menu.add(0, R.id.edittext_bold, largestOrder, getContext().getString(R.string.TextFormatting_bold));
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));
return true;
}
@Override
public boolean onActionItemClicked(ActionMode mode, MenuItem item) {
Editable text = getText();
if (text == null) {
return false;
}
if (item.getItemId() != R.id.edittext_bold &&
item.getItemId() != R.id.edittext_italic &&
item.getItemId() != R.id.edittext_strikethrough &&
item.getItemId() != R.id.edittext_monospace) {
return false;
}
int start = getSelectionStart();
int end = getSelectionEnd();
CharSequence charSequence = text.subSequence(start, end);
SpannableString replacement = new SpannableString(charSequence);
CharacterStyle style = null;
if (item.getItemId() == R.id.edittext_bold) {
style = MessageStyler.boldStyle();
} else if (item.getItemId() == R.id.edittext_italic) {
style = MessageStyler.italicStyle();
} else if (item.getItemId() == R.id.edittext_strikethrough) {
style = MessageStyler.strikethroughStyle();
} else if (item.getItemId() == R.id.edittext_monospace) {
style = MessageStyler.monoStyle();
}
if (style != null) {
replacement.setSpan(style, 0, charSequence.length(), Spannable.SPAN_EXCLUSIVE_INCLUSIVE);
}
clearComposingText();
text.replace(start, end, replacement);
mode.finish();
return true;
}
@Override
public boolean onPrepareActionMode(ActionMode mode, Menu menu) {
return false;
}
@Override
public void onDestroyActionMode(ActionMode mode) {}
});
}
}
private void setHintWithChecks(@Nullable CharSequence newHint) {

View File

@@ -29,6 +29,7 @@ import org.signal.core.util.concurrent.SignalExecutors;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.animation.AnimationCompleteListener;
import org.thoughtcrime.securesms.database.SignalDatabase;
import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.database.model.MmsMessageRecord;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
@@ -36,6 +37,7 @@ import org.thoughtcrime.securesms.permissions.Permissions;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.service.ExpiringMessageManager;
import org.thoughtcrime.securesms.util.DateUtils;
import org.thoughtcrime.securesms.util.MessageRecordUtil;
import org.thoughtcrime.securesms.util.Projection;
import org.thoughtcrime.securesms.util.SignalLocalMetrics;
import org.thoughtcrime.securesms.util.ViewUtil;
@@ -315,6 +317,8 @@ public class ConversationItemFooter extends ConstraintLayout {
dateView.setText(R.string.ConversationItem_click_to_approve_unencrypted);
} else if (messageRecord.isRateLimited()) {
dateView.setText(R.string.ConversationItem_send_paused);
} else if (MessageRecordUtil.isScheduled(messageRecord)) {
dateView.setText(DateUtils.getOnlyTimeString(getContext(), locale, ((MediaMmsMessageRecord) messageRecord).getScheduledDate()));
} else {
dateView.setText(DateUtils.getSimpleRelativeTimeSpanString(getContext(), locale, messageRecord.getTimestamp()));
}
@@ -392,7 +396,7 @@ public class ConversationItemFooter extends ConstraintLayout {
previousMessageId = newMessageId;
if (messageRecord.isFailed() || messageRecord.isPendingInsecureSmsFallback()) {
if (messageRecord.isFailed() || messageRecord.isPendingInsecureSmsFallback() || MessageRecordUtil.isScheduled(messageRecord)) {
deliveryStatusView.setNone();
return;
}

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