Compare commits

..

209 Commits

Author SHA1 Message Date
Greyson Parrelli
69e1146e2c Bump version to 7.18.2 2024-09-25 23:58:17 -04:00
Greyson Parrelli
a0c7b56ab4 Update translations and other static files. 2024-09-25 23:57:55 -04:00
Greyson Parrelli
6b7ea28e8f Fix issue where wallpapers don't immediately render after upgrade. 2024-09-24 14:16:34 -04:00
Greyson Parrelli
6f1949db98 Bump version to 7.18.1 2024-09-24 13:37:20 -04:00
Greyson Parrelli
551d873a1a Update translations and other static files. 2024-09-24 13:36:51 -04:00
Greyson Parrelli
760d5ab2ce Be even more cautious when repairing FTS tables. 2024-09-24 12:51:56 -04:00
Greyson Parrelli
ff4364586b Fix issue with attachments failing to download. 2024-09-24 12:41:27 -04:00
Greyson Parrelli
12b78336c6 Bump version to 7.18.0 2024-09-23 22:53:28 -04:00
Greyson Parrelli
70d6a8f1fe Update baseline profile. 2024-09-23 22:52:56 -04:00
Greyson Parrelli
2e81d717d0 Update translations and other static files. 2024-09-23 22:36:41 -04:00
Greyson Parrelli
6ddd780e0e Fix wire gradle dependency. 2024-09-23 22:36:34 -04:00
Greyson Parrelli
2449b5f4a4 Add more debug info around attachment deduping. 2024-09-23 09:23:57 -04:00
Alex Hart
fde78cf5b8 Remove unused parameter in LinkPreviewViewModel. 2024-09-23 09:53:46 -03:00
Greyson Parrelli
eab1f5944d Add support for long text backup. 2024-09-23 08:31:05 -04:00
Greyson Parrelli
ecd16dbe9c Fix payment notification backup import/export. 2024-09-22 15:09:04 -04:00
Greyson Parrelli
a76f5e600e Fix flakiness of the backup tests.
It's possible that pending writes to the key value store (from using
.apply()) may not be finished by the time we take the DB snapshot,
resulting in us seeing stale data in the snapshot. Now we block on
writes finishing.
2024-09-21 22:51:24 -04:00
Greyson Parrelli
054b517a04 Add backup support for remaining simple chat updates. 2024-09-21 15:49:12 -04:00
Greyson Parrelli
40ca94a7dd Fix sticker backup import/export. 2024-09-21 11:48:41 -04:00
Greyson Parrelli
ba1e8b6c14 Fix handling of invalid quote attachment locators in backups. 2024-09-21 11:22:37 -04:00
Greyson Parrelli
f3a9f7f91d Fix handling of stickers with invalid locators in backups. 2024-09-21 09:59:09 -04:00
Greyson Parrelli
3c0e9c9e4e Fix group receipt handling in backups. 2024-09-21 09:49:21 -04:00
Greyson Parrelli
9888b1a5f8 Fix backup support for account wallpapers. 2024-09-21 07:01:43 -04:00
Greyson Parrelli
ec49352635 Merge various proto utils together in core-util-jvm. 2024-09-20 23:29:08 -04:00
Greyson Parrelli
5b69d98579 Fix potential NPE when reading old attachments. 2024-09-20 22:07:07 -04:00
Greyson Parrelli
90998a4076 Fix various backup import-export inconsistencies. 2024-09-20 21:14:50 -04:00
Cody Henthorne
a10958ee13 Add optimize storage infrastructure for backupsv2. 2024-09-20 16:47:18 -04:00
Alex Hart
7935d12675 Fix incorrect display of ISK recurring cost. 2024-09-20 16:11:47 -03:00
Cody Henthorne
cafa5c9e28 Add more info for various spinner results. 2024-09-20 12:52:37 -04:00
Greyson Parrelli
a7bdfb6d76 Add support for backing up wallpapers. 2024-09-20 12:24:57 -04:00
Alex Hart
e14078d2ec Allow free tier to be enabled when Google Play Billing isn't available. 2024-09-20 10:52:18 -04:00
Alex Hart
12e25b0f40 Add google play billing token conversion endpoint and job. 2024-09-20 10:52:18 -04:00
Alex Hart
d23ef647d8 Hide paid tier on devices where the billing API is not available. 2024-09-20 10:52:18 -04:00
Alex Hart
d88265ede6 Update view state after enabling mic permissions to match view model state. 2024-09-20 10:52:18 -04:00
Greyson Parrelli
0e83e25e6e Setup infra for better archive upload progress tracking. 2024-09-20 10:52:18 -04:00
Jim Gustafson
1597ee70ba Update to RingRTC v2.48.0 2024-09-20 10:52:18 -04:00
Greyson Parrelli
01ee98af91 Use better update string for manual installs.
Fixes #13700
2024-09-20 10:52:18 -04:00
Greyson Parrelli
9a1d5f4dce Update to latest Backup.proto. 2024-09-20 10:52:18 -04:00
Greyson Parrelli
60bf121974 Update to libsignal 0.58.0 2024-09-20 10:52:18 -04:00
Cody Henthorne
46844ced7c Log notification posting exception when encountered. 2024-09-20 10:52:18 -04:00
Greyson Parrelli
1ac19e84c2 Fix issues with archive uploads matching digest. 2024-09-20 10:52:18 -04:00
Alex Hart
48bd57c56a Start re-work of play billing checkout flow. 2024-09-20 10:52:18 -04:00
Gaëtan Muller
b340097f9c Remove Multidex usages.
Since the min SDK is at least 21, it is no longer necessary to use the Multidex library.

See the following for more info: https://developer.android.com/build/multidex#mdex-on-l

Resolves #13696
2024-09-20 10:52:18 -04:00
Cody Henthorne
a1bf4d62ab Fix thumbnail rendering and refreshing on full download. 2024-09-20 10:52:18 -04:00
Michelle Tang
b74f04495e Update verified icon. 2024-09-20 10:52:18 -04:00
Greyson Parrelli
ba06efe35a Improve the Banner system. 2024-09-20 10:52:18 -04:00
Greyson Parrelli
24133c6dac Fix potential crash when reading very old attachments. 2024-09-20 10:52:18 -04:00
Alex Hart
64ada79e8f Switch wording for group link administration. 2024-09-20 10:52:18 -04:00
Alex Hart
8933d89b56 Filter call link events we don't have root keys for and disambiguate return / join. 2024-09-20 10:52:18 -04:00
Cody Henthorne
88d1c0cf87 Fix internal message details rendering warning. 2024-09-20 10:52:18 -04:00
Greyson Parrelli
703c00b9af Fix banner background. 2024-09-20 10:52:18 -04:00
Greyson Parrelli
c0d115325a Fix crash in legacy migration. 2024-09-20 10:52:18 -04:00
Greyson Parrelli
6f3f204cbe Log PNP setting change events. 2024-09-20 10:52:18 -04:00
Alex Hart
cd846f2b6d Fix call link join issue and add denial dialogs into call UI v2. 2024-09-20 10:52:17 -04:00
Alex Hart
5bd3eda17d Add snackbar that is displayed if you're currently in a different call. 2024-09-20 10:52:17 -04:00
Greyson Parrelli
c36c6e62e2 Add Flow.throttleLatest extension. 2024-09-20 10:52:17 -04:00
Cody Henthorne
6b9e921888 Fix incorrect image showing in gallery when other media is unavailable. 2024-09-20 10:52:17 -04:00
mtang-signal
f57b1a8f5e Restore picker after editing a message. 2024-09-20 10:52:17 -04:00
Greyson Parrelli
7727deef9f Fix handling of common backup status codes. 2024-09-20 10:52:17 -04:00
ᡠᠵᡠᡳ ᡠᠵᡠ ᠮᠠᠨᡩ᠋ᠠᠨ
789aea3a3a Set kotlin jvmToolchain for jvm modules.
Closes #13686

Fixes #13523
2024-09-20 10:52:17 -04:00
mtang-signal
81b4339bea Add capitalization to profile names. 2024-09-20 10:52:17 -04:00
RohitBeatroute
76175c7a6b Fix username discriminator from disappearing.
Closes #13687

Fixes #13680
2024-09-20 10:52:17 -04:00
Greyson Parrelli
e81fc2900d Bump version to 7.17.5 2024-09-19 16:03:42 -04:00
Greyson Parrelli
db9a2f04f3 Update translations and other static files. 2024-09-19 16:03:15 -04:00
Alex Hart
1d719333a3 Heal SEPA transfer keep-alive failures. 2024-09-19 12:23:51 -03:00
Alex Hart
71f6c77b42 Fix missing paymentMethodType in keep-alive payment creation. 2024-09-19 11:35:34 -03:00
Greyson Parrelli
7a66533e70 Bump version to 7.17.4 2024-09-18 07:12:04 -04:00
Greyson Parrelli
9106812b74 Reset the upload timestamp on attachments with fixed digests. 2024-09-18 07:05:52 -04:00
Greyson Parrelli
fcb2e3cc74 Make LimitedInputStream less strict. 2024-09-18 07:05:29 -04:00
Greyson Parrelli
1f638db959 Bump version to 7.17.3 2024-09-17 23:03:41 -04:00
Greyson Parrelli
832d15ff47 Ensure call link table matches upgraded table. 2024-09-17 23:02:49 -04:00
Greyson Parrelli
f8846e3593 Clear attachment uploadTimestamps. 2024-09-17 23:02:26 -04:00
Greyson Parrelli
59e0afde14 Bump version to 7.17.2 2024-09-16 13:13:40 -04:00
Greyson Parrelli
00058f7762 Update baseline profile. 2024-09-16 13:13:09 -04:00
Greyson Parrelli
56159043e3 Update translations and other static files. 2024-09-16 13:03:03 -04:00
Greyson Parrelli
2180b78466 Ensure username is reclaimed after account restore. 2024-09-16 12:54:58 -04:00
Greyson Parrelli
dc77226995 Address a FTS table configuration crash. 2024-09-16 09:51:14 -04:00
Alex Hart
0a346eda5b Fix calls count when there are no entries to display. 2024-09-16 09:42:13 -03:00
Cody Henthorne
6188502cb1 Bump version to 7.17.1 2024-09-13 13:52:40 -04:00
Cody Henthorne
b425920144 Update baseline profile. 2024-09-13 13:49:46 -04:00
Cody Henthorne
db60a3cb2c Update translations and other static files. 2024-09-13 13:46:26 -04:00
Cody Henthorne
6b2ff05adb Fix draft state being reapplied on input state change. 2024-09-13 13:38:22 -04:00
Cody Henthorne
0108a1d3e3 Bump version to 7.17.0 2024-09-13 11:18:56 -04:00
Cody Henthorne
64e61ccce3 Update baseline profile. 2024-09-13 11:06:37 -04:00
Cody Henthorne
efef179124 Update translations and other static files. 2024-09-13 11:00:26 -04:00
Jim Gustafson
6789715556 Update to RingRTC v2.47.1 2024-09-13 10:51:30 -04:00
Alex Hart
463fabcbc4 Polish pending participants views. 2024-09-13 10:51:30 -04:00
Greyson Parrelli
23d82a3a01 Remove skip/forgot PIN special case. 2024-09-13 10:51:30 -04:00
Alex Hart
d1475228f7 Add chevron to pending participants view. 2024-09-13 10:51:30 -04:00
Cody Henthorne
636b5a4ba6 Prevent sharing and clear drafts when entering disabled send conversations. 2024-09-13 10:51:30 -04:00
Greyson Parrelli
850515b363 Make FTS recovery more resiliant. 2024-09-13 10:51:30 -04:00
Greyson Parrelli
5c6644d1a1 Add extra transaction protections. 2024-09-13 10:51:30 -04:00
Cody Henthorne
0d37013481 Fix more voice note playback NPEs. 2024-09-13 10:51:30 -04:00
Cody Henthorne
5647215659 Fix state exception when registering without play services. 2024-09-13 10:51:30 -04:00
Greyson Parrelli
e80ebd87fe Refactor and simplify attachment archiving. 2024-09-13 10:51:30 -04:00
Cody Henthorne
816006c67e Refactor and cleanup backupv2 media restore. 2024-09-13 10:51:30 -04:00
Alex Hart
baa6032770 Fix overlap of join banner and camera toggle. 2024-09-13 10:51:30 -04:00
Greyson Parrelli
7735ca9dab Fix crash when downloading attachment from S3. 2024-09-13 10:51:30 -04:00
Alex Hart
36a8a399d9 Only display latest call link event in calls tab. 2024-09-13 10:51:30 -04:00
Alex Hart
9912a5fdfe Allow anyone to join a call link. 2024-09-13 10:51:30 -04:00
Alex Hart
c3be92d365 Upgrade several AndroidX libraries and Compose to latest stable versions. 2024-09-13 10:51:30 -04:00
Greyson Parrelli
0fe9df3023 Properly clear unknown ids from storage service. 2024-09-13 10:51:30 -04:00
Greyson Parrelli
cb126a2f08 Fix runAttempt not updating in job cache.
Thank you to @valldrac for finding this and diagnosing it!

Fixes #13679
2024-09-13 10:51:30 -04:00
Greyson Parrelli
7835b1d1fc Move more networking stuff into SignalNetwork. 2024-09-13 10:51:30 -04:00
Nicholas Tinsley
e247d311d8 Add call link support to storage service. 2024-09-13 10:51:30 -04:00
Alex Hart
1f2b5e90a3 Remove unnecessary check in call link processing. 2024-09-13 10:51:30 -04:00
Jim Gustafson
ee033b49fe Update to RingRTC v2.47.0 2024-09-13 10:51:30 -04:00
Greyson Parrelli
a7b958d811 Only run BackupMessageJob after the digest backfill has finished. 2024-09-13 10:51:30 -04:00
Greyson Parrelli
c4bcb7dc93 Improve digest backfill migration. 2024-09-13 10:51:30 -04:00
Greyson Parrelli
1e8626647e Fix digests for non-zero padding. 2024-09-13 10:51:30 -04:00
Nicholas Tinsley
a50f316659 Harden null safety in VoiceNotePlaybackService.
Addresses #13673.
2024-09-13 10:51:30 -04:00
Alex Hart
1f09f48e6b Add proper call tab return state. 2024-09-13 10:51:30 -04:00
Nicholas Tinsley
514f7cc767 Fix tests after reg v1 cleanup. 2024-09-13 10:51:30 -04:00
Greyson Parrelli
b858161f92 Fix NetworkResult handling of websocket timeouts. 2024-09-13 10:51:30 -04:00
Greyson Parrelli
85d90aa121 Add the ability to set no limit on LimitedInputStream. 2024-09-13 10:51:30 -04:00
Greyson Parrelli
a8fb4eb21a Rename TruncatingInputStream -> LimitedInputStream. 2024-09-13 10:51:30 -04:00
Greyson Parrelli
a6767e4f8a Replace other limiting streams with TruncatingInputStream. 2024-09-13 10:51:30 -04:00
Greyson Parrelli
b00855b097 Add support for more methods in TruncatingInputStream. 2024-09-13 10:51:30 -04:00
Cody Henthorne
929942de9d Verify digest for backupv2 local media restore. 2024-09-13 10:51:30 -04:00
Greyson Parrelli
6112ee9bd3 Initialize AppDependencies if needed in AvatarProvider. 2024-09-13 10:51:30 -04:00
Nicholas Tinsley
9261c34213 Clean up registration java packages hierarchy. 2024-09-13 10:51:30 -04:00
Nicholas Tinsley
f29d4f980a Removal final usage of VerifyResponseProcessor. 2024-09-11 15:14:05 -04:00
Nicholas Tinsley
bf46e5bc24 Consolidate odds and ends from reg v1 into reg v2. 2024-09-11 15:14:05 -04:00
Nicholas Tinsley
c9746b59ed Clean up reg v1 remnants using safe delete. 2024-09-11 15:14:05 -04:00
Alex Hart
2123c642a5 Change admin approval string for call links. 2024-09-11 15:14:04 -04:00
Alex Hart
118085f692 Fix aspect ratio of link preview thumbnails. 2024-09-11 15:14:04 -04:00
Cody Henthorne
2701b570bb Use trailing job to clear media restore progress. 2024-09-11 15:14:04 -04:00
Cody Henthorne
390ea341ca Fix incorrect padding buffer reuse. 2024-09-11 15:14:04 -04:00
Alex Hart
b7abd85992 Fix status bar theming in children of FragmentWrapperActivity. 2024-09-11 15:14:04 -04:00
Alex Hart
982b90d423 Add BillingDependencies and shared implementation. 2024-09-11 15:14:04 -04:00
Alex Hart
36bfd19bcf Fix db access in RemoteMegaphoneRepository. 2024-09-11 15:14:04 -04:00
Greyson Parrelli
7eac9ce1f4 Improve attachment deduping for videos. 2024-09-11 15:14:04 -04:00
mtang-signal
ba2d5bce41 Allow linking of devices if no passlock is set. 2024-09-11 15:14:04 -04:00
Michelle Tang
93c8cd133d Add education sheet to linked device biometrics. 2024-09-11 15:14:04 -04:00
Greyson Parrelli
d59985c7b1 Add migration to backfill digests. 2024-09-11 15:14:04 -04:00
Cody Henthorne
a8bf03af89 Add restore local backupv2 infra. 2024-09-11 15:14:04 -04:00
Greyson Parrelli
00d20a1917 Introduce SignalNetwork, share PushServiceSocket. 2024-09-11 15:14:04 -04:00
Greyson Parrelli
4e35906680 Add blocked check when adding 'user joined' message. 2024-09-11 15:14:04 -04:00
Alex Hart
4d23f11f6e Add shared calling intent system. 2024-09-11 15:14:04 -04:00
Greyson Parrelli
e5b482c7ad Fix error handling in NetworkResult.fromWebSocketRequest() 2024-09-11 15:14:04 -04:00
Greyson Parrelli
6c09b59d1b Close stream after calculating length. 2024-09-11 15:14:04 -04:00
Greyson Parrelli
8070f26207 Save correct size after attachment upload. 2024-09-11 15:14:04 -04:00
Nicholas Tinsley
623312d8f6 Inline StreamingTranscoder.
Delete InMemoryTranscoder.
2024-09-11 15:14:04 -04:00
Greyson Parrelli
ac9e5505ae Save IV on attachment download. 2024-09-11 15:14:04 -04:00
Greyson Parrelli
4b47d38d78 Add IV to the attachment table. 2024-09-11 15:14:04 -04:00
Cody Henthorne
07289b417b Bump version to 7.16.4 2024-09-11 15:07:24 -04:00
Cody Henthorne
6827955c41 Update baseline profile. 2024-09-11 15:06:15 -04:00
Cody Henthorne
269d3c43f6 Update translations and other static files. 2024-09-11 15:00:09 -04:00
Greyson Parrelli
ac10ff4cbe Improve validations on envelope. 2024-09-11 14:45:02 -04:00
Alex Hart
b681b4169f Fix callbacks for DonationPending and UserLaunchedExternalApplication donation delegate methods. 2024-09-11 14:37:16 -04:00
Alex Hart
7472166628 Bump version to 7.16.3 2024-09-05 12:36:56 -03:00
Alex Hart
04f9468cc6 Update baseline profile. 2024-09-05 11:59:48 -03:00
Alex Hart
c592a5b39d Update translations and other static files. 2024-09-05 11:54:04 -03:00
Alex Hart
a992da9a7b Fix test users for benchmarking. 2024-09-05 11:49:57 -03:00
Greyson Parrelli
1aed8eefcd Improve reliability of rebuilding the search index. 2024-09-05 11:49:57 -03:00
Greyson Parrelli
6682815663 Fix NPE in VoiceNotePlaybackService. 2024-09-05 11:49:57 -03:00
Cody Henthorne
676be03ffc Bump version to 7.16.2 2024-09-03 16:15:10 -04:00
Cody Henthorne
527096cc0e Update translations and other static files. 2024-09-03 16:09:12 -04:00
Cody Henthorne
83c3cc6a6d Fix notifications not showing after contact permission revoked on Android 15.
Fixes #13671
2024-09-03 15:56:12 -04:00
Cody Henthorne
0c4725dfa7 Fix unnecessary timer change message insert on sync messages. 2024-09-03 15:43:01 -04:00
Michelle Tang
2c7668253e Fix missing photos in gallery. 2024-09-03 12:20:38 -07:00
Nicholas Tinsley
ab7bdc3c03 Bump version to 7.16.1 2024-09-01 11:52:54 -04:00
Nicholas Tinsley
bb1b548c27 Update translations and other static files. 2024-09-01 10:29:32 -04:00
Cody Henthorne
216073f4c2 Fix versioned expiration timer capability bug. 2024-08-30 16:18:23 -04:00
Nicholas Tinsley
84ae8db549 Bump version to 7.16.0 2024-08-30 13:16:37 -04:00
Nicholas Tinsley
09bd460875 Update translations and other static files. 2024-08-30 13:07:53 -04:00
Greyson Parrelli
97ea5dc45e Protect against NPE in search. 2024-08-30 12:55:23 -04:00
Nicholas Tinsley
85449802d1 Properly handle video transcoding failures. 2024-08-30 12:55:23 -04:00
Nicholas Tinsley
d683b8a321 Preclude cancelation of pre-uploaded video attachments.
Addresses ##10225.
2024-08-30 12:55:23 -04:00
Greyson Parrelli
2b1bbdda15 Inline the withinTransaction() function. 2024-08-30 12:55:23 -04:00
Greyson Parrelli
011a36c8f3 Move back to manually implementing secure-delete. 2024-08-30 12:55:23 -04:00
Nicholas Tinsley
dd1976d431 Log why we're showing a debug log prompt. 2024-08-30 12:55:23 -04:00
Jim Gustafson
643f64e181 Use the Oboe ADM for some custom roms 2024-08-30 12:55:23 -04:00
Nicholas Tinsley
659e36673b Fix wrong string in pending group join request banner. 2024-08-30 12:55:23 -04:00
Nicholas Tinsley
907918d3fa Logging around attachments pre-uploads. 2024-08-30 12:55:23 -04:00
Nicholas Tinsley
243c86cec3 Prevent ISE on cell signal loss. 2024-08-30 12:55:23 -04:00
Nicholas Tinsley
dca10634e6 Attempt to fix impossible index out of bounds exception? 2024-08-30 12:55:22 -04:00
Nicholas Tinsley
5dfc4c422e Update styling of Call Link join button. 2024-08-30 12:55:22 -04:00
Greyson Parrelli
46753fc617 Another attempt at rebuilding the FTS tables. 2024-08-30 12:55:22 -04:00
Greyson Parrelli
e263d7da73 Fix crash when reading some contact cards. 2024-08-30 12:55:22 -04:00
Greyson Parrelli
c4ba579310 Mitigate app migration failing on missing table.
In an ideal world, we'd fix this with a database migration... but we're
seeing _really_ weird behavior around FTS tables, and I'd rather not
press my luck.
2024-08-30 12:55:22 -04:00
Jim Gustafson
d6d9e5ca64 Update to RingRTC v2.46.2 2024-08-30 12:55:22 -04:00
Cody Henthorne
90a8d90e40 Allow building libsignal from source.
Co-authored-by: Jordan Rose <jrose@signal.org>
2024-08-30 12:55:22 -04:00
Greyson Parrelli
b61ca37523 Do not link contacts to notification unless we have permission. 2024-08-30 12:55:22 -04:00
Nicholas Tinsley
b7af1e09e2 Increase logging around backup restores. 2024-08-30 12:55:22 -04:00
Nicholas Tinsley
ff47f784a3 Prevent IndexOutOfBounds exception when media is deleted. 2024-08-30 12:55:22 -04:00
Cody Henthorne
1f196f74ff Add support for versioned expiration timers.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2024-08-30 12:55:22 -04:00
Jordan Rose
4152294b57 Update to NDK r27, and explicitly specify it in the app build. 2024-08-30 12:55:22 -04:00
Greyson Parrelli
1aaa833127 Replace manual FTS5 fix with SQLite secure-delete flag.
We used to workaround this by manually optimizing the search index, but secure-delete does that for us with less work.
2024-08-30 12:55:22 -04:00
Nicholas Tinsley
2cfd19add6 Minor log statement rewording. 2024-08-27 13:21:20 -04:00
Greyson Parrelli
8e3000d852 Update sqlcipher to 4.6.0-S1 2024-08-27 13:21:20 -04:00
Greyson Parrelli
4e48a445bf Disable flaky test. 2024-08-27 13:21:20 -04:00
Nicholas Tinsley
45833ef24a Reset upload progress if attachment upload is interrupted. 2024-08-27 13:21:20 -04:00
Nicholas Tinsley
4354a9ff5e Add small logging to attachment finalization process. 2024-08-27 13:21:20 -04:00
Nicholas Tinsley
f1bf6105ea Don't try to download attachment if it's being restored. 2024-08-27 13:21:20 -04:00
Alex Hart
282ec6918b Add call audio toggle to calling v2. 2024-08-27 13:21:20 -04:00
Nicholas Tinsley
69d62d385e Small fixes for the video transcoding playground app. 2024-08-27 13:21:20 -04:00
Nicholas Tinsley
0f7f866562 Experimental HEVC encoding support for videos. 2024-08-27 13:21:20 -04:00
Alex Hart
5f66e2eb15 Add visibility rules and timeout for call controls for v2. 2024-08-27 13:21:20 -04:00
Alex Hart
3f71f90234 Add call participants overflow to calling v2 screen. 2024-08-27 13:21:20 -04:00
Nicholas Tinsley
204fcc28c7 Bump version to 7.15.4 2024-08-27 13:17:16 -04:00
Nicholas Tinsley
f53cb19943 Update translations and other static files. 2024-08-27 13:15:14 -04:00
Greyson Parrelli
cea8546ce5 Fix serialization issue during registration. 2024-08-27 12:14:01 -04:00
Alex Hart
bb7ee5915c Add billing fix. 2024-08-27 11:22:06 -04:00
Nicholas Tinsley
cf8e05fa39 Bump version to 7.15.3 2024-08-26 17:51:07 -04:00
Nicholas Tinsley
01cf0b69e0 Update translations and other static files. 2024-08-26 17:38:41 -04:00
Nicholas Tinsley
0aa764586e Dark mode support for DefaultBanner. 2024-08-26 17:35:57 -04:00
Nicholas Tinsley
532441db24 Log errors during media controller initialization. 2024-08-26 16:44:52 -04:00
Greyson Parrelli
2bc07e87d8 Add stopgap for FTS migration crash. 2024-08-26 15:58:22 -04:00
Greyson Parrelli
60ad879cac Improve network reliability. 2024-08-26 14:53:08 -04:00
623 changed files with 31102 additions and 17780 deletions

View File

@@ -21,8 +21,8 @@ plugins {
apply(from = "static-ips.gradle.kts")
val canonicalVersionCode = 1449
val canonicalVersionName = "7.15.2"
val canonicalVersionCode = 1465
val canonicalVersionName = "7.18.2"
val currentHotfixVersion = 0
val maxHotfixVersions = 100
@@ -54,6 +54,7 @@ val signalBuildToolsVersion: String by rootProject.extra
val signalCompileSdkVersion: String by rootProject.extra
val signalTargetSdkVersion: Int by rootProject.extra
val signalMinSdkVersion: Int by rootProject.extra
val signalNdkVersion: String by rootProject.extra
val signalJavaVersion: JavaVersion by rootProject.extra
val signalKotlinJvmTarget: String by rootProject.extra
@@ -80,6 +81,7 @@ android {
buildToolsVersion = signalBuildToolsVersion
compileSdkVersion = signalCompileSdkVersion
ndkVersion = signalNdkVersion
flavorDimensions += listOf("distribution", "environment")
useLibrary("org.apache.http.legacy")
@@ -89,6 +91,7 @@ android {
kotlinOptions {
jvmTarget = signalKotlinJvmTarget
freeCompilerArgs = listOf("-Xjvm-default=all")
}
keystores["debug"]?.let { properties ->
@@ -175,8 +178,6 @@ android {
minSdk = signalMinSdkVersion
targetSdk = signalTargetSdkVersion
multiDexEnabled = true
vectorDrawables.useSupportLibrary = true
project.ext.set("archivesBaseName", "Signal")
@@ -501,7 +502,6 @@ dependencies {
implementation(libs.androidx.compose.runtime.livedata)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.constraintlayout)
implementation(libs.androidx.multidex)
implementation(libs.androidx.navigation.fragment.ktx)
implementation(libs.androidx.navigation.ui.ktx)
implementation(libs.androidx.navigation.compose)
@@ -595,7 +595,6 @@ dependencies {
testImplementation(testLibs.robolectric.robolectric) {
exclude(group = "com.google.protobuf", module = "protobuf-java")
}
testImplementation(testLibs.robolectric.shadows.multidex)
testImplementation(testLibs.bouncycastle.bcprov.jdk15on) {
version {
strictly("1.70")

View File

@@ -10,7 +10,6 @@ import androidx.test.platform.app.InstrumentationRegistry
import com.github.difflib.DiffUtils
import com.github.difflib.UnifiedDiffUtils
import junit.framework.Assert.assertTrue
import org.junit.Ignore
import org.junit.Test
import org.junit.runner.RunWith
import org.signal.core.util.Base64
@@ -40,84 +39,175 @@ class ArchiveImportExportTests {
const val TAG = "ImportExport"
const val TESTS_FOLDER = "backupTests"
val SELF_ACI = ServiceId.ACI.from(UUID(100, 100))
val SELF_PNI = ServiceId.PNI.from(UUID(101, 101))
val SELF_ACI = ServiceId.ACI.from(UUID.fromString("00000000-0000-4000-8000-000000000001"))
val SELF_PNI = ServiceId.PNI.from(UUID.fromString("00000000-0000-4000-8000-000000000002"))
val SELF_E164 = "+10000000000"
val SELF_PROFILE_KEY: ByteArray = Base64.decode("YQKRq+3DQklInaOaMcmlzZnN0m/1hzLiaONX7gB12dg=")
val MASTER_KEY = Base64.decode("sHuBMP4ToZk4tcNU+S8eBUeCt8Am5EZnvuqTBJIR4Do")
}
@Test
// @Test
fun all() {
runTests()
}
@Ignore("Just for debugging")
@Test
fun temp() {
runTests { it == "chat_item_standard_message_formatted_text_03.binproto" }
}
// Passing
// @Test
fun accountData() {
runTests { it.startsWith("account_data_") }
}
@Ignore("Just for debugging")
@Test
fun adHocCall() {
runTests { it.startsWith("ad_hoc_call") }
}
// Passing
// @Test
fun chat() {
runTests { it.startsWith("chat_") && !it.contains("_item") }
}
// Passing
// @Test
fun chatItemContactMessage() {
runTests { it.startsWith("chat_item_contact_message_") }
}
// Passing
// @Test
fun chatItemExpirationTimerUpdate() {
runTests { it.startsWith("chat_item_expiration_timer_") }
}
// Passing
// @Test
fun chatItemGiftBadge() {
runTests { it.startsWith("chat_item_gift_badge_") }
}
@Test
fun chatItemGroupCallUpdate() {
runTests { it.startsWith("chat_item_group_call_update_") }
}
@Test
fun chatItemIndividualCallUpdate() {
runTests { it.startsWith("chat_item_individual_call_update_") }
}
// Passing
// @Test
fun chatItemLearnedProfileUpdate() {
runTests { it.startsWith("chat_item_learned_profile_update_") }
}
@Test
fun chatItemPaymentNotification() {
runTests { it.startsWith("chat_item_payment_notification_") }
}
// Passing
// @Test
fun chatItemProfileChangeUpdate() {
runTests { it.startsWith("chat_item_profile_change_update_") }
}
// Passing
// @Test
fun chatItemRemoteDelete() {
runTests { it.startsWith("chat_item_remote_delete_") }
}
// Passing
// @Test
fun chatItemSessionSwitchoverUpdate() {
runTests { it.startsWith("chat_item_session_switchover_update_") }
}
@Test
fun chatItemSimpleUpdates() {
runTests { it.startsWith("chat_item_simple_updates_") }
}
// Passing
// @Test
fun chatItemStandardMessageFormattedText() {
runTests { it.startsWith("chat_item_standard_message_formatted_text_") }
}
@Test
fun chatItemStandardMessageLongText() {
runTests { it.startsWith("chat_item_standard_message_long_text_") }
}
// Passing
// @Test
fun chatItemStandardMessageSpecialAttachments() {
runTests { it.startsWith("chat_item_standard_message_special_attachments_") }
}
// Passing
// @Test
fun chatItemStandardMessageStandardAttachments() {
runTests { it.startsWith("chat_item_standard_message_standard_attachments_") }
}
// Passing
// @Test
fun chatItemStandardMessageTextOnly() {
runTests { it.startsWith("chat_item_standard_message_text_only_") }
}
@Test
fun chatItemStandardMessageWithEdits() {
runTests { it.startsWith("chat_item_standard_message_with_edits_") }
}
@Test
fun chatItemStandardMessageWithQuote() {
runTests { it.startsWith("chat_item_standard_message_with_quote_") }
}
@Test
fun chatItemStickerMessage() {
runTests { it.startsWith("chat_item_sticker_message_") }
}
// Passing
// @Test
fun chatItemThreadMergeUpdate() {
runTests { it.startsWith("chat_item_thread_merge_update_") }
}
@Test
fun recipientCallLink() {
runTests { it.startsWith("recipient_call_link_") }
}
// Passing
// @Test
fun recipientContacts() {
runTests { it.startsWith("recipient_contacts_") }
}
@Ignore("Just for debugging")
@Test
// Passing
// @Test
fun recipientDistributionLists() {
runTests { it.startsWith("recipient_distribution_list_") }
}
@Ignore("Just for debugging")
@Test
// Passing
// @Test
fun recipientGroups() {
runTests { it.startsWith("recipient_groups_") }
}
@Ignore("Just for debugging")
@Test
fun chatStandardMessageTextOnly() {
runTests { it.startsWith("chat_standard_message_text_only_") }
}
@Ignore("Just for debugging")
@Test
fun chatStandardMessageFormattedText() {
runTests { it.startsWith("chat_standard_message_formatted_text_") }
}
@Ignore("Just for debugging")
@Test
fun chatStandardMessageLongText() {
runTests { it.startsWith("chat_standard_message_long_text_") }
}
@Ignore("Just for debugging")
@Test
fun chatStandardMessageStandardAttachments() {
runTests { it.startsWith("chat_standard_message_standard_attachments_") }
}
@Ignore("Just for debugging")
@Test
fun chatStandardMessageSpecialAttachments() {
runTests { it.startsWith("chat_standard_message_special_attachments_") }
}
@Ignore("Just for debugging")
@Test
fun chatSimpleUpdates() {
runTests { it.startsWith("chat_simple_updates_") }
}
@Ignore("Just for debugging")
@Test
fun chatContactMessage() {
runTests { it.startsWith("chat_contact_message_") }
}
private fun runTests(predicate: (String) -> Boolean = { true }) {
val testFiles = InstrumentationRegistry.getInstrumentation().context.resources.assets.list(TESTS_FOLDER)!!.filter(predicate)
val results: MutableList<TestResult> = mutableListOf()
@@ -152,7 +242,7 @@ class ArchiveImportExportTests {
val message = "Some tests failed! Only $successCount/${results.size} passed. Failure details are above. Failing tests:\n$failingTestNames"
Log.d(TAG, message)
throw AssertionError(message)
throw AssertionError("Some tests failed!")
} else {
Log.d(TAG, "All ${results.size} tests passed!")
}

View File

@@ -1,15 +1,23 @@
package org.thoughtcrime.securesms.database
import android.content.Context
import android.net.Uri
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.FlakyTest
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Before
import org.junit.Ignore
import org.junit.Test
import org.junit.runner.RunWith
import org.signal.core.util.copyTo
import org.signal.core.util.readFully
import org.signal.core.util.stream.NullOutputStream
import org.thoughtcrime.securesms.attachments.Attachment
import org.thoughtcrime.securesms.attachments.AttachmentId
import org.thoughtcrime.securesms.attachments.PointerAttachment
import org.thoughtcrime.securesms.attachments.UriAttachment
import org.thoughtcrime.securesms.mms.MediaStream
import org.thoughtcrime.securesms.mms.SentMediaQuality
@@ -17,6 +25,15 @@ import org.thoughtcrime.securesms.providers.BlobProvider
import org.thoughtcrime.securesms.testing.assertIs
import org.thoughtcrime.securesms.testing.assertIsNot
import org.thoughtcrime.securesms.util.MediaUtil
import org.thoughtcrime.securesms.util.Util
import org.whispersystems.signalservice.api.crypto.AttachmentCipherInputStream
import org.whispersystems.signalservice.api.crypto.AttachmentCipherOutputStream
import org.whispersystems.signalservice.api.crypto.NoCipherOutputStream
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentPointer
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentRemoteId
import org.whispersystems.signalservice.internal.crypto.PaddingInputStream
import java.io.ByteArrayOutputStream
import java.io.File
import java.util.Optional
@RunWith(AndroidJUnit4::class)
@@ -163,6 +180,91 @@ class AttachmentTableTest {
highInfo.file.exists() assertIs true
}
@Test
fun finalizeAttachmentAfterDownload_fixDigestOnNonZeroPadding() {
// Insert attachment metadata for badly-padded attachment
val plaintext = byteArrayOf(1, 2, 3, 4)
val key = Util.getSecretBytes(64)
val iv = Util.getSecretBytes(16)
val badlyPaddedPlaintext = PaddingInputStream(plaintext.inputStream(), plaintext.size.toLong()).readFully().also { it[it.size - 1] = 0x42 }
val badlyPaddedCiphertext = encryptPrePaddedBytes(badlyPaddedPlaintext, key, iv)
val badlyPaddedDigest = getDigest(badlyPaddedCiphertext)
val cipherFile = getTempFile()
cipherFile.writeBytes(badlyPaddedCiphertext)
val mmsId = -1L
val attachmentId = SignalDatabase.attachments.insertAttachmentsForMessage(mmsId, listOf(createAttachmentPointer(key, badlyPaddedDigest, plaintext.size)), emptyList()).values.first()
// Give data to attachment table
val cipherInputStream = AttachmentCipherInputStream.createForAttachment(cipherFile, plaintext.size.toLong(), key, badlyPaddedDigest, null, 4, false)
SignalDatabase.attachments.finalizeAttachmentAfterDownload(mmsId, attachmentId, cipherInputStream, iv)
// Verify the digest has been updated to the properly padded one
val properlyPaddedPlaintext = PaddingInputStream(plaintext.inputStream(), plaintext.size.toLong()).readFully()
val properlyPaddedCiphertext = encryptPrePaddedBytes(properlyPaddedPlaintext, key, iv)
val properlyPaddedDigest = getDigest(properlyPaddedCiphertext)
val newDigest = SignalDatabase.attachments.getAttachment(attachmentId)!!.remoteDigest!!
assertArrayEquals(properlyPaddedDigest, newDigest)
}
@Test
fun finalizeAttachmentAfterDownload_leaveDigestAloneForAllZeroPadding() {
// Insert attachment metadata for properly-padded attachment
val plaintext = byteArrayOf(1, 2, 3, 4)
val key = Util.getSecretBytes(64)
val iv = Util.getSecretBytes(16)
val paddedPlaintext = PaddingInputStream(plaintext.inputStream(), plaintext.size.toLong()).readFully()
val ciphertext = encryptPrePaddedBytes(paddedPlaintext, key, iv)
val digest = getDigest(ciphertext)
val cipherFile = getTempFile()
cipherFile.writeBytes(ciphertext)
val mmsId = -1L
val attachmentId = SignalDatabase.attachments.insertAttachmentsForMessage(mmsId, listOf(createAttachmentPointer(key, digest, plaintext.size)), emptyList()).values.first()
// Give data to attachment table
val cipherInputStream = AttachmentCipherInputStream.createForAttachment(cipherFile, plaintext.size.toLong(), key, digest, null, 4, false)
SignalDatabase.attachments.finalizeAttachmentAfterDownload(mmsId, attachmentId, cipherInputStream, iv)
// Verify the digest hasn't changed
val newDigest = SignalDatabase.attachments.getAttachment(attachmentId)!!.remoteDigest!!
assertArrayEquals(digest, newDigest)
}
private fun createAttachmentPointer(key: ByteArray, digest: ByteArray, size: Int): Attachment {
return PointerAttachment.forPointer(
pointer = Optional.of(
SignalServiceAttachmentPointer(
cdnNumber = 3,
remoteId = SignalServiceAttachmentRemoteId.V4("asdf"),
contentType = MediaUtil.IMAGE_JPEG,
key = key,
size = Optional.of(size),
preview = Optional.empty(),
width = 2,
height = 2,
digest = Optional.of(digest),
incrementalDigest = Optional.empty(),
incrementalMacChunkSize = 0,
fileName = Optional.of("file.jpg"),
voiceNote = false,
isBorderless = false,
isGif = false,
caption = Optional.empty(),
blurHash = Optional.empty(),
uploadTimestamp = 0,
uuid = null
)
)
).get()
}
private fun createAttachment(id: Long, uri: Uri, transformProperties: AttachmentTable.TransformProperties): UriAttachment {
return UriAttachmentBuilder.build(
id,
@@ -179,4 +281,24 @@ class AttachmentTableTest {
private fun createMediaStream(byteArray: ByteArray): MediaStream {
return MediaStream(byteArray.inputStream(), MediaUtil.IMAGE_JPEG, 2, 2)
}
private fun getDigest(ciphertext: ByteArray): ByteArray {
val digestStream = NoCipherOutputStream(NullOutputStream)
ciphertext.inputStream().copyTo(digestStream)
return digestStream.transmittedDigest
}
private fun encryptPrePaddedBytes(plaintext: ByteArray, key: ByteArray, iv: ByteArray): ByteArray {
val outputStream = ByteArrayOutputStream()
val cipherStream = AttachmentCipherOutputStream(key, iv, outputStream)
plaintext.inputStream().copyTo(cipherStream)
return outputStream.toByteArray()
}
private fun getTempFile(): File {
val dir = InstrumentationRegistry.getInstrumentation().targetContext.getDir("temp", Context.MODE_PRIVATE)
dir.mkdir()
return File.createTempFile("transfer", ".mms", dir)
}
}

View File

@@ -15,7 +15,6 @@ import org.signal.core.util.Base64
import org.signal.core.util.update
import org.thoughtcrime.securesms.attachments.AttachmentId
import org.thoughtcrime.securesms.attachments.Cdn
import org.thoughtcrime.securesms.attachments.PointerAttachment
import org.thoughtcrime.securesms.backup.v2.BackupRepository.getMediaName
import org.thoughtcrime.securesms.database.AttachmentTable.TransformProperties
import org.thoughtcrime.securesms.keyvalue.SignalStore
@@ -27,7 +26,9 @@ import org.thoughtcrime.securesms.providers.BlobProvider
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.MediaUtil
import org.thoughtcrime.securesms.util.Util
import org.whispersystems.signalservice.api.attachment.AttachmentUploadResult
import org.whispersystems.signalservice.api.backup.MediaId
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentRemoteId
import org.whispersystems.signalservice.api.push.ServiceId
import java.io.File
import java.util.UUID
@@ -110,15 +111,6 @@ class AttachmentTableTest_deduping {
assertDataFilesAreDifferent(id1, id2)
assertDataHashStartMatches(id1, id2)
}
// Non-matching mp4 fast start
test {
val id1 = insertWithData(DATA_A, TransformProperties(mp4FastStart = true))
val id2 = insertWithData(DATA_A, TransformProperties(mp4FastStart = false))
assertDataFilesAreDifferent(id1, id2)
assertDataHashStartMatches(id1, id2)
}
}
/**
@@ -261,6 +253,15 @@ class AttachmentTableTest_deduping {
assertDoesNotHaveRemoteFields(id2)
assertArchiveFieldsMatch(id1, id2)
upload(id2)
assertDataFilesAreTheSame(id1, id2)
assertDataHashStartMatches(id1, id2)
assertDataHashEndMatches(id1, id2)
assertSkipTransform(id1, true)
assertSkipTransform(id2, true)
assertRemoteFieldsMatch(id1, id2)
}
// This isn't so much "desirable behavior" as it is documenting how things work.
@@ -661,7 +662,8 @@ class AttachmentTableTest_deduping {
}
fun upload(attachmentId: AttachmentId, uploadTimestamp: Long = System.currentTimeMillis()) {
SignalDatabase.attachments.finalizeAttachmentAfterUpload(attachmentId, createPointerAttachment(attachmentId, uploadTimestamp), uploadTimestamp)
SignalDatabase.attachments.createKeyIvIfNecessary(attachmentId)
SignalDatabase.attachments.finalizeAttachmentAfterUpload(attachmentId, createUploadResult(attachmentId, uploadTimestamp))
val attachment = SignalDatabase.attachments.getAttachment(attachmentId)!!
SignalDatabase.attachments.setArchiveData(
@@ -763,6 +765,7 @@ class AttachmentTableTest_deduping {
assertEquals(lhsAttachment.remoteLocation, rhsAttachment.remoteLocation)
assertEquals(lhsAttachment.remoteKey, rhsAttachment.remoteKey)
assertArrayEquals(lhsAttachment.remoteIv, rhsAttachment.remoteIv)
assertArrayEquals(lhsAttachment.remoteDigest, rhsAttachment.remoteDigest)
assertArrayEquals(lhsAttachment.incrementalDigest, rhsAttachment.incrementalDigest)
assertEquals(lhsAttachment.incrementalMacChunkSize, rhsAttachment.incrementalMacChunkSize)
@@ -796,36 +799,19 @@ class AttachmentTableTest_deduping {
return MediaStream(this.inputStream(), MediaUtil.IMAGE_JPEG, 2, 2)
}
private fun createPointerAttachment(attachmentId: AttachmentId, uploadTimestamp: Long = System.currentTimeMillis()): PointerAttachment {
val location = "somewhere-${Random.nextLong()}"
val key = "somekey-${Random.nextLong()}"
val digest = Random.nextBytes(32)
val incrementalDigest = Random.nextBytes(16)
private fun createUploadResult(attachmentId: AttachmentId, uploadTimestamp: Long = System.currentTimeMillis()): AttachmentUploadResult {
val databaseAttachment = SignalDatabase.attachments.getAttachment(attachmentId)!!
return PointerAttachment(
"image/jpeg",
AttachmentTable.TRANSFER_PROGRESS_DONE,
databaseAttachment.size, // size
null,
Cdn.CDN_3, // cdnNumber
location,
key,
digest,
incrementalDigest,
5, // incrementalMacChunkSize
null,
databaseAttachment.voiceNote,
databaseAttachment.borderless,
databaseAttachment.videoGif,
databaseAttachment.width,
databaseAttachment.height,
uploadTimestamp,
databaseAttachment.caption,
databaseAttachment.stickerLocator,
databaseAttachment.blurHash,
databaseAttachment.uuid
return AttachmentUploadResult(
remoteId = SignalServiceAttachmentRemoteId.V4("somewhere-${Random.nextLong()}"),
cdnNumber = Cdn.CDN_3.cdnNumber,
key = databaseAttachment.remoteKey?.let { Base64.decode(it) } ?: Util.getSecretBytes(64),
iv = databaseAttachment.remoteIv ?: Util.getSecretBytes(16),
digest = Random.nextBytes(32),
incrementalDigest = Random.nextBytes(16),
incrementalDigestChunkSize = 5,
uploadTimestamp = uploadTimestamp,
dataSize = databaseAttachment.size
)
}
}

View File

@@ -86,7 +86,8 @@ class CallLinkTableTest {
linkKeyBytes = roomId,
adminPassBytes = null
),
state = SignalCallLinkState()
state = SignalCallLinkState(),
deletionTimestamp = 0L
)
)

View File

@@ -8,7 +8,6 @@ import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.signal.core.util.concurrent.SignalExecutors
import org.thoughtcrime.securesms.testing.SignalFlakyTest
import org.thoughtcrime.securesms.testing.SignalFlakyTestRule
import java.util.concurrent.CountDownLatch
import java.util.concurrent.atomic.AtomicBoolean
@@ -187,8 +186,8 @@ class SQLiteDatabaseTest {
assertTrue(hasRun2.get())
}
@SignalFlakyTest
@Test
// @SignalFlakyTest
// @Test
fun runPostSuccessfulTransaction_runsAfterMainTransactionInNestedTransaction() {
val hasRun1 = AtomicBoolean(false)
val hasRun2 = AtomicBoolean(false)

View File

@@ -34,6 +34,7 @@ import org.whispersystems.signalservice.internal.configuration.SignalServiceConf
import org.whispersystems.signalservice.internal.configuration.SignalServiceUrl
import org.whispersystems.signalservice.internal.configuration.SignalStorageUrl
import org.whispersystems.signalservice.internal.configuration.SignalSvr2Url
import org.whispersystems.signalservice.internal.push.PushServiceSocket
import java.util.Optional
/**
@@ -112,10 +113,10 @@ class InstrumentationApplicationDependencyProvider(val application: Application,
override fun provideSignalServiceMessageSender(
signalWebSocket: SignalWebSocket,
protocolStore: SignalServiceDataStore,
signalServiceConfiguration: SignalServiceConfiguration
pushServiceSocket: PushServiceSocket
): SignalServiceMessageSender {
if (signalServiceMessageSender == null) {
signalServiceMessageSender = spyk(objToCopy = default.provideSignalServiceMessageSender(signalWebSocket, protocolStore, signalServiceConfiguration))
signalServiceMessageSender = spyk(objToCopy = default.provideSignalServiceMessageSender(signalWebSocket, protocolStore, pushServiceSocket))
}
return signalServiceMessageSender!!
}

View File

@@ -77,7 +77,7 @@ class EditMessageSyncProcessorTest {
.build()
).build()
).build()
SignalDatabase.recipients.setExpireMessages(toRecipient.id, content.dataMessage?.expireTimer ?: 0)
SignalDatabase.recipients.setExpireMessages(toRecipient.id, content.dataMessage?.expireTimer ?: 0, content.dataMessage?.expireTimerVersion ?: 1)
val syncTextMessage = TestMessage(
envelope = MessageContentFuzzer.envelope(originalTimestamp),
content = syncContent,
@@ -112,7 +112,7 @@ class EditMessageSyncProcessorTest {
testResult.runSync(listOf(syncTextMessage, syncEditMessage))
SignalDatabase.recipients.setExpireMessages(toRecipient.id, (content.dataMessage?.expireTimer ?: 0) / 1000)
SignalDatabase.recipients.setExpireMessages(toRecipient.id, (content.dataMessage?.expireTimer ?: 0) / 1000, content.dataMessage?.expireTimerVersion ?: 1)
val originalTextMessage = OutgoingMessage(
threadRecipient = toRecipient,
sentTimeMillis = originalTimestamp,

View File

@@ -13,6 +13,7 @@ import org.junit.Ignore
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.signal.core.util.Base64
import org.signal.core.util.logging.Log
import org.signal.core.util.update
import org.signal.core.util.withinTransaction
@@ -33,6 +34,9 @@ import org.thoughtcrime.securesms.testing.assertIsNot
import org.thoughtcrime.securesms.testing.assertIsNotNull
import org.thoughtcrime.securesms.testing.assertIsSize
import org.thoughtcrime.securesms.util.IdentityUtil
import org.thoughtcrime.securesms.util.Util
import org.whispersystems.signalservice.api.attachment.AttachmentUploadResult
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentRemoteId
import java.util.UUID
@Suppress("ClassName")
@@ -574,30 +578,35 @@ class SyncMessageProcessorTest_synchronizeDeleteForMe {
// Has all three
SignalDatabase.attachments.finalizeAttachmentAfterUpload(
id = attachments[0].attachmentId,
attachment = attachments[0].copy(digest = byteArrayOf(attachments[0].attachmentId.id.toByte())),
uploadTimestamp = message1.timestamp + 1
uploadResult = attachments[0].toUploadResult(
digest = byteArrayOf(attachments[0].attachmentId.id.toByte()),
uploadTimestamp = message1.timestamp + 1
)
)
// Missing uuid and digest
SignalDatabase.attachments.finalizeAttachmentAfterUpload(
id = attachments[1].attachmentId,
attachment = attachments[1],
uploadTimestamp = message1.timestamp + 1
uploadResult = attachments[1].toUploadResult(uploadTimestamp = message1.timestamp + 1)
)
// Missing uuid and plain text
SignalDatabase.attachments.finalizeAttachmentAfterUpload(
id = attachments[2].attachmentId,
attachment = attachments[2].copy(digest = byteArrayOf(attachments[2].attachmentId.id.toByte())),
uploadTimestamp = message1.timestamp + 1
uploadResult = attachments[2].toUploadResult(
digest = byteArrayOf(attachments[2].attachmentId.id.toByte()),
uploadTimestamp = message1.timestamp + 1
)
)
SignalDatabase.rawDatabase.update(AttachmentTable.TABLE_NAME).values(AttachmentTable.DATA_HASH_END to null).where("${AttachmentTable.ID} = ?", attachments[2].attachmentId).run()
// Different has all three
SignalDatabase.attachments.finalizeAttachmentAfterUpload(
id = attachments[3].attachmentId,
attachment = attachments[3].copy(digest = byteArrayOf(attachments[3].attachmentId.id.toByte())),
uploadTimestamp = message1.timestamp + 1
uploadResult = attachments[3].toUploadResult(
digest = byteArrayOf(attachments[3].attachmentId.id.toByte()),
uploadTimestamp = message1.timestamp + 1
)
)
attachments = SignalDatabase.attachments.getAttachmentsForMessage(message1.messageId)
@@ -674,6 +683,7 @@ class SyncMessageProcessorTest_synchronizeDeleteForMe {
cdn = this.cdn,
location = this.remoteLocation,
key = this.remoteKey,
iv = this.remoteIv,
digest = digest,
incrementalDigest = this.incrementalDigest,
incrementalMacChunkSize = this.incrementalMacChunkSize,
@@ -693,11 +703,28 @@ class SyncMessageProcessorTest_synchronizeDeleteForMe {
uploadTimestamp = this.uploadTimestamp,
dataHash = this.dataHash,
archiveCdn = this.archiveCdn,
archiveThumbnailCdn = this.archiveThumbnailCdn,
archiveMediaName = this.archiveMediaName,
archiveMediaId = this.archiveMediaId,
thumbnailRestoreState = this.thumbnailRestoreState,
archiveTransferState = this.archiveTransferState,
uuid = uuid
)
}
private fun Attachment.toUploadResult(
digest: ByteArray = this.remoteDigest ?: byteArrayOf(),
uploadTimestamp: Long = this.uploadTimestamp
): AttachmentUploadResult {
return AttachmentUploadResult(
remoteId = SignalServiceAttachmentRemoteId.V4(this.remoteLocation ?: "some-location"),
cdnNumber = this.cdn.cdnNumber,
key = this.remoteKey?.let { Base64.decode(it) } ?: Util.getSecretBytes(64),
iv = this.remoteIv ?: Util.getSecretBytes(16),
digest = digest,
incrementalDigest = this.incrementalDigest,
incrementalDigestChunkSize = this.incrementalMacChunkSize,
dataSize = this.size,
uploadTimestamp = uploadTimestamp
)
}
}

View File

@@ -8,6 +8,7 @@ import android.content.SharedPreferences
import android.preference.PreferenceManager
import androidx.test.core.app.ActivityScenario
import androidx.test.platform.app.InstrumentationRegistry
import kotlinx.coroutines.runBlocking
import okhttp3.mockwebserver.MockResponse
import org.junit.rules.ExternalResource
import org.signal.libsignal.protocol.IdentityKey
@@ -25,18 +26,15 @@ import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.profiles.ProfileName
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.registration.RegistrationData
import org.thoughtcrime.securesms.registration.RegistrationRepository
import org.thoughtcrime.securesms.registration.RegistrationUtil
import org.thoughtcrime.securesms.registration.VerifyResponse
import org.thoughtcrime.securesms.registration.data.LocalRegistrationMetadataUtil
import org.thoughtcrime.securesms.registration.data.RegistrationData
import org.thoughtcrime.securesms.registration.data.RegistrationRepository
import org.thoughtcrime.securesms.registration.util.RegistrationUtil
import org.thoughtcrime.securesms.testing.GroupTestingUtils.asMember
import org.thoughtcrime.securesms.util.Util
import org.whispersystems.signalservice.api.profiles.SignalServiceProfile
import org.whispersystems.signalservice.api.push.ServiceId.ACI
import org.whispersystems.signalservice.api.push.SignalServiceAddress
import org.whispersystems.signalservice.internal.ServiceResponse
import org.whispersystems.signalservice.internal.ServiceResponseProcessor
import org.whispersystems.signalservice.internal.push.VerifyAccountResponse
import java.util.UUID
/**
@@ -48,6 +46,7 @@ import java.util.UUID
class SignalActivityRule(private val othersCount: Int = 4, private val createGroup: Boolean = false) : ExternalResource() {
val application: Application = AppDependencies.application
private val TEST_E164 = "+15555550101"
lateinit var context: Context
private set
@@ -93,31 +92,31 @@ class SignalActivityRule(private val othersCount: Int = 4, private val createGro
SignalStore.account.generateAciIdentityKeyIfNecessary()
SignalStore.account.generatePniIdentityKeyIfNecessary()
val registrationRepository = RegistrationRepository(application)
InstrumentationApplicationDependencyProvider.addMockWebRequestHandlers(Put("/v2/keys") { MockResponse().success() })
val response: ServiceResponse<VerifyResponse> = registrationRepository.registerAccount(
RegistrationData(
runBlocking {
val registrationData = RegistrationData(
code = "123123",
e164 = "+15555550101",
e164 = TEST_E164,
password = Util.getSecret(18),
registrationId = registrationRepository.registrationId,
profileKey = registrationRepository.getProfileKey("+15555550101"),
registrationId = RegistrationRepository.getRegistrationId(),
profileKey = RegistrationRepository.getProfileKey(TEST_E164),
fcmToken = null,
pniRegistrationId = registrationRepository.pniRegistrationId,
pniRegistrationId = RegistrationRepository.getPniRegistrationId(),
recoveryPassword = "asdfasdfasdfasdf"
),
VerifyResponse(
verifyAccountResponse = VerifyAccountResponse(UUID.randomUUID().toString(), UUID.randomUUID().toString(), false),
)
val remoteResult = RegistrationRepository.AccountRegistrationResult(
uuid = UUID.randomUUID().toString(),
pni = UUID.randomUUID().toString(),
storageCapable = false,
number = TEST_E164,
masterKey = null,
pin = null,
aciPreKeyCollection = RegistrationRepository.generateSignedAndLastResortPreKeys(SignalStore.account.aciIdentityKey, SignalStore.account.aciPreKeys),
pniPreKeyCollection = RegistrationRepository.generateSignedAndLastResortPreKeys(SignalStore.account.aciIdentityKey, SignalStore.account.pniPreKeys)
),
false
).blockingGet()
ServiceResponseProcessor.DefaultProcessor(response).resultOrThrow
)
val localRegistrationData = LocalRegistrationMetadataUtil.createLocalRegistrationMetadata(SignalStore.account.aciIdentityKey, SignalStore.account.pniIdentityKey, registrationData, remoteResult, false)
RegistrationRepository.registerAccountLocally(application, localRegistrationData)
}
SignalStore.svr.optOut()
RegistrationUtil.maybeMarkRegistrationComplete()
@@ -141,7 +140,7 @@ class SignalActivityRule(private val othersCount: Int = 4, private val createGro
val recipientId = RecipientId.from(SignalServiceAddress(aci, "+15555551%03d".format(i)))
SignalDatabase.recipients.setProfileName(recipientId, ProfileName.fromParts("Buddy", "#$i"))
SignalDatabase.recipients.setProfileKeyIfAbsent(recipientId, ProfileKeyUtil.createNew())
SignalDatabase.recipients.setCapabilities(recipientId, SignalServiceProfile.Capabilities(true, false))
SignalDatabase.recipients.setCapabilities(recipientId, SignalServiceProfile.Capabilities(true, false, true))
SignalDatabase.recipients.setProfileSharing(recipientId, true)
SignalDatabase.recipients.markRegistered(recipientId, aci)
val otherIdentity = IdentityKeyUtil.generateIdentityKeyPair()

View File

@@ -1,40 +0,0 @@
package org.signal.benchmark
import android.content.Context
import org.thoughtcrime.securesms.BuildConfig
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.push.AccountManagerFactory
import org.thoughtcrime.securesms.util.RemoteConfig
import org.whispersystems.signalservice.api.SignalServiceAccountManager
import org.whispersystems.signalservice.api.account.PreKeyUpload
import org.whispersystems.signalservice.api.push.ServiceId.ACI
import org.whispersystems.signalservice.api.push.ServiceId.PNI
import org.whispersystems.signalservice.internal.configuration.SignalServiceConfiguration
import java.io.IOException
import java.util.Optional
class DummyAccountManagerFactory : AccountManagerFactory() {
override fun createAuthenticated(context: Context, aci: ACI, pni: PNI, number: String, deviceId: Int, password: String): SignalServiceAccountManager {
return DummyAccountManager(
AppDependencies.signalServiceNetworkAccess.getConfiguration(number),
aci,
pni,
number,
deviceId,
password,
BuildConfig.SIGNAL_AGENT,
RemoteConfig.okHttpAutomaticRetry,
RemoteConfig.groupLimits.hardLimit
)
}
private class DummyAccountManager(configuration: SignalServiceConfiguration?, aci: ACI?, pni: PNI?, e164: String?, deviceId: Int, password: String?, signalAgent: String?, automaticNetworkRetry: Boolean, maxGroupSize: Int) : SignalServiceAccountManager(configuration, aci, pni, e164, deviceId, password, signalAgent, automaticNetworkRetry, maxGroupSize) {
@Throws(IOException::class)
override fun setGcmId(gcmRegistrationId: Optional<String>) {
}
@Throws(IOException::class)
override fun setPreKeys(preKeyUpload: PreKeyUpload) {
}
}
}

View File

@@ -3,8 +3,7 @@ package org.signal.benchmark.setup
import android.app.Application
import android.content.SharedPreferences
import android.preference.PreferenceManager
import org.signal.benchmark.DummyAccountManagerFactory
import org.signal.core.util.concurrent.safeBlockingGet
import kotlinx.coroutines.runBlocking
import org.signal.libsignal.protocol.SignalProtocolAddress
import org.thoughtcrime.securesms.crypto.IdentityKeyUtil
import org.thoughtcrime.securesms.crypto.MasterSecretUtil
@@ -14,25 +13,22 @@ import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.net.DeviceTransferBlockingInterceptor
import org.thoughtcrime.securesms.profiles.ProfileName
import org.thoughtcrime.securesms.push.AccountManagerFactory
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.registration.RegistrationData
import org.thoughtcrime.securesms.registration.RegistrationRepository
import org.thoughtcrime.securesms.registration.RegistrationUtil
import org.thoughtcrime.securesms.registration.VerifyResponse
import org.thoughtcrime.securesms.registration.data.LocalRegistrationMetadataUtil
import org.thoughtcrime.securesms.registration.data.RegistrationData
import org.thoughtcrime.securesms.registration.data.RegistrationRepository
import org.thoughtcrime.securesms.registration.util.RegistrationUtil
import org.thoughtcrime.securesms.util.Util
import org.whispersystems.signalservice.api.profiles.SignalServiceProfile
import org.whispersystems.signalservice.api.push.ServiceId.ACI
import org.whispersystems.signalservice.api.push.SignalServiceAddress
import org.whispersystems.signalservice.internal.ServiceResponse
import org.whispersystems.signalservice.internal.ServiceResponseProcessor
import org.whispersystems.signalservice.internal.push.VerifyAccountResponse
import java.util.UUID
object TestUsers {
private var generatedOthers: Int = 0
private val TEST_E164 = "+15555550101"
fun setupSelf(): Recipient {
val application: Application = AppDependencies.application
@@ -47,35 +43,30 @@ object TestUsers {
SignalStore.account.generateAciIdentityKeyIfNecessary()
SignalStore.account.generatePniIdentityKeyIfNecessary()
val registrationRepository = RegistrationRepository(application)
val registrationData = RegistrationData(
code = "123123",
e164 = "+15555550101",
password = Util.getSecret(18),
registrationId = registrationRepository.registrationId,
profileKey = registrationRepository.getProfileKey("+15555550101"),
fcmToken = "fcm-token",
pniRegistrationId = registrationRepository.pniRegistrationId,
recoveryPassword = "asdfasdfasdfasdf"
)
val verifyResponse = VerifyResponse(
VerifyAccountResponse(UUID.randomUUID().toString(), UUID.randomUUID().toString(), false),
masterKey = null,
pin = null,
aciPreKeyCollection = RegistrationRepository.generateSignedAndLastResortPreKeys(SignalStore.account.aciIdentityKey, SignalStore.account.aciPreKeys),
pniPreKeyCollection = RegistrationRepository.generateSignedAndLastResortPreKeys(SignalStore.account.aciIdentityKey, SignalStore.account.pniPreKeys)
)
AccountManagerFactory.setInstance(DummyAccountManagerFactory())
val response: ServiceResponse<VerifyResponse> = registrationRepository.registerAccount(
registrationData,
verifyResponse,
false
).safeBlockingGet()
ServiceResponseProcessor.DefaultProcessor(response).resultOrThrow
runBlocking {
val registrationData = RegistrationData(
code = "123123",
e164 = TEST_E164,
password = Util.getSecret(18),
registrationId = RegistrationRepository.getRegistrationId(),
profileKey = RegistrationRepository.getProfileKey(TEST_E164),
fcmToken = null,
pniRegistrationId = RegistrationRepository.getPniRegistrationId(),
recoveryPassword = "asdfasdfasdfasdf"
)
val remoteResult = RegistrationRepository.AccountRegistrationResult(
uuid = UUID.randomUUID().toString(),
pni = UUID.randomUUID().toString(),
storageCapable = false,
number = TEST_E164,
masterKey = null,
pin = null,
aciPreKeyCollection = RegistrationRepository.generateSignedAndLastResortPreKeys(SignalStore.account.aciIdentityKey, SignalStore.account.aciPreKeys),
pniPreKeyCollection = RegistrationRepository.generateSignedAndLastResortPreKeys(SignalStore.account.aciIdentityKey, SignalStore.account.pniPreKeys)
)
val localRegistrationData = LocalRegistrationMetadataUtil.createLocalRegistrationMetadata(SignalStore.account.aciIdentityKey, SignalStore.account.pniIdentityKey, registrationData, remoteResult, false)
RegistrationRepository.registerAccountLocally(application, localRegistrationData)
}
SignalStore.svr.optOut()
RegistrationUtil.maybeMarkRegistrationComplete()
@@ -100,7 +91,7 @@ object TestUsers {
val recipientId = RecipientId.from(SignalServiceAddress(aci, "+15555551%03d".format(i)))
SignalDatabase.recipients.setProfileName(recipientId, ProfileName.fromParts("Buddy", "#$i"))
SignalDatabase.recipients.setProfileKeyIfAbsent(recipientId, ProfileKeyUtil.createNew())
SignalDatabase.recipients.setCapabilities(recipientId, SignalServiceProfile.Capabilities(true, true))
SignalDatabase.recipients.setCapabilities(recipientId, SignalServiceProfile.Capabilities(true, true, true))
SignalDatabase.recipients.setProfileSharing(recipientId, true)
SignalDatabase.recipients.markRegistered(recipientId, aci)
val otherIdentity = IdentityKeyUtil.generateIdentityKeyPair()

View File

@@ -96,6 +96,7 @@ class ConversationElementGenerator {
0,
0,
0,
0,
false,
true,
null,

View File

@@ -1121,6 +1121,12 @@
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
android:exported="false"/>
<activity android:name=".backup.v2.ui.subscription.MessageBackupsCheckoutActivity"
android:theme="@style/Theme.Signal.DayNight.NoActionBar"
android:windowSoftInputMode="adjustResize"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
android:exported="false"/>
<service
android:enabled="true"
android:name=".service.webrtc.WebRtcCallService"

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

@@ -11,7 +11,8 @@ object AppCapabilities {
fun getCapabilities(storageCapable: Boolean): AccountAttributes.Capabilities {
return AccountAttributes.Capabilities(
storage = storageCapable,
deleteSync = true
deleteSync = true,
versionedExpirationTimer = true
)
}
}

View File

@@ -16,13 +16,13 @@
*/
package org.thoughtcrime.securesms;
import android.app.Application;
import android.content.Context;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import androidx.annotation.WorkerThread;
import androidx.multidex.MultiDexApplication;
import com.bumptech.glide.Glide;
import com.google.android.gms.security.ProviderInstaller;
@@ -84,7 +84,7 @@ import org.thoughtcrime.securesms.mms.SignalGlideModule;
import org.thoughtcrime.securesms.providers.BlobProvider;
import org.thoughtcrime.securesms.ratelimit.RateLimitUtil;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.registration.RegistrationUtil;
import org.thoughtcrime.securesms.registration.util.RegistrationUtil;
import org.thoughtcrime.securesms.ringrtc.RingRtcLogger;
import org.thoughtcrime.securesms.service.AnalyzeDatabaseAlarmListener;
import org.thoughtcrime.securesms.service.DirectoryRefreshListener;
@@ -129,7 +129,7 @@ import rxdogtag2.RxDogTag;
*
* @author Moxie Marlinspike
*/
public class ApplicationContext extends MultiDexApplication implements AppForegroundObserver.Listener {
public class ApplicationContext extends Application implements AppForegroundObserver.Listener {
private static final String TAG = Log.tag(ApplicationContext.class);
@@ -167,7 +167,7 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
.addBlocking("scrubber", () -> Scrubber.setIdentifierHmacKeyProvider(() -> SignalStore.svr().getOrCreateMasterKey().deriveLoggingKey()))
.addBlocking("first-launch", this::initializeFirstEverAppLaunch)
.addBlocking("app-migrations", this::initializeApplicationMigrations)
.addBlocking("lifecycle-observer", () -> AppDependencies.getAppForegroundObserver().addListener(this))
.addBlocking("lifecycle-observer", () -> AppForegroundObserver.addListener(this))
.addBlocking("message-retriever", this::initializeMessageRetrieval)
.addBlocking("dynamic-theme", () -> DynamicTheme.setDefaultDayNightMode(this))
.addBlocking("proxy-init", () -> {
@@ -366,7 +366,11 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
@VisibleForTesting
void initializeAppDependencies() {
AppDependencies.init(this, new ApplicationDependencyProvider(this));
if (!AppDependencies.isInitialized()) {
Log.i(TAG, "Initializing AppDependencies.");
AppDependencies.init(this, new ApplicationDependencyProvider(this));
}
AppForegroundObserver.begin();
}
private void initializeFirstEverAppLaunch() {

View File

@@ -35,8 +35,9 @@ class BiometricDeviceAuthentication(
private val DISALLOWED_BIOMETRIC_VERSIONS = setOf(28, 29)
}
fun canAuthenticate(): Boolean {
return biometricManager.canAuthenticate(ALLOWED_AUTHENTICATORS) == BiometricManager.BIOMETRIC_SUCCESS
fun canAuthenticate(context: Context): Boolean {
val isKeyGuardSecure = ServiceUtil.getKeyguardManager(context).isKeyguardSecure
return isKeyGuardSecure && biometricManager.canAuthenticate(ALLOWED_AUTHENTICATORS) == BiometricManager.BIOMETRIC_SUCCESS
}
fun authenticate(context: Context, force: Boolean, showConfirmDeviceCredentialIntent: () -> Unit): Boolean {

View File

@@ -53,6 +53,7 @@ import org.signal.core.util.concurrent.LifecycleDisposable;
import org.signal.core.util.concurrent.RxExtensions;
import org.signal.core.util.concurrent.SimpleTask;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.calls.YouAreAlreadyInACallSnackbar;
import org.thoughtcrime.securesms.components.RecyclerViewFastScroller;
import org.thoughtcrime.securesms.contacts.ContactChipViewModel;
import org.thoughtcrime.securesms.contacts.ContactSelectionDisplayMode;
@@ -1009,12 +1010,16 @@ public final class ContactSelectionListFragment extends LoggingFragment {
private class CallButtonClickCallbacks implements ContactSearchAdapter.CallButtonClickCallbacks {
@Override
public void onVideoCallButtonClicked(@NonNull Recipient recipient) {
CommunicationActions.startVideoCall(ContactSelectionListFragment.this, recipient);
CommunicationActions.startVideoCall(ContactSelectionListFragment.this, recipient, () -> {
YouAreAlreadyInACallSnackbar.show(requireView());
});
}
@Override
public void onAudioCallButtonClicked(@NonNull Recipient recipient) {
CommunicationActions.startVoiceCall(ContactSelectionListFragment.this, recipient);
CommunicationActions.startVoiceCall(ContactSelectionListFragment.this, recipient, () -> {
YouAreAlreadyInACallSnackbar.show(requireView());
});
}
}

View File

@@ -18,6 +18,7 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import org.signal.core.util.concurrent.LifecycleDisposable;
import org.signal.donations.StripeApi;
import org.thoughtcrime.securesms.calls.YouAreAlreadyInACallSnackbar;
import org.thoughtcrime.securesms.components.DebugLogsPromptDialogFragment;
import org.thoughtcrime.securesms.components.DeviceSpecificNotificationBottomSheet;
import org.thoughtcrime.securesms.components.PromptBatterySaverDialogFragment;
@@ -243,7 +244,9 @@ public class MainActivity extends PassphraseRequiredActivity implements VoiceNot
private void handleCallLinkInIntent(Intent intent) {
Uri data = intent.getData();
if (data != null) {
CommunicationActions.handlePotentialCallLinkUrl(this, data.toString());
CommunicationActions.handlePotentialCallLinkUrl(this, data.toString(), () -> {
YouAreAlreadyInACallSnackbar.show(findViewById(android.R.id.content));
});
}
}

View File

@@ -39,6 +39,7 @@ import org.signal.core.util.DimensionUnit;
import org.signal.core.util.concurrent.LifecycleDisposable;
import org.signal.core.util.concurrent.SimpleTask;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.calls.YouAreAlreadyInACallSnackbar;
import org.thoughtcrime.securesms.components.menu.ActionItem;
import org.thoughtcrime.securesms.components.menu.SignalContextMenu;
import org.thoughtcrime.securesms.contacts.management.ContactsManagementRepository;
@@ -305,7 +306,9 @@ public class NewConversationActivity extends ContactSelectionActivity
R.drawable.ic_phone_right_24,
getString(R.string.NewConversationActivity__audio_call),
R.color.signal_colorOnSurface,
() -> CommunicationActions.startVoiceCall(this, recipient)
() -> CommunicationActions.startVoiceCall(this, recipient, () -> {
YouAreAlreadyInACallSnackbar.show(findViewById(android.R.id.content));
})
);
} else {
return null;
@@ -321,7 +324,9 @@ public class NewConversationActivity extends ContactSelectionActivity
R.drawable.ic_video_call_24,
getString(R.string.NewConversationActivity__video_call),
R.color.signal_colorOnSurface,
() -> CommunicationActions.startVideoCall(this, recipient)
() -> CommunicationActions.startVideoCall(this, recipient, () -> {
YouAreAlreadyInACallSnackbar.show(findViewById(android.R.id.content));
})
);
}

View File

@@ -31,6 +31,7 @@ import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.registration.ui.RegistrationActivity;
import org.thoughtcrime.securesms.restore.RestoreActivity;
import org.thoughtcrime.securesms.service.KeyCachingService;
import org.thoughtcrime.securesms.util.AppForegroundObserver;
import org.thoughtcrime.securesms.util.AppStartup;
import org.thoughtcrime.securesms.util.RemoteConfig;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
@@ -92,8 +93,8 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
@Override
public void onMasterSecretCleared() {
Log.d(TAG, "onMasterSecretCleared()");
if (AppDependencies.getAppForegroundObserver().isForegrounded()) routeApplicationState(true);
else finish();
if (AppForegroundObserver.isForegrounded()) routeApplicationState(true);
else finish();
}
protected <T extends Fragment> T initFragment(@IdRes int target,
@@ -168,8 +169,6 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
return STATE_WELCOME_PUSH_SCREEN;
} else if (SignalStore.storageService().needsAccountRestore()) {
return STATE_ENTER_SIGNAL_PIN;
} else if (userHasSkippedOrForgottenPin()) {
return STATE_CREATE_SIGNAL_PIN;
} else if (userCanTransferOrRestore()) {
return STATE_TRANSFER_OR_RESTORE;
} else if (userMustSetProfileName()) {
@@ -209,7 +208,7 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
private Intent getPromptPassphraseIntent() {
Intent intent = getRoutedIntent(PassphrasePromptActivity.class, getIntent());
intent.putExtra(PassphrasePromptActivity.FROM_FOREGROUND, AppDependencies.getAppForegroundObserver().isForegrounded());
intent.putExtra(PassphrasePromptActivity.FROM_FOREGROUND, AppForegroundObserver.isForegrounded());
return intent;
}

View File

@@ -84,9 +84,10 @@ import org.thoughtcrime.securesms.components.webrtc.controls.ControlsAndInfoCont
import org.thoughtcrime.securesms.components.webrtc.controls.ControlsAndInfoViewModel;
import org.thoughtcrime.securesms.components.webrtc.participantslist.CallParticipantsListDialog;
import org.thoughtcrime.securesms.components.webrtc.requests.CallLinkIncomingRequestSheet;
import org.thoughtcrime.securesms.components.webrtc.v2.CallEvent;
import org.thoughtcrime.securesms.components.webrtc.v2.CallPermissionsDialogController;
import org.thoughtcrime.securesms.components.webrtc.v2.CallControlsChange;
import org.thoughtcrime.securesms.components.webrtc.v2.CallEvent;
import org.thoughtcrime.securesms.components.webrtc.v2.CallIntent;
import org.thoughtcrime.securesms.components.webrtc.v2.CallPermissionsDialogController;
import org.thoughtcrime.securesms.conversation.ui.error.SafetyNumberChangeDialog;
import org.thoughtcrime.securesms.dependencies.AppDependencies;
import org.thoughtcrime.securesms.events.WebRtcViewModel;
@@ -115,6 +116,7 @@ import org.whispersystems.signalservice.api.messages.calls.HangupMessage;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
@@ -132,23 +134,6 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
private static final int STANDARD_DELAY_FINISH = 1000;
private static final int VIBRATE_DURATION = 50;
/**
* ANSWER the call via voice-only.
*/
public static final String ANSWER_ACTION = WebRtcCallActivity.class.getCanonicalName() + ".ANSWER_ACTION";
/**
* ANSWER the call via video.
*/
public static final String ANSWER_VIDEO_ACTION = WebRtcCallActivity.class.getCanonicalName() + ".ANSWER_VIDEO_ACTION";
public static final String DENY_ACTION = WebRtcCallActivity.class.getCanonicalName() + ".DENY_ACTION";
public static final String END_CALL_ACTION = WebRtcCallActivity.class.getCanonicalName() + ".END_CALL_ACTION";
public static final String EXTRA_ENABLE_VIDEO_IF_AVAILABLE = WebRtcCallActivity.class.getCanonicalName() + ".ENABLE_VIDEO_IF_AVAILABLE";
public static final String EXTRA_STARTED_FROM_FULLSCREEN = WebRtcCallActivity.class.getCanonicalName() + ".STARTED_FROM_FULLSCREEN";
public static final String EXTRA_STARTED_FROM_CALL_LINK = WebRtcCallActivity.class.getCanonicalName() + ".STARTED_FROM_CALL_LINK";
public static final String EXTRA_LAUNCH_IN_PIP = WebRtcCallActivity.class.getCanonicalName() + ".STARTED_FROM_CALL_LINK";
private CallParticipantsListUpdatePopupWindow participantUpdateWindow;
private CallStateUpdatePopupWindow callStateUpdatePopupWindow;
private CallOverflowPopupWindow callOverflowPopupWindow;
@@ -184,7 +169,8 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
@SuppressLint({ "MissingInflatedId" })
@Override
public void onCreate(Bundle savedInstanceState) {
Log.i(TAG, "onCreate(" + getIntent().getBooleanExtra(EXTRA_STARTED_FROM_FULLSCREEN, false) + ")");
CallIntent callIntent = getCallIntent();
Log.i(TAG, "onCreate(" + callIntent.isStartedFromFullScreen() + ")");
lifecycleDisposable = new LifecycleDisposable();
lifecycleDisposable.bindTo(this);
@@ -214,18 +200,18 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
lifecycleDisposable.add(controlsAndInfo);
logIntent(getIntent());
logIntent(callIntent);
if (ANSWER_VIDEO_ACTION.equals(getIntent().getAction())) {
if (callIntent.getAction() == CallIntent.Action.ANSWER_VIDEO) {
enableVideoIfAvailable = true;
} else if (ANSWER_ACTION.equals(getIntent().getAction()) || getIntent().getBooleanExtra(EXTRA_STARTED_FROM_FULLSCREEN, false)) {
} else if (callIntent.getAction() == CallIntent.Action.ANSWER_AUDIO || callIntent.isStartedFromFullScreen()) {
enableVideoIfAvailable = false;
} else {
enableVideoIfAvailable = getIntent().getBooleanExtra(EXTRA_ENABLE_VIDEO_IF_AVAILABLE, false);
getIntent().removeExtra(EXTRA_ENABLE_VIDEO_IF_AVAILABLE);
enableVideoIfAvailable = callIntent.shouldEnableVideoIfAvailable();
callIntent.setShouldEnableVideoIfAvailable(false);
}
processIntent(getIntent());
processIntent(callIntent);
registerSystemPipChangeListeners();
@@ -241,9 +227,12 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
WindowUtil.setNavigationBarColor(this, ContextCompat.getColor(this, R.color.signal_dark_colorSurface));
if (!hasCameraPermission() & !hasAudioPermission()) {
askCameraAudioPermissions(() -> handleSetMuteVideo(false));
askCameraAudioPermissions(() -> {
callScreen.setMicEnabled(viewModel.getMicrophoneEnabled().getValue());
handleSetMuteVideo(false);
});
} else if (!hasAudioPermission()) {
askAudioPermissions(() -> {});
askAudioPermissions(() -> callScreen.setMicEnabled(viewModel.getMicrophoneEnabled().getValue()));
}
}
@@ -302,10 +291,11 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
@Override
public void onNewIntent(Intent intent) {
Log.i(TAG, "onNewIntent(" + intent.getBooleanExtra(EXTRA_STARTED_FROM_FULLSCREEN, false) + ")");
CallIntent callIntent = getCallIntent();
Log.i(TAG, "onNewIntent(" + callIntent.isStartedFromFullScreen() + ")");
super.onNewIntent(intent);
logIntent(intent);
processIntent(intent);
logIntent(callIntent);
processIntent(callIntent);
}
@Override
@@ -363,6 +353,7 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
@Override
protected void onUserLeaveHint() {
super.onUserLeaveHint();
enterPipModeIfPossible();
}
@@ -373,6 +364,10 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
}
}
private @NonNull CallIntent getCallIntent() {
return new CallIntent(getIntent());
}
private boolean enterPipModeIfPossible() {
if (isSystemPipEnabledAndAvailable()) {
if (viewModel.canEnterPipMode()) {
@@ -396,26 +391,20 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
return isSystemPipEnabledAndAvailable() && isInPictureInPictureMode();
}
private void logIntent(@NonNull Intent intent) {
Log.d(TAG, "Intent: Action: " + intent.getAction());
Log.d(TAG, "Intent: EXTRA_STARTED_FROM_FULLSCREEN: " + intent.getBooleanExtra(EXTRA_STARTED_FROM_FULLSCREEN, false));
Log.d(TAG, "Intent: EXTRA_ENABLE_VIDEO_IF_AVAILABLE: " + intent.getBooleanExtra(EXTRA_ENABLE_VIDEO_IF_AVAILABLE, false));
Log.d(TAG, "Intent: EXTRA_LAUNCH_IN_PIP: " + intent.getBooleanExtra(EXTRA_LAUNCH_IN_PIP, false));
private void logIntent(@NonNull CallIntent intent) {
Log.d(TAG, intent.toString());
}
private void processIntent(@NonNull Intent intent) {
if (ANSWER_ACTION.equals(intent.getAction())) {
handleAnswerWithAudio();
} else if (ANSWER_VIDEO_ACTION.equals(intent.getAction())) {
handleAnswerWithVideo();
} else if (DENY_ACTION.equals(intent.getAction())) {
handleDenyCall();
} else if (END_CALL_ACTION.equals(intent.getAction())) {
handleEndCall();
private void processIntent(@NonNull CallIntent intent) {
switch (intent.getAction()) {
case ANSWER_AUDIO -> handleAnswerWithAudio();
case ANSWER_VIDEO -> handleAnswerWithVideo();
case DENY -> handleDenyCall();
case END_CALL -> handleEndCall();
}
if (System.currentTimeMillis() - lastProcessedIntentTimestamp > TimeUnit.SECONDS.toMillis(1)) {
enterPipOnResume = intent.getBooleanExtra(EXTRA_LAUNCH_IN_PIP, false);
enterPipOnResume = intent.shouldLaunchInPip();
}
lastProcessedIntentTimestamp = System.currentTimeMillis();
@@ -529,7 +518,7 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
lifecycleDisposable.add(viewModel.getInCallstatus().subscribe(this::handleInCallStatus));
boolean isStartedFromCallLink = getIntent().getBooleanExtra(WebRtcCallActivity.EXTRA_STARTED_FROM_CALL_LINK, false);
boolean isStartedFromCallLink = getCallIntent().isStartedFromCallLink();
LiveDataUtil.combineLatest(LiveDataReactiveStreams.fromPublisher(viewModel.getCallParticipantsState().toFlowable(BackpressureStrategy.LATEST)),
orientationAndLandscapeEnabled,
viewModel.getEphemeralState(),

View File

@@ -17,6 +17,7 @@ import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobs.ApkUpdateJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.AppForegroundObserver
import org.thoughtcrime.securesms.util.Environment
import org.thoughtcrime.securesms.util.FileUtils
import java.io.FileInputStream
@@ -61,7 +62,7 @@ object ApkUpdateInstaller {
}
if (!userInitiated && !shouldAutoUpdate()) {
Log.w(TAG, "Not user-initiated and not eligible for auto-update. Prompting. (API=${Build.VERSION.SDK_INT}, Foreground=${AppDependencies.appForegroundObserver.isForegrounded}, AutoUpdate=${SignalStore.apkUpdate.autoUpdate})")
Log.w(TAG, "Not user-initiated and not eligible for auto-update. Prompting. (API=${Build.VERSION.SDK_INT}, Foreground=${AppForegroundObserver.isForegrounded()}, AutoUpdate=${SignalStore.apkUpdate.autoUpdate})")
ApkUpdateNotifications.showInstallPrompt(context, downloadId)
return
}
@@ -145,6 +146,6 @@ object ApkUpdateInstaller {
private fun shouldAutoUpdate(): Boolean {
// TODO Auto-updates temporarily restricted to nightlies. Once we have designs for allowing users to opt-out of auto-updates, we can re-enable this
return Environment.IS_NIGHTLY && Build.VERSION.SDK_INT >= 31 && SignalStore.apkUpdate.autoUpdate && !AppDependencies.appForegroundObserver.isForegrounded
return Environment.IS_NIGHTLY && Build.VERSION.SDK_INT >= 31 && SignalStore.apkUpdate.autoUpdate && !AppForegroundObserver.isForegrounded()
}
}

View File

@@ -83,7 +83,7 @@ object ApkUpdateNotifications {
ServiceUtil.getNotificationManager(context).notify(NotificationIds.APK_UPDATE_FAILED_INSTALL, notification)
}
fun showAutoUpdateSuccess(context: Context) {
fun showUpdateSuccess(context: Context, userInitiated: Boolean) {
val pendingIntent = PendingIntent.getActivity(
context,
0,
@@ -93,9 +93,15 @@ object ApkUpdateNotifications {
val appVersionName = context.packageManager.getPackageInfo(context.packageName, 0).versionName
val body = if (userInitiated) {
context.getString(R.string.ApkUpdateNotifications_manual_update_success_body, appVersionName)
} else {
context.getString(R.string.ApkUpdateNotifications_auto_update_success_body, appVersionName)
}
val notification = NotificationCompat.Builder(context, NotificationChannels.getInstance().APP_UPDATES)
.setContentTitle(context.getString(R.string.ApkUpdateNotifications_auto_update_success_title))
.setContentText(context.getString(R.string.ApkUpdateNotifications_auto_update_success_body, appVersionName))
.setContentText(body)
.setSmallIcon(R.drawable.ic_notification)
.setColor(ContextCompat.getColor(context, R.color.core_ultramarine))
.setContentIntent(pendingIntent)

View File

@@ -39,7 +39,7 @@ class ApkUpdatePackageInstallerReceiver : BroadcastReceiver() {
if (SignalStore.apkUpdate.lastApkUploadTime != SignalStore.apkUpdate.pendingApkUploadTime) {
Log.i(TAG, "Update installed successfully! Updating our lastApkUploadTime to ${SignalStore.apkUpdate.pendingApkUploadTime}")
SignalStore.apkUpdate.lastApkUploadTime = SignalStore.apkUpdate.pendingApkUploadTime
ApkUpdateNotifications.showAutoUpdateSuccess(context)
ApkUpdateNotifications.showUpdateSuccess(context, userInitiated)
} else {
Log.i(TAG, "Spurious 'success' notification?")
}

View File

@@ -32,6 +32,7 @@ class ArchivedAttachment : Attachment {
size: Long,
cdn: Int,
key: ByteArray,
iv: ByteArray?,
cdnKey: String?,
archiveCdn: Int?,
archiveMediaName: String,
@@ -60,6 +61,7 @@ class ArchivedAttachment : Attachment {
cdn = Cdn.fromCdnNumber(cdn),
remoteLocation = cdnKey,
remoteKey = Base64.encodeWithoutPadding(key),
remoteIv = iv,
remoteDigest = digest,
incrementalDigest = incrementalMac,
fastPreflightId = null,

View File

@@ -37,6 +37,8 @@ abstract class Attachment(
@JvmField
val remoteKey: String?,
@JvmField
val remoteIv: ByteArray?,
@JvmField
val remoteDigest: ByteArray?,
@JvmField
val incrementalDigest: ByteArray?,
@@ -86,6 +88,7 @@ abstract class Attachment(
cdn = Cdn.deserialize(parcel.readInt()),
remoteLocation = parcel.readString(),
remoteKey = parcel.readString(),
remoteIv = ParcelUtil.readByteArray(parcel),
remoteDigest = ParcelUtil.readByteArray(parcel),
incrementalDigest = ParcelUtil.readByteArray(parcel),
fastPreflightId = parcel.readString(),

View File

@@ -29,9 +29,6 @@ class DatabaseAttachment : Attachment {
@JvmField
val archiveCdn: Int
@JvmField
val archiveThumbnailCdn: Int
@JvmField
val archiveMediaName: String?
@@ -41,6 +38,9 @@ class DatabaseAttachment : Attachment {
@JvmField
val thumbnailRestoreState: AttachmentTable.ThumbnailRestoreState
@JvmField
val archiveTransferState: AttachmentTable.ArchiveTransferState
private val hasArchiveThumbnail: Boolean
private val hasThumbnail: Boolean
val displayOrder: Int
@@ -58,6 +58,7 @@ class DatabaseAttachment : Attachment {
cdn: Cdn,
location: String?,
key: String?,
iv: ByteArray?,
digest: ByteArray?,
incrementalDigest: ByteArray?,
incrementalMacChunkSize: Int,
@@ -77,10 +78,10 @@ class DatabaseAttachment : Attachment {
uploadTimestamp: Long,
dataHash: String?,
archiveCdn: Int,
archiveThumbnailCdn: Int,
archiveMediaName: String?,
archiveMediaId: String?,
thumbnailRestoreState: AttachmentTable.ThumbnailRestoreState,
archiveTransferState: AttachmentTable.ArchiveTransferState,
uuid: UUID?
) : super(
contentType = contentType,
@@ -90,6 +91,7 @@ class DatabaseAttachment : Attachment {
cdn = cdn,
remoteLocation = location,
remoteKey = key,
remoteIv = iv,
remoteDigest = digest,
incrementalDigest = incrementalDigest,
fastPreflightId = fastPreflightId,
@@ -115,10 +117,10 @@ class DatabaseAttachment : Attachment {
this.hasArchiveThumbnail = hasArchiveThumbnail
this.displayOrder = displayOrder
this.archiveCdn = archiveCdn
this.archiveThumbnailCdn = archiveThumbnailCdn
this.archiveMediaName = archiveMediaName
this.archiveMediaId = archiveMediaId
this.thumbnailRestoreState = thumbnailRestoreState
this.archiveTransferState = archiveTransferState
}
constructor(parcel: Parcel) : super(parcel) {
@@ -129,11 +131,11 @@ class DatabaseAttachment : Attachment {
mmsId = parcel.readLong()
displayOrder = parcel.readInt()
archiveCdn = parcel.readInt()
archiveThumbnailCdn = parcel.readInt()
archiveMediaName = parcel.readString()
archiveMediaId = parcel.readString()
hasArchiveThumbnail = ParcelUtil.readBoolean(parcel)
thumbnailRestoreState = AttachmentTable.ThumbnailRestoreState.deserialize(parcel.readInt())
archiveTransferState = AttachmentTable.ArchiveTransferState.deserialize(parcel.readInt())
}
override fun writeToParcel(dest: Parcel, flags: Int) {
@@ -145,11 +147,11 @@ class DatabaseAttachment : Attachment {
dest.writeLong(mmsId)
dest.writeInt(displayOrder)
dest.writeInt(archiveCdn)
dest.writeInt(archiveThumbnailCdn)
dest.writeString(archiveMediaName)
dest.writeString(archiveMediaId)
ParcelUtil.writeBoolean(dest, hasArchiveThumbnail)
dest.writeInt(thumbnailRestoreState.value)
dest.writeInt(archiveTransferState.value)
}
override val uri: Uri?

View File

@@ -0,0 +1,14 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.attachments
/**
* Thrown by jobs unable to rehydrate enough attachment information to download it.
*/
class InvalidAttachmentException : Exception {
constructor(s: String?) : super(s)
constructor(e: Exception?) : super(e)
}

View File

@@ -3,7 +3,7 @@ package org.thoughtcrime.securesms.attachments
import android.net.Uri
import android.os.Parcel
import androidx.annotation.VisibleForTesting
import org.signal.core.util.Base64.encodeWithPadding
import org.signal.core.util.Base64
import org.thoughtcrime.securesms.blurhash.BlurHash
import org.thoughtcrime.securesms.database.AttachmentTable
import org.thoughtcrime.securesms.stickers.StickerLocator
@@ -24,6 +24,7 @@ class PointerAttachment : Attachment {
cdn: Cdn,
location: String,
key: String?,
iv: ByteArray?,
digest: ByteArray?,
incrementalDigest: ByteArray?,
incrementalMacChunkSize: Int,
@@ -46,6 +47,7 @@ class PointerAttachment : Attachment {
cdn = cdn,
remoteLocation = location,
remoteKey = key,
remoteIv = iv,
remoteDigest = digest,
incrementalDigest = incrementalDigest,
fastPreflightId = fastPreflightId,
@@ -86,12 +88,17 @@ class PointerAttachment : Attachment {
@JvmStatic
@JvmOverloads
fun forPointer(pointer: Optional<SignalServiceAttachment>, stickerLocator: StickerLocator? = null, fastPreflightId: String? = null, transferState: Int = AttachmentTable.TRANSFER_PROGRESS_PENDING): Optional<Attachment> {
fun forPointer(
pointer: Optional<SignalServiceAttachment>,
stickerLocator: StickerLocator? = null,
fastPreflightId: String? = null,
transferState: Int = AttachmentTable.TRANSFER_PROGRESS_PENDING
): Optional<Attachment> {
if (!pointer.isPresent || !pointer.get().isPointer()) {
return Optional.empty()
}
val encodedKey: String? = pointer.get().asPointer().key?.let { encodeWithPadding(it) }
val encodedKey: String? = pointer.get().asPointer().key?.let { Base64.encodeWithPadding(it) }
return Optional.of(
PointerAttachment(
@@ -102,6 +109,7 @@ class PointerAttachment : Attachment {
cdn = Cdn.fromCdnNumber(pointer.get().asPointer().cdnNumber),
location = pointer.get().asPointer().remoteId.toString(),
key = encodedKey,
iv = null,
digest = pointer.get().asPointer().digest.orElse(null),
incrementalDigest = pointer.get().asPointer().incrementalDigest.orElse(null),
incrementalMacChunkSize = pointer.get().asPointer().incrementalMacChunkSize,
@@ -139,7 +147,8 @@ class PointerAttachment : Attachment {
fileName = quotedAttachment.fileName,
cdn = Cdn.fromCdnNumber(thumbnail?.asPointer()?.cdnNumber ?: 0),
location = thumbnail?.asPointer()?.remoteId?.toString() ?: "0",
key = thumbnail?.asPointer()?.key?.let { encodeWithPadding(it) },
key = thumbnail?.asPointer()?.key?.let { Base64.encodeWithPadding(it) },
iv = null,
digest = thumbnail?.asPointer()?.digest?.orElse(null),
incrementalDigest = thumbnail?.asPointer()?.incrementalDigest?.orElse(null),
incrementalMacChunkSize = thumbnail?.asPointer()?.incrementalMacChunkSize ?: 0,

View File

@@ -4,6 +4,7 @@ import android.net.Uri
import android.os.Parcel
import org.thoughtcrime.securesms.blurhash.BlurHash
import org.thoughtcrime.securesms.database.AttachmentTable
import org.thoughtcrime.securesms.stickers.StickerLocator
import java.util.UUID
/**
@@ -22,6 +23,7 @@ class TombstoneAttachment : Attachment {
cdn = Cdn.CDN_0,
remoteLocation = null,
remoteKey = null,
remoteIv = null,
remoteDigest = null,
incrementalDigest = null,
fastPreflightId = null,
@@ -47,10 +49,12 @@ class TombstoneAttachment : Attachment {
width: Int?,
height: Int?,
caption: String?,
fileName: String? = null,
blurHash: String?,
voiceNote: Boolean = false,
borderless: Boolean = false,
gif: Boolean = false,
stickerLocator: StickerLocator? = null,
quote: Boolean,
uuid: UUID?
) : super(
@@ -58,10 +62,11 @@ class TombstoneAttachment : Attachment {
quote = quote,
transferState = AttachmentTable.TRANSFER_PROGRESS_PERMANENT_FAILURE,
size = 0,
fileName = null,
fileName = fileName,
cdn = Cdn.CDN_0,
remoteLocation = null,
remoteKey = null,
remoteIv = null,
remoteDigest = null,
incrementalDigest = incrementalMac,
fastPreflightId = null,
@@ -73,7 +78,7 @@ class TombstoneAttachment : Attachment {
incrementalMacChunkSize = incrementalMacChunkSize ?: 0,
uploadTimestamp = 0,
caption = caption,
stickerLocator = null,
stickerLocator = stickerLocator,
blurHash = BlurHash.parseOrNull(blurHash),
audioHash = null,
transformProperties = null,

View File

@@ -75,6 +75,7 @@ class UriAttachment : Attachment {
cdn = Cdn.CDN_0,
remoteLocation = null,
remoteKey = null,
remoteIv = null,
remoteDigest = null,
incrementalDigest = null,
fastPreflightId = fastPreflightId,

View File

@@ -0,0 +1,45 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.attachments
import org.thoughtcrime.securesms.database.AttachmentTable
import org.thoughtcrime.securesms.database.AttachmentTable.TransformProperties
import org.thoughtcrime.securesms.util.MediaUtil
/**
* A basically-empty [Attachment] that is solely used for inserting an attachment into the [AttachmentTable].
*/
class WallpaperAttachment() : Attachment(
contentType = MediaUtil.IMAGE_WEBP,
transferState = AttachmentTable.TRANSFER_PROGRESS_DONE,
size = 0,
fileName = null,
cdn = Cdn.CDN_0,
remoteLocation = null,
remoteKey = null,
remoteIv = null,
remoteDigest = null,
incrementalDigest = null,
fastPreflightId = null,
voiceNote = false,
borderless = false,
videoGif = false,
width = 0,
height = 0,
incrementalMacChunkSize = 0,
quote = false,
uploadTimestamp = 0,
caption = null,
stickerLocator = null,
blurHash = null,
audioHash = null,
transformProperties = TransformProperties.empty(),
uuid = null
) {
override val uri = null
override val publicUri = null
override val thumbnailUri = null
}

View File

@@ -0,0 +1,115 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.shareIn
import org.signal.core.util.throttleLatest
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.keyvalue.protos.ArchiveUploadProgressState
import kotlin.math.max
import kotlin.time.Duration.Companion.milliseconds
/**
* Tracks the progress of uploading your message archive and provides an observable stream of results.
*/
object ArchiveUploadProgress {
private val PROGRESS_NONE = ArchiveUploadProgressState(
state = ArchiveUploadProgressState.State.None
)
private val _progress: MutableSharedFlow<Unit> = MutableSharedFlow(replay = 1)
private var uploadProgress: ArchiveUploadProgressState = SignalStore.backup.archiveUploadState ?: PROGRESS_NONE
/**
* Observe this to get updates on the current upload progress.
*/
val progress: Flow<ArchiveUploadProgressState> = _progress
.throttleLatest(500.milliseconds)
.map {
if (uploadProgress.state != ArchiveUploadProgressState.State.UploadingAttachments) {
return@map uploadProgress
}
val pendingCount = SignalDatabase.attachments.getPendingArchiveUploadCount()
if (pendingCount == uploadProgress.totalAttachments) {
return@map PROGRESS_NONE
}
// It's possible that new attachments may be pending upload after we start a backup.
// If we wanted the most accurate progress possible, we could maintain a new database flag that indicates whether an attachment has been flagged as part
// of the current upload batch. However, this gets us pretty close while keeping things simple and not having to juggle extra flags, with the caveat that
// the progress bar may occasionally be including media that is not actually referenced in the active backup file.
val totalCount = max(uploadProgress.totalAttachments, pendingCount)
ArchiveUploadProgressState(
state = ArchiveUploadProgressState.State.UploadingAttachments,
completedAttachments = totalCount - pendingCount,
totalAttachments = totalCount
)
}
.onEach {
updateState(it, notify = false)
}
.flowOn(Dispatchers.IO)
.shareIn(scope = CoroutineScope(Dispatchers.IO), started = SharingStarted.WhileSubscribed(), replay = 1)
val inProgress
get() = uploadProgress.state != ArchiveUploadProgressState.State.None
fun begin() {
updateState(
ArchiveUploadProgressState(
state = ArchiveUploadProgressState.State.BackingUpMessages
)
)
}
fun onMessageBackupCreated() {
updateState(
ArchiveUploadProgressState(
state = ArchiveUploadProgressState.State.UploadingMessages
)
)
}
fun onAttachmentsStarted(attachmentCount: Long) {
updateState(
ArchiveUploadProgressState(
state = ArchiveUploadProgressState.State.UploadingAttachments,
completedAttachments = 0,
totalAttachments = attachmentCount
)
)
}
fun onAttachmentFinished() {
_progress.tryEmit(Unit)
}
fun onMessageBackupFinishedEarly() {
updateState(PROGRESS_NONE)
}
private fun updateState(state: ArchiveUploadProgressState, notify: Boolean = true) {
uploadProgress = state
SignalStore.backup.archiveUploadState = state
if (notify) {
_progress.tryEmit(Unit)
}
}
}

View File

@@ -2,13 +2,13 @@ package org.thoughtcrime.securesms.backup
import org.greenrobot.eventbus.EventBus
import org.signal.core.util.logging.Log
import org.signal.core.util.stream.NullOutputStream
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
/**
* Given a backup file, run over it and verify it will decrypt properly when attempting to import it.
@@ -89,10 +89,4 @@ object BackupVerifier {
}
return true
}
private object NullOutputStream : OutputStream() {
override fun write(b: Int) = Unit
override fun write(b: ByteArray?) = Unit
override fun write(b: ByteArray?, off: Int, len: Int) = Unit
}
}

View File

@@ -15,7 +15,6 @@ import org.signal.core.util.concurrent.LimitedWorker
import org.signal.core.util.concurrent.SignalExecutors
import org.signal.core.util.fullWalCheckpoint
import org.signal.core.util.logging.Log
import org.signal.core.util.money.FiatMoney
import org.signal.core.util.stream.NonClosingOutputStream
import org.signal.core.util.withinTransaction
import org.signal.libsignal.messagebackup.MessageBackup
@@ -56,6 +55,7 @@ import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.jobs.RequestGroupV2InfoJob
import org.thoughtcrime.securesms.keyvalue.KeyValueStore
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.net.SignalNetwork
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.toMillis
import org.whispersystems.signalservice.api.NetworkResult
@@ -73,15 +73,14 @@ import org.whispersystems.signalservice.api.messages.SignalServiceAttachment.Pro
import org.whispersystems.signalservice.api.push.ServiceId.ACI
import org.whispersystems.signalservice.api.push.ServiceId.PNI
import org.whispersystems.signalservice.internal.crypto.PaddingInputStream
import org.whispersystems.signalservice.internal.push.AttachmentUploadForm
import org.whispersystems.signalservice.internal.push.SubscriptionsConfiguration
import org.whispersystems.signalservice.internal.push.http.ResumableUploadSpec
import java.io.ByteArrayOutputStream
import java.io.File
import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
import java.time.ZonedDateTime
import java.util.Currency
import java.util.Locale
import java.util.concurrent.atomic.AtomicLong
import kotlin.time.Duration.Companion.milliseconds
@@ -98,13 +97,15 @@ object BackupRepository {
private val resetInitializedStateErrorAction: StatusCodeErrorAction = { error ->
when (error.code) {
401 -> {
Log.i(TAG, "Resetting initialized state due to 401.")
Log.w(TAG, "Received status 401. Resetting initialized state + auth credentials.", error.exception)
SignalStore.backup.backupsInitialized = false
SignalStore.backup.clearAllCredentials()
}
403 -> {
Log.i(TAG, "Bad auth credential. Clearing stored credentials.")
SignalStore.backup.clearAllCredentials()
Log.w(TAG, "Received status 403. The user is not in the media tier. Updating local state.", error.exception)
SignalStore.backup.backupTier = MessageBackupTier.FREE
// TODO [backup] If the user thought they were in media tier but aren't, feels like we should have a special UX flow for this?
}
}
}
@@ -164,6 +165,8 @@ object BackupRepository {
private fun createSignalStoreSnapshot(baseName: String): SignalStore {
val context = AppDependencies.application
SignalStore.blockUntilAllWritesFinished()
// Need to do a WAL checkpoint to ensure that the database file we're copying has all pending writes
if (!KeyValueDatabase.getInstance(context).writableDatabase.fullWalCheckpoint()) {
Log.w(TAG, "Failed to checkpoint WAL for KeyValueDatabase! Not guaranteed to be using the most recent data.")
@@ -330,12 +333,17 @@ object BackupRepository {
fun localImport(mainStreamFactory: () -> InputStream, mainStreamLength: Long, selfData: SelfData): ImportResult {
val backupKey = SignalStore.svr.getOrCreateMasterKey().deriveBackupKey()
val frameReader = EncryptedBackupReader(
key = backupKey,
aci = selfData.aci,
length = mainStreamLength,
dataStream = mainStreamFactory
)
val frameReader = try {
EncryptedBackupReader(
key = backupKey,
aci = selfData.aci,
length = mainStreamLength,
dataStream = mainStreamFactory
)
} catch (e: IOException) {
Log.w(TAG, "Unable to import local archive", e)
return ImportResult.Failure
}
return frameReader.use { reader ->
import(backupKey, reader, selfData)
@@ -364,8 +372,7 @@ object BackupRepository {
private fun import(
backupKey: BackupKey,
frameReader: BackupImportReader,
selfData: SelfData,
importExtras: ((EventTimer) -> Unit)? = null
selfData: SelfData
): ImportResult {
val eventTimer = EventTimer()
@@ -443,23 +450,27 @@ object BackupRepository {
eventTimer.emit("chatItem")
}
importExtras?.invoke(eventTimer)
importState.chatIdToLocalThreadId.values.forEach {
SignalDatabase.threads.update(it, unarchive = false, allowDeletion = false)
}
}
SignalDatabase.groups.getGroups().use { groups ->
while (groups.hasNext()) {
val group = groups.next()
if (group.id.isV2) {
AppDependencies.jobManager.add(RequestGroupV2InfoJob(group.id as GroupId.V2))
}
}
}
Log.d(TAG, "import() ${eventTimer.stop().summary}")
val groupJobs = SignalDatabase.groups.getGroups().use { groups ->
groups
.asSequence()
.mapNotNull { group ->
if (group.id.isV2) {
RequestGroupV2InfoJob(group.id as GroupId.V2)
} else {
null
}
}
.toList()
}
AppDependencies.jobManager.addAll(groupJobs)
return ImportResult.Success(backupTime = header.backupTimeMs)
}
@@ -471,33 +482,30 @@ object BackupRepository {
}
fun listRemoteMediaObjects(limit: Int, cursor: String? = null): NetworkResult<ArchiveGetMediaItemsResponse> {
val api = AppDependencies.signalServiceAccountManager.archiveApi
val backupKey = SignalStore.svr.getOrCreateMasterKey().deriveBackupKey()
return initBackupAndFetchAuth(backupKey)
.then { credential ->
api.getArchiveMediaItemsPage(backupKey, SignalStore.account.requireAci(), credential, limit, cursor)
SignalNetwork.archive.getArchiveMediaItemsPage(backupKey, SignalStore.account.requireAci(), credential, limit, cursor)
}
}
fun getRemoteBackupUsedSpace(): NetworkResult<Long?> {
val api = AppDependencies.signalServiceAccountManager.archiveApi
val backupKey = SignalStore.svr.getOrCreateMasterKey().deriveBackupKey()
return initBackupAndFetchAuth(backupKey)
.then { credential ->
api.getBackupInfo(backupKey, SignalStore.account.requireAci(), credential)
SignalNetwork.archive.getBackupInfo(backupKey, SignalStore.account.requireAci(), credential)
.map { it.usedSpace }
}
}
private fun getBackupTier(): NetworkResult<MessageBackupTier> {
val api = AppDependencies.signalServiceAccountManager.archiveApi
private fun getBackupTier(aci: ACI): NetworkResult<MessageBackupTier> {
val backupKey = SignalStore.svr.getOrCreateMasterKey().deriveBackupKey()
return initBackupAndFetchAuth(backupKey)
.map { credential ->
val zkCredential = api.getZkCredential(backupKey, credential)
val zkCredential = SignalNetwork.archive.getZkCredential(backupKey, aci, credential)
if (zkCredential.backupLevel == BackupLevel.MEDIA) {
MessageBackupTier.PAID
} else {
@@ -510,17 +518,16 @@ object BackupRepository {
* Returns an object with details about the remote backup state.
*/
fun getRemoteBackupState(): NetworkResult<BackupMetadata> {
val api = AppDependencies.signalServiceAccountManager.archiveApi
val backupKey = SignalStore.svr.getOrCreateMasterKey().deriveBackupKey()
return initBackupAndFetchAuth(backupKey)
.then { credential ->
api.getBackupInfo(backupKey, SignalStore.account.requireAci(), credential)
SignalNetwork.archive.getBackupInfo(backupKey, SignalStore.account.requireAci(), credential)
.map { it to credential }
}
.then { pair ->
val (info, credential) = pair
api.debugGetUploadedMediaItemMetadata(backupKey, SignalStore.account.requireAci(), credential)
SignalNetwork.archive.debugGetUploadedMediaItemMetadata(backupKey, SignalStore.account.requireAci(), credential)
.also { Log.i(TAG, "MediaItemMetadataResult: $it") }
.map { mediaObjects ->
BackupMetadata(
@@ -536,35 +543,32 @@ object BackupRepository {
*
* @return True if successful, otherwise false.
*/
fun uploadBackupFile(backupStream: InputStream, backupStreamLength: Long): Boolean {
val api = AppDependencies.signalServiceAccountManager.archiveApi
fun uploadBackupFile(backupStream: InputStream, backupStreamLength: Long): NetworkResult<Unit> {
val backupKey = SignalStore.svr.getOrCreateMasterKey().deriveBackupKey()
return initBackupAndFetchAuth(backupKey)
.then { credential ->
api.getMessageBackupUploadForm(backupKey, SignalStore.account.requireAci(), credential)
SignalNetwork.archive.getMessageBackupUploadForm(backupKey, SignalStore.account.requireAci(), credential)
.also { Log.i(TAG, "UploadFormResult: $it") }
}
.then { form ->
api.getBackupResumableUploadUrl(form)
SignalNetwork.archive.getBackupResumableUploadUrl(form)
.also { Log.i(TAG, "ResumableUploadUrlResult: $it") }
.map { form to it }
}
.then { formAndUploadUrl ->
val (form, resumableUploadUrl) = formAndUploadUrl
api.uploadBackupFile(form, resumableUploadUrl, backupStream, backupStreamLength)
SignalNetwork.archive.uploadBackupFile(form, resumableUploadUrl, backupStream, backupStreamLength)
.also { Log.i(TAG, "UploadBackupFileResult: $it") }
}
.also { Log.i(TAG, "OverallResult: $it") } is NetworkResult.Success
}
fun downloadBackupFile(destination: File, listener: ProgressListener? = null): Boolean {
val api = AppDependencies.signalServiceAccountManager.archiveApi
val backupKey = SignalStore.svr.getOrCreateMasterKey().deriveBackupKey()
return initBackupAndFetchAuth(backupKey)
.then { credential ->
api.getBackupInfo(backupKey, SignalStore.account.requireAci(), credential)
SignalNetwork.archive.getBackupInfo(backupKey, SignalStore.account.requireAci(), credential)
}
.then { info -> getCdnReadCredentials(info.cdn ?: Cdn.CDN_3.cdnNumber).map { it.headers to info } }
.map { pair ->
@@ -575,12 +579,11 @@ object BackupRepository {
}
fun getBackupFileLastModified(): NetworkResult<ZonedDateTime?> {
val api = AppDependencies.signalServiceAccountManager.archiveApi
val backupKey = SignalStore.svr.getOrCreateMasterKey().deriveBackupKey()
return initBackupAndFetchAuth(backupKey)
.then { credential ->
api.getBackupInfo(backupKey, SignalStore.account.requireAci(), credential)
SignalNetwork.archive.getBackupInfo(backupKey, SignalStore.account.requireAci(), credential)
}
.then { info -> getCdnReadCredentials(info.cdn ?: Cdn.CDN_3.cdnNumber).map { it.headers to info } }
.then { pair ->
@@ -596,39 +599,39 @@ object BackupRepository {
* Returns an object with details about the remote backup state.
*/
fun debugGetArchivedMediaState(): NetworkResult<List<ArchiveGetMediaItemsResponse.StoredMediaObject>> {
val api = AppDependencies.signalServiceAccountManager.archiveApi
val backupKey = SignalStore.svr.getOrCreateMasterKey().deriveBackupKey()
return initBackupAndFetchAuth(backupKey)
.then { credential ->
api.debugGetUploadedMediaItemMetadata(backupKey, SignalStore.account.requireAci(), credential)
SignalNetwork.archive.debugGetUploadedMediaItemMetadata(backupKey, SignalStore.account.requireAci(), credential)
}
}
/**
* Retrieves an upload spec that can be used to upload attachment media.
* Retrieves an [AttachmentUploadForm] that can be used to upload an attachment to the transit cdn.
* To continue the upload, use [org.whispersystems.signalservice.api.attachment.AttachmentApi.getResumableUploadSpec].
*
* It's important to note that in order to get this to the archive cdn, you still need to use [copyAttachmentToArchive].
*/
fun getMediaUploadSpec(secretKey: ByteArray? = null): NetworkResult<ResumableUploadSpec> {
val api = AppDependencies.signalServiceAccountManager.archiveApi
fun getAttachmentUploadForm(): NetworkResult<AttachmentUploadForm> {
val backupKey = SignalStore.svr.getOrCreateMasterKey().deriveBackupKey()
return initBackupAndFetchAuth(backupKey)
.then { credential ->
api.getMediaUploadForm(backupKey, SignalStore.account.requireAci(), credential)
}
.then { form ->
api.getResumableUploadSpec(form, secretKey)
SignalNetwork.archive.getMediaUploadForm(backupKey, SignalStore.account.requireAci(), credential)
}
}
fun archiveThumbnail(thumbnailAttachment: Attachment, parentAttachment: DatabaseAttachment): NetworkResult<ArchiveMediaResponse> {
val api = AppDependencies.signalServiceAccountManager.archiveApi
/**
* Copies a thumbnail that has been uploaded to the transit cdn to the archive cdn.
*/
fun copyThumbnailToArchive(thumbnailAttachment: Attachment, parentAttachment: DatabaseAttachment): NetworkResult<ArchiveMediaResponse> {
val backupKey = SignalStore.svr.getOrCreateMasterKey().deriveBackupKey()
val request = thumbnailAttachment.toArchiveMediaRequest(parentAttachment.getThumbnailMediaName(), backupKey)
return initBackupAndFetchAuth(backupKey)
.then { credential ->
api.archiveAttachmentMedia(
SignalNetwork.archive.copyAttachmentToArchive(
backupKey = backupKey,
aci = SignalStore.account.requireAci(),
serviceCredential = credential,
@@ -637,16 +640,18 @@ object BackupRepository {
}
}
fun archiveMedia(attachment: DatabaseAttachment): NetworkResult<Unit> {
val api = AppDependencies.signalServiceAccountManager.archiveApi
/**
* Copies an attachment that has been uploaded to the transit cdn to the archive cdn.
*/
fun copyAttachmentToArchive(attachment: DatabaseAttachment): NetworkResult<Unit> {
val backupKey = SignalStore.svr.getOrCreateMasterKey().deriveBackupKey()
return initBackupAndFetchAuth(backupKey)
.then { credential ->
val mediaName = attachment.getMediaName()
val request = attachment.toArchiveMediaRequest(mediaName, backupKey)
api
.archiveAttachmentMedia(
SignalNetwork.archive
.copyAttachmentToArchive(
backupKey = backupKey,
aci = SignalStore.account.requireAci(),
serviceCredential = credential,
@@ -661,8 +666,7 @@ object BackupRepository {
.also { Log.i(TAG, "archiveMediaResult: $it") }
}
fun archiveMedia(databaseAttachments: List<DatabaseAttachment>): NetworkResult<BatchArchiveMediaResult> {
val api = AppDependencies.signalServiceAccountManager.archiveApi
fun copyAttachmentToArchive(databaseAttachments: List<DatabaseAttachment>): NetworkResult<BatchArchiveMediaResult> {
val backupKey = SignalStore.svr.getOrCreateMasterKey().deriveBackupKey()
return initBackupAndFetchAuth(backupKey)
@@ -679,8 +683,8 @@ object BackupRepository {
attachmentIdToMediaName[it.attachmentId] = mediaName.name
}
api
.archiveAttachmentMedia(
SignalNetwork.archive
.copyAttachmentToArchive(
backupKey = backupKey,
aci = SignalStore.account.requireAci(),
serviceCredential = credential,
@@ -703,7 +707,6 @@ object BackupRepository {
}
fun deleteArchivedMedia(attachments: List<DatabaseAttachment>): NetworkResult<Unit> {
val api = AppDependencies.signalServiceAccountManager.archiveApi
val backupKey = SignalStore.svr.getOrCreateMasterKey().deriveBackupKey()
val mediaToDelete = attachments
@@ -722,7 +725,7 @@ object BackupRepository {
return initBackupAndFetchAuth(backupKey)
.then { credential ->
api.deleteArchivedMedia(
SignalNetwork.archive.deleteArchivedMedia(
backupKey = backupKey,
aci = SignalStore.account.requireAci(),
serviceCredential = credential,
@@ -736,7 +739,6 @@ object BackupRepository {
}
fun deleteAbandonedMediaObjects(mediaObjects: Collection<ArchivedMediaObject>): NetworkResult<Unit> {
val api = AppDependencies.signalServiceAccountManager.archiveApi
val backupKey = SignalStore.svr.getOrCreateMasterKey().deriveBackupKey()
val mediaToDelete = mediaObjects
@@ -754,7 +756,7 @@ object BackupRepository {
return initBackupAndFetchAuth(backupKey)
.then { credential ->
api.deleteArchivedMedia(
SignalNetwork.archive.deleteArchivedMedia(
backupKey = backupKey,
aci = SignalStore.account.requireAci(),
serviceCredential = credential,
@@ -765,7 +767,6 @@ object BackupRepository {
}
fun debugDeleteAllArchivedMedia(): NetworkResult<Unit> {
val api = AppDependencies.signalServiceAccountManager.archiveApi
val backupKey = SignalStore.svr.getOrCreateMasterKey().deriveBackupKey()
return debugGetArchivedMediaState()
@@ -784,7 +785,7 @@ object BackupRepository {
} else {
getAuthCredential()
.then { credential ->
api.deleteArchivedMedia(
SignalNetwork.archive.deleteArchivedMedia(
backupKey = backupKey,
aci = SignalStore.account.requireAci(),
serviceCredential = credential,
@@ -808,12 +809,11 @@ object BackupRepository {
return NetworkResult.Success(cached)
}
val api = AppDependencies.signalServiceAccountManager.archiveApi
val backupKey = SignalStore.svr.getOrCreateMasterKey().deriveBackupKey()
return initBackupAndFetchAuth(backupKey)
.then { credential ->
api.getCdnReadCredentials(
SignalNetwork.archive.getCdnReadCredentials(
cdnNumber = cdnNumber,
backupKey = backupKey,
aci = SignalStore.account.requireAci(),
@@ -828,7 +828,7 @@ object BackupRepository {
.also { Log.i(TAG, "getCdnReadCredentialsResult: $it") }
}
fun restoreBackupTier(): MessageBackupTier? {
fun restoreBackupTier(aci: ACI): MessageBackupTier? {
// TODO: more complete error handling
try {
val lastModified = getBackupFileLastModified().successOrThrow()
@@ -841,7 +841,7 @@ object BackupRepository {
return null
}
SignalStore.backup.backupTier = try {
getBackupTier().successOrThrow()
getBackupTier(aci).successOrThrow()
} catch (e: Exception) {
Log.i(TAG, "Could not retrieve backup tier.", e)
null
@@ -867,12 +867,11 @@ object BackupRepository {
)
}
val api = AppDependencies.signalServiceAccountManager.archiveApi
val backupKey = SignalStore.svr.getOrCreateMasterKey().deriveBackupKey()
return initBackupAndFetchAuth(backupKey)
.then { credential ->
api.getBackupInfo(backupKey, SignalStore.account.requireAci(), credential).map {
SignalNetwork.archive.getBackupInfo(backupKey, SignalStore.account.requireAci(), credential).map {
SignalStore.backup.usedBackupMediaSpace = it.usedSpace ?: 0L
BackupDirectories(it.backupDir!!, it.mediaDir!!)
}
@@ -886,14 +885,13 @@ object BackupRepository {
}
suspend fun getAvailableBackupsTypes(availableBackupTiers: List<MessageBackupTier>): List<MessageBackupsType> {
return availableBackupTiers.map { getBackupsType(it) }
return availableBackupTiers.mapNotNull { getBackupsType(it) }
}
suspend fun getBackupsType(tier: MessageBackupTier): MessageBackupsType {
val backupCurrency = SignalStore.inAppPayments.getSubscriptionCurrency(InAppPaymentSubscriberRecord.Type.BACKUP)
suspend fun getBackupsType(tier: MessageBackupTier): MessageBackupsType? {
return when (tier) {
MessageBackupTier.FREE -> getFreeType()
MessageBackupTier.PAID -> getPaidType(backupCurrency)
MessageBackupTier.PAID -> getPaidType()
}
}
@@ -905,11 +903,12 @@ object BackupRepository {
)
}
private suspend fun getPaidType(currency: Currency): MessageBackupsType {
private suspend fun getPaidType(): MessageBackupsType? {
val config = getSubscriptionsConfiguration()
val product = AppDependencies.billingApi.queryProduct() ?: return null
return MessageBackupsType.Paid(
pricePerMonth = FiatMoney(config.currencies[currency.currencyCode.lowercase()]!!.backupSubscription[SubscriptionsConfiguration.BACKUPS_LEVEL]!!, currency),
pricePerMonth = product.price,
storageAllowanceBytes = config.backupConfiguration.backupLevelConfigurationMap[SubscriptionsConfiguration.BACKUPS_LEVEL]!!.storageAllowanceBytes
)
}
@@ -941,15 +940,13 @@ object BackupRepository {
* Should be the basis of all backup operations.
*/
private fun initBackupAndFetchAuth(backupKey: BackupKey): NetworkResult<ArchiveServiceCredential> {
val api = AppDependencies.signalServiceAccountManager.archiveApi
return if (SignalStore.backup.backupsInitialized) {
getAuthCredential().runOnStatusCodeError(resetInitializedStateErrorAction)
} else {
return api
.triggerBackupIdReservation(backupKey)
return SignalNetwork.archive
.triggerBackupIdReservation(backupKey, SignalStore.account.requireAci())
.then { getAuthCredential() }
.then { credential -> api.setPublicKey(backupKey, SignalStore.account.requireAci(), credential).map { credential } }
.then { credential -> SignalNetwork.archive.setPublicKey(backupKey, SignalStore.account.requireAci(), credential).map { credential } }
.runIfSuccessful { SignalStore.backup.backupsInitialized = true }
.runOnStatusCodeError(resetInitializedStateErrorAction)
}
@@ -969,7 +966,7 @@ object BackupRepository {
Log.w(TAG, "No credentials found for today, need to fetch new ones! This shouldn't happen under normal circumstances. We should ensure the routine fetch is running properly.")
return AppDependencies.signalServiceAccountManager.archiveApi.getServiceCredentials(currentTime).map { result ->
return SignalNetwork.archive.getServiceCredentials(currentTime).map { result ->
SignalStore.backup.addCredentials(result.credentials.toList())
SignalStore.backup.clearCredentialsOlderThan(currentTime)
SignalStore.backup.credentialsByDay.getForCurrentTime(currentTime.milliseconds)!!

View File

@@ -30,6 +30,7 @@ object BackupRestoreManager {
SignalExecutors.BOUNDED.execute {
synchronized(this) {
val restoringAttachments = messageRecords
.asSequence()
.mapNotNull { (it as? MmsMessageRecord?)?.slideDeck?.slides }
.flatten()
.mapNotNull { it.asAttachment() as? DatabaseAttachment }
@@ -41,10 +42,11 @@ object BackupRestoreManager {
.toSet()
reprioritizedAttachments += restoringAttachments.map { it.first }
val thumbnailJobs = restoringAttachments.map {
val (attachmentId, mmsId) = it
val thumbnailJobs = restoringAttachments.map { (attachmentId, mmsId) ->
RestoreAttachmentThumbnailJob(attachmentId = attachmentId, messageId = mmsId, highPriority = true)
}
if (thumbnailJobs.isNotEmpty()) {
AppDependencies.jobManager.addAll(thumbnailJobs)
}

View File

@@ -5,14 +5,6 @@
package org.thoughtcrime.securesms.backup.v2
class BackupV2Event(val type: Type, val count: Long, val estimatedTotalCount: Long) {
enum class Type {
PROGRESS_MESSAGES,
PROGRESS_ATTACHMENTS,
FINISHED
}
}
class LocalBackupV2Event(val type: Type, val count: Long = 0, val estimatedTotalCount: Long = 0) {
enum class Type {
PROGRESS_ACCOUNT,

View File

@@ -6,8 +6,14 @@
package org.thoughtcrime.securesms.backup.v2.database
import org.signal.core.util.deleteAll
import org.thoughtcrime.securesms.attachments.Attachment
import org.thoughtcrime.securesms.attachments.AttachmentId
import org.thoughtcrime.securesms.database.AttachmentTable
fun AttachmentTable.clearAllDataForBackupRestore() {
writableDatabase.deleteAll(AttachmentTable.TABLE_NAME)
}
fun AttachmentTable.restoreWallpaperAttachment(attachment: Attachment): AttachmentId? {
return insertAttachmentsForMessage(AttachmentTable.WALLPAPER_MESSAGE_ID, listOf(attachment), emptyList()).values.firstOrNull()
}

View File

@@ -47,7 +47,8 @@ fun CallLinkTable.restoreFromBackup(callLink: CallLink): RecipientId? {
name = callLink.name,
restrictions = callLink.restrictions.toLocal(),
expiration = Instant.ofEpochMilli(callLink.expirationMs)
)
),
deletionTimestamp = 0L
)
)
}

View File

@@ -20,15 +20,12 @@ import org.signal.core.util.requireLong
import org.signal.core.util.requireLongOrNull
import org.signal.core.util.requireString
import org.thoughtcrime.securesms.attachments.AttachmentId
import org.thoughtcrime.securesms.attachments.Cdn
import org.thoughtcrime.securesms.attachments.DatabaseAttachment
import org.thoughtcrime.securesms.backup.v2.BackupRepository.getMediaName
import org.thoughtcrime.securesms.backup.v2.proto.ChatItem
import org.thoughtcrime.securesms.backup.v2.proto.ChatUpdateMessage
import org.thoughtcrime.securesms.backup.v2.proto.ContactAttachment
import org.thoughtcrime.securesms.backup.v2.proto.ContactMessage
import org.thoughtcrime.securesms.backup.v2.proto.ExpirationTimerChatUpdate
import org.thoughtcrime.securesms.backup.v2.proto.FilePointer
import org.thoughtcrime.securesms.backup.v2.proto.GroupCall
import org.thoughtcrime.securesms.backup.v2.proto.IndividualCall
import org.thoughtcrime.securesms.backup.v2.proto.LearnedProfileChatUpdate
@@ -46,6 +43,7 @@ import org.thoughtcrime.securesms.backup.v2.proto.Sticker
import org.thoughtcrime.securesms.backup.v2.proto.StickerMessage
import org.thoughtcrime.securesms.backup.v2.proto.Text
import org.thoughtcrime.securesms.backup.v2.proto.ThreadMergeChatUpdate
import org.thoughtcrime.securesms.backup.v2.util.toRemoteFilePointer
import org.thoughtcrime.securesms.contactshare.Contact
import org.thoughtcrime.securesms.database.AttachmentTable
import org.thoughtcrime.securesms.database.CallTable
@@ -95,7 +93,7 @@ import org.thoughtcrime.securesms.backup.v2.proto.GiftBadge as BackupGiftBadge
*
* All of this complexity is hidden from the user -- they just get a normal iterator interface.
*/
class ChatItemExportIterator(private val cursor: Cursor, private val batchSize: Int, private val archiveMedia: Boolean) : Iterator<ChatItem?>, Closeable {
class ChatItemExportIterator(private val cursor: Cursor, private val batchSize: Int, private val mediaArchiveEnabled: Boolean) : Iterator<ChatItem?>, Closeable {
companion object {
private val TAG = Log.tag(ChatItemExportIterator::class.java)
@@ -182,6 +180,15 @@ class ChatItemExportIterator(private val cursor: Cursor, private val batchSize:
MessageTypes.isReportedSpam(record.type) -> {
builder.updateMessage = simpleUpdate(SimpleChatUpdate.Type.REPORTED_SPAM)
}
MessageTypes.isMessageRequestAccepted(record.type) -> {
builder.updateMessage = simpleUpdate(SimpleChatUpdate.Type.MESSAGE_REQUEST_ACCEPTED)
}
MessageTypes.isBlocked(record.type) -> {
builder.updateMessage = simpleUpdate(SimpleChatUpdate.Type.BLOCKED)
}
MessageTypes.isUnblocked(record.type) -> {
builder.updateMessage = simpleUpdate(SimpleChatUpdate.Type.UNBLOCKED)
}
MessageTypes.isExpirationTimerUpdate(record.type) -> {
builder.updateMessage = ChatUpdateMessage(expirationTimerChange = ExpirationTimerChatUpdate(record.expiresIn))
builder.expiresInMs = 0
@@ -275,7 +282,7 @@ class ChatItemExportIterator(private val cursor: Cursor, private val batchSize:
directionless = ChatItem.DirectionlessMessageDetails()
} else if (MessageTypes.isOutgoingMessageType(record.type)) {
outgoing = ChatItem.OutgoingMessageDetails(
sendStatus = record.toBackupSendStatus(groupReceipts)
sendStatus = record.toRemoteSendStatus(groupReceipts)
)
} else {
incoming = ChatItem.IncomingMessageDetails(
@@ -496,7 +503,7 @@ class ChatItemExportIterator(private val cursor: Cursor, private val batchSize:
PaymentNotification(
amountMob = payment.amount.serializeAmountString(),
feeMob = payment.fee.serializeAmountString(),
note = payment.note,
note = payment.note.takeUnless { it.isEmpty() },
transactionDetails = payment.getTransactionDetails()
)
}
@@ -581,7 +588,7 @@ class ChatItemExportIterator(private val cursor: Cursor, private val batchSize:
return org.thoughtcrime.securesms.backup.v2.proto.LinkPreview(
url = url,
title = title,
image = (thumbnail.orNull() as? DatabaseAttachment)?.toBackupAttachment()?.pointer,
image = (thumbnail.orNull() as? DatabaseAttachment)?.toRemoteMessageAttachment()?.pointer,
description = description,
date = date
)
@@ -593,7 +600,7 @@ class ChatItemExportIterator(private val cursor: Cursor, private val batchSize:
val contacts = sharedContacts.map {
ContactAttachment(
name = it.name.toBackup(),
avatar = (it.avatar?.attachment as? DatabaseAttachment)?.toBackupAttachment()?.pointer,
avatar = (it.avatar?.attachment as? DatabaseAttachment)?.toRemoteMessageAttachment()?.pointer,
organization = it.organization,
number = it.phoneNumbers.map { phone ->
ContactAttachment.Phone(
@@ -636,8 +643,7 @@ class ChatItemExportIterator(private val cursor: Cursor, private val batchSize:
familyName = familyName,
prefix = prefix,
suffix = suffix,
middleName = middleName,
displayName = displayName
middleName = middleName
)
}
@@ -679,16 +685,18 @@ class ChatItemExportIterator(private val cursor: Cursor, private val batchSize:
val linkPreviews = parseLinkPreviews(attachments)
val linkPreviewAttachments = linkPreviews.mapNotNull { it.thumbnail.orElse(null) }.toSet()
val quotedAttachments = attachments?.filter { it.quote } ?: emptyList()
val longTextAttachment = attachments?.firstOrNull { it.contentType == "text/x-signal-plain" }
val messageAttachments = attachments
?.filterNot { it.quote }
?.filterNot { linkPreviewAttachments.contains(it) }
?.filterNot { it == longTextAttachment }
?: emptyList()
return StandardMessage(
quote = this.toQuote(quotedAttachments),
text = text,
attachments = messageAttachments.toBackupAttachments(),
linkPreview = linkPreviews.map { it.toBackupLinkPreview() },
longText = null,
longText = longTextAttachment?.toRemoteFilePointer(mediaArchiveEnabled),
reactions = reactionRecords.toBackupReactions()
)
}
@@ -699,9 +707,13 @@ class ChatItemExportIterator(private val cursor: Cursor, private val batchSize:
Quote(
targetSentTimestamp = this.quoteTargetSentTimestamp.takeIf { !this.quoteMissing && it != MessageTable.QUOTE_TARGET_MISSING_ID },
authorId = this.quoteAuthor,
text = this.quoteBody,
text = this.quoteBody?.let { body ->
Text(
body = body,
bodyRanges = this.quoteBodyRanges?.toBackupBodyRanges() ?: emptyList()
)
},
attachments = attachments?.toBackupQuoteAttachments() ?: emptyList(),
bodyRanges = this.quoteBodyRanges?.toBackupBodyRanges() ?: emptyList(),
type = when (type) {
QuoteModel.Type.NORMAL -> Quote.Type.NORMAL
QuoteModel.Type.GIFT_BADGE -> Quote.Type.GIFTBADGE
@@ -739,7 +751,7 @@ class ChatItemExportIterator(private val cursor: Cursor, private val batchSize:
packKey = Hex.fromStringCondensed(stickerLocator.packKey).toByteString(),
stickerId = stickerLocator.stickerId,
emoji = stickerLocator.emoji,
data_ = this.toBackupAttachment().pointer
data_ = this.toRemoteMessageAttachment().pointer
),
reactions = reactions.toBackupReactions()
)
@@ -750,50 +762,14 @@ class ChatItemExportIterator(private val cursor: Cursor, private val batchSize:
Quote.QuotedAttachment(
contentType = attachment.contentType,
fileName = attachment.fileName,
thumbnail = attachment.toBackupAttachment()
thumbnail = attachment.toRemoteMessageAttachment().takeUnless { it.pointer?.invalidAttachmentLocator != null }
)
}
}
private fun DatabaseAttachment.toBackupAttachment(): MessageAttachment {
val builder = FilePointer.Builder()
builder.contentType = this.contentType?.takeUnless { it.isBlank() }
builder.incrementalMac = this.incrementalDigest?.toByteString()
builder.incrementalMacChunkSize = this.incrementalMacChunkSize.takeIf { it > 0 }
builder.fileName = this.fileName
builder.width = this.width.takeUnless { it == 0 }
builder.height = this.height.takeUnless { it == 0 }
builder.caption = this.caption
builder.blurHash = this.blurHash?.hash
if (this.remoteKey.isNullOrBlank() || this.remoteDigest == null || this.size == 0L) {
builder.invalidAttachmentLocator = FilePointer.InvalidAttachmentLocator()
} else {
if (archiveMedia) {
builder.backupLocator = FilePointer.BackupLocator(
mediaName = this.archiveMediaName ?: this.getMediaName().toString(),
cdnNumber = if (this.archiveMediaName != null) this.archiveCdn else Cdn.CDN_3.cdnNumber, // TODO (clark): Update when new proto with optional cdn is landed
key = Base64.decode(remoteKey).toByteString(),
size = this.size.toInt(),
digest = this.remoteDigest.toByteString()
)
} else {
if (this.remoteLocation.isNullOrBlank()) {
builder.invalidAttachmentLocator = FilePointer.InvalidAttachmentLocator()
} else {
builder.attachmentLocator = FilePointer.AttachmentLocator(
cdnKey = this.remoteLocation,
cdnNumber = this.cdn.cdnNumber,
uploadTimestamp = this.uploadTimestamp,
key = Base64.decode(remoteKey).toByteString(),
size = this.size.toInt(),
digest = this.remoteDigest.toByteString()
)
}
}
}
private fun DatabaseAttachment.toRemoteMessageAttachment(): MessageAttachment {
return MessageAttachment(
pointer = builder.build(),
pointer = this.toRemoteFilePointer(mediaArchiveEnabled),
wasDownloaded = this.transferState == AttachmentTable.TRANSFER_PROGRESS_DONE || this.transferState == AttachmentTable.TRANSFER_NEEDS_RESTORE,
flag = if (this.voiceNote) {
MessageAttachment.Flag.VOICE_MESSAGE
@@ -810,21 +786,23 @@ class ChatItemExportIterator(private val cursor: Cursor, private val batchSize:
private fun List<DatabaseAttachment>.toBackupAttachments(): List<MessageAttachment> {
return this.map { attachment ->
attachment.toBackupAttachment()
attachment.toRemoteMessageAttachment()
}
}
private fun PaymentTable.PaymentTransaction.getTransactionDetails(): PaymentNotification.TransactionDetails? {
if (failureReason != null || state == State.FAILED) {
return PaymentNotification.TransactionDetails(failedTransaction = PaymentNotification.TransactionDetails.FailedTransaction(reason = failureReason.toBackupFailureReason()))
if (this.failureReason != null || this.state == State.FAILED) {
return PaymentNotification.TransactionDetails(failedTransaction = PaymentNotification.TransactionDetails.FailedTransaction(reason = this.failureReason.toBackupFailureReason()))
}
return PaymentNotification.TransactionDetails(
transaction = PaymentNotification.TransactionDetails.Transaction(
status = this.state.toBackupState(),
timestamp = timestamp,
blockIndex = blockIndex,
blockTimestamp = blockTimestamp,
mobileCoinIdentification = paymentMetaData.mobileCoinTxoIdentification?.toBackup()
timestamp = this.timestamp,
blockIndex = this.blockIndex,
blockTimestamp = this.blockTimestamp,
mobileCoinIdentification = this.paymentMetaData.mobileCoinTxoIdentification?.toBackup(),
transaction = this.transaction?.toByteString(),
receipt = this.receipt?.toByteString()
)
)
}
@@ -906,19 +884,18 @@ class ChatItemExportIterator(private val cursor: Cursor, private val batchSize:
emoji = it.emoji,
authorId = it.author.toLong(),
sentTimestamp = it.dateSent,
receivedTimestamp = it.dateReceived,
sortOrder = 0 // TODO [backup] make this it.dateReceived once comparator support is added
sortOrder = it.dateReceived
)
} ?: emptyList()
}
private fun BackupMessageRecord.toBackupSendStatus(groupReceipts: List<GroupReceiptTable.GroupReceiptInfo>?): List<SendStatus> {
private fun BackupMessageRecord.toRemoteSendStatus(groupReceipts: List<GroupReceiptTable.GroupReceiptInfo>?): List<SendStatus> {
if (!MessageTypes.isOutgoingMessageType(this.type)) {
return emptyList()
}
if (!groupReceipts.isNullOrEmpty()) {
return groupReceipts.toBackupSendStatus(this.networkFailureRecipientIds, this.identityMismatchRecipientIds)
return groupReceipts.toRemoteSendStatus(this, this.networkFailureRecipientIds, this.identityMismatchRecipientIds)
}
val statusBuilder = SendStatus.Builder()
@@ -928,21 +905,16 @@ class ChatItemExportIterator(private val cursor: Cursor, private val batchSize:
when {
this.identityMismatchRecipientIds.contains(this.toRecipientId) -> {
statusBuilder.failed = SendStatus.Failed(
identityKeyMismatch = true
reason = SendStatus.Failed.FailureReason.IDENTITY_KEY_MISMATCH
)
}
this.networkFailureRecipientIds.contains(this.toRecipientId) -> {
statusBuilder.failed = SendStatus.Failed(
network = true
reason = SendStatus.Failed.FailureReason.NETWORK
)
}
this.baseType == MessageTypes.BASE_SENT_TYPE -> {
statusBuilder.sent = SendStatus.Sent(
sealedSender = this.sealedSender
)
}
this.hasDeliveryReceipt -> {
statusBuilder.delivered = SendStatus.Delivered(
this.viewed -> {
statusBuilder.viewed = SendStatus.Viewed(
sealedSender = this.sealedSender
)
}
@@ -951,8 +923,21 @@ class ChatItemExportIterator(private val cursor: Cursor, private val batchSize:
sealedSender = this.sealedSender
)
}
this.viewed -> {
statusBuilder.viewed = SendStatus.Viewed(
this.hasDeliveryReceipt -> {
statusBuilder.delivered = SendStatus.Delivered(
sealedSender = this.sealedSender
)
}
this.baseType == MessageTypes.BASE_SENT_FAILED_TYPE -> {
statusBuilder.failed = SendStatus.Failed(
reason = SendStatus.Failed.FailureReason.UNKNOWN
)
}
this.baseType == MessageTypes.BASE_SENDING_SKIPPED_TYPE -> {
statusBuilder.skipped = SendStatus.Skipped()
}
this.baseType == MessageTypes.BASE_SENT_TYPE -> {
statusBuilder.sent = SendStatus.Sent(
sealedSender = this.sealedSender
)
}
@@ -964,7 +949,7 @@ class ChatItemExportIterator(private val cursor: Cursor, private val batchSize:
return listOf(statusBuilder.build())
}
private fun List<GroupReceiptTable.GroupReceiptInfo>.toBackupSendStatus(networkFailureRecipientIds: Set<Long>, identityMismatchRecipientIds: Set<Long>): List<SendStatus> {
private fun List<GroupReceiptTable.GroupReceiptInfo>.toRemoteSendStatus(messageRecord: BackupMessageRecord, networkFailureRecipientIds: Set<Long>, identityMismatchRecipientIds: Set<Long>): List<SendStatus> {
return this.map {
val statusBuilder = SendStatus.Builder()
.recipientId(it.recipientId.toLong())
@@ -973,12 +958,17 @@ class ChatItemExportIterator(private val cursor: Cursor, private val batchSize:
when {
identityMismatchRecipientIds.contains(it.recipientId.toLong()) -> {
statusBuilder.failed = SendStatus.Failed(
identityKeyMismatch = true
reason = SendStatus.Failed.FailureReason.IDENTITY_KEY_MISMATCH
)
}
networkFailureRecipientIds.contains(it.recipientId.toLong()) -> {
statusBuilder.failed = SendStatus.Failed(
network = true
reason = SendStatus.Failed.FailureReason.NETWORK
)
}
messageRecord.baseType == MessageTypes.BASE_SENT_FAILED_TYPE -> {
statusBuilder.failed = SendStatus.Failed(
reason = SendStatus.Failed.FailureReason.UNKNOWN
)
}
it.status == GroupReceiptTable.STATUS_UNKNOWN -> {
@@ -1081,7 +1071,10 @@ class ChatItemExportIterator(private val cursor: Cursor, private val batchSize:
MessageTypes.isPaymentsActivated(this) ||
MessageTypes.isPaymentsRequestToActivate(this) ||
MessageTypes.isUnsupportedMessageType(this) ||
MessageTypes.isReportedSpam(this)
MessageTypes.isReportedSpam(this) ||
MessageTypes.isMessageRequestAccepted(this) ||
MessageTypes.isBlocked(this) ||
MessageTypes.isUnblocked(this)
}
private fun String.e164ToLong(): Long? {

View File

@@ -7,7 +7,6 @@ package org.thoughtcrime.securesms.backup.v2.database
import android.content.ContentValues
import androidx.core.content.contentValuesOf
import okio.ByteString
import org.signal.core.util.Base64
import org.signal.core.util.Hex
import org.signal.core.util.SqlUtil
@@ -17,17 +16,13 @@ import org.signal.core.util.orNull
import org.signal.core.util.requireLong
import org.signal.core.util.toInt
import org.signal.core.util.update
import org.thoughtcrime.securesms.attachments.ArchivedAttachment
import org.thoughtcrime.securesms.attachments.Attachment
import org.thoughtcrime.securesms.attachments.Cdn
import org.thoughtcrime.securesms.attachments.PointerAttachment
import org.thoughtcrime.securesms.attachments.TombstoneAttachment
import org.thoughtcrime.securesms.backup.v2.ImportState
import org.thoughtcrime.securesms.backup.v2.proto.BodyRange
import org.thoughtcrime.securesms.backup.v2.proto.ChatItem
import org.thoughtcrime.securesms.backup.v2.proto.ChatUpdateMessage
import org.thoughtcrime.securesms.backup.v2.proto.ContactAttachment
import org.thoughtcrime.securesms.backup.v2.proto.FilePointer
import org.thoughtcrime.securesms.backup.v2.proto.GroupCall
import org.thoughtcrime.securesms.backup.v2.proto.IndividualCall
import org.thoughtcrime.securesms.backup.v2.proto.LinkPreview
@@ -39,8 +34,8 @@ import org.thoughtcrime.securesms.backup.v2.proto.SendStatus
import org.thoughtcrime.securesms.backup.v2.proto.SimpleChatUpdate
import org.thoughtcrime.securesms.backup.v2.proto.StandardMessage
import org.thoughtcrime.securesms.backup.v2.proto.Sticker
import org.thoughtcrime.securesms.backup.v2.util.toLocalAttachment
import org.thoughtcrime.securesms.contactshare.Contact
import org.thoughtcrime.securesms.database.AttachmentTable
import org.thoughtcrime.securesms.database.CallTable
import org.thoughtcrime.securesms.database.GroupReceiptTable
import org.thoughtcrime.securesms.database.MessageTable
@@ -67,15 +62,14 @@ import org.thoughtcrime.securesms.database.model.databaseprotos.ThreadMergeEvent
import org.thoughtcrime.securesms.mms.QuoteModel
import org.thoughtcrime.securesms.payments.CryptoValueUtil
import org.thoughtcrime.securesms.payments.Direction
import org.thoughtcrime.securesms.payments.FailureReason
import org.thoughtcrime.securesms.payments.State
import org.thoughtcrime.securesms.payments.proto.PaymentMetaData
import org.thoughtcrime.securesms.profiles.ProfileName
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.stickers.StickerLocator
import org.thoughtcrime.securesms.util.JsonUtils
import org.whispersystems.signalservice.api.backup.MediaName
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentPointer
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentRemoteId
import org.whispersystems.signalservice.api.payments.Money
import org.whispersystems.signalservice.api.push.ServiceId
import org.whispersystems.signalservice.api.util.UuidUtil
@@ -101,6 +95,7 @@ class ChatItemImportInserter(
MessageTable.DATE_SENT,
MessageTable.DATE_RECEIVED,
MessageTable.DATE_SERVER,
MessageTable.RECEIPT_TIMESTAMP,
MessageTable.TYPE,
MessageTable.THREAD_ID,
MessageTable.READ,
@@ -341,7 +336,7 @@ class ChatItemImportInserter(
address.country
)
},
Contact.Avatar(null, backupContact.avatar.toLocalAttachment(), true)
Contact.Avatar(null, backupContact.avatar.toLocalAttachment(importState = importState, voiceNote = false, borderless = false, gif = false, wasDownloaded = true), true)
)
}
@@ -386,18 +381,25 @@ class ChatItemImportInserter(
}
}
val linkPreviews = this.standardMessage.linkPreview.map { it.toLocalLinkPreview() }
val linkPreviewAttachments = linkPreviews.mapNotNull { it.thumbnail.orNull() }
val attachments = this.standardMessage.attachments.mapNotNull { attachment ->
val linkPreviewAttachments: List<Attachment> = linkPreviews.mapNotNull { it.thumbnail.orNull() }
val attachments: List<Attachment> = this.standardMessage.attachments.mapNotNull { attachment ->
attachment.toLocalAttachment()
}
val quoteAttachments = this.standardMessage.quote?.attachments?.mapNotNull {
val longTextAttachments: List<Attachment> = this.standardMessage.longText?.let { longTextPointer ->
longTextPointer.toLocalAttachment(
importState = importState,
contentType = "text/x-signal-plain"
)
}?.let { listOf(it) } ?: emptyList()
val quoteAttachments: List<Attachment> = this.standardMessage.quote?.attachments?.mapNotNull {
it.toLocalAttachment()
} ?: emptyList()
if (attachments.isNotEmpty() || linkPreviewAttachments.isNotEmpty() || quoteAttachments.isNotEmpty()) {
if (attachments.isNotEmpty() || linkPreviewAttachments.isNotEmpty() || quoteAttachments.isNotEmpty() || longTextAttachments.isNotEmpty()) {
followUp = { messageRowId ->
val attachmentMap = SignalDatabase.attachments.insertAttachmentsForMessage(messageRowId, attachments + linkPreviewAttachments, quoteAttachments)
val attachmentMap = SignalDatabase.attachments.insertAttachmentsForMessage(messageRowId, attachments + linkPreviewAttachments + longTextAttachments, quoteAttachments)
if (linkPreviews.isNotEmpty()) {
db.update(
MessageTable.TABLE_NAME,
@@ -425,8 +427,6 @@ class ChatItemImportInserter(
return MessageInsert(contentValues, followUp)
}
private class BatchInsert(val inserts: List<MessageInsert>, val query: SqlUtil.Query)
private fun ChatItem.toMessageContentValues(fromRecipientId: RecipientId, chatRecipientId: RecipientId, threadId: Long): ContentValues {
val contentValues = ContentValues()
@@ -441,8 +441,8 @@ class ChatItemImportInserter(
contentValues.putNull(MessageTable.LATEST_REVISION_ID)
contentValues.putNull(MessageTable.ORIGINAL_MESSAGE_ID)
contentValues.put(MessageTable.REVISION_NUMBER, 0)
contentValues.put(MessageTable.EXPIRES_IN, this.expiresInMs ?: 0)
contentValues.put(MessageTable.EXPIRE_STARTED, this.expireStartDate ?: 0)
contentValues.put(MessageTable.EXPIRES_IN, this.expiresInMs)
contentValues.put(MessageTable.EXPIRE_STARTED, this.expireStartDate)
if (this.outgoing != null) {
val viewed = this.outgoing.sendStatus.any { it.viewed != null }
@@ -502,6 +502,7 @@ class ChatItemImportInserter(
chatRecipientId,
transaction.timestamp ?: 0,
transaction.blockIndex ?: 0,
transaction.blockTimestamp ?: 0,
paymentNotification.note ?: "",
if (chatItem.outgoing != null) Direction.SENT else Direction.RECEIVED,
transaction.status.toLocalStatus(),
@@ -510,7 +511,8 @@ class ChatItemImportInserter(
transaction.transaction?.toByteArray(),
transaction.receipt?.toByteArray(),
mobileCoinIdentification,
chatItem.incoming?.read ?: true
chatItem.incoming?.read ?: true,
null
)
}
@@ -531,7 +533,7 @@ class ChatItemImportInserter(
ReactionTable.MESSAGE_ID to messageId,
ReactionTable.AUTHOR_ID to authorId,
ReactionTable.DATE_SENT to it.sentTimestamp,
ReactionTable.DATE_RECEIVED to (it.receivedTimestamp ?: it.sortOrder),
ReactionTable.DATE_RECEIVED to it.sortOrder,
ReactionTable.EMOJI to it.emoji
)
} else {
@@ -560,7 +562,7 @@ class ChatItemImportInserter(
GroupReceiptTable.RECIPIENT_ID to recipientId.serialize(),
GroupReceiptTable.STATUS to sendStatus.toLocalSendStatus(),
GroupReceiptTable.TIMESTAMP to sendStatus.timestamp,
GroupReceiptTable.UNIDENTIFIED to sendStatus.sealedSender
GroupReceiptTable.UNIDENTIFIED to sendStatus.sealedSender.toInt()
)
} else {
Log.w(TAG, "[GroupReceipts] Could not find a local recipient for backup recipient ID ${sendStatus.recipientId}! Skipping.")
@@ -571,10 +573,21 @@ class ChatItemImportInserter(
private fun ChatItem.getMessageType(): Long {
var type: Long = if (this.outgoing != null) {
if (this.outgoing.sendStatus.count { it.failed?.identityKeyMismatch == true } > 0) {
if (this.outgoing.sendStatus.count { it.failed?.reason == SendStatus.Failed.FailureReason.IDENTITY_KEY_MISMATCH } > 0) {
MessageTypes.BASE_SENT_FAILED_TYPE
} else if (this.outgoing.sendStatus.count { it.failed?.network == true } > 0) {
} else if (this.outgoing.sendStatus.count { it.failed?.reason == SendStatus.Failed.FailureReason.UNKNOWN } > 0) {
MessageTypes.BASE_SENT_FAILED_TYPE
} else if (this.outgoing.sendStatus.count { it.failed?.reason == SendStatus.Failed.FailureReason.NETWORK } > 0) {
MessageTypes.BASE_SENDING_TYPE
} else if (this.outgoing.sendStatus.count { it.pending != null } > 0) {
MessageTypes.BASE_SENDING_TYPE
} else if (this.outgoing.sendStatus.count { it.skipped != null } > 0) {
val count = this.outgoing.sendStatus.count { it.skipped != null }
if (count == this.outgoing.sendStatus.size) {
MessageTypes.BASE_SENDING_SKIPPED_TYPE
} else {
MessageTypes.BASE_SENDING_TYPE
}
} else {
MessageTypes.BASE_SENT_TYPE
}
@@ -627,7 +640,9 @@ class ChatItemImportInserter(
SimpleChatUpdate.Type.PAYMENT_ACTIVATION_REQUEST -> MessageTypes.SPECIAL_TYPE_PAYMENTS_ACTIVATE_REQUEST or typeWithoutBase
SimpleChatUpdate.Type.UNSUPPORTED_PROTOCOL_MESSAGE -> MessageTypes.UNSUPPORTED_MESSAGE_TYPE or typeWithoutBase
SimpleChatUpdate.Type.REPORTED_SPAM -> MessageTypes.SPECIAL_TYPE_REPORTED_SPAM or typeWithoutBase
else -> throw NotImplementedError()
SimpleChatUpdate.Type.BLOCKED -> MessageTypes.SPECIAL_TYPE_BLOCKED or typeWithoutBase
SimpleChatUpdate.Type.UNBLOCKED -> MessageTypes.SPECIAL_TYPE_UNBLOCKED or typeWithoutBase
SimpleChatUpdate.Type.MESSAGE_REQUEST_ACCEPTED -> MessageTypes.SPECIAL_TYPE_MESSAGE_REQUEST_ACCEPTED or typeWithoutBase
}
}
updateMessage.expirationTimerChange != null -> {
@@ -705,17 +720,17 @@ class ChatItemImportInserter(
private fun ContentValues.addPaymentNotification(chatItem: ChatItem, chatRecipientId: RecipientId) {
val paymentNotification = chatItem.paymentNotification!!
if (chatItem.paymentNotification.amountMob.isNullOrEmpty()) {
addPaymentTombstoneNoAmount()
this.addPaymentTombstoneNoAmount()
return
}
val amount = paymentNotification.amountMob?.tryParseMoney() ?: return addPaymentTombstoneNoAmount()
val fee = paymentNotification.feeMob?.tryParseMoney() ?: return addPaymentTombstoneNoAmount()
val amount = paymentNotification.amountMob?.tryParseMoney() ?: return this.addPaymentTombstoneNoAmount()
val fee = paymentNotification.feeMob?.tryParseMoney() ?: return this.addPaymentTombstoneNoAmount()
if (chatItem.paymentNotification.transactionDetails?.failedTransaction != null) {
addFailedPaymentNotification(chatItem, amount, fee, chatRecipientId)
this.addFailedPaymentNotification(chatItem, amount, fee, chatRecipientId)
return
}
addPaymentTombstoneNoMetadata(chatItem.paymentNotification)
this.addPaymentTombstoneNoMetadata(chatItem.paymentNotification)
}
private fun PaymentNotification.TransactionDetails.MobileCoinTxoIdentification.toLocal(): PaymentMetaData {
@@ -732,6 +747,7 @@ class ChatItemImportInserter(
chatRecipientId,
0,
0,
0,
chatItem.paymentNotification?.note ?: "",
if (chatItem.outgoing != null) Direction.SENT else Direction.RECEIVED,
State.FAILED,
@@ -740,7 +756,8 @@ class ChatItemImportInserter(
null,
null,
null,
chatItem.incoming?.read ?: true
chatItem.incoming?.read ?: true,
chatItem.paymentNotification?.transactionDetails?.failedTransaction?.reason?.toLocalPaymentFailureReason()
)
if (uuid != null) {
put(MessageTable.BODY, uuid.toString())
@@ -750,6 +767,14 @@ class ChatItemImportInserter(
}
}
private fun PaymentNotification.TransactionDetails.FailedTransaction.FailureReason.toLocalPaymentFailureReason(): FailureReason {
return when (this) {
PaymentNotification.TransactionDetails.FailedTransaction.FailureReason.GENERIC -> FailureReason.UNKNOWN
PaymentNotification.TransactionDetails.FailedTransaction.FailureReason.NETWORK -> FailureReason.NETWORK
PaymentNotification.TransactionDetails.FailedTransaction.FailureReason.INSUFFICIENT_FUNDS -> FailureReason.INSUFFICIENT_FUNDS
}
}
private fun ContentValues.addPaymentTombstoneNoAmount() {
put(MessageTable.TYPE, getAsLong(MessageTable.TYPE) or MessageTypes.SPECIAL_TYPE_PAYMENTS_TOMBSTONE)
}
@@ -812,10 +837,10 @@ class ChatItemImportInserter(
private fun ContentValues.addQuote(quote: Quote) {
this.put(MessageTable.QUOTE_ID, quote.targetSentTimestamp ?: MessageTable.QUOTE_TARGET_MISSING_ID)
this.put(MessageTable.QUOTE_AUTHOR, importState.remoteToLocalRecipientId[quote.authorId]!!.serialize())
this.put(MessageTable.QUOTE_BODY, quote.text)
this.put(MessageTable.QUOTE_BODY, quote.text?.body)
this.put(MessageTable.QUOTE_TYPE, quote.type.toLocalQuoteType())
this.put(MessageTable.QUOTE_BODY_RANGES, quote.bodyRanges.toLocalBodyRanges()?.encode())
// TODO quote attachments
this.put(MessageTable.QUOTE_BODY_RANGES, quote.text?.bodyRanges?.toLocalBodyRanges()?.encode())
// TODO [backup] quote attachments
this.put(MessageTable.QUOTE_MISSING, (quote.targetSentTimestamp == null).toInt())
}
@@ -842,7 +867,7 @@ class ChatItemImportInserter(
}
val networkFailures = chatItem.outgoing.sendStatus
.filter { status -> status.failed?.network ?: false }
.filter { status -> status.failed?.reason == SendStatus.Failed.FailureReason.NETWORK }
.mapNotNull { status -> importState.remoteToLocalRecipientId[status.recipientId] }
.map { recipientId -> NetworkFailure(recipientId) }
.toSet()
@@ -858,7 +883,7 @@ class ChatItemImportInserter(
}
val mismatches = chatItem.outgoing.sendStatus
.filter { status -> status.failed?.identityKeyMismatch ?: false }
.filter { status -> status.failed?.reason == SendStatus.Failed.FailureReason.IDENTITY_KEY_MISMATCH }
.mapNotNull { status -> importState.remoteToLocalRecipientId[status.recipientId] }
.map { recipientId -> IdentityKeyMismatch(recipientId, null) } // TODO We probably want the actual identity key in this status situation?
.toSet()
@@ -916,29 +941,6 @@ class ChatItemImportInserter(
?: false
}
private fun LinkPreview.toLocalLinkPreview(): org.thoughtcrime.securesms.linkpreview.LinkPreview {
return org.thoughtcrime.securesms.linkpreview.LinkPreview(
this.url,
this.title ?: "",
this.description ?: "",
this.date ?: 0,
Optional.ofNullable(this.image?.toLocalAttachment())
)
}
private fun MessageAttachment.toLocalAttachment(contentType: String? = this.pointer?.contentType, fileName: String? = this.pointer?.fileName): Attachment? {
return this.pointer?.toLocalAttachment(
voiceNote = this.flag == MessageAttachment.Flag.VOICE_MESSAGE,
borderless = this.flag == MessageAttachment.Flag.BORDERLESS,
gif = this.flag == MessageAttachment.Flag.GIF,
wasDownloaded = this.wasDownloaded,
stickerLocator = null,
contentType = contentType,
fileName = fileName,
uuid = this.clientUuid
)
}
private fun Quote.QuotedAttachment.toLocalAttachment(): Attachment? {
val thumbnail = this.thumbnail?.toLocalAttachment(this.contentType, this.fileName)
@@ -961,6 +963,11 @@ class ChatItemImportInserter(
if (this == null) return null
return data_.toLocalAttachment(
importState = importState,
voiceNote = false,
gif = false,
borderless = false,
wasDownloaded = true,
stickerLocator = StickerLocator(
packId = Hex.toStringCondensed(packId.toByteArray()),
packKey = Hex.toStringCondensed(packKey.toByteArray()),
@@ -970,93 +977,43 @@ class ChatItemImportInserter(
)
}
private fun FilePointer?.toLocalAttachment(
borderless: Boolean = false,
gif: Boolean = false,
voiceNote: Boolean = false,
wasDownloaded: Boolean = true,
stickerLocator: StickerLocator? = null,
contentType: String? = this?.contentType,
fileName: String? = this?.fileName,
uuid: ByteString? = null
): Attachment? {
return if (this == null) {
null
} else if (this.attachmentLocator != null) {
val signalAttachmentPointer = SignalServiceAttachmentPointer(
cdnNumber = this.attachmentLocator.cdnNumber,
remoteId = SignalServiceAttachmentRemoteId.from(this.attachmentLocator.cdnKey),
contentType = contentType,
key = this.attachmentLocator.key.toByteArray(),
size = Optional.ofNullable(this.attachmentLocator.size),
preview = Optional.empty(),
width = this.width ?: 0,
height = this.height ?: 0,
digest = Optional.ofNullable(this.attachmentLocator.digest.toByteArray()),
incrementalDigest = Optional.ofNullable(this.incrementalMac?.toByteArray()),
incrementalMacChunkSize = this.incrementalMacChunkSize ?: 0,
fileName = Optional.ofNullable(fileName),
voiceNote = voiceNote,
isBorderless = borderless,
isGif = gif,
caption = Optional.ofNullable(this.caption),
blurHash = Optional.ofNullable(this.blurHash),
uploadTimestamp = this.attachmentLocator.uploadTimestamp,
uuid = UuidUtil.fromByteStringOrNull(uuid)
)
PointerAttachment.forPointer(
pointer = Optional.of(signalAttachmentPointer),
stickerLocator = stickerLocator,
transferState = if (wasDownloaded) AttachmentTable.TRANSFER_NEEDS_RESTORE else AttachmentTable.TRANSFER_PROGRESS_PENDING
).orNull()
} else if (this.invalidAttachmentLocator != null) {
TombstoneAttachment(
contentType = contentType,
incrementalMac = this.incrementalMac?.toByteArray(),
incrementalMacChunkSize = this.incrementalMacChunkSize,
width = this.width,
height = this.height,
caption = this.caption,
blurHash = this.blurHash,
voiceNote = voiceNote,
borderless = borderless,
gif = gif,
quote = false,
uuid = UuidUtil.fromByteStringOrNull(uuid)
)
} else if (this.backupLocator != null) {
ArchivedAttachment(
contentType = contentType,
size = this.backupLocator.size.toLong(),
cdn = this.backupLocator.transitCdnNumber ?: Cdn.CDN_0.cdnNumber,
key = this.backupLocator.key.toByteArray(),
cdnKey = this.backupLocator.transitCdnKey,
archiveCdn = this.backupLocator.cdnNumber,
archiveMediaName = this.backupLocator.mediaName,
archiveMediaId = importState.backupKey.deriveMediaId(MediaName(this.backupLocator.mediaName)).encode(),
archiveThumbnailMediaId = importState.backupKey.deriveMediaId(MediaName.forThumbnailFromMediaName(this.backupLocator.mediaName)).encode(),
digest = this.backupLocator.digest.toByteArray(),
incrementalMac = this.incrementalMac?.toByteArray(),
incrementalMacChunkSize = this.incrementalMacChunkSize,
width = this.width,
height = this.height,
caption = this.caption,
blurHash = this.blurHash,
voiceNote = voiceNote,
borderless = borderless,
gif = gif,
quote = false,
stickerLocator = stickerLocator,
uuid = UuidUtil.fromByteStringOrNull(uuid),
fileName = fileName
)
} else {
null
}
private fun LinkPreview.toLocalLinkPreview(): org.thoughtcrime.securesms.linkpreview.LinkPreview {
return org.thoughtcrime.securesms.linkpreview.LinkPreview(
this.url,
this.title ?: "",
this.description ?: "",
this.date ?: 0,
Optional.ofNullable(this.image?.toLocalAttachment(importState = importState, voiceNote = false, borderless = false, gif = false, wasDownloaded = true))
)
}
private fun MessageAttachment.toLocalAttachment(): Attachment? {
return this.pointer?.toLocalAttachment(
importState = importState,
voiceNote = this.flag == MessageAttachment.Flag.VOICE_MESSAGE,
gif = this.flag == MessageAttachment.Flag.GIF,
borderless = this.flag == MessageAttachment.Flag.BORDERLESS,
wasDownloaded = this.wasDownloaded,
uuid = this.clientUuid
)
}
private fun MessageAttachment.toLocalAttachment(contentType: String?, fileName: String?): Attachment? {
return pointer?.toLocalAttachment(
importState = importState,
voiceNote = flag == MessageAttachment.Flag.VOICE_MESSAGE,
gif = flag == MessageAttachment.Flag.GIF,
borderless = flag == MessageAttachment.Flag.BORDERLESS,
wasDownloaded = wasDownloaded,
contentType = contentType,
fileName = fileName,
uuid = clientUuid
)
}
private fun ContactAttachment.Name?.toLocal(): Contact.Name {
return Contact.Name(this?.displayName, this?.givenName, this?.familyName, this?.prefix, this?.suffix, this?.middleName)
val displayName = ProfileName.fromParts(this?.givenName, this?.familyName).toString()
return Contact.Name(displayName, this?.givenName, this?.familyName, this?.prefix, this?.suffix, this?.middleName)
}
private fun ContactAttachment.Phone.Type?.toLocal(): Contact.Phone.Type {

View File

@@ -0,0 +1,129 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2.database
import android.text.TextUtils
import org.signal.core.util.Base64
import org.thoughtcrime.securesms.attachments.DatabaseAttachment
import org.thoughtcrime.securesms.attachments.InvalidAttachmentException
import org.thoughtcrime.securesms.backup.v2.BackupRepository
import org.thoughtcrime.securesms.backup.v2.BackupRepository.getThumbnailMediaName
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.Util
import org.whispersystems.signalservice.api.backup.MediaName
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentPointer
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentRemoteId
import java.io.IOException
import java.util.Optional
/**
* Creates a [SignalServiceAttachmentPointer] for the archived attachment of the given [DatabaseAttachment].
*/
@Throws(InvalidAttachmentException::class)
fun DatabaseAttachment.createArchiveAttachmentPointer(useArchiveCdn: Boolean): SignalServiceAttachmentPointer {
if (remoteKey.isNullOrBlank()) {
throw InvalidAttachmentException("empty encrypted key")
}
if (remoteDigest == null) {
throw InvalidAttachmentException("no digest")
}
return try {
val (remoteId, cdnNumber) = if (useArchiveCdn) {
val backupKey = SignalStore.svr.getOrCreateMasterKey().deriveBackupKey()
val backupDirectories = BackupRepository.getCdnBackupDirectories().successOrThrow()
val id = SignalServiceAttachmentRemoteId.Backup(
backupDir = backupDirectories.backupDir,
mediaDir = backupDirectories.mediaDir,
mediaId = backupKey.deriveMediaId(MediaName(archiveMediaName!!)).encode()
)
id to archiveCdn
} else {
if (remoteLocation.isNullOrEmpty()) {
throw InvalidAttachmentException("empty content id")
}
SignalServiceAttachmentRemoteId.from(remoteLocation) to cdn.cdnNumber
}
val key = Base64.decode(remoteKey)
SignalServiceAttachmentPointer(
cdnNumber = cdnNumber,
remoteId = remoteId,
contentType = null,
key = key,
size = Optional.of(Util.toIntExact(size)),
preview = Optional.empty(),
width = 0,
height = 0,
digest = Optional.ofNullable(remoteDigest),
incrementalDigest = Optional.ofNullable(getIncrementalDigest()),
incrementalMacChunkSize = incrementalMacChunkSize,
fileName = Optional.ofNullable(fileName),
voiceNote = voiceNote,
isBorderless = borderless,
isGif = videoGif,
caption = Optional.empty(),
blurHash = Optional.ofNullable(blurHash).map { it.hash },
uploadTimestamp = uploadTimestamp,
uuid = uuid
)
} catch (e: IOException) {
throw InvalidAttachmentException(e)
} catch (e: ArithmeticException) {
throw InvalidAttachmentException(e)
}
}
/**
* Creates a [SignalServiceAttachmentPointer] for an archived thumbnail of the given [DatabaseAttachment].
*/
@Throws(InvalidAttachmentException::class)
fun DatabaseAttachment.createArchiveThumbnailPointer(): SignalServiceAttachmentPointer {
if (TextUtils.isEmpty(remoteKey)) {
throw InvalidAttachmentException("empty encrypted key")
}
val backupKey = SignalStore.svr.getOrCreateMasterKey().deriveBackupKey()
val backupDirectories = BackupRepository.getCdnBackupDirectories().successOrThrow()
return try {
val key = backupKey.deriveThumbnailTransitKey(getThumbnailMediaName())
val mediaId = backupKey.deriveMediaId(getThumbnailMediaName()).encode()
SignalServiceAttachmentPointer(
cdnNumber = archiveCdn,
remoteId = SignalServiceAttachmentRemoteId.Backup(
backupDir = backupDirectories.backupDir,
mediaDir = backupDirectories.mediaDir,
mediaId = mediaId
),
contentType = null,
key = key,
size = Optional.empty(),
preview = Optional.empty(),
width = 0,
height = 0,
digest = Optional.empty(),
incrementalDigest = Optional.empty(),
incrementalMacChunkSize = incrementalMacChunkSize,
fileName = Optional.empty(),
voiceNote = voiceNote,
isBorderless = borderless,
isGif = videoGif,
caption = Optional.empty(),
blurHash = Optional.ofNullable(blurHash).map { it.hash },
uploadTimestamp = uploadTimestamp,
uuid = uuid
)
} catch (e: IOException) {
throw InvalidAttachmentException(e)
} catch (e: ArithmeticException) {
throw InvalidAttachmentException(e)
}
}

View File

@@ -310,7 +310,7 @@ private fun DecryptedGroup.toSnapshot(): Group.GroupSnapshot? {
return Group.GroupSnapshot(
title = Group.GroupAttributeBlob(title = this.title),
avatarUrl = this.avatar,
disappearingMessagesTimer = Group.GroupAttributeBlob(disappearingMessagesDuration = this.disappearingMessagesTimer?.duration ?: 0),
disappearingMessagesTimer = this.disappearingMessagesTimer?.takeIf { it.duration > 0 }?.let { Group.GroupAttributeBlob(disappearingMessagesDuration = it.duration) },
accessControl = this.accessControl?.toSnapshot(),
version = this.revision,
members = this.members.map { it.toSnapshot() },
@@ -324,11 +324,11 @@ private fun DecryptedGroup.toSnapshot(): Group.GroupSnapshot? {
}
private fun Group.Member.toLocal(): DecryptedMember {
return DecryptedMember(aciBytes = userId, role = role.toLocal(), profileKey = profileKey, joinedAtRevision = joinedAtVersion)
return DecryptedMember(aciBytes = userId, role = role.toLocal(), joinedAtRevision = joinedAtVersion)
}
private fun DecryptedMember.toSnapshot(): Group.Member {
return Group.Member(userId = aciBytes, role = role.toSnapshot(), profileKey = profileKey, joinedAtVersion = joinedAtRevision)
return Group.Member(userId = aciBytes, role = role.toSnapshot(), joinedAtVersion = joinedAtRevision)
}
private fun Group.MemberPendingProfileKey.toLocal(operations: GroupsV2Operations.GroupOperations): DecryptedPendingMember {
@@ -355,7 +355,6 @@ private fun DecryptedPendingMember.toSnapshot(): Group.MemberPendingProfileKey {
private fun Group.MemberPendingAdminApproval.toLocal(): DecryptedRequestingMember {
return DecryptedRequestingMember(
aciBytes = this.userId,
profileKey = this.profileKey,
timestamp = this.timestamp
)
}
@@ -363,7 +362,6 @@ private fun Group.MemberPendingAdminApproval.toLocal(): DecryptedRequestingMembe
private fun DecryptedRequestingMember.toSnapshot(): Group.MemberPendingAdminApproval {
return Group.MemberPendingAdminApproval(
userId = this.aciBytes,
profileKey = this.profileKey,
timestamp = this.timestamp
)
}

View File

@@ -7,8 +7,8 @@ package org.thoughtcrime.securesms.backup.v2.database
import android.database.Cursor
import androidx.core.content.contentValuesOf
import com.google.protobuf.InvalidProtocolBufferException
import org.signal.core.util.SqlUtil
import org.signal.core.util.decodeOrNull
import org.signal.core.util.insertInto
import org.signal.core.util.logging.Log
import org.signal.core.util.requireBlob
@@ -16,20 +16,26 @@ import org.signal.core.util.requireBoolean
import org.signal.core.util.requireInt
import org.signal.core.util.requireLong
import org.signal.core.util.toInt
import org.thoughtcrime.securesms.attachments.AttachmentId
import org.thoughtcrime.securesms.backup.v2.ImportState
import org.thoughtcrime.securesms.backup.v2.proto.Chat
import org.thoughtcrime.securesms.backup.v2.util.BackupConverters
import org.thoughtcrime.securesms.backup.v2.util.ChatStyleConverter
import org.thoughtcrime.securesms.backup.v2.util.parseChatWallpaper
import org.thoughtcrime.securesms.backup.v2.util.toLocal
import org.thoughtcrime.securesms.backup.v2.util.toLocalAttachment
import org.thoughtcrime.securesms.conversation.colors.ChatColors
import org.thoughtcrime.securesms.database.RecipientTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.ThreadTable
import org.thoughtcrime.securesms.database.model.databaseprotos.ChatColor
import org.thoughtcrime.securesms.database.model.databaseprotos.Wallpaper
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.wallpaper.UriChatWallpaper
import java.io.Closeable
private val TAG = Log.tag(ThreadTable::class.java)
fun ThreadTable.getThreadsForBackup(): ChatExportIterator {
fun ThreadTable.getThreadsForBackup(db: SignalDatabase): ChatExportIterator {
//language=sql
val query = """
SELECT
@@ -39,17 +45,19 @@ fun ThreadTable.getThreadsForBackup(): ChatExportIterator {
${ThreadTable.READ},
${ThreadTable.ARCHIVED},
${RecipientTable.TABLE_NAME}.${RecipientTable.MESSAGE_EXPIRATION_TIME},
${RecipientTable.TABLE_NAME}.${RecipientTable.MESSAGE_EXPIRATION_TIME_VERSION},
${RecipientTable.TABLE_NAME}.${RecipientTable.MUTE_UNTIL},
${RecipientTable.TABLE_NAME}.${RecipientTable.MENTION_SETTING},
${RecipientTable.TABLE_NAME}.${RecipientTable.CHAT_COLORS},
${RecipientTable.TABLE_NAME}.${RecipientTable.CUSTOM_CHAT_COLORS_ID}
${RecipientTable.TABLE_NAME}.${RecipientTable.CUSTOM_CHAT_COLORS_ID},
${RecipientTable.TABLE_NAME}.${RecipientTable.WALLPAPER}
FROM ${ThreadTable.TABLE_NAME}
LEFT OUTER JOIN ${RecipientTable.TABLE_NAME} ON ${ThreadTable.TABLE_NAME}.${ThreadTable.RECIPIENT_ID} = ${RecipientTable.TABLE_NAME}.${RecipientTable.ID}
WHERE ${ThreadTable.ACTIVE} = 1
"""
val cursor = readableDatabase.query(query)
return ChatExportIterator(cursor)
return ChatExportIterator(cursor, db)
}
fun ThreadTable.clearAllDataForBackupRestore() {
@@ -58,10 +66,16 @@ fun ThreadTable.clearAllDataForBackupRestore() {
clearCache()
}
fun ThreadTable.restoreFromBackup(chat: Chat, recipientId: RecipientId, importState: ImportState): Long? {
fun ThreadTable.restoreFromBackup(chat: Chat, recipientId: RecipientId, importState: ImportState): Long {
val chatColor = chat.style?.toLocal(importState)
// TODO [backup] Wallpaper
val wallpaperAttachmentId: AttachmentId? = chat.style?.wallpaperPhoto?.let { filePointer ->
filePointer.toLocalAttachment(importState)?.let {
SignalDatabase.attachments.restoreWallpaperAttachment(it)
}
}
val chatWallpaper = chat.style?.parseChatWallpaper(wallpaperAttachmentId)
val threadId = writableDatabase
.insertInto(ThreadTable.TABLE_NAME)
@@ -73,6 +87,7 @@ fun ThreadTable.restoreFromBackup(chat: Chat, recipientId: RecipientId, importSt
ThreadTable.ACTIVE to 1
)
.run()
writableDatabase
.update(
RecipientTable.TABLE_NAME,
@@ -80,8 +95,11 @@ fun ThreadTable.restoreFromBackup(chat: Chat, recipientId: RecipientId, importSt
RecipientTable.MENTION_SETTING to (if (chat.dontNotifyForMentionsIfMuted) RecipientTable.MentionSetting.DO_NOT_NOTIFY.id else RecipientTable.MentionSetting.ALWAYS_NOTIFY.id),
RecipientTable.MUTE_UNTIL to chat.muteUntilMs,
RecipientTable.MESSAGE_EXPIRATION_TIME to chat.expirationTimerMs,
RecipientTable.MESSAGE_EXPIRATION_TIME_VERSION to chat.expireTimerVersion,
RecipientTable.CHAT_COLORS to chatColor?.serialize()?.encode(),
RecipientTable.CUSTOM_CHAT_COLORS_ID to (chatColor?.id ?: ChatColors.Id.NotSet).longValue
RecipientTable.CUSTOM_CHAT_COLORS_ID to (chatColor?.id ?: ChatColors.Id.NotSet).longValue,
RecipientTable.WALLPAPER_URI to if (chatWallpaper is UriChatWallpaper) chatWallpaper.uri.toString() else null,
RecipientTable.WALLPAPER to chatWallpaper?.serialize()?.encode()
),
"${RecipientTable.ID} = ?",
SqlUtil.buildArgs(recipientId.toLong())
@@ -90,7 +108,7 @@ fun ThreadTable.restoreFromBackup(chat: Chat, recipientId: RecipientId, importSt
return threadId
}
class ChatExportIterator(private val cursor: Cursor) : Iterator<Chat>, Closeable {
class ChatExportIterator(private val cursor: Cursor, private val db: SignalDatabase) : Iterator<Chat>, Closeable {
override fun hasNext(): Boolean {
return cursor.count > 0 && !cursor.isLast
}
@@ -100,14 +118,15 @@ class ChatExportIterator(private val cursor: Cursor) : Iterator<Chat>, Closeable
throw NoSuchElementException()
}
val serializedChatColors = cursor.requireBlob(RecipientTable.CHAT_COLORS)
val chatColorId = ChatColors.Id.forLongValue(cursor.requireLong(RecipientTable.CUSTOM_CHAT_COLORS_ID))
val chatColors: ChatColors? = serializedChatColors?.let { serialized ->
try {
ChatColors.forChatColor(chatColorId, ChatColor.ADAPTER.decode(serialized))
} catch (e: InvalidProtocolBufferException) {
null
}
val customChatColorsId = ChatColors.Id.forLongValue(cursor.requireLong(RecipientTable.CUSTOM_CHAT_COLORS_ID))
val chatColors: ChatColors? = cursor.requireBlob(RecipientTable.CHAT_COLORS)?.let { serializedChatColors ->
val chatColor = ChatColor.ADAPTER.decodeOrNull(serializedChatColors)
chatColor?.let { ChatColors.forChatColor(customChatColorsId, it) }
}
val chatWallpaper: Wallpaper? = cursor.requireBlob(RecipientTable.WALLPAPER)?.let { serializedWallpaper ->
Wallpaper.ADAPTER.decodeOrNull(serializedWallpaper)
}
return Chat(
@@ -116,10 +135,16 @@ class ChatExportIterator(private val cursor: Cursor) : Iterator<Chat>, Closeable
archived = cursor.requireBoolean(ThreadTable.ARCHIVED),
pinnedOrder = cursor.requireInt(ThreadTable.PINNED),
expirationTimerMs = cursor.requireLong(RecipientTable.MESSAGE_EXPIRATION_TIME),
expireTimerVersion = cursor.requireInt(RecipientTable.MESSAGE_EXPIRATION_TIME_VERSION),
muteUntilMs = cursor.requireLong(RecipientTable.MUTE_UNTIL),
markedUnread = ThreadTable.ReadStatus.deserialize(cursor.requireInt(ThreadTable.READ)) == ThreadTable.ReadStatus.FORCED_UNREAD,
dontNotifyForMentionsIfMuted = RecipientTable.MentionSetting.DO_NOT_NOTIFY.id == cursor.requireInt(RecipientTable.MENTION_SETTING),
style = BackupConverters.constructRemoteChatStyle(chatColors, chatColorId)
style = ChatStyleConverter.constructRemoteChatStyle(
db = db,
chatColors = chatColors,
chatColorId = customChatColorsId,
chatWallpaper = chatWallpaper
)
)
}

View File

@@ -63,6 +63,10 @@ class ArchiveFileSystem private constructor(private val context: Context, root:
fun fromFile(context: Context, backupDirectory: File): ArchiveFileSystem {
return ArchiveFileSystem(context, DocumentFile.fromFile(backupDirectory))
}
fun openInputStream(context: Context, uri: Uri): InputStream? {
return context.contentResolver.openInputStream(uri)
}
}
private val signalBackups: DocumentFile
@@ -284,29 +288,22 @@ class FilesFileSystem(private val context: Context, private val root: DocumentFi
* undefined and should be avoided.
*/
fun fileOutputStream(mediaName: MediaName): OutputStream? {
val subFileDirectoryName = mediaName.name.substring(0..1)
val subFileDirectory = subFolders[subFileDirectoryName]!!
val subFileDirectory = subFileDirectoryFor(mediaName)
val file = subFileDirectory.createFile("application/octet-stream", mediaName.name)
return file?.outputStream(context)
}
/**
* Given a [file], open and return an [InputStream].
*/
fun fileInputStream(file: DocumentFileInfo): InputStream? {
return file.documentFile.inputStream(context)
}
/**
* Delete a file for the given [mediaName] if it exists.
*
* @return true if deleted, false if not, null if not found
*/
fun delete(mediaName: MediaName): Boolean? {
val subFileDirectoryName = mediaName.name.substring(0..1)
val subFileDirectory = subFolders[subFileDirectoryName]!!
return subFileDirectoryFor(mediaName).delete(context, mediaName.name)
}
return subFileDirectory.delete(context, mediaName.name)
private fun subFileDirectoryFor(mediaName: MediaName): DocumentFile {
return subFolders[mediaName.name.substring(0..1)]!!
}
}

View File

@@ -23,7 +23,6 @@ import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
import java.util.Collections
import kotlin.random.Random
typealias ArchiveResult = org.signal.core.util.Result<Unit, LocalArchiver.FailureCause>
@@ -70,10 +69,9 @@ object LocalArchiver {
}
source()?.use { sourceStream ->
val iv = Random.nextBytes(16) // todo [local-backup] but really do an iv from table
val iv = attachment.remoteIv
val combinedKey = Base64.decode(attachment.remoteKey)
var destination: OutputStream? = filesFileSystem.fileOutputStream(mediaName)
val destination: OutputStream? = filesFileSystem.fileOutputStream(mediaName)
if (destination == null) {
Log.w(TAG, "Unable to create output file for attachment")

View File

@@ -8,14 +8,18 @@ package org.thoughtcrime.securesms.backup.v2.processor
import okio.ByteString.Companion.EMPTY
import okio.ByteString.Companion.toByteString
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.attachments.AttachmentId
import org.thoughtcrime.securesms.backup.v2.ImportState
import org.thoughtcrime.securesms.backup.v2.database.restoreSelfFromBackup
import org.thoughtcrime.securesms.backup.v2.database.restoreWallpaperAttachment
import org.thoughtcrime.securesms.backup.v2.proto.AccountData
import org.thoughtcrime.securesms.backup.v2.proto.ChatStyle
import org.thoughtcrime.securesms.backup.v2.proto.Frame
import org.thoughtcrime.securesms.backup.v2.stream.BackupFrameEmitter
import org.thoughtcrime.securesms.backup.v2.util.BackupConverters
import org.thoughtcrime.securesms.backup.v2.util.ChatStyleConverter
import org.thoughtcrime.securesms.backup.v2.util.parseChatWallpaper
import org.thoughtcrime.securesms.backup.v2.util.toLocal
import org.thoughtcrime.securesms.backup.v2.util.toLocalAttachment
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.UsernameQrCodeColorScheme
import org.thoughtcrime.securesms.conversation.colors.ChatColors
@@ -50,6 +54,7 @@ object AccountDataProcessor {
val donationSubscriber = db.inAppPaymentSubscriberTable.getByCurrencyCode(donationCurrency.currencyCode, InAppPaymentSubscriberRecord.Type.DONATION)
val chatColors = SignalStore.chatColors.chatColors
val chatWallpaper = SignalStore.wallpaper.currentRawWallpaper
emitter.emit(
Frame(
@@ -87,12 +92,12 @@ object AccountDataProcessor {
hasSeenGroupStoryEducationSheet = signalStore.storyValues.userHasSeenGroupStoryEducationSheet,
hasCompletedUsernameOnboarding = signalStore.uiHintValues.hasCompletedUsernameOnboarding(),
customChatColors = db.chatColorsTable.getSavedChatColors().toRemoteChatColors(),
defaultChatStyle = BackupConverters.constructRemoteChatStyle(chatColors, chatColors?.id ?: ChatColors.Id.NotSet)?.also {
it.newBuilder().apply {
// TODO [backup] We should do this elsewhere once we handle wallpaper better
dimWallpaperInDarkMode = (SignalStore.wallpaper.wallpaper?.dimLevelForDarkTheme ?: 0f) > 0f
}.build()
}
defaultChatStyle = ChatStyleConverter.constructRemoteChatStyle(
db = db,
chatColors = chatColors,
chatColorId = chatColors?.id ?: ChatColors.Id.NotSet,
chatWallpaper = chatWallpaper
)
),
donationSubscriberData = donationSubscriber?.toSubscriberData(signalStore.inAppPaymentValues.isDonationSubscriptionManuallyCancelled())
)
@@ -155,11 +160,17 @@ object AccountDataProcessor {
if (settings.defaultChatStyle != null) {
val chatColors = settings.defaultChatStyle.toLocal(importState)
SignalStore.chatColors.chatColors = chatColors
if (SignalStore.wallpaper.wallpaper != null) {
SignalStore.wallpaper.setDimInDarkTheme(settings.defaultChatStyle.dimWallpaperInDarkMode)
val wallpaperAttachmentId: AttachmentId? = settings.defaultChatStyle.wallpaperPhoto?.let { filePointer ->
filePointer.toLocalAttachment(importState)?.let {
SignalDatabase.attachments.restoreWallpaperAttachment(it)
}
}
// TODO [backup] wallpaper
SignalStore.wallpaper.wallpaper = settings.defaultChatStyle.parseChatWallpaper(wallpaperAttachmentId)
} else {
SignalStore.chatColors.chatColors = null
SignalStore.wallpaper.wallpaper = null
}
if (accountData.donationSubscriberData != null) {

View File

@@ -20,7 +20,7 @@ object ChatBackupProcessor {
val TAG = Log.tag(ChatBackupProcessor::class.java)
fun export(db: SignalDatabase, exportState: ExportState, emitter: BackupFrameEmitter) {
db.threadTable.getThreadsForBackup().use { reader ->
db.threadTable.getThreadsForBackup(db).use { reader ->
for (chat in reader) {
if (exportState.recipientIds.contains(chat.recipientId)) {
exportState.threadIds.add(chat.id)
@@ -39,7 +39,7 @@ object ChatBackupProcessor {
return
}
SignalDatabase.threads.restoreFromBackup(chat, recipientId, importState)?.let { threadId ->
SignalDatabase.threads.restoreFromBackup(chat, recipientId, importState).let { threadId ->
importState.chatIdToLocalRecipientId[chat.id] = recipientId
importState.chatIdToLocalThreadId[chat.id] = threadId
importState.chatIdToBackupRecipientId[chat.id] = chat.recipientId

View File

@@ -41,6 +41,8 @@ object RecipientBackupProcessor {
)
)
)
} else {
Log.w(TAG, "Missing release channel id on export!")
}
db.recipientTable.getContactsForBackup(selfId).use { reader ->

View File

@@ -7,10 +7,13 @@ package org.thoughtcrime.securesms.backup.v2.processor
import okio.ByteString.Companion.toByteString
import org.signal.core.util.Hex
import org.signal.core.util.insertInto
import org.thoughtcrime.securesms.backup.v2.proto.Frame
import org.thoughtcrime.securesms.backup.v2.proto.StickerPack
import org.thoughtcrime.securesms.backup.v2.stream.BackupFrameEmitter
import org.thoughtcrime.securesms.database.SQLiteDatabase
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.StickerTable
import org.thoughtcrime.securesms.database.StickerTable.StickerPackRecordReader
import org.thoughtcrime.securesms.database.model.StickerPackRecord
import org.thoughtcrime.securesms.dependencies.AppDependencies
@@ -31,8 +34,27 @@ object StickerBackupProcessor {
}
fun import(stickerPack: StickerPack) {
SignalDatabase.rawDatabase
.insertInto(StickerTable.TABLE_NAME)
.values(
StickerTable.PACK_ID to Hex.toStringCondensed(stickerPack.packId.toByteArray()),
StickerTable.PACK_KEY to Hex.toStringCondensed(stickerPack.packKey.toByteArray()),
StickerTable.PACK_TITLE to "",
StickerTable.PACK_AUTHOR to "",
StickerTable.INSTALLED to 1,
StickerTable.COVER to 1,
StickerTable.EMOJI to "",
StickerTable.CONTENT_TYPE to "",
StickerTable.FILE_PATH to ""
)
.run(SQLiteDatabase.CONFLICT_IGNORE)
AppDependencies.jobManager.add(
StickerPackDownloadJob.forInstall(Hex.toStringCondensed(stickerPack.packId.toByteArray()), Hex.toStringCondensed(stickerPack.packKey.toByteArray()), false)
StickerPackDownloadJob.forInstall(
Hex.toStringCondensed(stickerPack.packId.toByteArray()),
Hex.toStringCondensed(stickerPack.packKey.toByteArray()),
false
)
)
}
}

View File

@@ -9,8 +9,8 @@ import com.google.common.io.CountingInputStream
import org.signal.core.util.readFully
import org.signal.core.util.readNBytesOrThrow
import org.signal.core.util.readVarInt32
import org.signal.core.util.stream.LimitedInputStream
import org.signal.core.util.stream.MacInputStream
import org.signal.core.util.stream.TruncatingInputStream
import org.thoughtcrime.securesms.backup.v2.proto.BackupInfo
import org.thoughtcrime.securesms.backup.v2.proto.Frame
import org.whispersystems.signalservice.api.backup.BackupKey
@@ -56,7 +56,7 @@ class EncryptedBackupReader(
stream = GZIPInputStream(
CipherInputStream(
TruncatingInputStream(
LimitedInputStream(
wrapped = countingStream,
maxBytes = length - MAC_SIZE
),
@@ -121,7 +121,7 @@ class EncryptedBackupReader(
}
val macStream = MacInputStream(
wrapped = TruncatingInputStream(dataStream, maxBytes = streamLength - MAC_SIZE),
wrapped = LimitedInputStream(dataStream, maxBytes = streamLength - MAC_SIZE),
mac = mac
)

View File

@@ -9,13 +9,14 @@ import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
@@ -45,11 +46,13 @@ private const val NONE = -1
@Composable
fun BackupStatus(
data: BackupStatusData,
onActionClick: () -> Unit = {}
onActionClick: () -> Unit = {},
contentPadding: PaddingValues = PaddingValues(horizontal = 12.dp, vertical = 8.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.padding(contentPadding)
.border(1.dp, color = MaterialTheme.colorScheme.outline.copy(alpha = 0.38f), shape = RoundedCornerShape(12.dp))
.fillMaxWidth()
.padding(14.dp)
@@ -71,7 +74,8 @@ fun BackupStatus(
) {
Text(
text = data.title,
style = MaterialTheme.typography.bodyMedium
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface
)
if (data.progress >= 0f) {
@@ -108,17 +112,19 @@ fun BackupStatus(
@Composable
fun BackupStatusPreview() {
Previews.Preview {
Column(
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Column {
BackupStatus(
data = BackupStatusData.CouldNotCompleteBackup
)
HorizontalDivider()
BackupStatus(
data = BackupStatusData.NotEnoughFreeSpace("12 GB")
)
HorizontalDivider()
BackupStatus(
data = BackupStatusData.RestoringMedia(50, 100)
)
@@ -201,7 +207,7 @@ sealed interface BackupStatusData {
)
override val statusRes: Int = when (status) {
Status.NONE -> R.string.default_error_msg
Status.NONE -> NONE
Status.LOW_BATTERY -> R.string.default_error_msg
Status.WAITING_FOR_INTERNET -> R.string.default_error_msg
Status.WAITING_FOR_WIFI -> R.string.default_error_msg

View File

@@ -0,0 +1,38 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2.ui.subscription
import android.content.Context
import android.content.Intent
import androidx.activity.result.contract.ActivityResultContract
import androidx.fragment.app.Fragment
import org.signal.core.util.getParcelableExtraCompat
import org.thoughtcrime.securesms.components.FragmentWrapperActivity
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.CheckoutFlowActivity.Result
/**
* Self-contained activity for message backups checkout, which utilizes Google Play Billing
* instead of the normal donations routes.
*/
class MessageBackupsCheckoutActivity : FragmentWrapperActivity() {
companion object {
private const val RESULT_DATA = "result_data"
}
override fun getFragment(): Fragment = MessageBackupsFlowFragment()
class Contract : ActivityResultContract<Unit, Result?>() {
override fun createIntent(context: Context, input: Unit): Intent {
return Intent(context, MessageBackupsCheckoutActivity::class.java)
}
override fun parseResult(resultCode: Int, intent: Intent?): Result? {
return intent?.getParcelableExtraCompat(RESULT_DATA, Result::class.java)
}
}
}

View File

@@ -1,254 +0,0 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2.ui.subscription
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.SheetState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.view.updateLayoutParams
import org.signal.core.ui.BottomSheets
import org.signal.core.ui.Buttons
import org.signal.core.ui.Previews
import org.signal.core.util.money.FiatMoney
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.app.subscription.models.GooglePayButton
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
import org.thoughtcrime.securesms.databinding.PaypalButtonBinding
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
import java.math.BigDecimal
import java.util.Currency
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MessageBackupsCheckoutSheet(
messageBackupsType: MessageBackupsType.Paid,
availablePaymentMethods: List<InAppPaymentData.PaymentMethodType>,
sheetState: SheetState,
onDismissRequest: () -> Unit,
onPaymentMethodSelected: (InAppPaymentData.PaymentMethodType) -> Unit
) {
ModalBottomSheet(
onDismissRequest = onDismissRequest,
sheetState = sheetState,
dragHandle = { BottomSheets.Handle() },
modifier = Modifier.padding()
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.padding(horizontal = dimensionResource(id = R.dimen.core_ui__gutter))
.navigationBarsPadding()
) {
SheetContent(
messageBackupsType = messageBackupsType,
availablePaymentGateways = availablePaymentMethods,
onPaymentGatewaySelected = onPaymentMethodSelected
)
}
}
}
@Composable
private fun SheetContent(
messageBackupsType: MessageBackupsType.Paid,
availablePaymentGateways: List<InAppPaymentData.PaymentMethodType>,
onPaymentGatewaySelected: (InAppPaymentData.PaymentMethodType) -> Unit
) {
val resources = LocalContext.current.resources
val formattedPrice = remember(messageBackupsType.pricePerMonth) {
FiatMoneyUtil.format(resources, messageBackupsType.pricePerMonth, FiatMoneyUtil.formatOptions().trimZerosAfterDecimal())
}
Text(
text = stringResource(id = R.string.MessageBackupsCheckoutSheet__pay_s_per_month, formattedPrice),
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(top = 48.dp)
)
Text(
text = stringResource(id = R.string.MessageBackupsCheckoutSheet__youll_get),
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 5.dp)
)
MessageBackupsTypeBlock(
messageBackupsType = messageBackupsType,
isCurrent = false,
isSelected = false,
onSelected = {},
enabled = false,
modifier = Modifier.padding(top = 24.dp)
)
Column(
verticalArrangement = spacedBy(12.dp),
modifier = Modifier.padding(top = 48.dp, bottom = 24.dp)
) {
availablePaymentGateways.forEach {
when (it) {
InAppPaymentData.PaymentMethodType.GOOGLE_PAY -> GooglePayButton {
onPaymentGatewaySelected(InAppPaymentData.PaymentMethodType.GOOGLE_PAY)
}
InAppPaymentData.PaymentMethodType.PAYPAL -> PayPalButton {
onPaymentGatewaySelected(InAppPaymentData.PaymentMethodType.PAYPAL)
}
InAppPaymentData.PaymentMethodType.CARD -> CreditOrDebitCardButton {
onPaymentGatewaySelected(InAppPaymentData.PaymentMethodType.CARD)
}
InAppPaymentData.PaymentMethodType.SEPA_DEBIT -> SepaButton {
onPaymentGatewaySelected(InAppPaymentData.PaymentMethodType.SEPA_DEBIT)
}
InAppPaymentData.PaymentMethodType.IDEAL -> IdealButton {
onPaymentGatewaySelected(InAppPaymentData.PaymentMethodType.IDEAL)
}
InAppPaymentData.PaymentMethodType.UNKNOWN -> error("Unsupported payment method type $it")
}
}
}
}
@Composable
private fun PayPalButton(
onClick: () -> Unit
) {
AndroidView(factory = {
val view = LayoutInflater.from(it).inflate(R.layout.paypal_button, null)
view.layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
view
}) {
val binding = PaypalButtonBinding.bind(it)
binding.paypalButton.updateLayoutParams<ViewGroup.MarginLayoutParams> {
marginStart = 0
marginEnd = 0
}
binding.paypalButton.setOnClickListener {
onClick()
}
}
}
@Composable
private fun GooglePayButton(
onClick: () -> Unit
) {
val model = GooglePayButton.Model(onClick, true)
AndroidView(factory = {
LayoutInflater.from(it).inflate(R.layout.google_pay_button_pref, null)
}) {
val holder = GooglePayButton.ViewHolder(it)
holder.bind(model)
}
}
@Composable
private fun SepaButton(
onClick: () -> Unit
) {
Buttons.LargeTonal(
onClick = onClick,
modifier = Modifier.fillMaxWidth()
) {
Icon(
painter = painterResource(id = R.drawable.bank_transfer),
contentDescription = null,
modifier = Modifier.padding(end = 8.dp)
)
Text(text = stringResource(id = R.string.GatewaySelectorBottomSheet__bank_transfer))
}
}
@Composable
private fun IdealButton(
onClick: () -> Unit
) {
Buttons.LargeTonal(
onClick = onClick,
modifier = Modifier.fillMaxWidth()
) {
Image(
painter = painterResource(id = R.drawable.logo_ideal),
contentDescription = null,
modifier = Modifier
.size(32.dp)
.padding(end = 8.dp)
)
Text(text = stringResource(id = R.string.GatewaySelectorBottomSheet__ideal))
}
}
@Composable
private fun CreditOrDebitCardButton(
onClick: () -> Unit
) {
Buttons.LargePrimary(
onClick = onClick,
modifier = Modifier.fillMaxWidth()
) {
Icon(
painter = painterResource(id = R.drawable.credit_card),
contentDescription = null,
modifier = Modifier.padding(end = 8.dp)
)
Text(
text = stringResource(id = R.string.GatewaySelectorBottomSheet__credit_or_debit_card)
)
}
}
@Preview
@Composable
private fun MessageBackupsCheckoutSheetPreview() {
val availablePaymentGateways = InAppPaymentData.PaymentMethodType.values().toList() - InAppPaymentData.PaymentMethodType.UNKNOWN
Previews.Preview {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(horizontal = dimensionResource(id = R.dimen.core_ui__gutter))
) {
SheetContent(
messageBackupsType = MessageBackupsType.Paid(
pricePerMonth = FiatMoney(BigDecimal.ZERO, Currency.getInstance("USD")),
storageAllowanceBytes = 107374182400
),
availablePaymentGateways = availablePaymentGateways,
onPaymentGatewaySelected = {}
)
}
}
}

View File

@@ -49,7 +49,7 @@ fun MessageBackupsEducationScreen(
Scaffolds.Settings(
onNavigationClick = onNavigationClick,
navigationIconPainter = painterResource(id = R.drawable.symbol_x_24),
title = stringResource(id = R.string.RemoteBackupsSettingsFragment__signal_backups)
title = ""
) {
Column(
modifier = Modifier

View File

@@ -5,63 +5,37 @@
package org.thoughtcrime.securesms.backup.v2.ui.subscription
import android.app.Activity
import androidx.activity.OnBackPressedCallback
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.fragment.findNavController
import io.reactivex.rxjava3.processors.PublishProcessor
import org.signal.donations.InAppPaymentType
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.InAppPaymentCheckoutDelegate
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.InAppPaymentProcessorAction
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
import org.thoughtcrime.securesms.compose.ComposeFragment
import org.thoughtcrime.securesms.compose.Nav
import org.thoughtcrime.securesms.database.InAppPaymentTable
import org.thoughtcrime.securesms.lock.v2.CreateSvrPinActivity
import org.thoughtcrime.securesms.util.navigation.safeNavigate
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.util.Util
import org.thoughtcrime.securesms.util.viewModel
/**
* Handles the selection, payment, and changing of a user's backup tier.
*/
class MessageBackupsFlowFragment : ComposeFragment(), InAppPaymentCheckoutDelegate.Callback {
class MessageBackupsFlowFragment : ComposeFragment() {
private val viewModel: MessageBackupsFlowViewModel by viewModel { MessageBackupsFlowViewModel() }
private val inAppPaymentIdProcessor = PublishProcessor.create<InAppPaymentTable.InAppPaymentId>()
@OptIn(ExperimentalMaterial3Api::class)
@Composable
override fun FragmentContent() {
val state by viewModel.stateFlow.collectAsState()
val pin by viewModel.pinState
val navController = rememberNavController()
val checkoutDelegate = remember {
InAppPaymentCheckoutDelegate(this, this, inAppPaymentIdProcessor)
}
LaunchedEffect(state.inAppPayment?.id) {
val inAppPaymentId = state.inAppPayment?.id
if (inAppPaymentId != null) {
inAppPaymentIdProcessor.onNext(inAppPaymentId)
}
}
val checkoutSheetState = rememberModalBottomSheetState(
skipPartiallyExpanded = true
)
val lifecycleOwner = LocalLifecycleOwner.current
LaunchedEffect(Unit) {
navController.setLifecycleOwner(this@MessageBackupsFlowFragment)
@@ -69,7 +43,7 @@ class MessageBackupsFlowFragment : ComposeFragment(), InAppPaymentCheckoutDelega
lifecycleOwner,
object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
viewModel.goToPreviousScreen()
viewModel.goToPreviousStage()
}
}
)
@@ -79,40 +53,39 @@ class MessageBackupsFlowFragment : ComposeFragment(), InAppPaymentCheckoutDelega
navController = navController,
startDestination = state.startScreen.name
) {
composable(route = MessageBackupsScreen.EDUCATION.name) {
composable(route = MessageBackupsStage.Route.EDUCATION.name) {
MessageBackupsEducationScreen(
onNavigationClick = viewModel::goToPreviousScreen,
onEnableBackups = viewModel::goToNextScreen,
onNavigationClick = viewModel::goToPreviousStage,
onEnableBackups = viewModel::goToNextStage,
onLearnMore = {}
)
}
composable(route = MessageBackupsScreen.PIN_EDUCATION.name) {
MessageBackupsPinEducationScreen(
onNavigationClick = viewModel::goToPreviousScreen,
onCreatePinClick = {},
onUseCurrentPinClick = viewModel::goToNextScreen,
recommendedPinSize = 16 // TODO [message-backups] This value should come from some kind of config
composable(route = MessageBackupsStage.Route.BACKUP_KEY_EDUCATION.name) {
MessageBackupsKeyEducationScreen(
onNavigationClick = viewModel::goToPreviousStage,
onNextClick = viewModel::goToNextStage
)
}
composable(route = MessageBackupsScreen.PIN_CONFIRMATION.name) {
MessageBackupsPinConfirmationScreen(
pin = pin,
isPinIncorrect = state.displayIncorrectPinError,
onPinChanged = viewModel::onPinEntryUpdated,
pinKeyboardType = state.pinKeyboardType,
onPinKeyboardTypeSelected = viewModel::onPinKeyboardTypeUpdated,
onNextClick = viewModel::goToNextScreen,
onCreateNewPinClick = this@MessageBackupsFlowFragment::createANewPin
composable(route = MessageBackupsStage.Route.BACKUP_KEY_RECORD.name) {
val context = LocalContext.current
MessageBackupsKeyRecordScreen(
backupKey = state.backupKey,
onNavigationClick = viewModel::goToPreviousStage,
onNextClick = viewModel::goToNextStage,
onCopyToClipboardClick = {
Util.copyToClipboard(context, it)
}
)
}
composable(route = MessageBackupsScreen.TYPE_SELECTION.name) {
composable(route = MessageBackupsStage.Route.TYPE_SELECTION.name) {
MessageBackupsTypeSelectionScreen(
currentBackupTier = state.currentMessageBackupTier,
selectedBackupTier = state.selectedMessageBackupTier,
availableBackupTypes = state.availableBackupTypes,
availableBackupTypes = state.availableBackupTypes.filter { it.tier == MessageBackupTier.FREE || state.hasBackupSubscriberAvailable },
onMessageBackupsTierSelected = { tier ->
val type = state.availableBackupTypes.first { it.tier == tier }
val label = when (type) {
@@ -122,174 +95,32 @@ class MessageBackupsFlowFragment : ComposeFragment(), InAppPaymentCheckoutDelega
viewModel.onMessageBackupTierUpdated(tier, label)
},
onNavigationClick = viewModel::goToPreviousScreen,
onNavigationClick = viewModel::goToPreviousStage,
onReadMoreClicked = {},
onCancelSubscriptionClicked = viewModel::displayCancellationDialog,
onNextClicked = viewModel::goToNextScreen
onNextClicked = viewModel::goToNextStage
)
}
}
if (state.screen == MessageBackupsScreen.CHECKOUT_SHEET) {
MessageBackupsCheckoutSheet(
messageBackupsType = state.availableBackupTypes.filterIsInstance<MessageBackupsType.Paid>().first { it.tier == state.selectedMessageBackupTier!! },
availablePaymentMethods = state.availablePaymentMethods,
sheetState = checkoutSheetState,
onDismissRequest = {
viewModel.goToPreviousScreen()
},
onPaymentMethodSelected = {
viewModel.onPaymentMethodUpdated(it)
viewModel.goToNextScreen()
}
)
}
if (state.screen == MessageBackupsScreen.CANCELLATION_DIALOG) {
ConfirmBackupCancellationDialog(
onConfirmAndDownloadNow = {
// TODO [message-backups] Set appropriate state to handle post-cancellation action.
viewModel.goToNextScreen()
},
onConfirmAndDownloadLater = {
// TODO [message-backups] Set appropriate state to handle post-cancellation action.
viewModel.goToNextScreen()
},
onKeepSubscriptionClick = viewModel::goToPreviousScreen
)
LaunchedEffect(state.stage) {
val newRoute = state.stage.route.name
val currentRoute = navController.currentDestination?.route
if (currentRoute != newRoute) {
if (currentRoute != null && MessageBackupsStage.Route.valueOf(currentRoute).isAfter(state.stage.route)) {
navController.popBackStack()
} else {
navController.navigate(newRoute)
}
}
}
LaunchedEffect(state.screen) {
val route = navController.currentDestination?.route ?: return@LaunchedEffect
if (route == state.screen.name) {
return@LaunchedEffect
if (state.stage == MessageBackupsStage.CHECKOUT_SHEET) {
AppDependencies.billingApi.launchBillingFlow(requireActivity())
}
if (state.screen == MessageBackupsScreen.COMPLETED) {
if (!findNavController().popBackStack()) {
requireActivity().finishAfterTransition()
}
return@LaunchedEffect
}
if (state.screen == MessageBackupsScreen.CREATING_IN_APP_PAYMENT) {
return@LaunchedEffect
}
if (state.screen == MessageBackupsScreen.PROCESS_PAYMENT) {
checkoutDelegate.handleGatewaySelectionResponse(state.inAppPayment!!)
viewModel.goToPreviousScreen()
return@LaunchedEffect
}
if (state.screen == MessageBackupsScreen.PROCESS_CANCELLATION) {
cancelSubscription()
viewModel.goToPreviousScreen()
return@LaunchedEffect
}
if (state.screen == MessageBackupsScreen.CHECKOUT_SHEET) {
return@LaunchedEffect
}
if (state.screen == MessageBackupsScreen.CANCELLATION_DIALOG) {
return@LaunchedEffect
}
if (state.screen == MessageBackupsScreen.PROCESS_FREE) {
checkoutDelegate.setActivityResult(InAppPaymentProcessorAction.UPDATE_SUBSCRIPTION, InAppPaymentType.RECURRING_BACKUP)
viewModel.goToNextScreen()
return@LaunchedEffect
}
val routeScreen = MessageBackupsScreen.valueOf(route)
if (routeScreen.isAfter(state.screen)) {
navController.popBackStack()
} else {
navController.navigate(state.screen.name)
if (state.stage == MessageBackupsStage.COMPLETED) {
requireActivity().setResult(Activity.RESULT_OK)
requireActivity().finishAfterTransition()
}
}
}
private fun createANewPin() {
viewModel.onPinEntryUpdated("")
startActivity(CreateSvrPinActivity.getIntentForPinChangeFromSettings(requireContext()))
}
private fun cancelSubscription() {
findNavController().safeNavigate(
MessageBackupsFlowFragmentDirections.actionDonateToSignalFragmentToStripePaymentInProgressFragment(
InAppPaymentProcessorAction.CANCEL_SUBSCRIPTION,
null,
InAppPaymentType.RECURRING_BACKUP
)
)
}
override fun navigateToStripePaymentInProgress(inAppPayment: InAppPaymentTable.InAppPayment) {
findNavController().safeNavigate(
MessageBackupsFlowFragmentDirections.actionDonateToSignalFragmentToStripePaymentInProgressFragment(
InAppPaymentProcessorAction.PROCESS_NEW_IN_APP_PAYMENT,
inAppPayment,
inAppPayment.type
)
)
}
override fun navigateToPayPalPaymentInProgress(inAppPayment: InAppPaymentTable.InAppPayment) {
findNavController().safeNavigate(
MessageBackupsFlowFragmentDirections.actionDonateToSignalFragmentToPaypalPaymentInProgressFragment(
InAppPaymentProcessorAction.PROCESS_NEW_IN_APP_PAYMENT,
inAppPayment,
inAppPayment.type
)
)
}
override fun navigateToCreditCardForm(inAppPayment: InAppPaymentTable.InAppPayment) {
findNavController().safeNavigate(
MessageBackupsFlowFragmentDirections.actionDonateToSignalFragmentToCreditCardFragment(inAppPayment)
)
}
override fun navigateToIdealDetailsFragment(inAppPayment: InAppPaymentTable.InAppPayment) {
findNavController().safeNavigate(
MessageBackupsFlowFragmentDirections.actionDonateToSignalFragmentToIdealTransferDetailsFragment(inAppPayment)
)
}
override fun navigateToBankTransferMandate(inAppPayment: InAppPaymentTable.InAppPayment) {
findNavController().safeNavigate(
MessageBackupsFlowFragmentDirections.actionDonateToSignalFragmentToBankTransferMandateFragment(inAppPayment)
)
}
override fun onPaymentComplete(inAppPayment: InAppPaymentTable.InAppPayment) {
// TODO [message-backups] What do? probably some kind of success thing?
if (!findNavController().popBackStack()) {
requireActivity().finishAfterTransition()
}
}
override fun onSubscriptionCancelled(inAppPaymentType: InAppPaymentType) {
viewModel.onCancellationComplete()
if (!findNavController().popBackStack()) {
requireActivity().finishAfterTransition()
}
}
override fun onProcessorActionProcessed() = Unit
override fun onUserLaunchedAnExternalApplication() {
// TODO [message-backups] What do? Are we even supporting bank transfers?
}
override fun navigateToDonationPending(inAppPayment: InAppPaymentTable.InAppPayment) {
// TODO [message-backups] What do? Are we even supporting bank transfers?
}
override fun exitCheckoutFlow() {
requireActivity().finishAfterTransition()
}
}

View File

@@ -7,20 +7,18 @@ package org.thoughtcrime.securesms.backup.v2.ui.subscription
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
import org.thoughtcrime.securesms.database.InAppPaymentTable
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.lock.v2.PinKeyboardType
import org.whispersystems.signalservice.api.backup.BackupKey
data class MessageBackupsFlowState(
val hasBackupSubscriberAvailable: Boolean = false,
val selectedMessageBackupTierLabel: String? = null,
val selectedMessageBackupTier: MessageBackupTier? = SignalStore.backup.backupTier,
val currentMessageBackupTier: MessageBackupTier? = SignalStore.backup.backupTier,
val availableBackupTypes: List<MessageBackupsType> = emptyList(),
val selectedPaymentMethod: InAppPaymentData.PaymentMethodType? = null,
val availablePaymentMethods: List<InAppPaymentData.PaymentMethodType> = emptyList(),
val pinKeyboardType: PinKeyboardType = SignalStore.pin.keyboardType,
val inAppPayment: InAppPaymentTable.InAppPayment? = null,
val startScreen: MessageBackupsScreen,
val screen: MessageBackupsScreen = startScreen,
val displayIncorrectPinError: Boolean = false
val startScreen: MessageBackupsStage,
val stage: MessageBackupsStage = startScreen,
val backupKey: BackupKey = SignalStore.svr.getOrCreateMasterKey().deriveBackupKey(),
val failure: Throwable? = null
)

View File

@@ -5,56 +5,72 @@
package org.thoughtcrime.securesms.backup.v2.ui.subscription
import android.text.TextUtils
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.take
import kotlinx.coroutines.flow.timeout
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.reactive.asFlow
import kotlinx.coroutines.withContext
import org.signal.core.util.money.FiatMoney
import org.signal.core.util.billing.BillingPurchaseResult
import org.signal.core.util.logging.Log
import org.signal.donations.InAppPaymentType
import org.thoughtcrime.securesms.backup.v2.BackupRepository
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationSerializationHelper.toFiatValue
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppDonations
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository.toPaymentSourceType
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayOrderStrategy
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.RecurringInAppPaymentRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.InAppPaymentError
import org.thoughtcrime.securesms.database.InAppPaymentTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobs.InAppPaymentPurchaseTokenJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.lock.v2.PinKeyboardType
import org.thoughtcrime.securesms.lock.v2.SvrConstants
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.RemoteConfig
import org.whispersystems.signalservice.api.kbs.PinHashUtil.verifyLocalPinHash
import org.whispersystems.signalservice.internal.push.SubscriptionsConfiguration
import java.math.BigDecimal
import kotlin.time.Duration.Companion.seconds
class MessageBackupsFlowViewModel : ViewModel() {
companion object {
private val TAG = Log.tag(MessageBackupsFlowViewModel::class)
}
private val internalStateFlow = MutableStateFlow(
MessageBackupsFlowState(
availableBackupTypes = emptyList(),
selectedMessageBackupTier = SignalStore.backup.backupTier,
availablePaymentMethods = GatewayOrderStrategy.getStrategy().orderedGateways.filter { InAppDonations.isPaymentSourceAvailable(it.toPaymentSourceType(), InAppPaymentType.RECURRING_BACKUP) },
startScreen = if (SignalStore.backup.backupTier == null) MessageBackupsScreen.EDUCATION else MessageBackupsScreen.TYPE_SELECTION
startScreen = if (SignalStore.backup.backupTier == null) MessageBackupsStage.EDUCATION else MessageBackupsStage.TYPE_SELECTION
)
)
private val internalPinState = mutableStateOf("")
private var isDowngrading = false
val stateFlow: StateFlow<MessageBackupsFlowState> = internalStateFlow
val pinState: State<String> = internalPinState
init {
check(SignalStore.backup.backupTier != MessageBackupTier.PAID) { "This screen does not support cancellation or downgrades." }
viewModelScope.launch {
try {
ensureSubscriberIdForBackups()
internalStateFlow.update {
it.copy(
hasBackupSubscriberAvailable = true
)
}
} catch (e: Exception) {
Log.w(TAG, "Failed to ensure a subscriber id exists.", e)
}
internalStateFlow.update {
it.copy(
availableBackupTypes = BackupRepository.getAvailableBackupsTypes(
@@ -63,71 +79,83 @@ class MessageBackupsFlowViewModel : ViewModel() {
)
}
}
}
fun goToNextScreen() {
val pinSnapshot = pinState.value
viewModelScope.launch {
AppDependencies.billingApi.getBillingPurchaseResults().collect { result ->
when (result) {
is BillingPurchaseResult.Success -> {
internalStateFlow.update { it.copy(stage = MessageBackupsStage.PROCESS_PAYMENT) }
internalStateFlow.update {
when (it.screen) {
MessageBackupsScreen.EDUCATION -> it.copy(screen = MessageBackupsScreen.PIN_EDUCATION)
MessageBackupsScreen.PIN_EDUCATION -> it.copy(screen = MessageBackupsScreen.PIN_CONFIRMATION)
MessageBackupsScreen.PIN_CONFIRMATION -> validatePinAndUpdateState(it, pinSnapshot)
MessageBackupsScreen.TYPE_SELECTION -> validateTypeAndUpdateState(it)
MessageBackupsScreen.CHECKOUT_SHEET -> validateGatewayAndUpdateState(it)
MessageBackupsScreen.CREATING_IN_APP_PAYMENT -> error("This is driven by an async coroutine.")
MessageBackupsScreen.CANCELLATION_DIALOG -> it.copy(screen = MessageBackupsScreen.PROCESS_CANCELLATION)
MessageBackupsScreen.PROCESS_PAYMENT -> it.copy(screen = MessageBackupsScreen.COMPLETED)
MessageBackupsScreen.PROCESS_CANCELLATION -> it.copy(screen = MessageBackupsScreen.COMPLETED)
MessageBackupsScreen.PROCESS_FREE -> it.copy(screen = MessageBackupsScreen.COMPLETED)
MessageBackupsScreen.COMPLETED -> error("Unsupported state transition from terminal state COMPLETED")
try {
handleSuccess(
result,
internalStateFlow.value.inAppPayment!!.id
)
internalStateFlow.update {
it.copy(
stage = MessageBackupsStage.COMPLETED
)
}
} catch (e: Exception) {
internalStateFlow.update {
it.copy(
stage = MessageBackupsStage.FAILURE,
failure = e
)
}
}
}
else -> goToPreviousStage()
}
}
}
}
fun goToPreviousScreen() {
/**
* Go to the next stage of the pipeline, based off of the current stage and state data.
*/
fun goToNextStage() {
internalStateFlow.update {
if (it.screen == it.startScreen) {
it.copy(screen = MessageBackupsScreen.COMPLETED)
when (it.stage) {
MessageBackupsStage.EDUCATION -> it.copy(stage = MessageBackupsStage.BACKUP_KEY_EDUCATION)
MessageBackupsStage.BACKUP_KEY_EDUCATION -> it.copy(stage = MessageBackupsStage.BACKUP_KEY_RECORD)
MessageBackupsStage.BACKUP_KEY_RECORD -> it.copy(stage = MessageBackupsStage.TYPE_SELECTION)
MessageBackupsStage.TYPE_SELECTION -> validateTypeAndUpdateState(it)
MessageBackupsStage.CHECKOUT_SHEET -> validateGatewayAndUpdateState(it)
MessageBackupsStage.CREATING_IN_APP_PAYMENT -> error("This is driven by an async coroutine.")
MessageBackupsStage.PROCESS_PAYMENT -> error("This is driven by an async coroutine.")
MessageBackupsStage.PROCESS_FREE -> error("This is driven by an async coroutine.")
MessageBackupsStage.COMPLETED -> error("Unsupported state transition from terminal state COMPLETED")
MessageBackupsStage.FAILURE -> error("Unsupported state transition from terminal state FAILURE")
}
}
}
fun goToPreviousStage() {
internalStateFlow.update {
if (it.stage == it.startScreen) {
it.copy(stage = MessageBackupsStage.COMPLETED)
} else {
val previousScreen = when (it.screen) {
MessageBackupsScreen.EDUCATION -> MessageBackupsScreen.COMPLETED
MessageBackupsScreen.PIN_EDUCATION -> MessageBackupsScreen.EDUCATION
MessageBackupsScreen.PIN_CONFIRMATION -> MessageBackupsScreen.PIN_EDUCATION
MessageBackupsScreen.TYPE_SELECTION -> MessageBackupsScreen.PIN_CONFIRMATION
MessageBackupsScreen.CHECKOUT_SHEET -> MessageBackupsScreen.TYPE_SELECTION
MessageBackupsScreen.CREATING_IN_APP_PAYMENT -> MessageBackupsScreen.TYPE_SELECTION
MessageBackupsScreen.PROCESS_PAYMENT -> MessageBackupsScreen.TYPE_SELECTION
MessageBackupsScreen.PROCESS_CANCELLATION -> MessageBackupsScreen.TYPE_SELECTION
MessageBackupsScreen.PROCESS_FREE -> MessageBackupsScreen.TYPE_SELECTION
MessageBackupsScreen.CANCELLATION_DIALOG -> MessageBackupsScreen.TYPE_SELECTION
MessageBackupsScreen.COMPLETED -> error("Unsupported state transition from terminal state COMPLETED")
val previousScreen = when (it.stage) {
MessageBackupsStage.EDUCATION -> MessageBackupsStage.COMPLETED
MessageBackupsStage.BACKUP_KEY_EDUCATION -> MessageBackupsStage.EDUCATION
MessageBackupsStage.BACKUP_KEY_RECORD -> MessageBackupsStage.BACKUP_KEY_EDUCATION
MessageBackupsStage.TYPE_SELECTION -> MessageBackupsStage.BACKUP_KEY_RECORD
MessageBackupsStage.CHECKOUT_SHEET -> MessageBackupsStage.TYPE_SELECTION
MessageBackupsStage.CREATING_IN_APP_PAYMENT -> MessageBackupsStage.CREATING_IN_APP_PAYMENT
MessageBackupsStage.PROCESS_PAYMENT -> MessageBackupsStage.PROCESS_PAYMENT
MessageBackupsStage.PROCESS_FREE -> MessageBackupsStage.PROCESS_FREE
MessageBackupsStage.COMPLETED -> error("Unsupported state transition from terminal state COMPLETED")
MessageBackupsStage.FAILURE -> error("Unsupported state transition from terminal state FAILURE")
}
it.copy(screen = previousScreen)
it.copy(stage = previousScreen)
}
}
}
fun displayCancellationDialog() {
internalStateFlow.update {
check(it.screen == MessageBackupsScreen.TYPE_SELECTION)
it.copy(screen = MessageBackupsScreen.CANCELLATION_DIALOG)
}
}
fun onPinEntryUpdated(pin: String) {
internalPinState.value = pin
}
fun onPinKeyboardTypeUpdated(pinKeyboardType: PinKeyboardType) {
internalStateFlow.update { it.copy(pinKeyboardType = pinKeyboardType) }
}
fun onPaymentMethodUpdated(paymentMethod: InAppPaymentData.PaymentMethodType) {
internalStateFlow.update { it.copy(selectedPaymentMethod = paymentMethod) }
}
fun onMessageBackupTierUpdated(messageBackupTier: MessageBackupTier, messageBackupTierLabel: String) {
internalStateFlow.update {
it.copy(
@@ -137,79 +165,44 @@ class MessageBackupsFlowViewModel : ViewModel() {
}
}
fun onCancellationComplete() {
if (isDowngrading) {
SignalStore.backup.areBackupsEnabled = true
SignalStore.backup.backupTier = MessageBackupTier.FREE
// TODO [message-backups] -- Trigger backup now?
}
}
private fun validatePinAndUpdateState(state: MessageBackupsFlowState, pin: String): MessageBackupsFlowState {
val pinHash = SignalStore.svr.localPinHash
if (pinHash == null || TextUtils.isEmpty(pin) || pin.length < SvrConstants.MINIMUM_PIN_LENGTH) {
return state.copy(
screen = MessageBackupsScreen.PIN_CONFIRMATION,
displayIncorrectPinError = true
)
}
if (!verifyLocalPinHash(pinHash, pin)) {
return state.copy(
screen = MessageBackupsScreen.PIN_CONFIRMATION,
displayIncorrectPinError = true
)
}
internalPinState.value = ""
return state.copy(
screen = MessageBackupsScreen.TYPE_SELECTION,
displayIncorrectPinError = false
)
}
private fun validateTypeAndUpdateState(state: MessageBackupsFlowState): MessageBackupsFlowState {
return when (state.selectedMessageBackupTier!!) {
MessageBackupTier.FREE -> {
if (SignalStore.backup.backupTier == MessageBackupTier.PAID) {
isDowngrading = true
state.copy(screen = MessageBackupsScreen.PROCESS_CANCELLATION)
} else {
SignalStore.backup.areBackupsEnabled = true
SignalStore.backup.backupTier = MessageBackupTier.FREE
SignalStore.backup.areBackupsEnabled = true
SignalStore.backup.backupTier = MessageBackupTier.FREE
state.copy(screen = MessageBackupsScreen.PROCESS_FREE)
}
state.copy(stage = MessageBackupsStage.COMPLETED)
}
MessageBackupTier.PAID -> state.copy(screen = MessageBackupsScreen.CHECKOUT_SHEET)
MessageBackupTier.PAID -> state.copy(stage = MessageBackupsStage.CHECKOUT_SHEET)
}
}
private fun validateGatewayAndUpdateState(state: MessageBackupsFlowState): MessageBackupsFlowState {
val backupsType = state.availableBackupTypes.first { it.tier == state.selectedMessageBackupTier }
check(state.selectedMessageBackupTier == MessageBackupTier.PAID)
check(state.availableBackupTypes.any { it.tier == state.selectedMessageBackupTier })
check(state.hasBackupSubscriberAvailable)
viewModelScope.launch(Dispatchers.IO) {
withContext(Dispatchers.Main) {
internalStateFlow.update { it.copy(inAppPayment = null) }
}
val currency = SignalStore.inAppPayments.getSubscriptionCurrency(InAppPaymentSubscriberRecord.Type.BACKUP)
val paidFiat = AppDependencies.billingApi.queryProduct()!!.price
SignalDatabase.inAppPayments.clearCreated()
val id = SignalDatabase.inAppPayments.insert(
type = InAppPaymentType.RECURRING_BACKUP,
state = InAppPaymentTable.State.CREATED,
subscriberId = null,
subscriberId = InAppPaymentsRepository.requireSubscriber(InAppPaymentSubscriberRecord.Type.BACKUP).subscriberId,
endOfPeriod = null,
inAppPaymentData = InAppPaymentData(
badge = null,
label = state.selectedMessageBackupTierLabel!!,
amount = if (backupsType is MessageBackupsType.Paid) backupsType.pricePerMonth.toFiatValue() else FiatMoney(BigDecimal.ZERO, currency).toFiatValue(),
amount = paidFiat.toFiatValue(),
level = SubscriptionsConfiguration.BACKUPS_LEVEL.toLong(),
recipientId = Recipient.self().id.serialize(),
paymentMethodType = state.selectedPaymentMethod!!,
paymentMethodType = InAppPaymentData.PaymentMethodType.GOOGLE_PLAY_BILLING,
redemption = InAppPaymentData.RedemptionState(
stage = InAppPaymentData.RedemptionState.Stage.INIT
)
@@ -219,10 +212,57 @@ class MessageBackupsFlowViewModel : ViewModel() {
val inAppPayment = SignalDatabase.inAppPayments.getById(id)!!
withContext(Dispatchers.Main) {
internalStateFlow.update { it.copy(inAppPayment = inAppPayment, screen = MessageBackupsScreen.PROCESS_PAYMENT) }
internalStateFlow.update { it.copy(inAppPayment = inAppPayment, stage = MessageBackupsStage.PROCESS_PAYMENT) }
}
}
return state.copy(screen = MessageBackupsScreen.CREATING_IN_APP_PAYMENT)
return state.copy(stage = MessageBackupsStage.CREATING_IN_APP_PAYMENT)
}
/**
* Ensures we have a SubscriberId created and available for use. This is considered safe because
* the screen this is called in is assumed to only be accessible if the user does not currently have
* a subscription.
*/
private suspend fun ensureSubscriberIdForBackups() {
val product = AppDependencies.billingApi.queryProduct() ?: error("No product available.")
SignalStore.inAppPayments.setSubscriberCurrency(product.price.currency, InAppPaymentSubscriberRecord.Type.BACKUP)
RecurringInAppPaymentRepository.ensureSubscriberId(InAppPaymentSubscriberRecord.Type.BACKUP).blockingAwait()
}
/**
* Handles a successful BillingPurchaseResult. Updates the in app payment, enqueues the appropriate job chain,
* and handles any resulting error. Like donations, we will wait up to 10s for the completion of the job chain.
*/
@OptIn(FlowPreview::class)
private suspend fun handleSuccess(result: BillingPurchaseResult.Success, inAppPaymentId: InAppPaymentTable.InAppPaymentId) {
withContext(Dispatchers.IO) {
val inAppPayment = SignalDatabase.inAppPayments.getById(inAppPaymentId)!!
SignalDatabase.inAppPayments.update(
inAppPayment.copy(
data = inAppPayment.data.copy(
redemption = inAppPayment.data.redemption!!.copy(
googlePlayBillingPurchaseToken = result.purchaseToken
)
)
)
)
InAppPaymentPurchaseTokenJob.createJobChain(inAppPayment).enqueue()
}
val terminalInAppPayment = withContext(Dispatchers.IO) {
InAppPaymentsRepository.observeUpdates(inAppPaymentId).asFlow()
.filter { it.state == InAppPaymentTable.State.END }
.take(1)
.timeout(10.seconds)
.first()
}
if (terminalInAppPayment.data.error != null) {
throw InAppPaymentError(terminalInAppPayment.data.error)
} else {
return
}
}
}

View File

@@ -0,0 +1,114 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2.ui.subscription
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import org.signal.core.ui.Buttons
import org.signal.core.ui.Previews
import org.signal.core.ui.Scaffolds
import org.signal.core.ui.SignalPreview
import org.thoughtcrime.securesms.R
/**
* Screen detailing how a backups key is used to restore a backup
*/
@Composable
fun MessageBackupsKeyEducationScreen(
onNavigationClick: () -> Unit = {},
onNextClick: () -> Unit = {}
) {
Scaffolds.Settings(
title = "",
navigationIconPainter = painterResource(R.drawable.symbol_arrow_left_24),
onNavigationClick = onNavigationClick
) {
Column(
modifier = Modifier
.padding(it)
.padding(horizontal = dimensionResource(R.dimen.core_ui__gutter))
.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
painter = painterResource(R.drawable.symbol_key_24),
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier
.padding(top = 24.dp)
.size(80.dp)
.background(
color = MaterialTheme.colorScheme.primaryContainer,
shape = CircleShape
)
.padding(16.dp)
)
Text(
text = stringResource(R.string.MessageBackupsKeyEducationScreen__your_backup_key),
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier.padding(top = 16.dp)
)
Text(
text = stringResource(R.string.MessageBackupsKeyEducationScreen__your_backup_key_is_a),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 12.dp)
)
Text(
text = stringResource(R.string.MessageBackupsKeyEducationScreen__if_you_forget_your_key),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 16.dp)
)
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.padding(bottom = 24.dp)
) {
Buttons.LargeTonal(
onClick = onNextClick,
modifier = Modifier.align(Alignment.BottomEnd)
) {
Text(
text = stringResource(R.string.MessageBackupsKeyEducationScreen__next)
)
}
}
}
}
}
@SignalPreview
@Composable
private fun MessageBackupsKeyEducationScreenPreview() {
Previews.Preview {
MessageBackupsKeyEducationScreen()
}
}

View File

@@ -0,0 +1,260 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2.ui.subscription
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Checkbox
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.launch
import org.signal.core.ui.BottomSheets
import org.signal.core.ui.Buttons
import org.signal.core.ui.Previews
import org.signal.core.ui.Scaffolds
import org.signal.core.ui.SignalPreview
import org.signal.core.ui.theme.SignalTheme
import org.signal.core.util.Hex
import org.thoughtcrime.securesms.R
import org.whispersystems.signalservice.api.backup.BackupKey
import kotlin.random.Random
/**
* Screen displaying the backup key allowing the user to write it down
* or copy it.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MessageBackupsKeyRecordScreen(
backupKey: BackupKey,
onNavigationClick: () -> Unit = {},
onCopyToClipboardClick: (String) -> Unit = {},
onNextClick: () -> Unit = {}
) {
val coroutineScope = rememberCoroutineScope()
val sheetState = rememberModalBottomSheetState(
skipPartiallyExpanded = true
)
Scaffolds.Settings(
title = "",
navigationIconPainter = painterResource(R.drawable.symbol_arrow_left_24),
onNavigationClick = onNavigationClick
) { paddingValues ->
Column(
modifier = Modifier
.padding(paddingValues)
.padding(horizontal = dimensionResource(R.dimen.core_ui__gutter))
.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
painter = painterResource(R.drawable.symbol_lock_24),
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier
.padding(top = 24.dp)
.size(80.dp)
.background(
color = MaterialTheme.colorScheme.primaryContainer,
shape = CircleShape
)
.padding(16.dp)
)
Text(
text = stringResource(R.string.MessageBackupsKeyRecordScreen__record_your_backup_key),
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier.padding(top = 16.dp)
)
Text(
text = stringResource(R.string.MessageBackupsKeyRecordScreen__this_key_is_required_to_recover),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 12.dp)
)
val backupKeyString = remember(backupKey) {
backupKey.value.toList().chunked(2).map { Hex.toStringCondensed(it.toByteArray()) }.joinToString(" ")
}
Box(
modifier = Modifier
.padding(top = 24.dp, bottom = 16.dp)
.background(
color = SignalTheme.colors.colorSurface1,
shape = RoundedCornerShape(10.dp)
)
.padding(24.dp)
) {
Text(
text = backupKeyString,
style = MaterialTheme.typography.bodyLarge
.copy(
fontSize = 18.sp,
fontWeight = FontWeight(400),
letterSpacing = 1.44.sp,
lineHeight = 36.sp,
textAlign = TextAlign.Center,
fontFamily = FontFamily.Monospace
)
)
}
Buttons.Small(
onClick = { onCopyToClipboardClick(backupKeyString) }
) {
Text(
text = stringResource(R.string.MessageBackupsKeyRecordScreen__copy_to_clipboard)
)
}
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.padding(bottom = 24.dp)
) {
Buttons.LargeTonal(
onClick = {
coroutineScope.launch {
sheetState.show()
}
},
modifier = Modifier.align(Alignment.BottomEnd)
) {
Text(
text = stringResource(R.string.MessageBackupsKeyRecordScreen__next)
)
}
}
}
if (sheetState.isVisible) {
ModalBottomSheet(
dragHandle = null,
onDismissRequest = {
coroutineScope.launch {
sheetState.hide()
}
}
) {
BottomSheetContent(
onContinueClick = onNextClick,
onSeeKeyAgainClick = {
coroutineScope.launch {
sheetState.hide()
}
}
)
}
}
}
}
@Composable
private fun BottomSheetContent(
onContinueClick: () -> Unit,
onSeeKeyAgainClick: () -> Unit
) {
var checked by remember { mutableStateOf(false) }
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = dimensionResource(R.dimen.core_ui__gutter))
) {
BottomSheets.Handle()
Text(
text = stringResource(R.string.MessageBackupsKeyRecordScreen__keep_your_key_safe),
style = MaterialTheme.typography.titleLarge,
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 30.dp)
)
Text(
text = stringResource(R.string.MessageBackupsKeyRecordScreen__signal_will_not),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 12.dp)
)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(vertical = 24.dp)
) {
Checkbox(
checked = checked,
onCheckedChange = { checked = it }
)
Text(
text = stringResource(R.string.MessageBackupsKeyRecordScreen__ive_recorded_my_key),
style = MaterialTheme.typography.bodyLarge
)
}
Buttons.LargeTonal(
enabled = checked,
onClick = onContinueClick,
modifier = Modifier.padding(bottom = 16.dp)
) {
Text(text = stringResource(R.string.MessageBackupsKeyRecordScreen__continue))
}
TextButton(
onClick = onSeeKeyAgainClick,
modifier = Modifier.padding(bottom = 24.dp)
) {
Text(
text = stringResource(R.string.MessageBackupsKeyRecordScreen__see_key_again)
)
}
}
}
@SignalPreview
@Composable
private fun MessageBackupsKeyRecordScreenPreview() {
Previews.Preview {
MessageBackupsKeyRecordScreen(
backupKey = BackupKey(Random.nextBytes(32))
)
}
}

View File

@@ -1,231 +0,0 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2.ui.subscription
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import org.signal.core.ui.Buttons
import org.signal.core.ui.Previews
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.lock.v2.PinKeyboardType
/**
* Screen which requires the user to enter their pin before enabling backups.
*/
@Composable
fun MessageBackupsPinConfirmationScreen(
pin: String,
isPinIncorrect: Boolean,
onPinChanged: (String) -> Unit,
pinKeyboardType: PinKeyboardType,
onPinKeyboardTypeSelected: (PinKeyboardType) -> Unit,
onNextClick: () -> Unit,
onCreateNewPinClick: () -> Unit
) {
val focusRequester = remember { FocusRequester() }
Surface {
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = dimensionResource(id = R.dimen.core_ui__gutter))
) {
LazyColumn(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
) {
item {
Text(
text = stringResource(id = R.string.MessageBackupsPinConfirmationScreen__enter_your_pin),
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier.padding(top = 40.dp)
)
}
item {
Text(
text = stringResource(id = R.string.MessageBackupsPinConfirmationScreen__enter_your_signal_pin_to_enable_backups),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 16.dp)
)
}
item {
val keyboardType = remember(pinKeyboardType) {
when (pinKeyboardType) {
PinKeyboardType.NUMERIC -> KeyboardType.NumberPassword
PinKeyboardType.ALPHA_NUMERIC -> KeyboardType.Password
}
}
TextField(
value = pin,
onValueChange = onPinChanged,
textStyle = LocalTextStyle.current.copy(textAlign = TextAlign.Center),
keyboardActions = KeyboardActions(
onDone = { onNextClick() }
),
keyboardOptions = KeyboardOptions(
keyboardType = keyboardType,
imeAction = ImeAction.Done
),
modifier = Modifier
.padding(top = 72.dp)
.fillMaxWidth()
.focusRequester(focusRequester),
visualTransformation = PasswordVisualTransformation(),
isError = isPinIncorrect,
supportingText = {
if (isPinIncorrect) {
Text(
text = stringResource(id = R.string.PinRestoreEntryFragment_incorrect_pin),
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.padding(top = 12.dp)
)
}
}
)
}
item {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.fillMaxWidth()
.padding(top = 48.dp)
) {
PinKeyboardTypeToggle(
pinKeyboardType = pinKeyboardType,
onPinKeyboardTypeSelected = onPinKeyboardTypeSelected
)
}
}
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 16.dp)
) {
if (isPinIncorrect) {
TextButton(onClick = onCreateNewPinClick) {
Text(
text = stringResource(id = R.string.MessageBackupsPinConfirmationScreen__create_new_pin)
)
}
}
Spacer(modifier = Modifier.weight(1f))
Buttons.LargeTonal(
onClick = onNextClick
) {
Text(
text = stringResource(id = R.string.MessageBackupsPinConfirmationScreen__next)
)
}
}
LaunchedEffect(Unit) {
focusRequester.requestFocus()
}
}
}
}
@Preview
@Composable
private fun MessageBackupsPinConfirmationScreenPreview() {
Previews.Preview {
MessageBackupsPinConfirmationScreen(
pin = "",
isPinIncorrect = true,
onPinChanged = {},
pinKeyboardType = PinKeyboardType.ALPHA_NUMERIC,
onPinKeyboardTypeSelected = {},
onNextClick = {},
onCreateNewPinClick = {}
)
}
}
@Preview
@Composable
private fun PinKeyboardTypeTogglePreview() {
Previews.Preview {
var type by remember { mutableStateOf(PinKeyboardType.ALPHA_NUMERIC) }
PinKeyboardTypeToggle(
pinKeyboardType = type,
onPinKeyboardTypeSelected = { type = it }
)
}
}
@Composable
private fun PinKeyboardTypeToggle(
pinKeyboardType: PinKeyboardType,
onPinKeyboardTypeSelected: (PinKeyboardType) -> Unit
) {
val callback = remember(pinKeyboardType) {
{ onPinKeyboardTypeSelected(pinKeyboardType.other) }
}
val iconRes = remember(pinKeyboardType) {
when (pinKeyboardType) {
PinKeyboardType.NUMERIC -> R.drawable.symbol_keyboard_24
PinKeyboardType.ALPHA_NUMERIC -> R.drawable.symbol_number_pad_24
}
}
TextButton(onClick = callback) {
Icon(
painter = painterResource(id = iconRes),
tint = MaterialTheme.colorScheme.primary,
contentDescription = null,
modifier = Modifier.padding(end = 8.dp)
)
Text(
text = stringResource(id = R.string.MessageBackupsPinConfirmationScreen__switch_keyboard)
)
}
}

View File

@@ -1,133 +0,0 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2.ui.subscription
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import org.signal.core.ui.Buttons
import org.signal.core.ui.Previews
import org.signal.core.ui.Scaffolds
import org.thoughtcrime.securesms.R
/**
* Explanation screen that details how the user's pin is utilized with backups,
* and how long they should make their pin.
*/
@Composable
fun MessageBackupsPinEducationScreen(
onNavigationClick: () -> Unit,
onCreatePinClick: () -> Unit,
onUseCurrentPinClick: () -> Unit,
recommendedPinSize: Int
) {
Scaffolds.Settings(
title = stringResource(id = R.string.RemoteBackupsSettingsFragment__signal_backups),
onNavigationClick = onNavigationClick,
navigationIconPainter = painterResource(id = R.drawable.symbol_arrow_left_24)
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(it)
.padding(horizontal = dimensionResource(id = R.dimen.core_ui__gutter))
) {
LazyColumn(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxWidth()
.weight(1f)
) {
item {
Image(
painter = painterResource(id = R.drawable.ic_signal_logo_large), // TODO [message-backups] Finalized image
contentDescription = null,
modifier = Modifier
.padding(top = 48.dp)
.size(88.dp)
)
}
item {
Text(
text = stringResource(id = R.string.MessageBackupsPinEducationScreen__pins_protect_your_backup),
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier.padding(top = 16.dp)
)
}
item {
Text(
text = stringResource(id = R.string.MessageBackupsPinEducationScreen__your_signal_pin_lets_you),
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(top = 16.dp)
)
}
item {
Text(
text = stringResource(id = R.string.MessageBackupsPinEducationScreen__if_you_forget_your_pin),
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(top = 16.dp)
)
}
}
Buttons.LargePrimary(
onClick = onUseCurrentPinClick,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = stringResource(id = R.string.MessageBackupsPinEducationScreen__use_current_signal_pin)
)
}
TextButton(
onClick = onCreatePinClick,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 16.dp)
) {
Text(
text = stringResource(id = R.string.MessageBackupsPinEducationScreen__create_new_pin)
)
}
}
}
}
@Preview
@Composable
private fun MessageBackupsPinScreenPreview() {
Previews.Preview {
MessageBackupsPinEducationScreen(
onNavigationClick = {},
onCreatePinClick = {},
onUseCurrentPinClick = {},
recommendedPinSize = 16
)
}
}

View File

@@ -1,22 +0,0 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2.ui.subscription
enum class MessageBackupsScreen {
EDUCATION,
PIN_EDUCATION,
PIN_CONFIRMATION,
TYPE_SELECTION,
CANCELLATION_DIALOG,
CHECKOUT_SHEET,
CREATING_IN_APP_PAYMENT,
PROCESS_PAYMENT,
PROCESS_CANCELLATION,
PROCESS_FREE,
COMPLETED;
fun isAfter(other: MessageBackupsScreen): Boolean = ordinal > other.ordinal
}

View File

@@ -0,0 +1,36 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2.ui.subscription
/**
* Pipeline for subscribing to message backups.
*/
enum class MessageBackupsStage(
val route: Route
) {
EDUCATION(route = Route.EDUCATION),
BACKUP_KEY_EDUCATION(route = Route.BACKUP_KEY_EDUCATION),
BACKUP_KEY_RECORD(route = Route.BACKUP_KEY_RECORD),
TYPE_SELECTION(route = Route.TYPE_SELECTION),
CHECKOUT_SHEET(route = Route.TYPE_SELECTION),
CREATING_IN_APP_PAYMENT(route = Route.TYPE_SELECTION),
PROCESS_PAYMENT(route = Route.TYPE_SELECTION),
PROCESS_FREE(route = Route.TYPE_SELECTION),
COMPLETED(route = Route.TYPE_SELECTION),
FAILURE(route = Route.TYPE_SELECTION);
/**
* Compose navigation route to display while in a given stage.
*/
enum class Route {
EDUCATION,
BACKUP_KEY_EDUCATION,
BACKUP_KEY_RECORD,
TYPE_SELECTION;
fun isAfter(other: Route): Boolean = ordinal > other.ordinal
}
}

View File

@@ -22,7 +22,6 @@ import androidx.compose.foundation.text.ClickableText
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -71,8 +70,7 @@ fun MessageBackupsTypeSelectionScreen(
onMessageBackupsTierSelected: (MessageBackupTier) -> Unit,
onNavigationClick: () -> Unit,
onReadMoreClicked: () -> Unit,
onNextClicked: () -> Unit,
onCancelSubscriptionClicked: () -> Unit
onNextClicked: () -> Unit
) {
Scaffolds.Settings(
title = "",
@@ -170,17 +168,6 @@ fun MessageBackupsTypeSelectionScreen(
)
)
}
if (hasCurrentBackupTier) {
TextButton(
onClick = onCancelSubscriptionClicked,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 14.dp)
) {
Text(text = stringResource(id = R.string.MessageBackupsTypeSelectionScreen__cancel_subscription))
}
}
}
}
}
@@ -198,7 +185,6 @@ private fun MessageBackupsTypeSelectionScreenPreview() {
onNavigationClick = {},
onReadMoreClicked = {},
onNextClicked = {},
onCancelSubscriptionClicked = {},
currentBackupTier = null
)
}
@@ -217,7 +203,6 @@ private fun MessageBackupsTypeSelectionScreenWithCurrentTierPreview() {
onNavigationClick = {},
onReadMoreClicked = {},
onNextClicked = {},
onCancelSubscriptionClicked = {},
currentBackupTier = MessageBackupTier.PAID
)
}

View File

@@ -25,7 +25,7 @@ import org.thoughtcrime.securesms.jobs.BackupRestoreMediaJob
import org.thoughtcrime.securesms.jobs.SyncArchivedMediaJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.registration.RegistrationUtil
import org.thoughtcrime.securesms.registration.util.RegistrationUtil
import java.io.InputStream
import kotlin.time.Duration.Companion.seconds

View File

@@ -0,0 +1,173 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2.util
import okio.ByteString
import okio.ByteString.Companion.toByteString
import org.signal.core.util.Base64
import org.signal.core.util.orNull
import org.thoughtcrime.securesms.attachments.ArchivedAttachment
import org.thoughtcrime.securesms.attachments.Attachment
import org.thoughtcrime.securesms.attachments.Cdn
import org.thoughtcrime.securesms.attachments.DatabaseAttachment
import org.thoughtcrime.securesms.attachments.PointerAttachment
import org.thoughtcrime.securesms.attachments.TombstoneAttachment
import org.thoughtcrime.securesms.backup.v2.BackupRepository.getMediaName
import org.thoughtcrime.securesms.backup.v2.ImportState
import org.thoughtcrime.securesms.backup.v2.proto.FilePointer
import org.thoughtcrime.securesms.database.AttachmentTable
import org.thoughtcrime.securesms.stickers.StickerLocator
import org.whispersystems.signalservice.api.backup.MediaName
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentPointer
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentRemoteId
import org.whispersystems.signalservice.api.util.UuidUtil
import java.util.Optional
/**
* Converts a [FilePointer] to a local [Attachment] object for inserting into the database.
*/
fun FilePointer?.toLocalAttachment(
importState: ImportState,
voiceNote: Boolean = false,
borderless: Boolean = false,
gif: Boolean = false,
wasDownloaded: Boolean = false,
stickerLocator: StickerLocator? = null,
contentType: String? = this?.contentType,
fileName: String? = this?.fileName,
uuid: ByteString? = null
): Attachment? {
if (this == null) return null
if (this.attachmentLocator != null) {
val signalAttachmentPointer = SignalServiceAttachmentPointer(
cdnNumber = this.attachmentLocator.cdnNumber,
remoteId = SignalServiceAttachmentRemoteId.from(attachmentLocator.cdnKey),
contentType = contentType,
key = this.attachmentLocator.key.toByteArray(),
size = Optional.ofNullable(attachmentLocator.size),
preview = Optional.empty(),
width = this.width ?: 0,
height = this.height ?: 0,
digest = Optional.ofNullable(this.attachmentLocator.digest.toByteArray()),
incrementalDigest = Optional.ofNullable(this.incrementalMac?.toByteArray()),
incrementalMacChunkSize = this.incrementalMacChunkSize ?: 0,
fileName = Optional.ofNullable(fileName),
voiceNote = voiceNote,
isBorderless = borderless,
isGif = gif,
caption = Optional.ofNullable(this.caption),
blurHash = Optional.ofNullable(this.blurHash),
uploadTimestamp = this.attachmentLocator.uploadTimestamp,
uuid = UuidUtil.fromByteStringOrNull(uuid)
)
return PointerAttachment.forPointer(
pointer = Optional.of(signalAttachmentPointer),
stickerLocator = stickerLocator,
transferState = if (wasDownloaded) AttachmentTable.TRANSFER_NEEDS_RESTORE else AttachmentTable.TRANSFER_PROGRESS_PENDING
).orNull()
} else if (this.invalidAttachmentLocator != null) {
return TombstoneAttachment(
contentType = contentType,
incrementalMac = this.incrementalMac?.toByteArray(),
incrementalMacChunkSize = this.incrementalMacChunkSize,
width = this.width,
height = this.height,
caption = this.caption,
fileName = this.fileName,
blurHash = this.blurHash,
voiceNote = voiceNote,
borderless = borderless,
gif = gif,
quote = false,
stickerLocator = stickerLocator,
uuid = UuidUtil.fromByteStringOrNull(uuid)
)
} else if (this.backupLocator != null) {
return ArchivedAttachment(
contentType = contentType,
size = this.backupLocator.size.toLong(),
cdn = this.backupLocator.transitCdnNumber ?: Cdn.CDN_0.cdnNumber,
key = this.backupLocator.key.toByteArray(),
iv = null,
cdnKey = this.backupLocator.transitCdnKey,
archiveCdn = this.backupLocator.cdnNumber,
archiveMediaName = this.backupLocator.mediaName,
archiveMediaId = importState.backupKey.deriveMediaId(MediaName(this.backupLocator.mediaName)).encode(),
archiveThumbnailMediaId = importState.backupKey.deriveMediaId(MediaName.forThumbnailFromMediaName(this.backupLocator.mediaName)).encode(),
digest = this.backupLocator.digest.toByteArray(),
incrementalMac = this.incrementalMac?.toByteArray(),
incrementalMacChunkSize = this.incrementalMacChunkSize,
width = this.width,
height = this.height,
caption = this.caption,
blurHash = this.blurHash,
voiceNote = voiceNote,
borderless = borderless,
gif = gif,
quote = false,
stickerLocator = stickerLocator,
uuid = UuidUtil.fromByteStringOrNull(uuid),
fileName = fileName
)
}
return null
}
/**
* @param mediaArchiveEnabled True if this user has enable media backup, otherwise false.
*/
fun DatabaseAttachment.toRemoteFilePointer(mediaArchiveEnabled: Boolean): FilePointer {
val builder = FilePointer.Builder()
builder.contentType = this.contentType?.takeUnless { it.isBlank() }
builder.incrementalMac = this.incrementalDigest?.toByteString()
builder.incrementalMacChunkSize = this.incrementalMacChunkSize.takeIf { it > 0 }
builder.fileName = this.fileName
builder.width = this.width.takeIf { it > 0 }
builder.height = this.height.takeIf { it > 0 }
builder.caption = this.caption
builder.blurHash = this.blurHash?.hash
if (this.remoteKey.isNullOrBlank() || this.remoteDigest == null || this.size == 0L) {
builder.invalidAttachmentLocator = FilePointer.InvalidAttachmentLocator()
return builder.build()
}
if (this.transferState == AttachmentTable.TRANSFER_PROGRESS_PERMANENT_FAILURE && this.archiveTransferState != AttachmentTable.ArchiveTransferState.FINISHED) {
builder.invalidAttachmentLocator = FilePointer.InvalidAttachmentLocator()
return builder.build()
}
val pending = this.archiveTransferState != AttachmentTable.ArchiveTransferState.FINISHED && (this.transferState != AttachmentTable.TRANSFER_PROGRESS_DONE && this.transferState != AttachmentTable.TRANSFER_RESTORE_OFFLOADED)
if (mediaArchiveEnabled && !pending) {
builder.backupLocator = FilePointer.BackupLocator(
mediaName = this.archiveMediaName ?: this.getMediaName().toString(),
cdnNumber = if (this.archiveMediaName != null) this.archiveCdn else Cdn.CDN_3.cdnNumber, // TODO [backup]: Update when new proto with optional cdn is landed
key = Base64.decode(remoteKey).toByteString(),
size = this.size.toInt(),
digest = this.remoteDigest.toByteString(),
transitCdnNumber = this.cdn.cdnNumber.takeIf { this.remoteLocation != null },
transitCdnKey = this.remoteLocation
)
return builder.build()
}
if (this.remoteLocation.isNullOrBlank()) {
builder.invalidAttachmentLocator = FilePointer.InvalidAttachmentLocator()
return builder.build()
}
builder.attachmentLocator = FilePointer.AttachmentLocator(
cdnKey = this.remoteLocation,
cdnNumber = this.cdn.cdnNumber,
uploadTimestamp = this.uploadTimestamp,
key = Base64.decode(remoteKey).toByteString(),
size = this.size.toInt(),
digest = this.remoteDigest.toByteString()
)
return builder.build()
}

View File

@@ -0,0 +1,234 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2.util
import org.thoughtcrime.securesms.attachments.AttachmentId
import org.thoughtcrime.securesms.backup.v2.ImportState
import org.thoughtcrime.securesms.backup.v2.proto.ChatStyle
import org.thoughtcrime.securesms.backup.v2.proto.FilePointer
import org.thoughtcrime.securesms.conversation.colors.ChatColors
import org.thoughtcrime.securesms.conversation.colors.ChatColorsPalette
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.databaseprotos.Wallpaper
import org.thoughtcrime.securesms.mms.PartAuthority
import org.thoughtcrime.securesms.mms.PartUriParser
import org.thoughtcrime.securesms.util.UriUtil
import org.thoughtcrime.securesms.wallpaper.ChatWallpaper
import org.thoughtcrime.securesms.wallpaper.ChatWallpaperFactory
import org.thoughtcrime.securesms.wallpaper.GradientChatWallpaper
import org.thoughtcrime.securesms.wallpaper.SingleColorChatWallpaper
import org.thoughtcrime.securesms.wallpaper.UriChatWallpaper
/**
* Contains a collection of methods to chat styles to and from their archive format.
* These are in a file of their own just because they're rather long (with all of the various constants to map between) and used in multiple places.
*/
object ChatStyleConverter {
fun constructRemoteChatStyle(
db: SignalDatabase,
chatColors: ChatColors?,
chatColorId: ChatColors.Id,
chatWallpaper: Wallpaper?
): ChatStyle? {
if (chatColors == null && chatWallpaper == null) {
return null
}
val chatStyleBuilder = ChatStyle.Builder()
if (chatColors != null) {
when (chatColorId) {
ChatColors.Id.NotSet -> {}
ChatColors.Id.Auto -> {
chatStyleBuilder.autoBubbleColor = ChatStyle.AutomaticBubbleColor()
}
ChatColors.Id.BuiltIn -> {
chatStyleBuilder.bubbleColorPreset = chatColors.toRemote()
}
is ChatColors.Id.Custom -> {
chatStyleBuilder.customColorId = chatColorId.longValue
}
}
}
if (chatWallpaper != null) {
when {
chatWallpaper.singleColor != null -> {
chatStyleBuilder.wallpaperPreset = chatWallpaper.singleColor.color.toRemoteWallpaperPreset()
}
chatWallpaper.linearGradient != null -> {
chatStyleBuilder.wallpaperPreset = chatWallpaper.linearGradient.toRemoteWallpaperPreset()
}
chatWallpaper.file_ != null -> {
chatStyleBuilder.wallpaperPhoto = chatWallpaper.file_.toFilePointer(db)
}
}
chatStyleBuilder.dimWallpaperInDarkMode = chatWallpaper.dimLevelInDarkTheme > 0
}
return chatStyleBuilder.build()
}
}
fun ChatStyle.toLocal(importState: ImportState): ChatColors? {
if (this.bubbleColorPreset != null) {
return when (this.bubbleColorPreset) {
// Solids
ChatStyle.BubbleColorPreset.SOLID_CRIMSON -> ChatColorsPalette.Bubbles.CRIMSON
ChatStyle.BubbleColorPreset.SOLID_VERMILION -> ChatColorsPalette.Bubbles.VERMILION
ChatStyle.BubbleColorPreset.SOLID_BURLAP -> ChatColorsPalette.Bubbles.BURLAP
ChatStyle.BubbleColorPreset.SOLID_FOREST -> ChatColorsPalette.Bubbles.FOREST
ChatStyle.BubbleColorPreset.SOLID_WINTERGREEN -> ChatColorsPalette.Bubbles.WINTERGREEN
ChatStyle.BubbleColorPreset.SOLID_TEAL -> ChatColorsPalette.Bubbles.TEAL
ChatStyle.BubbleColorPreset.SOLID_BLUE -> ChatColorsPalette.Bubbles.BLUE
ChatStyle.BubbleColorPreset.SOLID_INDIGO -> ChatColorsPalette.Bubbles.INDIGO
ChatStyle.BubbleColorPreset.SOLID_VIOLET -> ChatColorsPalette.Bubbles.VIOLET
ChatStyle.BubbleColorPreset.SOLID_PLUM -> ChatColorsPalette.Bubbles.PLUM
ChatStyle.BubbleColorPreset.SOLID_TAUPE -> ChatColorsPalette.Bubbles.TAUPE
ChatStyle.BubbleColorPreset.SOLID_STEEL -> ChatColorsPalette.Bubbles.STEEL
// Gradients
ChatStyle.BubbleColorPreset.GRADIENT_EMBER -> ChatColorsPalette.Bubbles.EMBER
ChatStyle.BubbleColorPreset.GRADIENT_MIDNIGHT -> ChatColorsPalette.Bubbles.MIDNIGHT
ChatStyle.BubbleColorPreset.GRADIENT_INFRARED -> ChatColorsPalette.Bubbles.INFRARED
ChatStyle.BubbleColorPreset.GRADIENT_LAGOON -> ChatColorsPalette.Bubbles.LAGOON
ChatStyle.BubbleColorPreset.GRADIENT_FLUORESCENT -> ChatColorsPalette.Bubbles.FLUORESCENT
ChatStyle.BubbleColorPreset.GRADIENT_BASIL -> ChatColorsPalette.Bubbles.BASIL
ChatStyle.BubbleColorPreset.GRADIENT_SUBLIME -> ChatColorsPalette.Bubbles.SUBLIME
ChatStyle.BubbleColorPreset.GRADIENT_SEA -> ChatColorsPalette.Bubbles.SEA
ChatStyle.BubbleColorPreset.GRADIENT_TANGERINE -> ChatColorsPalette.Bubbles.TANGERINE
ChatStyle.BubbleColorPreset.UNKNOWN_BUBBLE_COLOR_PRESET, ChatStyle.BubbleColorPreset.SOLID_ULTRAMARINE -> ChatColorsPalette.Bubbles.ULTRAMARINE
}
}
if (this.autoBubbleColor != null) {
return ChatColorsPalette.Bubbles.default.withId(ChatColors.Id.Auto)
}
if (this.customColorId != null) {
return importState.remoteToLocalColorId[this.customColorId]?.let { localId ->
val colorId = ChatColors.Id.forLongValue(localId)
ChatColorsPalette.Bubbles.default.withId(colorId)
}
}
return null
}
fun ChatColors.toRemote(): ChatStyle.BubbleColorPreset? {
when (this) {
// Solids
ChatColorsPalette.Bubbles.CRIMSON -> return ChatStyle.BubbleColorPreset.SOLID_CRIMSON
ChatColorsPalette.Bubbles.VERMILION -> return ChatStyle.BubbleColorPreset.SOLID_VERMILION
ChatColorsPalette.Bubbles.BURLAP -> return ChatStyle.BubbleColorPreset.SOLID_BURLAP
ChatColorsPalette.Bubbles.FOREST -> return ChatStyle.BubbleColorPreset.SOLID_FOREST
ChatColorsPalette.Bubbles.WINTERGREEN -> return ChatStyle.BubbleColorPreset.SOLID_WINTERGREEN
ChatColorsPalette.Bubbles.TEAL -> return ChatStyle.BubbleColorPreset.SOLID_TEAL
ChatColorsPalette.Bubbles.BLUE -> return ChatStyle.BubbleColorPreset.SOLID_BLUE
ChatColorsPalette.Bubbles.INDIGO -> return ChatStyle.BubbleColorPreset.SOLID_INDIGO
ChatColorsPalette.Bubbles.VIOLET -> return ChatStyle.BubbleColorPreset.SOLID_VIOLET
ChatColorsPalette.Bubbles.PLUM -> return ChatStyle.BubbleColorPreset.SOLID_PLUM
ChatColorsPalette.Bubbles.TAUPE -> return ChatStyle.BubbleColorPreset.SOLID_TAUPE
ChatColorsPalette.Bubbles.STEEL -> return ChatStyle.BubbleColorPreset.SOLID_STEEL
ChatColorsPalette.Bubbles.ULTRAMARINE -> return ChatStyle.BubbleColorPreset.SOLID_ULTRAMARINE
// Gradients
ChatColorsPalette.Bubbles.EMBER -> return ChatStyle.BubbleColorPreset.GRADIENT_EMBER
ChatColorsPalette.Bubbles.MIDNIGHT -> return ChatStyle.BubbleColorPreset.GRADIENT_MIDNIGHT
ChatColorsPalette.Bubbles.INFRARED -> return ChatStyle.BubbleColorPreset.GRADIENT_INFRARED
ChatColorsPalette.Bubbles.LAGOON -> return ChatStyle.BubbleColorPreset.GRADIENT_LAGOON
ChatColorsPalette.Bubbles.FLUORESCENT -> return ChatStyle.BubbleColorPreset.GRADIENT_FLUORESCENT
ChatColorsPalette.Bubbles.BASIL -> return ChatStyle.BubbleColorPreset.GRADIENT_BASIL
ChatColorsPalette.Bubbles.SUBLIME -> return ChatStyle.BubbleColorPreset.GRADIENT_SUBLIME
ChatColorsPalette.Bubbles.SEA -> return ChatStyle.BubbleColorPreset.GRADIENT_SEA
ChatColorsPalette.Bubbles.TANGERINE -> return ChatStyle.BubbleColorPreset.GRADIENT_TANGERINE
}
return null
}
fun ChatStyle.WallpaperPreset.toLocal(): ChatWallpaper? {
return when (this) {
ChatStyle.WallpaperPreset.SOLID_BLUSH -> SingleColorChatWallpaper.BLUSH
ChatStyle.WallpaperPreset.SOLID_COPPER -> SingleColorChatWallpaper.COPPER
ChatStyle.WallpaperPreset.SOLID_DUST -> SingleColorChatWallpaper.DUST
ChatStyle.WallpaperPreset.SOLID_CELADON -> SingleColorChatWallpaper.CELADON
ChatStyle.WallpaperPreset.SOLID_RAINFOREST -> SingleColorChatWallpaper.RAINFOREST
ChatStyle.WallpaperPreset.SOLID_PACIFIC -> SingleColorChatWallpaper.PACIFIC
ChatStyle.WallpaperPreset.SOLID_FROST -> SingleColorChatWallpaper.FROST
ChatStyle.WallpaperPreset.SOLID_NAVY -> SingleColorChatWallpaper.NAVY
ChatStyle.WallpaperPreset.SOLID_LILAC -> SingleColorChatWallpaper.LILAC
ChatStyle.WallpaperPreset.SOLID_PINK -> SingleColorChatWallpaper.PINK
ChatStyle.WallpaperPreset.SOLID_EGGPLANT -> SingleColorChatWallpaper.EGGPLANT
ChatStyle.WallpaperPreset.SOLID_SILVER -> SingleColorChatWallpaper.SILVER
ChatStyle.WallpaperPreset.GRADIENT_SUNSET -> GradientChatWallpaper.SUNSET
ChatStyle.WallpaperPreset.GRADIENT_NOIR -> GradientChatWallpaper.NOIR
ChatStyle.WallpaperPreset.GRADIENT_HEATMAP -> GradientChatWallpaper.HEATMAP
ChatStyle.WallpaperPreset.GRADIENT_AQUA -> GradientChatWallpaper.AQUA
ChatStyle.WallpaperPreset.GRADIENT_IRIDESCENT -> GradientChatWallpaper.IRIDESCENT
ChatStyle.WallpaperPreset.GRADIENT_MONSTERA -> GradientChatWallpaper.MONSTERA
ChatStyle.WallpaperPreset.GRADIENT_BLISS -> GradientChatWallpaper.BLISS
ChatStyle.WallpaperPreset.GRADIENT_SKY -> GradientChatWallpaper.SKY
ChatStyle.WallpaperPreset.GRADIENT_PEACH -> GradientChatWallpaper.PEACH
else -> null
}
}
fun ChatStyle.parseChatWallpaper(wallpaperAttachmentId: AttachmentId?): ChatWallpaper? {
val chatWallpaper = if (this.wallpaperPreset != null) {
this.wallpaperPreset.toLocal()
} else if (wallpaperAttachmentId != null) {
UriChatWallpaper(PartAuthority.getAttachmentDataUri(wallpaperAttachmentId), 0f)
} else {
null
}
return if (chatWallpaper != null && this.dimWallpaperInDarkMode) {
ChatWallpaperFactory.updateWithDimming(chatWallpaper, ChatWallpaper.FIXED_DIM_LEVEL_FOR_DARK_THEME)
} else {
chatWallpaper
}
}
private fun Int.toRemoteWallpaperPreset(): ChatStyle.WallpaperPreset {
return when (this) {
SingleColorChatWallpaper.BLUSH.color -> ChatStyle.WallpaperPreset.SOLID_BLUSH
SingleColorChatWallpaper.COPPER.color -> ChatStyle.WallpaperPreset.SOLID_COPPER
SingleColorChatWallpaper.DUST.color -> ChatStyle.WallpaperPreset.SOLID_DUST
SingleColorChatWallpaper.CELADON.color -> ChatStyle.WallpaperPreset.SOLID_CELADON
SingleColorChatWallpaper.RAINFOREST.color -> ChatStyle.WallpaperPreset.SOLID_RAINFOREST
SingleColorChatWallpaper.PACIFIC.color -> ChatStyle.WallpaperPreset.SOLID_PACIFIC
SingleColorChatWallpaper.FROST.color -> ChatStyle.WallpaperPreset.SOLID_FROST
SingleColorChatWallpaper.NAVY.color -> ChatStyle.WallpaperPreset.SOLID_NAVY
SingleColorChatWallpaper.LILAC.color -> ChatStyle.WallpaperPreset.SOLID_LILAC
SingleColorChatWallpaper.PINK.color -> ChatStyle.WallpaperPreset.SOLID_PINK
SingleColorChatWallpaper.EGGPLANT.color -> ChatStyle.WallpaperPreset.SOLID_EGGPLANT
SingleColorChatWallpaper.SILVER.color -> ChatStyle.WallpaperPreset.SOLID_SILVER
else -> ChatStyle.WallpaperPreset.UNKNOWN_WALLPAPER_PRESET
}
}
private fun Wallpaper.LinearGradient.toRemoteWallpaperPreset(): ChatStyle.WallpaperPreset {
val colorArray = colors.toIntArray()
return when {
colorArray contentEquals GradientChatWallpaper.SUNSET.colors -> ChatStyle.WallpaperPreset.GRADIENT_SUNSET
colorArray contentEquals GradientChatWallpaper.NOIR.colors -> ChatStyle.WallpaperPreset.GRADIENT_NOIR
colorArray contentEquals GradientChatWallpaper.HEATMAP.colors -> ChatStyle.WallpaperPreset.GRADIENT_HEATMAP
colorArray contentEquals GradientChatWallpaper.AQUA.colors -> ChatStyle.WallpaperPreset.GRADIENT_AQUA
colorArray contentEquals GradientChatWallpaper.IRIDESCENT.colors -> ChatStyle.WallpaperPreset.GRADIENT_IRIDESCENT
colorArray contentEquals GradientChatWallpaper.MONSTERA.colors -> ChatStyle.WallpaperPreset.GRADIENT_MONSTERA
colorArray contentEquals GradientChatWallpaper.BLISS.colors -> ChatStyle.WallpaperPreset.GRADIENT_BLISS
colorArray contentEquals GradientChatWallpaper.SKY.colors -> ChatStyle.WallpaperPreset.GRADIENT_SKY
colorArray contentEquals GradientChatWallpaper.PEACH.colors -> ChatStyle.WallpaperPreset.GRADIENT_PEACH
else -> ChatStyle.WallpaperPreset.UNKNOWN_WALLPAPER_PRESET
}
}
private fun Wallpaper.File.toFilePointer(db: SignalDatabase): FilePointer? {
val attachmentId: AttachmentId = UriUtil.parseOrNull(this.uri)?.let { PartUriParser(it).partId } ?: return null
val attachment = db.attachmentTable.getAttachment(attachmentId)
return attachment?.toRemoteFilePointer(mediaArchiveEnabled = true)
}

View File

@@ -1,112 +0,0 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2.util
import org.thoughtcrime.securesms.backup.v2.ImportState
import org.thoughtcrime.securesms.backup.v2.proto.ChatStyle
import org.thoughtcrime.securesms.conversation.colors.ChatColors
import org.thoughtcrime.securesms.conversation.colors.ChatColorsPalette
// TODO [backup] Passing in chatColorId probably unnecessary. Only stored as separate column in recipient table for querying, I believe.
object BackupConverters {
fun constructRemoteChatStyle(chatColors: ChatColors?, chatColorId: ChatColors.Id): ChatStyle? {
var chatStyleBuilder: ChatStyle.Builder? = null
if (chatColors != null) {
chatStyleBuilder = ChatStyle.Builder()
when (chatColorId) {
ChatColors.Id.NotSet -> {}
ChatColors.Id.Auto -> {
chatStyleBuilder.autoBubbleColor = ChatStyle.AutomaticBubbleColor()
}
ChatColors.Id.BuiltIn -> {
chatStyleBuilder.bubbleColorPreset = chatColors.toRemote()
}
is ChatColors.Id.Custom -> {
chatStyleBuilder.customColorId = chatColorId.longValue
}
}
}
// TODO [backup] wallpaper
return chatStyleBuilder?.build()
}
}
fun ChatStyle.toLocal(importState: ImportState): ChatColors? {
if (this.bubbleColorPreset != null) {
return when (this.bubbleColorPreset) {
ChatStyle.BubbleColorPreset.SOLID_CRIMSON -> ChatColorsPalette.Bubbles.CRIMSON
ChatStyle.BubbleColorPreset.SOLID_VERMILION -> ChatColorsPalette.Bubbles.VERMILION
ChatStyle.BubbleColorPreset.SOLID_BURLAP -> ChatColorsPalette.Bubbles.BURLAP
ChatStyle.BubbleColorPreset.SOLID_FOREST -> ChatColorsPalette.Bubbles.FOREST
ChatStyle.BubbleColorPreset.SOLID_WINTERGREEN -> ChatColorsPalette.Bubbles.WINTERGREEN
ChatStyle.BubbleColorPreset.SOLID_TEAL -> ChatColorsPalette.Bubbles.TEAL
ChatStyle.BubbleColorPreset.SOLID_BLUE -> ChatColorsPalette.Bubbles.BLUE
ChatStyle.BubbleColorPreset.SOLID_INDIGO -> ChatColorsPalette.Bubbles.INDIGO
ChatStyle.BubbleColorPreset.SOLID_VIOLET -> ChatColorsPalette.Bubbles.VIOLET
ChatStyle.BubbleColorPreset.SOLID_PLUM -> ChatColorsPalette.Bubbles.PLUM
ChatStyle.BubbleColorPreset.SOLID_TAUPE -> ChatColorsPalette.Bubbles.TAUPE
ChatStyle.BubbleColorPreset.SOLID_STEEL -> ChatColorsPalette.Bubbles.STEEL
ChatStyle.BubbleColorPreset.GRADIENT_EMBER -> ChatColorsPalette.Bubbles.EMBER
ChatStyle.BubbleColorPreset.GRADIENT_MIDNIGHT -> ChatColorsPalette.Bubbles.MIDNIGHT
ChatStyle.BubbleColorPreset.GRADIENT_INFRARED -> ChatColorsPalette.Bubbles.INFRARED
ChatStyle.BubbleColorPreset.GRADIENT_LAGOON -> ChatColorsPalette.Bubbles.LAGOON
ChatStyle.BubbleColorPreset.GRADIENT_FLUORESCENT -> ChatColorsPalette.Bubbles.FLUORESCENT
ChatStyle.BubbleColorPreset.GRADIENT_BASIL -> ChatColorsPalette.Bubbles.BASIL
ChatStyle.BubbleColorPreset.GRADIENT_SUBLIME -> ChatColorsPalette.Bubbles.SUBLIME
ChatStyle.BubbleColorPreset.GRADIENT_SEA -> ChatColorsPalette.Bubbles.SEA
ChatStyle.BubbleColorPreset.GRADIENT_TANGERINE -> ChatColorsPalette.Bubbles.TANGERINE
ChatStyle.BubbleColorPreset.UNKNOWN_BUBBLE_COLOR_PRESET, ChatStyle.BubbleColorPreset.SOLID_ULTRAMARINE -> ChatColorsPalette.Bubbles.ULTRAMARINE
}
}
if (this.autoBubbleColor != null) {
return ChatColorsPalette.Bubbles.default.withId(ChatColors.Id.Auto)
}
if (this.customColorId != null) {
return importState.remoteToLocalColorId[this.customColorId]?.let { localId ->
val colorId = ChatColors.Id.forLongValue(localId)
ChatColorsPalette.Bubbles.default.withId(colorId)
}
}
return null
}
fun ChatColors.toRemote(): ChatStyle.BubbleColorPreset? {
when (this) {
// Solids
ChatColorsPalette.Bubbles.CRIMSON -> return ChatStyle.BubbleColorPreset.SOLID_CRIMSON
ChatColorsPalette.Bubbles.VERMILION -> return ChatStyle.BubbleColorPreset.SOLID_VERMILION
ChatColorsPalette.Bubbles.BURLAP -> return ChatStyle.BubbleColorPreset.SOLID_BURLAP
ChatColorsPalette.Bubbles.FOREST -> return ChatStyle.BubbleColorPreset.SOLID_FOREST
ChatColorsPalette.Bubbles.WINTERGREEN -> return ChatStyle.BubbleColorPreset.SOLID_WINTERGREEN
ChatColorsPalette.Bubbles.TEAL -> return ChatStyle.BubbleColorPreset.SOLID_TEAL
ChatColorsPalette.Bubbles.BLUE -> return ChatStyle.BubbleColorPreset.SOLID_BLUE
ChatColorsPalette.Bubbles.INDIGO -> return ChatStyle.BubbleColorPreset.SOLID_INDIGO
ChatColorsPalette.Bubbles.VIOLET -> return ChatStyle.BubbleColorPreset.SOLID_VIOLET
ChatColorsPalette.Bubbles.PLUM -> return ChatStyle.BubbleColorPreset.SOLID_PLUM
ChatColorsPalette.Bubbles.TAUPE -> return ChatStyle.BubbleColorPreset.SOLID_TAUPE
ChatColorsPalette.Bubbles.STEEL -> return ChatStyle.BubbleColorPreset.SOLID_STEEL
ChatColorsPalette.Bubbles.ULTRAMARINE -> return ChatStyle.BubbleColorPreset.SOLID_ULTRAMARINE
// Gradients
ChatColorsPalette.Bubbles.EMBER -> return ChatStyle.BubbleColorPreset.GRADIENT_EMBER
ChatColorsPalette.Bubbles.MIDNIGHT -> return ChatStyle.BubbleColorPreset.GRADIENT_MIDNIGHT
ChatColorsPalette.Bubbles.INFRARED -> return ChatStyle.BubbleColorPreset.GRADIENT_INFRARED
ChatColorsPalette.Bubbles.LAGOON -> return ChatStyle.BubbleColorPreset.GRADIENT_LAGOON
ChatColorsPalette.Bubbles.FLUORESCENT -> return ChatStyle.BubbleColorPreset.GRADIENT_FLUORESCENT
ChatColorsPalette.Bubbles.BASIL -> return ChatStyle.BubbleColorPreset.GRADIENT_BASIL
ChatColorsPalette.Bubbles.SUBLIME -> return ChatStyle.BubbleColorPreset.GRADIENT_SUBLIME
ChatColorsPalette.Bubbles.SEA -> return ChatStyle.BubbleColorPreset.GRADIENT_SEA
ChatColorsPalette.Bubbles.TANGERINE -> return ChatStyle.BubbleColorPreset.GRADIENT_TANGERINE
}
return null
}

View File

@@ -5,44 +5,33 @@
package org.thoughtcrime.securesms.banner
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.runtime.Composable
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import org.signal.core.util.logging.Log
/**
* This class represents a banner across the top of the screen.
*
* Typically, a class will subclass [Banner] and have a nested class that subclasses [BannerFactory].
* The constructor for an implementation of [Banner] should be very lightweight, as it is may be called frequently.
* Banners are submitted to a [BannerManager], which will render the first [enabled] Banner in it's list.
* After a Banner is selected, the [BannerManager] will listen to the [dataFlow] and use the emitted [Model]s to render the [DisplayBanner] composable.
*/
abstract class Banner {
companion object {
private val TAG = Log.tag(Banner::class)
/**
* A helper function to create a [Flow] of a [Banner].
*
* @param bannerFactory a block the produces a [Banner], or null. Returning null will complete the [Flow] without emitting any values.
*/
@JvmStatic
fun <T : Banner> createAndEmit(bannerFactory: () -> T): Flow<T> {
return bannerFactory().let {
flow { emit(it) }
}
}
}
abstract class Banner<Model> {
/**
* Whether or not the [Banner] should be shown (enabled) or hidden (disabled).
* Whether or not the [Banner] is eligible for display. This is read on the main thread and therefore should be very fast.
*/
abstract val enabled: Boolean
/**
* Composable function to display content when [enabled] is true.
*
* @see [org.thoughtcrime.securesms.banner.ui.compose.DefaultBanner]
* A [Flow] that emits the model to be displayed in the [DisplayBanner] composable.
* This flow will only be subscribed to if the banner is [enabled].
*/
abstract val dataFlow: Flow<Model>
/**
* Composable function to display the content emitted from [dataFlow].
* You likely want to use [org.thoughtcrime.securesms.banner.ui.compose.DefaultBanner].
*/
@Composable
abstract fun DisplayBanner()
abstract fun DisplayBanner(model: Model, contentPadding: PaddingValues)
}

View File

@@ -6,11 +6,15 @@
package org.thoughtcrime.securesms.banner
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.ViewCompositionStrategy
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import org.signal.core.ui.theme.SignalTheme
import org.signal.core.util.logging.Log
/**
@@ -18,7 +22,7 @@ import org.signal.core.util.logging.Log
* Usually, the [Flow]s will come from [Banner.BannerFactory] instances, but may also be produced by the other properties of the host.
*/
class BannerManager @JvmOverloads constructor(
allFlows: Iterable<Flow<Banner>>,
private val banners: List<Banner<*>>,
private val onNewBannerShownListener: () -> Unit = {},
private val onNoBannerShownListener: () -> Unit = {}
) {
@@ -28,33 +32,31 @@ class BannerManager @JvmOverloads constructor(
}
/**
* Takes the flows and combines them into one so that a new [Flow] value from any of them will trigger an update to the UI.
*
* **NOTE**: This will **not** emit its first value until **all** of the input flows have each emitted *at least one value*.
* Re-evaluates the [Banner]s, choosing one to render (if any) and updating the view.
*/
private val combinedFlow: Flow<List<Banner>> = combine(allFlows) { banners: Array<Banner> ->
banners.filter { it.enabled }.toList()
}
fun updateContent(composeView: ComposeView) {
val banner: Banner<Any>? = banners.firstOrNull { it.enabled } as Banner<Any>?
/**
* Sets the content of the provided [ComposeView] to one that consumes the lists emitted by [combinedFlow] and displays them.
*/
fun setContent(composeView: ComposeView) {
composeView.apply {
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
setContent {
val state = combinedFlow.collectAsStateWithLifecycle(initialValue = emptyList())
if (banner == null) {
onNoBannerShownListener()
return@setContent
}
val bannerToDisplay = state.value.firstOrNull()
if (bannerToDisplay != null) {
Box {
bannerToDisplay.DisplayBanner()
val state: State<Any?> = banner.dataFlow.collectAsStateWithLifecycle(initialValue = null)
val bannerState by state
bannerState?.let { model ->
SignalTheme {
Box {
banner.DisplayBanner(model, PaddingValues(horizontal = 12.dp, vertical = 8.dp))
}
}
onNewBannerShownListener()
} else {
onNoBannerShownListener()
}
} ?: onNoBannerShownListener()
}
}
}

View File

@@ -1,24 +0,0 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.banner
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
abstract class DismissibleBannerProducer<T : Banner>(bannerProducer: (dismissListener: () -> Unit) -> T) {
abstract fun createDismissedBanner(): T
private val mutableSharedFlow: MutableSharedFlow<T> = MutableSharedFlow(replay = 1)
private val dismissListener = {
mutableSharedFlow.tryEmit(createDismissedBanner())
}
init {
mutableSharedFlow.tryEmit(bannerProducer(dismissListener))
}
val flow: Flow<T> = mutableSharedFlow
}

View File

@@ -6,50 +6,54 @@
package org.thoughtcrime.securesms.banner.banners
import android.os.Build
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
import org.signal.core.ui.Previews
import org.signal.core.ui.SignalPreview
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.banner.Banner
import org.thoughtcrime.securesms.banner.DismissibleBannerProducer
import org.thoughtcrime.securesms.banner.ui.compose.Action
import org.thoughtcrime.securesms.banner.ui.compose.DefaultBanner
import org.thoughtcrime.securesms.keyvalue.SignalStore
class BubbleOptOutBanner(inBubble: Boolean, private val actionListener: (Boolean) -> Unit) : Banner() {
class BubbleOptOutBanner(private val inBubble: Boolean, private val actionListener: (Boolean) -> Unit) : Banner<Unit>() {
override val enabled: Boolean = inBubble && !SignalStore.tooltips.hasSeenBubbleOptOutTooltip() && Build.VERSION.SDK_INT > 29
override val enabled: Boolean
get() = inBubble && !SignalStore.tooltips.hasSeenBubbleOptOutTooltip() && Build.VERSION.SDK_INT > 29
override val dataFlow: Flow<Unit>
get() = flowOf(Unit)
@Composable
override fun DisplayBanner() {
DefaultBanner(
title = null,
body = stringResource(id = R.string.BubbleOptOutTooltip__description),
actions = listOf(
Action(R.string.BubbleOptOutTooltip__turn_off) {
actionListener(true)
},
Action(R.string.BubbleOptOutTooltip__not_now) {
actionListener(false)
}
)
)
}
override fun DisplayBanner(model: Unit, contentPadding: PaddingValues) = Banner(contentPadding, actionListener)
}
private class Producer(inBubble: Boolean, actionListener: (Boolean) -> Unit) : DismissibleBannerProducer<BubbleOptOutBanner>(bannerProducer = {
BubbleOptOutBanner(inBubble) { turnOffBubbles ->
actionListener(turnOffBubbles)
it()
}
}) {
override fun createDismissedBanner(): BubbleOptOutBanner {
return BubbleOptOutBanner(false) {}
}
}
@Composable
private fun Banner(contentPadding: PaddingValues, actionListener: (Boolean) -> Unit = {}) {
DefaultBanner(
title = null,
body = stringResource(id = R.string.BubbleOptOutTooltip__description),
actions = listOf(
Action(R.string.BubbleOptOutTooltip__turn_off) {
actionListener(true)
},
Action(R.string.BubbleOptOutTooltip__not_now) {
actionListener(false)
}
),
paddingValues = contentPadding
)
}
companion object {
fun createFlow(inBubble: Boolean, actionListener: (Boolean) -> Unit): Flow<BubbleOptOutBanner> {
return Producer(inBubble, actionListener).flow
}
@SignalPreview
@Composable
private fun BannerPreview() {
Previews.Preview {
Banner(PaddingValues(0.dp))
}
}

View File

@@ -5,10 +5,14 @@
package org.thoughtcrime.securesms.banner.banners
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.fragment.app.FragmentManager
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
import org.signal.core.ui.Previews
import org.signal.core.ui.SignalPreview
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.banner.Banner
import org.thoughtcrime.securesms.banner.ui.compose.Action
@@ -18,36 +22,53 @@ import org.thoughtcrime.securesms.contacts.sync.CdsPermanentErrorBottomSheet
import org.thoughtcrime.securesms.keyvalue.SignalStore
import kotlin.time.Duration.Companion.days
class CdsPermanentErrorBanner(private val fragmentManager: FragmentManager) : Banner() {
private val timeUntilUnblock = SignalStore.misc.cdsBlockedUtil - System.currentTimeMillis()
override val enabled: Boolean = SignalStore.misc.isCdsBlocked && timeUntilUnblock >= PERMANENT_TIME_CUTOFF
@Composable
override fun DisplayBanner() {
DefaultBanner(
title = null,
body = stringResource(id = R.string.reminder_cds_permanent_error_body),
importance = Importance.ERROR,
actions = listOf(
Action(R.string.reminder_cds_permanent_error_learn_more) {
CdsPermanentErrorBottomSheet.show(fragmentManager)
}
)
)
}
class CdsPermanentErrorBanner(private val fragmentManager: FragmentManager) : Banner<Unit>() {
companion object {
/**
* Even if we're not truly "permanently blocked", if the time until we're unblocked is long enough, we'd rather show the permanent error message than
* telling the user to wait for 3 months or something.
*/
val PERMANENT_TIME_CUTOFF = 30.days.inWholeMilliseconds
}
@JvmStatic
fun createFlow(childFragmentManager: FragmentManager): Flow<CdsPermanentErrorBanner> = createAndEmit {
CdsPermanentErrorBanner(childFragmentManager)
override val enabled: Boolean
get() {
val timeUntilUnblock = SignalStore.misc.cdsBlockedUtil - System.currentTimeMillis()
return SignalStore.misc.isCdsBlocked && timeUntilUnblock >= PERMANENT_TIME_CUTOFF
}
override val dataFlow
get() = flowOf(Unit)
@Composable
override fun DisplayBanner(model: Unit, contentPadding: PaddingValues) {
Banner(
contentPadding = contentPadding,
onLearnMoreClicked = { CdsPermanentErrorBottomSheet.show(fragmentManager) }
)
}
}
@Composable
private fun Banner(contentPadding: PaddingValues, onLearnMoreClicked: () -> Unit = {}) {
DefaultBanner(
title = null,
body = stringResource(id = R.string.reminder_cds_permanent_error_body),
importance = Importance.ERROR,
actions = listOf(
Action(R.string.reminder_cds_permanent_error_learn_more) {
onLearnMoreClicked()
}
),
paddingValues = contentPadding
)
}
@SignalPreview
@Composable
private fun BannerPreview() {
Previews.Preview {
Banner(PaddingValues(0.dp))
}
}

View File

@@ -5,10 +5,14 @@
package org.thoughtcrime.securesms.banner.banners
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.fragment.app.FragmentManager
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
import org.signal.core.ui.Previews
import org.signal.core.ui.SignalPreview
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.banner.Banner
import org.thoughtcrime.securesms.banner.ui.compose.Action
@@ -17,30 +21,45 @@ import org.thoughtcrime.securesms.banner.ui.compose.Importance
import org.thoughtcrime.securesms.contacts.sync.CdsTemporaryErrorBottomSheet
import org.thoughtcrime.securesms.keyvalue.SignalStore
class CdsTemporaryErrorBanner(private val fragmentManager: FragmentManager) : Banner() {
private val timeUntilUnblock = SignalStore.misc.cdsBlockedUtil - System.currentTimeMillis()
class CdsTemporaryErrorBanner(private val fragmentManager: FragmentManager) : Banner<Unit>() {
override val enabled: Boolean = SignalStore.misc.isCdsBlocked && timeUntilUnblock < CdsPermanentErrorBanner.PERMANENT_TIME_CUTOFF
override val enabled: Boolean
get() {
val timeUntilUnblock = SignalStore.misc.cdsBlockedUtil - System.currentTimeMillis()
return SignalStore.misc.isCdsBlocked && timeUntilUnblock < CdsPermanentErrorBanner.PERMANENT_TIME_CUTOFF
}
override val dataFlow
get() = flowOf(Unit)
@Composable
override fun DisplayBanner() {
DefaultBanner(
title = null,
body = stringResource(id = R.string.reminder_cds_warning_body),
importance = Importance.ERROR,
actions = listOf(
Action(R.string.reminder_cds_warning_learn_more) {
CdsTemporaryErrorBottomSheet.show(fragmentManager)
}
)
override fun DisplayBanner(model: Unit, contentPadding: PaddingValues) {
Banner(
contentPadding = contentPadding,
onLearnMoreClicked = { CdsTemporaryErrorBottomSheet.show(fragmentManager) }
)
}
}
companion object {
@Composable
private fun Banner(contentPadding: PaddingValues, onLearnMoreClicked: () -> Unit = {}) {
DefaultBanner(
title = null,
body = stringResource(id = R.string.reminder_cds_warning_body),
importance = Importance.ERROR,
actions = listOf(
Action(R.string.reminder_cds_warning_learn_more) {
onLearnMoreClicked()
}
),
paddingValues = contentPadding
)
}
@JvmStatic
fun createFlow(childFragmentManager: FragmentManager): Flow<CdsTemporaryErrorBanner> = createAndEmit {
CdsTemporaryErrorBanner(childFragmentManager)
}
@SignalPreview
@Composable
private fun BannerPreview() {
Previews.Preview {
Banner(PaddingValues(0.dp))
}
}

View File

@@ -0,0 +1,69 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.banner.banners
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
import org.signal.core.ui.Previews
import org.signal.core.ui.SignalPreview
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.banner.Banner
import org.thoughtcrime.securesms.banner.ui.compose.Action
import org.thoughtcrime.securesms.banner.ui.compose.DefaultBanner
import org.thoughtcrime.securesms.banner.ui.compose.Importance
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.PlayStoreUtil
/**
* Shown when a build is actively deprecated and unable to connect to the service.
*/
class DeprecatedBuildBanner : Banner<Unit>() {
override val enabled: Boolean
get() = SignalStore.misc.isClientDeprecated
override val dataFlow: Flow<Unit>
get() = flowOf(Unit)
@Composable
override fun DisplayBanner(model: Unit, contentPadding: PaddingValues) {
val context = LocalContext.current
Banner(
contentPadding = contentPadding,
onUpdateClicked = {
PlayStoreUtil.openPlayStoreOrOurApkDownloadPage(context)
}
)
}
}
@Composable
private fun Banner(contentPadding: PaddingValues, onUpdateClicked: () -> Unit = {}) {
DefaultBanner(
title = null,
body = stringResource(id = R.string.ExpiredBuildReminder_this_version_of_signal_has_expired),
importance = Importance.ERROR,
actions = listOf(
Action(R.string.ExpiredBuildReminder_update_now) {
onUpdateClicked()
}
),
paddingValues = contentPadding
)
}
@SignalPreview
@Composable
private fun BannerPreview() {
Previews.Preview {
Banner(contentPadding = PaddingValues(0.dp))
}
}

View File

@@ -7,12 +7,16 @@ package org.thoughtcrime.securesms.banner.banners
import android.content.Context
import android.os.Build
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
import org.signal.core.ui.Previews
import org.signal.core.ui.SignalPreview
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.banner.Banner
import org.thoughtcrime.securesms.banner.DismissibleBannerProducer
import org.thoughtcrime.securesms.banner.ui.compose.Action
import org.thoughtcrime.securesms.banner.ui.compose.DefaultBanner
import org.thoughtcrime.securesms.keyvalue.SignalStore
@@ -20,43 +24,52 @@ import org.thoughtcrime.securesms.util.PowerManagerCompat
import org.thoughtcrime.securesms.util.ServiceUtil
import org.thoughtcrime.securesms.util.TextSecurePreferences
class DozeBanner(private val context: Context, val dismissed: Boolean, private val onDismiss: () -> Unit) : Banner() {
override val enabled: Boolean = !dismissed &&
Build.VERSION.SDK_INT >= 23 && !SignalStore.account.fcmEnabled && !TextSecurePreferences.hasPromptedOptimizeDoze(context) && !ServiceUtil.getPowerManager(context).isIgnoringBatteryOptimizations(context.packageName)
class DozeBanner(private val context: Context) : Banner<Unit>() {
override val enabled: Boolean
get() = Build.VERSION.SDK_INT >= 23 && !SignalStore.account.fcmEnabled && !TextSecurePreferences.hasPromptedOptimizeDoze(context) && !ServiceUtil.getPowerManager(context).isIgnoringBatteryOptimizations(context.packageName)
override val dataFlow: Flow<Unit>
get() = flowOf(Unit)
@Composable
override fun DisplayBanner() {
override fun DisplayBanner(model: Unit, contentPadding: PaddingValues) {
if (Build.VERSION.SDK_INT < 23) {
throw IllegalStateException("Showing a Doze banner for an OS prior to Android 6.0")
}
DefaultBanner(
title = stringResource(id = R.string.DozeReminder_optimize_for_missing_play_services),
body = stringResource(id = R.string.DozeReminder_this_device_does_not_support_play_services_tap_to_disable_system_battery),
actions = listOf(
Action(android.R.string.ok) {
TextSecurePreferences.setPromptedOptimizeDoze(context, true)
PowerManagerCompat.requestIgnoreBatteryOptimizations(context)
}
),
Banner(
contentPadding = contentPadding,
onDismissListener = {
TextSecurePreferences.setPromptedOptimizeDoze(context, true)
onDismiss()
},
onOkListener = {
TextSecurePreferences.setPromptedOptimizeDoze(context, true)
PowerManagerCompat.requestIgnoreBatteryOptimizations(context)
}
)
}
}
private class Producer(private val context: Context) : DismissibleBannerProducer<DozeBanner>(bannerProducer = {
DozeBanner(context = context, dismissed = false, onDismiss = it)
}) {
override fun createDismissedBanner(): DozeBanner {
return DozeBanner(context, true) {}
}
}
@Composable
private fun Banner(contentPadding: PaddingValues, onDismissListener: () -> Unit = {}, onOkListener: () -> Unit = {}) {
DefaultBanner(
title = stringResource(id = R.string.DozeReminder_optimize_for_missing_play_services),
body = stringResource(id = R.string.DozeReminder_this_device_does_not_support_play_services_tap_to_disable_system_battery),
onDismissListener = onDismissListener,
actions = listOf(
Action(android.R.string.ok) {
onOkListener()
}
),
paddingValues = contentPadding
)
}
companion object {
@JvmStatic
fun createFlow(context: Context): Flow<DozeBanner> {
return Producer(context).flow
}
@SignalPreview
@Composable
private fun BannerPreview() {
Previews.Preview {
Banner(contentPadding = PaddingValues(0.dp))
}
}

View File

@@ -5,11 +5,15 @@
package org.thoughtcrime.securesms.banner.banners
import android.content.Context
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.flowOf
import org.signal.core.ui.Previews
import org.signal.core.ui.SignalPreview
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.banner.Banner
import org.thoughtcrime.securesms.banner.ui.compose.Action
@@ -17,27 +21,45 @@ import org.thoughtcrime.securesms.banner.ui.compose.DefaultBanner
import org.thoughtcrime.securesms.banner.ui.compose.Importance
import org.thoughtcrime.securesms.util.PlayStoreUtil
class EnclaveFailureBanner(enclaveFailed: Boolean, private val context: Context) : Banner() {
override val enabled: Boolean = enclaveFailed
class EnclaveFailureBanner(private val enclaveFailed: Boolean) : Banner<Unit>() {
override val enabled: Boolean
get() = enclaveFailed
override val dataFlow: Flow<Unit>
get() = flowOf(Unit)
@Composable
override fun DisplayBanner() {
DefaultBanner(
title = null,
body = stringResource(id = R.string.EnclaveFailureReminder_update_signal),
importance = Importance.ERROR,
actions = listOf(
Action(R.string.ExpiredBuildReminder_update_now) {
PlayStoreUtil.openPlayStoreOrOurApkDownloadPage(context)
}
)
override fun DisplayBanner(model: Unit, contentPadding: PaddingValues) {
val context = LocalContext.current
Banner(
contentPadding = contentPadding,
onUpdateNow = {
PlayStoreUtil.openPlayStoreOrOurApkDownloadPage(context)
}
)
}
}
companion object {
@JvmStatic
fun Flow<Boolean>.mapBooleanFlowToBannerFlow(context: Context): Flow<EnclaveFailureBanner> {
return map { EnclaveFailureBanner(it, context) }
}
@Composable
private fun Banner(contentPadding: PaddingValues, onUpdateNow: () -> Unit = {}) {
DefaultBanner(
title = null,
body = stringResource(id = R.string.EnclaveFailureReminder_update_signal),
importance = Importance.ERROR,
actions = listOf(
Action(R.string.ExpiredBuildReminder_update_now) {
onUpdateNow()
}
),
paddingValues = contentPadding
)
}
@SignalPreview
@Composable
private fun BannerPreview() {
Previews.Preview {
Banner(contentPadding = PaddingValues(0.dp))
}
}

View File

@@ -5,51 +5,75 @@
package org.thoughtcrime.securesms.banner.banners
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
import org.signal.core.ui.Previews
import org.signal.core.ui.SignalPreview
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.banner.Banner
import org.thoughtcrime.securesms.banner.DismissibleBannerProducer
import org.thoughtcrime.securesms.banner.ui.compose.Action
import org.thoughtcrime.securesms.banner.ui.compose.DefaultBanner
class GroupsV1MigrationSuggestionsBanner(private val suggestionsSize: Int, private val onAddMembers: () -> Unit, private val onNoThanks: () -> Unit) : Banner() {
override val enabled: Boolean = suggestionsSize > 0
/**
* After migrating a group from v1 -> v2, this banner is used to show suggestions for members to add who couldn't be added automatically.
* Intended to be shown only in a conversation.
*/
class GroupsV1MigrationSuggestionsBanner(
private val suggestionsSize: Int,
private val onAddMembers: () -> Unit,
private val onNoThanks: () -> Unit
) : Banner<Int>() {
override val enabled: Boolean
get() = suggestionsSize > 0
override val dataFlow: Flow<Int>
get() = flowOf(suggestionsSize)
@Composable
override fun DisplayBanner() {
DefaultBanner(
title = null,
body = pluralStringResource(
id = R.plurals.GroupsV1MigrationSuggestionsReminder_members_couldnt_be_added_to_the_new_group,
count = suggestionsSize,
suggestionsSize
),
actions = listOf(
Action(R.plurals.GroupsV1MigrationSuggestionsReminder_add_members, isPluralizedLabel = true, pluralQuantity = suggestionsSize, onAddMembers),
Action(R.string.GroupsV1MigrationSuggestionsReminder_no_thanks, onClick = onNoThanks)
)
override fun DisplayBanner(model: Int, contentPadding: PaddingValues) {
Banner(
contentPadding = contentPadding,
suggestionsSize = model,
onAddMembers = onAddMembers,
onNoThanks = onNoThanks
)
}
}
private class Producer(suggestionsSize: Int, onAddMembers: () -> Unit, onNoThanks: () -> Unit) : DismissibleBannerProducer<GroupsV1MigrationSuggestionsBanner>(bannerProducer = {
GroupsV1MigrationSuggestionsBanner(
suggestionsSize,
onAddMembers
) {
onNoThanks()
it()
}
}) {
override fun createDismissedBanner(): GroupsV1MigrationSuggestionsBanner {
return GroupsV1MigrationSuggestionsBanner(0, {}, {})
}
}
@Composable
private fun Banner(contentPadding: PaddingValues, suggestionsSize: Int, onAddMembers: () -> Unit = {}, onNoThanks: () -> Unit = {}) {
DefaultBanner(
title = null,
body = pluralStringResource(
id = R.plurals.GroupsV1MigrationSuggestionsReminder_members_couldnt_be_added_to_the_new_group,
count = suggestionsSize,
suggestionsSize
),
actions = listOf(
Action(R.plurals.GroupsV1MigrationSuggestionsReminder_add_members, isPluralizedLabel = true, pluralQuantity = suggestionsSize, onAddMembers),
Action(R.string.GroupsV1MigrationSuggestionsReminder_no_thanks, onClick = onNoThanks)
),
paddingValues = contentPadding
)
}
companion object {
fun createFlow(suggestionsSize: Int, onAddMembers: () -> Unit, onNoThanks: () -> Unit): Flow<GroupsV1MigrationSuggestionsBanner> {
return Producer(suggestionsSize, onAddMembers, onNoThanks).flow
}
@SignalPreview
@Composable
private fun BannerPreviewSingular() {
Previews.Preview {
Banner(contentPadding = PaddingValues(0.dp), suggestionsSize = 1)
}
}
@SignalPreview
@Composable
private fun BannerPreviewPlural() {
Previews.Preview {
Banner(contentPadding = PaddingValues(0.dp), suggestionsSize = 2)
}
}

View File

@@ -5,18 +5,16 @@
package org.thoughtcrime.securesms.banner.banners
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.runtime.Composable
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.signal.core.util.logging.Log
import org.signal.core.util.throttleLatest
import org.thoughtcrime.securesms.backup.v2.ui.status.BackupStatus
import org.thoughtcrime.securesms.backup.v2.ui.status.BackupStatusData
import org.thoughtcrime.securesms.banner.Banner
@@ -24,68 +22,46 @@ import org.thoughtcrime.securesms.database.DatabaseObserver
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore
import kotlin.time.Duration.Companion.seconds
class MediaRestoreProgressBanner(private val data: MediaRestoreEvent) : Banner() {
class MediaRestoreProgressBanner : Banner<BackupStatusData>() {
companion object {
private val TAG = Log.tag(MediaRestoreProgressBanner::class)
override val enabled: Boolean
get() = SignalStore.backup.isRestoreInProgress
/**
* Create a Lifecycle-aware [Flow] of [MediaRestoreProgressBanner] that observes the database for changes in attachments and emits banners when attachments are updated.
*/
@JvmStatic
fun createLifecycleAwareFlow(lifecycleOwner: LifecycleOwner): Flow<MediaRestoreProgressBanner> {
if (SignalStore.backup.isRestoreInProgress) {
val observer = LifecycleObserver()
lifecycleOwner.lifecycle.addObserver(observer)
return observer.flow
} else {
return flow {
emit(MediaRestoreProgressBanner(MediaRestoreEvent(0L, 0L)))
override val dataFlow: Flow<BackupStatusData>
get() {
if (!SignalStore.backup.isRestoreInProgress) {
return flowOf(BackupStatusData.RestoringMedia(0, 0))
}
val dbNotificationFlow = callbackFlow {
val queryObserver = DatabaseObserver.Observer {
trySend(Unit)
}
queryObserver.onChanged()
AppDependencies.databaseObserver.registerAttachmentUpdatedObserver(queryObserver)
awaitClose {
AppDependencies.databaseObserver.unregisterObserver(queryObserver)
}
}
}
}
override var enabled: Boolean = data.totalBytes > 0L && data.totalBytes != data.completedBytes
return dbNotificationFlow
.throttleLatest(1.seconds)
.map {
val totalRestoreSize = SignalStore.backup.totalRestorableAttachmentSize
val remainingAttachmentSize = SignalDatabase.attachments.getRemainingRestorableAttachmentSize()
val completedBytes = totalRestoreSize - remainingAttachmentSize
BackupStatusData.RestoringMedia(completedBytes, totalRestoreSize)
}
.flowOn(Dispatchers.IO)
}
@Composable
override fun DisplayBanner() {
BackupStatus(data = BackupStatusData.RestoringMedia(data.completedBytes, data.totalBytes))
}
data class MediaRestoreEvent(val completedBytes: Long, val totalBytes: Long)
private class LifecycleObserver : DefaultLifecycleObserver {
private var attachmentObserver: DatabaseObserver.Observer? = null
private val _mutableSharedFlow = MutableSharedFlow<MediaRestoreEvent>(replay = 1)
val flow = _mutableSharedFlow.map { MediaRestoreProgressBanner(it) }
override fun onStart(owner: LifecycleOwner) {
val queryObserver = DatabaseObserver.Observer {
owner.lifecycleScope.launch {
_mutableSharedFlow.emit(loadData())
}
}
attachmentObserver = queryObserver
queryObserver.onChanged()
AppDependencies.databaseObserver.registerAttachmentObserver(queryObserver)
}
override fun onStop(owner: LifecycleOwner) {
attachmentObserver?.let {
AppDependencies.databaseObserver.unregisterObserver(it)
}
}
private suspend fun loadData() = withContext(Dispatchers.IO) {
// TODO [backups]: define and query data for interrupted/paused restores
val totalRestoreSize = SignalStore.backup.totalRestorableAttachmentSize
val remainingAttachmentSize = SignalDatabase.attachments.getTotalRestorableAttachmentSize()
val completedBytes = totalRestoreSize - remainingAttachmentSize
MediaRestoreEvent(completedBytes, totalRestoreSize)
}
override fun DisplayBanner(model: BackupStatusData, contentPadding: PaddingValues) {
BackupStatus(data = model)
}
}

View File

@@ -5,11 +5,16 @@
package org.thoughtcrime.securesms.banner.banners
import android.content.Context
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
import org.signal.core.ui.Previews
import org.signal.core.ui.SignalPreview
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.banner.Banner
import org.thoughtcrime.securesms.banner.ui.compose.Action
@@ -21,71 +26,96 @@ import org.thoughtcrime.securesms.util.Util
import kotlin.time.Duration.Companion.milliseconds
/**
* Banner to let the user know their build is about to expire or has expired.
*
* @param status can be used to filter which conditions are shown.
* Banner to let the user know their build is about to expire.
*/
class OutdatedBuildBanner(val context: Context, private val daysUntilExpiry: Int, private val status: ExpiryStatus) : Banner() {
override val enabled = when (status) {
ExpiryStatus.OUTDATED_ONLY -> SignalStore.misc.isClientDeprecated
ExpiryStatus.EXPIRED_ONLY -> daysUntilExpiry <= MAX_DAYS_UNTIL_EXPIRE
ExpiryStatus.OUTDATED_OR_EXPIRED -> SignalStore.misc.isClientDeprecated || daysUntilExpiry <= MAX_DAYS_UNTIL_EXPIRE
}
@Composable
override fun DisplayBanner() {
val bodyText = when (status) {
ExpiryStatus.OUTDATED_ONLY -> if (daysUntilExpiry == 0) {
stringResource(id = R.string.OutdatedBuildReminder_your_version_of_signal_will_expire_today)
} else {
pluralStringResource(id = R.plurals.OutdatedBuildReminder_your_version_of_signal_will_expire_in_n_days, count = daysUntilExpiry, daysUntilExpiry)
}
ExpiryStatus.EXPIRED_ONLY -> stringResource(id = R.string.OutdatedBuildReminder_your_version_of_signal_will_expire_today)
ExpiryStatus.OUTDATED_OR_EXPIRED -> if (SignalStore.misc.isClientDeprecated) {
stringResource(id = R.string.OutdatedBuildReminder_your_version_of_signal_will_expire_today)
} else if (daysUntilExpiry == 0) {
stringResource(id = R.string.OutdatedBuildReminder_your_version_of_signal_will_expire_today)
} else {
pluralStringResource(id = R.plurals.OutdatedBuildReminder_your_version_of_signal_will_expire_in_n_days, count = daysUntilExpiry, daysUntilExpiry)
}
}
DefaultBanner(
title = null,
body = bodyText,
importance = if (SignalStore.misc.isClientDeprecated) {
Importance.ERROR
} else {
Importance.NORMAL
},
actions = listOf(
Action(R.string.ExpiredBuildReminder_update_now) {
PlayStoreUtil.openPlayStoreOrOurApkDownloadPage(context)
}
)
)
}
/**
* A enumeration for [OutdatedBuildBanner] to limit it to showing either [OUTDATED_ONLY] status, [EXPIRED_ONLY] status, or both.
*
* [OUTDATED_ONLY] refers to builds that are still valid but need to be updated.
* [EXPIRED_ONLY] refers to builds that are no longer allowed to connect to the service.
*/
enum class ExpiryStatus {
OUTDATED_ONLY,
EXPIRED_ONLY,
OUTDATED_OR_EXPIRED
}
class OutdatedBuildBanner : Banner<Int>() {
companion object {
private const val MAX_DAYS_UNTIL_EXPIRE = 10
}
@JvmStatic
fun createFlow(context: Context, status: ExpiryStatus): Flow<OutdatedBuildBanner> = createAndEmit {
override val enabled: Boolean
get() {
val daysUntilExpiry = Util.getTimeUntilBuildExpiry(SignalStore.misc.estimatedServerTime).milliseconds.inWholeDays.toInt()
OutdatedBuildBanner(context, daysUntilExpiry, status)
return daysUntilExpiry <= MAX_DAYS_UNTIL_EXPIRE
}
override val dataFlow: Flow<Int>
get() = flowOf(Util.getTimeUntilBuildExpiry(SignalStore.misc.estimatedServerTime).milliseconds.inWholeDays.toInt())
@Composable
override fun DisplayBanner(model: Int, contentPadding: PaddingValues) {
val context = LocalContext.current
Banner(
contentPadding = contentPadding,
daysUntilExpiry = model,
onUpdateClicked = {
PlayStoreUtil.openPlayStoreOrOurApkDownloadPage(context)
}
)
}
data class Model(
val daysUntilExpiry: Int,
val isClientDeprecated: Boolean
)
}
@Composable
private fun Banner(contentPadding: PaddingValues, daysUntilExpiry: Int, onUpdateClicked: () -> Unit = {}) {
val bodyText = if (daysUntilExpiry == 0) {
stringResource(id = R.string.OutdatedBuildReminder_your_version_of_signal_will_expire_today)
} else {
pluralStringResource(id = R.plurals.OutdatedBuildReminder_your_version_of_signal_will_expire_in_n_days, count = daysUntilExpiry, daysUntilExpiry)
}
DefaultBanner(
title = null,
body = bodyText,
importance = if (daysUntilExpiry == 0) {
Importance.ERROR
} else {
Importance.NORMAL
},
actions = listOf(
Action(R.string.ExpiredBuildReminder_update_now) {
onUpdateClicked()
}
),
paddingValues = contentPadding
)
}
@SignalPreview
@Composable
private fun BannerPreviewExpireToday() {
Previews.Preview {
Banner(
contentPadding = PaddingValues(0.dp),
daysUntilExpiry = 0
)
}
}
@SignalPreview
@Composable
private fun BannerPreviewExpireTomorrow() {
Previews.Preview {
Banner(
contentPadding = PaddingValues(0.dp),
daysUntilExpiry = 1
)
}
}
@SignalPreview
@Composable
private fun BannerPreviewExpireLater() {
Previews.Preview {
Banner(
contentPadding = PaddingValues(0.dp),
daysUntilExpiry = 3
)
}
}

View File

@@ -5,44 +5,79 @@
package org.thoughtcrime.securesms.banner.banners
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
import org.signal.core.ui.Previews
import org.signal.core.ui.SignalPreview
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.banner.Banner
import org.thoughtcrime.securesms.banner.DismissibleBannerProducer
import org.thoughtcrime.securesms.banner.ui.compose.Action
import org.thoughtcrime.securesms.banner.ui.compose.DefaultBanner
class PendingGroupJoinRequestsBanner(override val enabled: Boolean, private val suggestionsSize: Int, private val onViewClicked: () -> Unit, private val onDismissListener: (() -> Unit)?) : Banner() {
/**
* Shows the number of pending requests to join the group.
* Intended to be shown at the top of a conversation.
*/
class PendingGroupJoinRequestsBanner(private val suggestionsSize: Int, private val onViewClicked: () -> Unit) : Banner<Int>() {
override val enabled: Boolean
get() = suggestionsSize > 0
override val dataFlow: Flow<Int> = flowOf(suggestionsSize)
@Composable
override fun DisplayBanner() {
DefaultBanner(
title = null,
body = pluralStringResource(
id = R.plurals.GroupsV1MigrationSuggestionsReminder_members_couldnt_be_added_to_the_new_group,
count = suggestionsSize,
suggestionsSize
),
actions = listOf(
Action(R.string.PendingGroupJoinRequestsReminder_view, onClick = onViewClicked)
),
onDismissListener = onDismissListener
override fun DisplayBanner(model: Int, contentPadding: PaddingValues) {
Banner(
contentPadding = contentPadding,
suggestionsSize = model,
onViewClicked = onViewClicked
)
}
}
private class Producer(suggestionsSize: Int, onViewClicked: () -> Unit) : DismissibleBannerProducer<PendingGroupJoinRequestsBanner>(bannerProducer = {
PendingGroupJoinRequestsBanner(suggestionsSize > 0, suggestionsSize, onViewClicked, it)
}) {
override fun createDismissedBanner(): PendingGroupJoinRequestsBanner {
return PendingGroupJoinRequestsBanner(false, 0, {}, null)
}
@Composable
private fun Banner(contentPadding: PaddingValues, suggestionsSize: Int, onViewClicked: () -> Unit = {}) {
var visible by remember { mutableStateOf(true) }
if (!visible) {
return
}
companion object {
fun createFlow(suggestionsSize: Int, onViewClicked: () -> Unit): Flow<PendingGroupJoinRequestsBanner> {
return Producer(suggestionsSize, onViewClicked).flow
}
DefaultBanner(
title = null,
body = pluralStringResource(
id = R.plurals.PendingGroupJoinRequestsReminder_d_pending_member_requests,
count = suggestionsSize,
suggestionsSize
),
onDismissListener = { visible = false },
actions = listOf(
Action(R.string.PendingGroupJoinRequestsReminder_view, onClick = onViewClicked)
),
paddingValues = contentPadding
)
}
@SignalPreview
@Composable
private fun BannerPreviewSingular() {
Previews.Preview {
Banner(contentPadding = PaddingValues(0.dp), suggestionsSize = 1)
}
}
@SignalPreview
@Composable
private fun BannerPreviewPlural() {
Previews.Preview {
Banner(contentPadding = PaddingValues(0.dp), suggestionsSize = 2)
}
}

View File

@@ -6,51 +6,45 @@
package org.thoughtcrime.securesms.banner.banners
import android.content.Context
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.map
import org.signal.core.util.logging.Log
import kotlinx.coroutines.flow.flowOf
import org.signal.core.ui.Previews
import org.signal.core.ui.SignalPreview
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.banner.Banner
import org.thoughtcrime.securesms.banner.ui.compose.DefaultBanner
import org.thoughtcrime.securesms.banner.ui.compose.Importance
import org.thoughtcrime.securesms.util.TextSecurePreferences
class ServiceOutageBanner(outageInProgress: Boolean) : Banner() {
class ServiceOutageBanner(val context: Context) : Banner<Unit>() {
constructor(context: Context) : this(TextSecurePreferences.getServiceOutage(context))
override val enabled: Boolean
get() = TextSecurePreferences.getServiceOutage(context)
override val enabled = outageInProgress
override val dataFlow: Flow<Unit> = flowOf(Unit)
@Composable
override fun DisplayBanner() {
DefaultBanner(
title = null,
body = stringResource(id = R.string.reminder_header_service_outage_text),
importance = Importance.ERROR
)
}
override fun DisplayBanner(model: Unit, contentPadding: PaddingValues) = Banner(contentPadding)
}
/**
* A class that can be held by a listener but still produce new [ServiceOutageBanner] in its flow.
* Designed for being called upon by a listener that is listening to changes in [TextSecurePreferences]
*/
class Producer(private val context: Context) {
private val _flow = MutableSharedFlow<Boolean>(replay = 1)
val flow: Flow<ServiceOutageBanner> = _flow.map { ServiceOutageBanner(context) }
@Composable
private fun Banner(contentPadding: PaddingValues) {
DefaultBanner(
title = null,
body = stringResource(id = R.string.reminder_header_service_outage_text),
importance = Importance.ERROR,
paddingValues = contentPadding
)
}
init {
queryAndEmit()
}
fun queryAndEmit() {
_flow.tryEmit(TextSecurePreferences.getServiceOutage(context))
}
}
companion object {
private val TAG = Log.tag(ServiceOutageBanner::class)
@SignalPreview
@Composable
private fun BannerPreview() {
Previews.Preview {
Banner(contentPadding = PaddingValues(0.dp))
}
}

View File

@@ -6,12 +6,15 @@
package org.thoughtcrime.securesms.banner.banners
import android.content.Context
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.map
import org.signal.core.util.logging.Log
import kotlinx.coroutines.flow.flowOf
import org.signal.core.ui.Previews
import org.signal.core.ui.SignalPreview
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.banner.Banner
import org.thoughtcrime.securesms.banner.ui.compose.Action
@@ -24,43 +27,42 @@ import org.thoughtcrime.securesms.util.TextSecurePreferences
/**
* A banner displayed when the client is unauthorized (deregistered).
*/
class UnauthorizedBanner(val context: Context) : Banner() {
class UnauthorizedBanner(val context: Context) : Banner<Unit>() {
override val enabled = TextSecurePreferences.isUnauthorizedReceived(context) || !SignalStore.account.isRegistered
override val enabled: Boolean
get() = TextSecurePreferences.isUnauthorizedReceived(context) || !SignalStore.account.isRegistered
override val dataFlow: Flow<Unit>
get() = flowOf(Unit)
@Composable
override fun DisplayBanner() {
DefaultBanner(
title = null,
body = stringResource(id = R.string.UnauthorizedReminder_this_is_likely_because_you_registered_your_phone_number_with_Signal_on_a_different_device),
importance = Importance.ERROR,
actions = listOf(
Action(R.string.UnauthorizedReminder_reregister_action) {
val registrationIntent = RegistrationActivity.newIntentForReRegistration(context)
context.startActivity(registrationIntent)
}
)
)
}
/**
* A class that can be held by a listener but still produce new [UnauthorizedBanner] in its flow.
* Designed for being called upon by a listener that is listening to changes in [TextSecurePreferences]
*/
class Producer(private val context: Context) {
private val _flow = MutableSharedFlow<Boolean>(replay = 1)
val flow: Flow<UnauthorizedBanner> = _flow.map { UnauthorizedBanner(context) }
init {
queryAndEmit()
}
fun queryAndEmit() {
_flow.tryEmit(TextSecurePreferences.isUnauthorizedReceived(context))
}
}
companion object {
private val TAG = Log.tag(UnauthorizedBanner::class)
override fun DisplayBanner(model: Unit, contentPadding: PaddingValues) {
Banner(contentPadding)
}
}
@Composable
private fun Banner(contentPadding: PaddingValues) {
val context = LocalContext.current
DefaultBanner(
title = null,
body = stringResource(id = R.string.UnauthorizedReminder_this_is_likely_because_you_registered_your_phone_number_with_Signal_on_a_different_device),
importance = Importance.ERROR,
actions = listOf(
Action(R.string.UnauthorizedReminder_reregister_action) {
val registrationIntent = RegistrationActivity.newIntentForReRegistration(context)
context.startActivity(registrationIntent)
}
),
paddingValues = contentPadding
)
}
@SignalPreview
@Composable
private fun BannerPreview() {
Previews.Preview {
Banner(PaddingValues(0.dp))
}
}

View File

@@ -5,10 +5,14 @@
package org.thoughtcrime.securesms.banner.banners
import android.content.Context
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
import org.signal.core.ui.Previews
import org.signal.core.ui.SignalPreview
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.banner.Banner
import org.thoughtcrime.securesms.banner.ui.compose.Action
@@ -18,40 +22,61 @@ import org.thoughtcrime.securesms.keyvalue.AccountValues
import org.thoughtcrime.securesms.keyvalue.AccountValues.UsernameSyncState
import org.thoughtcrime.securesms.keyvalue.SignalStore
class UsernameOutOfSyncBanner(private val context: Context, private val usernameSyncState: UsernameSyncState, private val onActionClick: (Boolean) -> Unit) : Banner() {
class UsernameOutOfSyncBanner(private val onActionClick: (UsernameSyncState) -> Unit) : Banner<UsernameSyncState>() {
override val enabled = when (usernameSyncState) {
AccountValues.UsernameSyncState.USERNAME_AND_LINK_CORRUPTED -> true
AccountValues.UsernameSyncState.LINK_CORRUPTED -> true
AccountValues.UsernameSyncState.IN_SYNC -> false
}
override val enabled: Boolean
get() {
return when (SignalStore.account.usernameSyncState) {
AccountValues.UsernameSyncState.USERNAME_AND_LINK_CORRUPTED -> true
AccountValues.UsernameSyncState.LINK_CORRUPTED -> true
AccountValues.UsernameSyncState.IN_SYNC -> false
}
}
override val dataFlow: Flow<UsernameSyncState>
get() = flowOf(SignalStore.account.usernameSyncState)
@Composable
override fun DisplayBanner() {
DefaultBanner(
title = null,
body = if (usernameSyncState == UsernameSyncState.USERNAME_AND_LINK_CORRUPTED) {
stringResource(id = R.string.UsernameOutOfSyncReminder__username_and_link_corrupt)
} else {
stringResource(id = R.string.UsernameOutOfSyncReminder__link_corrupt)
},
importance = Importance.ERROR,
actions = listOf(
Action(R.string.UsernameOutOfSyncReminder__fix_now) {
onActionClick(usernameSyncState == UsernameSyncState.USERNAME_AND_LINK_CORRUPTED)
}
)
override fun DisplayBanner(model: UsernameSyncState, contentPadding: PaddingValues) {
Banner(
contentPadding = contentPadding,
usernameSyncState = model,
onFixClicked = onActionClick
)
}
}
companion object {
@Composable
private fun Banner(contentPadding: PaddingValues, usernameSyncState: UsernameSyncState, onFixClicked: (UsernameSyncState) -> Unit = {}) {
DefaultBanner(
title = null,
body = if (usernameSyncState == UsernameSyncState.USERNAME_AND_LINK_CORRUPTED) {
stringResource(id = R.string.UsernameOutOfSyncReminder__username_and_link_corrupt)
} else {
stringResource(id = R.string.UsernameOutOfSyncReminder__link_corrupt)
},
importance = Importance.ERROR,
actions = listOf(
Action(R.string.UsernameOutOfSyncReminder__fix_now) {
onFixClicked(usernameSyncState)
}
),
paddingValues = contentPadding
)
}
/**
* @param onActionClick input is true if both the username and the link are corrupted, false if only the link is corrupted
*/
@JvmStatic
fun createFlow(context: Context, onActionClick: (Boolean) -> Unit): Flow<UsernameOutOfSyncBanner> = createAndEmit {
UsernameOutOfSyncBanner(context, SignalStore.account.usernameSyncState, onActionClick)
}
@SignalPreview
@Composable
private fun BannerPreviewUsernameCorrupted() {
Previews.Preview {
Banner(contentPadding = PaddingValues(0.dp), usernameSyncState = UsernameSyncState.USERNAME_AND_LINK_CORRUPTED)
}
}
@SignalPreview
@Composable
private fun BannerPreviewLinkCorrupted() {
Previews.Preview {
Banner(contentPadding = PaddingValues(0.dp), usernameSyncState = UsernameSyncState.LINK_CORRUPTED)
}
}

View File

@@ -10,12 +10,14 @@ import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
@@ -25,6 +27,7 @@ import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.pluralStringResource
@@ -49,11 +52,13 @@ fun DefaultBanner(
actions: List<Action> = emptyList(),
showProgress: Boolean = false,
progressText: String = "",
progressPercent: Int = -1
progressPercent: Int = -1,
paddingValues: PaddingValues
) {
Box(
modifier = Modifier
.padding(horizontal = 12.dp, vertical = 8.dp)
.padding(paddingValues)
.clip(RoundedCornerShape(12.dp))
.background(
color = when (importance) {
Importance.NORMAL -> MaterialTheme.colorScheme.surface
@@ -102,26 +107,34 @@ fun DefaultBanner(
if (progressPercent >= 0) {
LinearProgressIndicator(
progress = { progressPercent / 100f },
color = MaterialTheme.colorScheme.primary,
color = when (importance) {
Importance.NORMAL -> MaterialTheme.colorScheme.primary
Importance.ERROR -> colorResource(id = R.color.signal_light_colorPrimary)
},
trackColor = MaterialTheme.colorScheme.primaryContainer,
modifier = Modifier.padding(vertical = 12.dp)
modifier = Modifier
.padding(vertical = 12.dp)
.fillMaxWidth()
)
} else {
LinearProgressIndicator(
color = MaterialTheme.colorScheme.primary,
color = when (importance) {
Importance.NORMAL -> MaterialTheme.colorScheme.primary
Importance.ERROR -> colorResource(id = R.color.signal_light_colorPrimary)
},
trackColor = MaterialTheme.colorScheme.primaryContainer,
modifier = Modifier.padding(vertical = 12.dp)
)
}
Text(
text = progressText,
style = MaterialTheme.typography.bodySmall,
color = when (importance) {
Importance.NORMAL -> MaterialTheme.colorScheme.onSurfaceVariant
Importance.ERROR -> colorResource(id = R.color.signal_light_colorOnSurface)
}
)
}
Text(
text = progressText,
style = MaterialTheme.typography.bodySmall,
color = when (importance) {
Importance.NORMAL -> MaterialTheme.colorScheme.onSurfaceVariant
Importance.ERROR -> colorResource(id = R.color.signal_light_colorOnSurface)
}
)
}
Box(modifier = Modifier.size(48.dp)) {
@@ -143,11 +156,18 @@ fun DefaultBanner(
}
Row(
horizontalArrangement = Arrangement.End,
modifier = Modifier.fillMaxWidth()
modifier = Modifier
.fillMaxWidth()
.padding(end = 8.dp)
) {
for (action in actions) {
TextButton(onClick = action.onClick) {
TextButton(
onClick = action.onClick,
colors = when (importance) {
Importance.NORMAL -> ButtonDefaults.textButtonColors()
Importance.ERROR -> ButtonDefaults.textButtonColors(contentColor = colorResource(R.color.signal_light_colorPrimary))
}
) {
Text(
text = if (!action.isPluralizedLabel) {
stringResource(id = action.label)
@@ -179,7 +199,8 @@ private fun BubblesOptOutPreview() {
actions = listOf(
Action(R.string.BubbleOptOutTooltip__turn_off) {},
Action(R.string.BubbleOptOutTooltip__not_now) {}
)
),
paddingValues = PaddingValues(horizontal = 12.dp, vertical = 8.dp)
)
}
}
@@ -192,9 +213,10 @@ private fun ForcedUpgradePreview() {
title = null,
body = stringResource(id = R.string.OutdatedBuildReminder_your_version_of_signal_will_expire_today),
importance = Importance.ERROR,
actions = listOf(Action(R.string.ExpiredBuildReminder_update_now) {}),
onDismissListener = {},
onHideListener = { },
onDismissListener = {}
actions = listOf(Action(R.string.ExpiredBuildReminder_update_now) {}),
paddingValues = PaddingValues(horizontal = 12.dp, vertical = 8.dp)
)
}
}
@@ -211,11 +233,12 @@ private fun FullyLoadedErrorPreview() {
title = "Error",
body = "Creating more errors.",
importance = Importance.ERROR,
onDismissListener = {},
actions = actions,
showProgress = true,
progressText = "4 out of 10 errors created.",
progressPercent = 40,
onDismissListener = {}
paddingValues = PaddingValues(horizontal = 12.dp, vertical = 8.dp)
)
}
}

View File

@@ -0,0 +1,53 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.calls
import android.view.View
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import com.google.android.material.snackbar.Snackbar
import org.signal.core.ui.Snackbars
import org.thoughtcrime.securesms.R
/**
* Snackbar which can be displayed whenever the user tries to join a call but is already in another.
*/
object YouAreAlreadyInACallSnackbar {
/**
* Composable component
*/
@Composable
fun YouAreAlreadyInACallSnackbar(
displaySnackbar: Boolean,
modifier: Modifier = Modifier
) {
val message = stringResource(R.string.CommunicationActions__you_are_already_in_a_call)
val hostState = remember { SnackbarHostState() }
Snackbars.Host(hostState, modifier = modifier)
LaunchedEffect(displaySnackbar) {
if (displaySnackbar) {
hostState.showSnackbar(message)
}
}
}
/**
* View system component
*/
@JvmStatic
fun show(view: View) {
Snackbar.make(
view,
view.context.getString(R.string.CommunicationActions__you_are_already_in_a_call),
Snackbar.LENGTH_LONG
).show()
}
}

View File

@@ -1,47 +0,0 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.calls.links
import android.content.Context
import android.util.AttributeSet
import android.view.View
import androidx.annotation.ColorRes
import androidx.appcompat.widget.LinearLayoutCompat
import androidx.core.content.ContextCompat
import com.google.android.material.button.MaterialButton
import org.thoughtcrime.securesms.R
/**
* ConversationItem action button for joining a call link.
*/
class CallLinkJoinButton @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null
) : LinearLayoutCompat(context, attrs) {
init {
orientation = VERTICAL
inflate(context, R.layout.call_link_join_button, this)
}
private val joinStroke: View = findViewById(R.id.join_stroke)
private val joinButton: MaterialButton = findViewById(R.id.join_button)
fun setTextColor(@ColorRes textColorResId: Int) {
val color = ContextCompat.getColor(context, textColorResId)
joinButton.setTextColor(color)
}
fun setStrokeColor(@ColorRes strokeColorResId: Int) {
val color = ContextCompat.getColor(context, strokeColorResId)
joinStroke.setBackgroundColor(color)
}
fun setJoinClickListener(onClickListener: OnClickListener) {
joinButton.setOnClickListener(onClickListener)
}
}

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