Compare commits

..

152 Commits

Author SHA1 Message Date
Alex Hart
7b34dc75b3 Bump version to 7.60.1 2025-10-09 17:32:05 -03:00
Alex Hart
8d3d86372f Update translations and other static files. 2025-10-09 17:05:00 -03:00
Michelle Tang
7de9218b80 Put poll receive support behind feature flag. 2025-10-09 16:33:44 -03:00
Cody Henthorne
f9ddba5aed Fix duplicate e164 short codes cleanup bug. 2025-10-09 15:00:06 -04:00
Cody Henthorne
9ab1996f4a Include last backup proto size in size calculation. 2025-10-09 11:35:22 -04:00
Alex Hart
c7666626a1 Bump version to 7.60.0 2025-10-08 16:26:38 -03:00
Alex Hart
9a6c869bb5 Update translations and other static files. 2025-10-08 16:21:53 -03:00
Jeffrey Starke
ac86140133 Rename AppScaffold detailContent/listContent to primaryContent/secondaryContent. 2025-10-08 16:15:28 -03:00
jeffrey-signal
534756c833 Add split pane UI for new conversation screen. 2025-10-08 16:15:28 -03:00
Cody Henthorne
0f35eb7f7b Fix bad state when in restore flow and become unregistered. 2025-10-08 16:15:28 -03:00
Cody Henthorne
a5cca5b0fd Reset backup id on zk verification failure during restore attempts. 2025-10-08 16:15:28 -03:00
Michelle Tang
6e8f982e7b Process notification profiles before accounts during storage sync. 2025-10-08 16:15:28 -03:00
Michelle Tang
a14517fceb Update vote result color. 2025-10-08 16:15:28 -03:00
Alex Hart
fe17e01ff5 Fix improper filename on sent media items. 2025-10-08 16:15:28 -03:00
Cody Henthorne
585fb3eea8 Fix window insets for pixel 10 devices. 2025-10-08 16:15:28 -03:00
Alex Hart
3e07834c20 Log out windowsizeclass. 2025-10-06 13:13:11 -03:00
Alex Hart
14cc0f12a6 Fix BackupSubscriptionCheckJob tests. 2025-10-03 15:31:20 -04:00
Alex Hart
1d403d3dee Implement AppScaffold back-gesture. 2025-10-03 15:31:20 -04:00
gram-signal
d36a4232be Bump libsignal to v0.83.0 2025-10-03 15:31:20 -04:00
Jim Gustafson
5b8750a84f Update to RingRTC v2.59.0 2025-10-03 15:31:20 -04:00
Michelle Tang
0323cb5d98 Bump version to 7.59.1 2025-10-03 15:25:44 -04:00
Michelle Tang
f4369f90e0 Update translations and other static files. 2025-10-03 15:20:37 -04:00
Cody Henthorne
8b19cbb603 Show correct dialog when validating AEP after registration. 2025-10-03 15:17:10 -04:00
Alex Hart
aa3a797e19 Drop UNIQUE constraint from table column. 2025-10-03 15:41:29 -03:00
Cody Henthorne
827ceafffb Show skip restore warning. 2025-10-03 14:13:15 -04:00
Michelle Tang
cf1afb739f Bump version to 7.59.0 2025-10-02 13:17:59 -04:00
Michelle Tang
b9fe377afd Update translations and other static files. 2025-10-02 13:11:22 -04:00
Michelle Tang
a381697949 Update button color when viewing votes. 2025-10-02 12:56:04 -04:00
Michelle Tang
2d87078495 Show character count when creating a poll. 2025-10-02 12:56:04 -04:00
Alex Hart
1b9695cb98 Reject last-use kyber key sets that we've seen before. 2025-10-02 12:56:04 -04:00
Cody Henthorne
5324290fab Dedupe attachment downloads for matching attachments and fix size calculations. 2025-10-02 12:56:04 -04:00
Michelle Tang
b8e4ffb5ae Release polls behind feature flag. 2025-10-02 12:56:04 -04:00
Cody Henthorne
67a693107e Fix bad data for users that rotated their recovery key. 2025-10-02 12:56:04 -04:00
andrew-signal
e08b86cda6 Bump to libsignal v0.82.1 2025-10-02 12:56:04 -04:00
Michelle Tang
92bab9fb20 Drop duplicate name error when editing profiles. 2025-10-02 12:56:04 -04:00
Michelle Tang
e7502f08ce Clear etag on internal only config refresh. 2025-10-02 12:56:04 -04:00
Alex Hart
3a530022fc Add additional dialog checks. 2025-10-02 12:56:04 -04:00
jeffrey-signal
2c8144b32f Add more compose preview annotations. 2025-10-02 12:56:04 -04:00
Alex Hart
87535a917a Fully check result code when processing purchase results. 2025-10-02 12:56:04 -04:00
Alex Hart
76448f5426 Apply missing callback for entering remote backups settings fragment. 2025-10-02 12:56:04 -04:00
Alex Hart
019df97a22 Add proper gesture when user navigates to or from a conversation. 2025-10-02 12:56:04 -04:00
Cody Henthorne
51897bb74f Fix outgoing disappearing message export oddity check. 2025-10-02 12:56:04 -04:00
jeffrey-signal
5f3b4056e9 Fix incorrect nav bar colors on registration screens for Android 8.1 and newer. 2025-10-02 12:56:04 -04:00
Jeffrey Starke
73a3c21716 Media review - Fix alignment of recipient name and arrow. 2025-10-02 12:56:04 -04:00
jeffrey-signal
a37209d8ba Prevent infinite archive attachment reconciliation attempts after server storage quota disagreement. 2025-10-02 12:56:04 -04:00
andrew-signal
415021eedf Bump to libsignal v0.82.0 2025-10-02 12:56:04 -04:00
Alex Hart
ea6d512cc8 Do not reroute to main activity if launching in a bubble. 2025-10-02 12:56:04 -04:00
Alex Hart
fba6673907 Eliminate dependency material icons core. 2025-10-02 12:56:04 -04:00
jeffrey-signal
faba4682ed Fix indeterminate progress dialog animation. 2025-10-02 12:56:04 -04:00
Jeffrey Starke
71b92f03bc Fix DonationsService ServiceResponse to use exception body instead of message. 2025-10-02 12:56:04 -04:00
Alex Hart
d4a1cb0bfb Upgrade compose to latest stable. 2025-10-02 12:56:04 -04:00
Alex Hart
e16ca2b2d2 Several navhost behavioural updates to ensure the right pane is displayed at the right time. 2025-10-02 12:56:04 -04:00
Michelle Tang
77e678e05c Bump version to 7.58.2 2025-10-02 12:53:42 -04:00
Michelle Tang
efe0e3b816 Update translations and other static files. 2025-10-02 12:49:37 -04:00
andrew-signal
6c497e131a Remove android.libsignalWebSocketEnabled flag and always use LibSignalChatConnection. 2025-09-30 19:22:47 -04:00
Michelle Tang
ccb8c1b1b9 Bump version to 7.58.1 2025-09-29 16:18:06 -04:00
Michelle Tang
4aa965144d Update translations and other static files. 2025-09-29 16:09:49 -04:00
Cody Henthorne
786bcc3da7 Fix header case bugs in status code errors. 2025-09-26 12:54:18 -04:00
Cody Henthorne
4447b29e6c Fix upload to archive running while not on wifi. 2025-09-25 13:27:07 -04:00
Jeffrey Starke
3ebbb94a1a Bump version to 7.58.0 2025-09-24 16:39:40 -04:00
Jeffrey Starke
64a7cdafa8 Update translations and other static files. 2025-09-24 16:37:04 -04:00
Cody Henthorne
c3350c0bb0 Clear credentials in pre-restore state. 2025-09-24 16:29:57 -04:00
Cody Henthorne
e2be1e0c79 Prevent IMO from running before registration. 2025-09-24 16:29:57 -04:00
Alex Hart
228a993237 Ignore PNI messages for everything except server delivery receipts. 2025-09-24 16:29:57 -04:00
Alex Hart
04923487c4 Ignore mismatch state if FREE tier user has GPB sub.
Co-authored-by: jeffrey-signal <jeffrey@signal.org>
2025-09-24 16:29:57 -04:00
Alex Hart
9777aa411c Remove transitions from base NavHost. 2025-09-24 16:29:57 -04:00
Alex Hart
d0c1e93b3c Do not display a price if it's been zeroed. 2025-09-24 16:29:57 -04:00
Alex Hart
9b517a14cb Remove separate controllers and consolidate logic. 2025-09-24 16:29:57 -04:00
Alex Hart
369085e162 Add new log sections to backups. 2025-09-24 16:29:57 -04:00
Alex Hart
93815a0504 Add checks to skip check job if we have a pending or pre-pending transaction. 2025-09-24 16:29:57 -04:00
Alex Hart
b88097a6ae Utilize keepLonger throughout BillingApiImpl. 2025-09-24 16:29:57 -04:00
Alex Hart
120cc9c521 Fix padding on button when we have a current tier. 2025-09-24 16:29:57 -04:00
Cody Henthorne
58304a0fb6 Fix RTL ByteSize rendering. 2025-09-24 16:29:57 -04:00
Cody Henthorne
6e867d678c Fix de related crash and bug. 2025-09-24 16:29:57 -04:00
Cody Henthorne
8b2f58e0e7 Remove hard coded message backups remote config. 2025-09-24 16:29:57 -04:00
Cody Henthorne
6976ac7d44 Move v3 classes to base registration package. 2025-09-24 16:29:57 -04:00
Cody Henthorne
8dc2077ad0 Remove regv2. 2025-09-24 16:29:57 -04:00
jeffrey-signal
52fa86046b Fix backups UI scaling issues. 2025-09-24 16:29:57 -04:00
Alex Hart
3352ebaa06 Move large screen check to wrapper. 2025-09-24 16:29:57 -04:00
Cody Henthorne
cbfdc4b57a Improve free tier UX around media. 2025-09-24 16:29:57 -04:00
Greyson Parrelli
c5753b96ff Update BackupMediaSnapshot to be based on attachments in backup frames. 2025-09-24 16:29:57 -04:00
Alex Hart
f39ad24cc1 Increase desired width increment to aleviate situations where certain text would still flow to multiple lines. 2025-09-24 16:29:57 -04:00
Greyson Parrelli
6b6877bae7 Update username link logo.
Resolves #14258
2025-09-24 16:29:57 -04:00
andrew-signal
930254da7b Bump to libsignal v0.81.1. 2025-09-24 16:29:56 -04:00
Alex Hart
3df2fa53e8 Don't exit multiselect mode when swapping screens. 2025-09-24 16:29:56 -04:00
Alex Hart
c901639ce8 Add lint detection for System.out.println add kotlin.io.println usage. 2025-09-24 16:29:56 -04:00
Alex Hart
9e1cec7a60 Fix anchor offset when search or action mode is entered. 2025-09-24 16:29:56 -04:00
Alex Hart
9269c66d1e Add remote config support for large screen UI. 2025-09-24 16:29:56 -04:00
Alex Hart
fd999be41a Add new navigation and pane support. 2025-09-24 16:29:56 -04:00
Alex Hart
146a5f5701 Remove ParcelableGroupId. 2025-09-23 20:21:30 -04:00
Alex Hart
d49ef1dd7d Convert RecipientId to Kotlin. 2025-09-23 20:21:30 -04:00
Greyson Parrelli
49c5fead39 Catch ZK validation error in profile fetch. 2025-09-23 20:21:30 -04:00
Greyson Parrelli
9c705f3a45 Remove unnecessary SMS entrypoint.
Fixes #14213
2025-09-23 20:21:30 -04:00
Alex Hart
bea204ab82 Convert GroupId to Kotlin. 2025-09-23 20:21:29 -04:00
Jeffrey Starke
9350438866 Bump version to 7.57.2 2025-09-23 20:00:39 -04:00
Jeffrey Starke
4d827adc8b Update translations and other static files. 2025-09-23 19:50:47 -04:00
Cody Henthorne
9f839b75fb Improve restore error messaging and actual available restore method options. 2025-09-23 14:32:11 -04:00
Alex Hart
c0482e8247 Ensure api availability is properly loaded in checkout flow. 2025-09-23 15:12:37 -03:00
Greyson Parrelli
17f27f45fc Bump version to 7.57.1 2025-09-18 10:36:19 -04:00
Greyson Parrelli
2401e33222 Update translations and other static files. 2025-09-18 10:35:53 -04:00
Greyson Parrelli
4345179a1d Fix replying to non-media. 2025-09-18 10:10:56 -04:00
Greyson Parrelli
5aa6fc78ee Bump version to 7.57.0 2025-09-17 14:37:30 -04:00
Greyson Parrelli
e0a86ead58 Update translations and other static files. 2025-09-17 14:36:54 -04:00
Alex Hart
169d0fa964 Convert Media to kotlin. 2025-09-17 14:21:43 -04:00
Greyson Parrelli
c5397bc7d2 Fix potential crash in story send.
Fixes #14331
2025-09-17 14:21:43 -04:00
Greyson Parrelli
43f6e0ad8e Fix restore error string formatting. 2025-09-17 14:21:43 -04:00
Alex Hart
736811393f Upgrade Kotlin, AGP, Gradle versions and bring in kotlinx-serialization for use with navigation-compose. 2025-09-17 14:21:43 -04:00
andrew-signal
957ddc82b5 Switch lookupUsernameHash to use libsignal's typed API wrapper. 2025-09-17 14:21:43 -04:00
andrew-signal
16d6e98355 Pass all android.libsignal.* prefixed remote configs down automatically. 2025-09-17 14:21:43 -04:00
Alex Hart
2a90809ba3 Add Billing API and Google API availability error dialogs. 2025-09-17 14:21:43 -04:00
andrew-signal
0713a88ddb Bump to libsignal v0.81.0 2025-09-17 14:21:43 -04:00
Greyson Parrelli
c78b47fbe3 Make max envelope size remote configurable. 2025-09-17 14:21:43 -04:00
jeffrey-signal
5807cbc9e9 Disable autofill for PIN entry fields. 2025-09-17 14:21:43 -04:00
Cody Henthorne
6d90330e86 Improve restore complete dialog for old device. 2025-09-17 14:21:43 -04:00
Michelle Tang
862bab55af Add more logging around notification profile overrides. 2025-09-17 14:21:43 -04:00
jeffrey-signal
7235a3730c Fix crash when opening the change number registration lock screen. 2025-09-17 14:21:43 -04:00
jeffrey-signal
c24993960d Fix inconsistent default PIN keyboard type. 2025-09-17 14:21:43 -04:00
Greyson Parrelli
7f429dc769 Bring back proper archive delete reconciliation. 2025-09-17 14:21:43 -04:00
Michelle Tang
a575626abb Add logging around overrides in notification profiles. 2025-09-17 14:21:43 -04:00
moiseev-signal
0b71b1837c Upgrade to libsignal 0.80.3 and add a new trust root for sealed sender. 2025-09-17 14:21:43 -04:00
jeffrey-signal
f0df1b99e5 Always include english translations for emoji search.
Updates the `emoji_search` table by including English emoji labels alongside existing localized labels, enabling users to search for emojis in both their preferred language and English.
2025-09-17 14:21:43 -04:00
Alex Hart
23b7ea90a1 Add fixes for primary choice when returning to chats. 2025-09-17 14:21:43 -04:00
Alex Hart
53a6b0c719 Fix navigator to ensure we don't end up with a weird backstack. 2025-09-17 14:21:43 -04:00
Alex Hart
bf3135b2d0 Fix various issues in main activity display. 2025-09-17 14:21:43 -04:00
Alex Hart
897461b594 Expand the detail anchor if we select a conversation while the list is maximized. 2025-09-17 14:21:43 -04:00
Alex Hart
63800306a0 Pre-seed navigation when intent is processed before navigator is set. 2025-09-17 14:21:43 -04:00
Greyson Parrelli
b649b8c943 Hide some error states for unregistered users. 2025-09-17 14:21:42 -04:00
andrew-signal
2c0aa40c61 Disable vector drawable rasterization in donations library. 2025-09-17 14:21:42 -04:00
Alex Hart
2eb4f650d8 Convert NotificationsSettingsFragment to compose. 2025-09-17 14:21:42 -04:00
Ehren Kret
7af811eb3f Accept legacy call links. 2025-09-17 14:21:42 -04:00
Alex Hart
d7f43c436e Mark decision state during instrumentation testing. 2025-09-17 14:21:42 -04:00
Cody Henthorne
2792b9e676 Add prompt to re-enable local backups post restore. 2025-09-17 14:21:42 -04:00
Cody Henthorne
bdf2ef5a05 Allow for multiple captchas to be solved during registration. 2025-09-17 14:21:42 -04:00
Alex Hart
23b5a3dcb0 Start conversion from Fragment Nav Framework to utilizing a centralized AppSettingsRouter. 2025-09-17 14:21:42 -04:00
Alex Hart
909ea6b925 Add MainActivity scaffold anchoring. 2025-09-17 14:21:42 -04:00
Greyson Parrelli
a5922c31b1 Fix debuglog spacing. 2025-09-17 14:21:42 -04:00
Greyson Parrelli
d8758bcc4e Add data seeding playground. 2025-09-17 14:21:42 -04:00
Jim Gustafson
f88181cc82 Update to RingRTC v2.57.1 2025-09-17 14:21:42 -04:00
Michelle Tang
c3f1036686 Always fetch remote configs on app update. 2025-09-17 14:21:42 -04:00
Greyson Parrelli
96292cd4a1 Bump version to 7.56.9 2025-09-17 14:20:05 -04:00
Greyson Parrelli
81f6035027 Update translations and other static files. 2025-09-17 14:03:43 -04:00
Greyson Parrelli
52005cf62c Fix bug when replying with a voice note. 2025-09-17 13:30:02 -04:00
Greyson Parrelli
f5effa5be9 Bump version to 7.56.8 2025-09-16 10:48:51 -04:00
Greyson Parrelli
cae7906f04 Mark some archive logs as keep longer. 2025-09-16 10:48:29 -04:00
Greyson Parrelli
7ea8cc6b0a Fix database migrations post-backup-restore. 2025-09-16 09:44:49 -04:00
Greyson Parrelli
8669a3d6e0 Bump version to 7.56.7 2025-09-15 20:42:16 -04:00
Greyson Parrelli
cb3bc91865 Update translations and other static files. 2025-09-15 20:41:54 -04:00
Cody Henthorne
1a0c4b8135 Fix crash with media restore progress banner. 2025-09-15 20:33:58 -04:00
Cody Henthorne
6a456a288d Fix signal backup daily schedule bug. 2025-09-15 20:33:58 -04:00
Cody Henthorne
901a81fb74 Add edit proxy ability to quick restore flow. 2025-09-15 20:33:58 -04:00
Cody Henthorne
b1b99855b2 Improve understanding of last signal backup time in main backup settings screen. 2025-09-15 12:51:41 -04:00
Alex Hart
c6f0b4cf83 Remove frequency row. 2025-09-15 13:20:29 -03:00
651 changed files with 30803 additions and 12304 deletions

View File

@@ -12,6 +12,7 @@ plugins {
alias(libs.plugins.jetbrains.kotlin.android)
alias(libs.plugins.ktlint)
alias(libs.plugins.compose.compiler)
alias(libs.plugins.kotlinx.serialization)
id("androidx.navigation.safeargs")
id("kotlin-parcelize")
id("com.squareup.wire")
@@ -21,8 +22,8 @@ plugins {
apply(from = "static-ips.gradle.kts")
val canonicalVersionCode = 1585
val canonicalVersionName = "7.56.6"
val canonicalVersionCode = 1598
val canonicalVersionName = "7.60.1"
val currentHotfixVersion = 0
val maxHotfixVersions = 100
@@ -217,7 +218,7 @@ android {
buildConfigField("String", "SVR2_MRENCLAVE_LEGACY_LEGACY", "\"9314436a9a144992bb3680770ea5fd7934a7ffd29257844a33763a238903d570\"")
buildConfigField("String", "SVR2_MRENCLAVE_LEGACY", "\"093be9ea32405e85ae28dbb48eb668aebeb7dbe29517b9b86ad4bec4dfe0e6a6\"")
buildConfigField("String", "SVR2_MRENCLAVE", "\"29cd63c87bea751e3bfd0fbd401279192e2e5c99948b4ee9437eafc4968355fb\"")
buildConfigField("String", "UNIDENTIFIED_SENDER_TRUST_ROOT", "\"BXu6QIKVz5MA8gstzfOgRQGqyLqOwNKHL6INkv3IHWMF\"")
buildConfigField("String[]", "UNIDENTIFIED_SENDER_TRUST_ROOTS", "new String[]{ \"BXu6QIKVz5MA8gstzfOgRQGqyLqOwNKHL6INkv3IHWMF\", \"BUkY0I+9+oPgDCn4+Ac6Iu813yvqkDr/ga8DzLxFxuk6\"}")
buildConfigField("String", "ZKGROUP_SERVER_PUBLIC_PARAMS", "\"AMhf5ywVwITZMsff/eCyudZx9JDmkkkbV6PInzG4p8x3VqVJSFiMvnvlEKWuRob/1eaIetR31IYeAbm0NdOuHH8Qi+Rexi1wLlpzIo1gstHWBfZzy1+qHRV5A4TqPp15YzBPm0WSggW6PbSn+F4lf57VCnHF7p8SvzAA2ZZJPYJURt8X7bbg+H3i+PEjH9DXItNEqs2sNcug37xZQDLm7X36nOoGPs54XsEGzPdEV+itQNGUFEjY6X9Uv+Acuks7NpyGvCoKxGwgKgE5XyJ+nNKlyHHOLb6N1NuHyBrZrgtY/JYJHRooo5CEqYKBqdFnmbTVGEkCvJKxLnjwKWf+fEPoWeQFj5ObDjcKMZf2Jm2Ae69x+ikU5gBXsRmoF94GXTLfN0/vLt98KDPnxwAQL9j5V1jGOY8jQl6MLxEs56cwXN0dqCnImzVH3TZT1cJ8SW1BRX6qIVxEzjsSGx3yxF3suAilPMqGRp4ffyopjMD1JXiKR2RwLKzizUe5e8XyGOy9fplzhw3jVzTRyUZTRSZKkMLWcQ/gv0E4aONNqs4P+NameAZYOD12qRkxosQQP5uux6B2nRyZ7sAV54DgFyLiRcq1FvwKw2EPQdk4HDoePrO/RNUbyNddnM/mMgj4FW65xCoT1LmjrIjsv/Ggdlx46ueczhMgtBunx1/w8k8V+l8LVZ8gAT6wkU5J+DPQalQguMg12Jzug3q4TbdHiGCmD9EunCwOmsLuLJkz6EcSYXtrlDEnAM+hicw7iergYLLlMXpfTdGxJCWJmP4zqUFeTTmsmhsjGBt7NiEB/9pFFEB3pSbf4iiUukw63Eo8Aqnf4iwob6X1QviCWuc8t0LUlT9vALgh/f2DPVOOmR0RW6bgRvc7DSF20V/omg+YBw==\"")
buildConfigField("String", "GENERIC_SERVER_PUBLIC_PARAMS", "\"AByD873dTilmOSG0TjKrvpeaKEsUmIO8Vx9BeMmftwUs9v7ikPwM8P3OHyT0+X3EUMZrSe9VUp26Wai51Q9I8mdk0hX/yo7CeFGJyzoOqn8e/i4Ygbn5HoAyXJx5eXfIbqpc0bIxzju4H/HOQeOpt6h742qii5u/cbwOhFZCsMIbElZTaeU+BWMBQiZHIGHT5IE0qCordQKZ5iPZom0HeFa8Yq0ShuEyAl0WINBiY6xE3H/9WnvzXBbMuuk//eRxXgzO8ieCeK8FwQNxbfXqZm6Ro1cMhCOF3u7xoX83QhpN\"")
buildConfigField("String", "BACKUP_SERVER_PUBLIC_PARAMS", "\"AJwNSU55fsFCbgaxGRD11wO1juAs8Yr5GF8FPlGzzvdJJIKH5/4CC7ZJSOe3yL2vturVaRU2Cx0n751Vt8wkj1bozK3CBV1UokxV09GWf+hdVImLGjXGYLLhnI1J2TWEe7iWHyb553EEnRb5oxr9n3lUbNAJuRmFM7hrr0Al0F0wrDD4S8lo2mGaXe0MJCOM166F8oYRQqpFeEHfiLnxA1O8ZLh7vMdv4g9jI5phpRBTsJ5IjiJrWeP0zdIGHEssUeprDZ9OUJ14m0v61eYJMKsf59Bn+mAT2a7YfB+Don9O\"")
@@ -237,7 +238,6 @@ android {
buildConfigField("String", "STRIPE_BASE_URL", "\"https://api.stripe.com/v1\"")
buildConfigField("String", "STRIPE_PUBLISHABLE_KEY", "\"pk_live_6cmGZopuTsV8novGgJJW9JpC00vLIgtQ1D\"")
buildConfigField("boolean", "TRACING_ENABLED", "false")
buildConfigField("boolean", "MESSAGE_BACKUP_RESTORE_ENABLED", "true")
buildConfigField("boolean", "LINK_DEVICE_UX_ENABLED", "false")
ndk {
@@ -378,7 +378,6 @@ android {
buildConfigField("boolean", "MANAGES_APP_UPDATES", "true")
buildConfigField("String", "APK_UPDATE_MANIFEST_URL", "\"${apkUpdateManifestUrl}\"")
buildConfigField("String", "BUILD_DISTRIBUTION_TYPE", "\"nightly\"")
buildConfigField("boolean", "MESSAGE_BACKUP_RESTORE_ENABLED", "true")
}
create("prod") {
@@ -405,7 +404,7 @@ android {
buildConfigField("String", "SVR2_MRENCLAVE_LEGACY_LEGACY", "\"38e01eff4fe357dc0b0e8ef7a44b4abc5489fbccba3a78780f3872c277f62bf3\"")
buildConfigField("String", "SVR2_MRENCLAVE_LEGACY", "\"2e8cefe6e3f389d8426adb24e9b7fb7adf10902c96f06f7bbcee36277711ed91\"")
buildConfigField("String", "SVR2_MRENCLAVE", "\"a75542d82da9f6914a1e31f8a7407053b99cc99a0e7291d8fbd394253e19b036\"")
buildConfigField("String", "UNIDENTIFIED_SENDER_TRUST_ROOT", "\"BbqY1DzohE4NUZoVF+L18oUPrK3kILllLEJh2UnPSsEx\"")
buildConfigField("String[]", "UNIDENTIFIED_SENDER_TRUST_ROOTS", "new String[]{\"BbqY1DzohE4NUZoVF+L18oUPrK3kILllLEJh2UnPSsEx\", \"BYhU6tPjqP46KGZEzRs1OL4U39V5dlPJ/X09ha4rErkm\"}")
buildConfigField("String", "ZKGROUP_SERVER_PUBLIC_PARAMS", "\"ABSY21VckQcbSXVNCGRYJcfWHiAMZmpTtTELcDmxgdFbtp/bWsSxZdMKzfCp8rvIs8ocCU3B37fT3r4Mi5qAemeGeR2X+/YmOGR5ofui7tD5mDQfstAI9i+4WpMtIe8KC3wU5w3Inq3uNWVmoGtpKndsNfwJrCg0Hd9zmObhypUnSkfYn2ooMOOnBpfdanRtrvetZUayDMSC5iSRcXKpdlukrpzzsCIvEwjwQlJYVPOQPj4V0F4UXXBdHSLK05uoPBCQG8G9rYIGedYsClJXnbrgGYG3eMTG5hnx4X4ntARBgELuMWWUEEfSK0mjXg+/2lPmWcTZWR9nkqgQQP0tbzuiPm74H2wMO4u1Wafe+UwyIlIT9L7KLS19Aw8r4sPrXZSSsOZ6s7M1+rTJN0bI5CKY2PX29y5Ok3jSWufIKcgKOnWoP67d5b2du2ZVJjpjfibNIHbT/cegy/sBLoFwtHogVYUewANUAXIaMPyCLRArsKhfJ5wBtTminG/PAvuBdJ70Z/bXVPf8TVsR292zQ65xwvWTejROW6AZX6aqucUjlENAErBme1YHmOSpU6tr6doJ66dPzVAWIanmO/5mgjNEDeK7DDqQdB1xd03HT2Qs2TxY3kCK8aAb/0iM0HQiXjxZ9HIgYhbtvGEnDKW5ILSUydqH/KBhW4Pb0jZWnqN/YgbWDKeJxnDbYcUob5ZY5Lt5ZCMKuaGUvCJRrCtuugSMaqjowCGRempsDdJEt+cMaalhZ6gczklJB/IbdwENW9KeVFPoFNFzhxWUIS5ML9riVYhAtE6JE5jX0xiHNVIIPthb458cfA8daR0nYfYAUKogQArm0iBezOO+mPk5vCNWI+wwkyFCqNDXz/qxl1gAntuCJtSfq9OC3NkdhQlgYQ==\"")
buildConfigField("String", "GENERIC_SERVER_PUBLIC_PARAMS", "\"AHILOIrFPXX9laLbalbA9+L1CXpSbM/bTJXZGZiuyK1JaI6dK5FHHWL6tWxmHKYAZTSYmElmJ5z2A5YcirjO/yfoemE03FItyaf8W1fE4p14hzb5qnrmfXUSiAIVrhaXVwIwSzH6RL/+EO8jFIjJ/YfExfJ8aBl48CKHgu1+A6kWynhttonvWWx6h7924mIzW0Czj2ROuh4LwQyZypex4GuOPW8sgIT21KNZaafgg+KbV7XM1x1tF3XA17B4uGUaDbDw2O+nR1+U5p6qHPzmJ7ggFjSN6Utu+35dS1sS0P9N\"")
buildConfigField("String", "BACKUP_SERVER_PUBLIC_PARAMS", "\"AHYrGb9IfugAAJiPKp+mdXUx+OL9zBolPYHYQz6GI1gWjpEu5me3zVNSvmYY4zWboZHif+HG1sDHSuvwFd0QszSwuSF4X4kRP3fJREdTZ5MCR0n55zUppTwfHRW2S4sdQ0JGz7YDQIJCufYSKh0pGNEHL6hv79Agrdnr4momr3oXdnkpVBIp3HWAQ6IbXQVSG18X36GaicI1vdT0UFmTwU2KTneluC2eyL9c5ff8PcmiS+YcLzh0OKYQXB5ZfQ06d6DiINvDQLy75zcfUOniLAj0lGJiHxGczin/RXisKSR8\"")
@@ -417,7 +416,6 @@ android {
buildConfigField("String", "BUILD_ENVIRONMENT_TYPE", "\"Staging\"")
buildConfigField("String", "STRIPE_PUBLISHABLE_KEY", "\"pk_test_sngOd8FnXNkpce9nPXawKrJD00kIDngZkD\"")
buildConfigField("boolean", "MESSAGE_BACKUP_RESTORE_ENABLED", "true")
}
create("backup") {
@@ -429,7 +427,6 @@ android {
buildConfigField("boolean", "MANAGES_APP_UPDATES", "true")
buildConfigField("String", "BUILD_ENVIRONMENT_TYPE", "\"Backup\"")
buildConfigField("boolean", "MESSAGE_BACKUP_RESTORE_ENABLED", "true")
}
}
@@ -511,7 +508,6 @@ dependencies {
implementation(project(":core-ui"))
implementation(libs.androidx.fragment.ktx)
implementation(libs.androidx.fragment.compose)
implementation(libs.androidx.appcompat) {
version {
strictly("1.6.1")
@@ -607,6 +603,7 @@ dependencies {
implementation(libs.rxdogtag)
implementation(libs.androidx.credentials)
implementation(libs.androidx.credentials.compat)
implementation(libs.kotlinx.serialization.json)
implementation(project(":billing"))

View File

@@ -21,7 +21,6 @@ import assertk.assertions.isEqualTo
import assertk.assertions.isNull
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockkStatic
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.StandardTestDispatcher
@@ -43,7 +42,6 @@ import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.testing.CoroutineDispatcherRule
import org.thoughtcrime.securesms.testing.InAppPaymentsRule
import org.thoughtcrime.securesms.testing.SignalActivityRule
import org.thoughtcrime.securesms.util.RemoteConfig
import java.math.BigDecimal
import java.util.Currency
@@ -67,9 +65,6 @@ class MessageBackupsCheckoutActivityTest {
every { AppDependencies.billingApi.getBillingPurchaseResults() } returns purchaseResults
coEvery { AppDependencies.billingApi.queryProduct() } returns BillingProduct(price = FiatMoney(BigDecimal.ONE, Currency.getInstance("USD")))
coEvery { AppDependencies.billingApi.launchBillingFlow(any()) } returns Unit
mockkStatic(RemoteConfig::class)
every { RemoteConfig.messageBackups } returns true
}
@Test

View File

@@ -36,6 +36,8 @@ import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.groups.GroupMigrationMembershipChange
import org.thoughtcrime.securesms.linkpreview.LinkPreview
import org.thoughtcrime.securesms.mediapreview.MediaIntentFactory
import org.thoughtcrime.securesms.polls.PollOption
import org.thoughtcrime.securesms.polls.PollRecord
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.stickers.StickerLocator
@@ -348,5 +350,11 @@ class V2ConversationItemShapeTest {
override fun onShowUnverifiedProfileSheet(forGroup: Boolean) = Unit
override fun onUpdateSignalClicked() = Unit
override fun onViewResultsClicked(pollId: Long) = Unit
override fun onViewPollClicked(messageId: Long) = Unit
override fun onToggleVote(poll: PollRecord, pollOption: PollOption, isChecked: Boolean) = Unit
}
}

View File

@@ -10,7 +10,7 @@ import org.junit.runner.RunWith
import org.signal.core.util.count
import org.signal.core.util.readToSingleInt
import org.thoughtcrime.securesms.backup.v2.ArchivedMediaObject
import org.thoughtcrime.securesms.database.BackupMediaSnapshotTable.ArchiveMediaItem
import org.thoughtcrime.securesms.database.BackupMediaSnapshotTable.MediaEntry
import org.thoughtcrime.securesms.testing.SignalActivityRule
@RunWith(AndroidJUnit4::class)
@@ -21,7 +21,7 @@ class BackupMediaSnapshotTableTest {
@Test
fun givenAnEmptyTable_whenIWriteToTable_thenIExpectEmptyTable() {
SignalDatabase.backupMediaSnapshots.writeFullSizePendingMediaObjects(generateArchiveMediaItemSequence(count = 100))
SignalDatabase.backupMediaSnapshots.writePendingMediaEntries(generateArchiveMediaItemSequence(count = 100))
val count = getCountForLatestSnapshot(includeThumbnails = true)
@@ -30,7 +30,7 @@ class BackupMediaSnapshotTableTest {
@Test
fun givenAnEmptyTable_whenIWriteToTableAndCommit_thenIExpectFilledTable() {
SignalDatabase.backupMediaSnapshots.writeFullSizePendingMediaObjects(generateArchiveMediaItemSequence(count = 100))
SignalDatabase.backupMediaSnapshots.writePendingMediaEntries(generateArchiveMediaItemSequence(count = 100))
SignalDatabase.backupMediaSnapshots.commitPendingRows()
val count = getCountForLatestSnapshot(includeThumbnails = false)
@@ -43,8 +43,8 @@ class BackupMediaSnapshotTableTest {
val inputCount = 100
val countWithThumbnails = inputCount * 2
SignalDatabase.backupMediaSnapshots.writeFullSizePendingMediaObjects(generateArchiveMediaItemSequence(count = inputCount))
SignalDatabase.backupMediaSnapshots.writeThumbnailPendingMediaObjects(generateArchiveMediaItemSequence(count = inputCount))
SignalDatabase.backupMediaSnapshots.writePendingMediaEntries(generateArchiveMediaItemSequence(count = inputCount))
SignalDatabase.backupMediaSnapshots.writePendingMediaEntries(generateArchiveMediaItemSequence(count = inputCount, thumbnail = true))
SignalDatabase.backupMediaSnapshots.commitPendingRows()
val count = getCountForLatestSnapshot(includeThumbnails = true)
@@ -52,40 +52,16 @@ class BackupMediaSnapshotTableTest {
assertThat(count).isEqualTo(countWithThumbnails)
}
@Test
fun givenAnEmptyTable_whenIWriteToTableAndCommitQuotes_thenIExpectFilledTableWithNoThumbnails() {
val inputCount = 100
SignalDatabase.backupMediaSnapshots.writeFullSizePendingMediaObjects(generateArchiveMediaItemSequence(count = inputCount, quote = true))
SignalDatabase.backupMediaSnapshots.commitPendingRows()
val count = getCountForLatestSnapshot(includeThumbnails = true)
assertThat(count).isEqualTo(inputCount)
}
@Test
fun givenAnEmptyTable_whenIWriteToTableAndCommitNonMedia_thenIExpectFilledTableWithNoThumbnails() {
val inputCount = 100
SignalDatabase.backupMediaSnapshots.writeFullSizePendingMediaObjects(generateArchiveMediaItemSequence(count = inputCount, contentType = "text/plain"))
SignalDatabase.backupMediaSnapshots.commitPendingRows()
val count = getCountForLatestSnapshot(includeThumbnails = true)
assertThat(count).isEqualTo(inputCount)
}
@Test
fun givenAFilledTable_whenIReinsertObjects_thenIExpectUncommittedOverrides() {
val initialCount = 100
val additionalCount = 25
SignalDatabase.backupMediaSnapshots.writeFullSizePendingMediaObjects(generateArchiveMediaItemSequence(count = initialCount))
SignalDatabase.backupMediaSnapshots.writePendingMediaEntries(generateArchiveMediaItemSequence(count = initialCount))
SignalDatabase.backupMediaSnapshots.commitPendingRows()
// This relies on how the sequence of mediaIds is generated in tests -- the ones we generate here will have the mediaIds as the ones we generated above
SignalDatabase.backupMediaSnapshots.writeFullSizePendingMediaObjects(generateArchiveMediaItemSequence(count = additionalCount))
SignalDatabase.backupMediaSnapshots.writePendingMediaEntries(generateArchiveMediaItemSequence(count = additionalCount))
val pendingCount = getCountForPending(includeThumbnails = false)
val latestVersionCount = getCountForLatestSnapshot(includeThumbnails = false)
@@ -99,11 +75,11 @@ class BackupMediaSnapshotTableTest {
val initialCount = 100
val additionalCount = 25
SignalDatabase.backupMediaSnapshots.writeFullSizePendingMediaObjects(generateArchiveMediaItemSequence(count = initialCount))
SignalDatabase.backupMediaSnapshots.writePendingMediaEntries(generateArchiveMediaItemSequence(count = initialCount))
SignalDatabase.backupMediaSnapshots.commitPendingRows()
// This relies on how the sequence of mediaIds is generated in tests -- the ones we generate here will have the mediaIds as the ones we generated above
SignalDatabase.backupMediaSnapshots.writeFullSizePendingMediaObjects(generateArchiveMediaItemSequence(count = additionalCount))
SignalDatabase.backupMediaSnapshots.writePendingMediaEntries(generateArchiveMediaItemSequence(count = additionalCount))
SignalDatabase.backupMediaSnapshots.commitPendingRows()
val pendingCount = getCountForPending(includeThumbnails = false)
@@ -120,10 +96,10 @@ class BackupMediaSnapshotTableTest {
val initialCount = 100
val additionalCount = 25
SignalDatabase.backupMediaSnapshots.writeFullSizePendingMediaObjects(generateArchiveMediaItemSequence(count = initialCount))
SignalDatabase.backupMediaSnapshots.writePendingMediaEntries(generateArchiveMediaItemSequence(count = initialCount))
SignalDatabase.backupMediaSnapshots.commitPendingRows()
SignalDatabase.backupMediaSnapshots.writeFullSizePendingMediaObjects(generateArchiveMediaItemSequence(count = additionalCount))
SignalDatabase.backupMediaSnapshots.writePendingMediaEntries(generateArchiveMediaItemSequence(count = additionalCount))
SignalDatabase.backupMediaSnapshots.commitPendingRows()
val page = SignalDatabase.backupMediaSnapshots.getPageOfOldMediaObjects(pageSize = 1_000)
@@ -146,7 +122,7 @@ class BackupMediaSnapshotTableTest {
createArchiveMediaObject(seed = 2, cdn = 2)
)
SignalDatabase.backupMediaSnapshots.writeFullSizePendingMediaObjects(localData.asSequence())
SignalDatabase.backupMediaSnapshots.writePendingMediaEntries(localData)
SignalDatabase.backupMediaSnapshots.commitPendingRows()
val mismatches = SignalDatabase.backupMediaSnapshots.getMediaObjectsWithNonMatchingCdn(remoteData)
@@ -165,7 +141,7 @@ class BackupMediaSnapshotTableTest {
createArchiveMediaObject(seed = 2, cdn = 99)
)
SignalDatabase.backupMediaSnapshots.writeFullSizePendingMediaObjects(localData.asSequence())
SignalDatabase.backupMediaSnapshots.writePendingMediaEntries(localData)
SignalDatabase.backupMediaSnapshots.commitPendingRows()
val mismatches = SignalDatabase.backupMediaSnapshots.getMediaObjectsWithNonMatchingCdn(remoteData)
@@ -187,7 +163,7 @@ class BackupMediaSnapshotTableTest {
createArchiveMediaObject(seed = 2, cdn = 2)
)
SignalDatabase.backupMediaSnapshots.writeFullSizePendingMediaObjects(localData.asSequence())
SignalDatabase.backupMediaSnapshots.writePendingMediaEntries(localData)
SignalDatabase.backupMediaSnapshots.commitPendingRows()
val notFound = SignalDatabase.backupMediaSnapshots.getMediaObjectsThatCantBeFound(remoteData)
@@ -206,7 +182,7 @@ class BackupMediaSnapshotTableTest {
createArchiveMediaObject(seed = 3, cdn = 2)
)
SignalDatabase.backupMediaSnapshots.writeFullSizePendingMediaObjects(localData.asSequence())
SignalDatabase.backupMediaSnapshots.writePendingMediaEntries(localData)
SignalDatabase.backupMediaSnapshots.commitPendingRows()
val notFound = SignalDatabase.backupMediaSnapshots.getMediaObjectsThatCantBeFound(remoteData)
@@ -223,7 +199,7 @@ class BackupMediaSnapshotTableTest {
@Test
fun getCurrentSnapshotVersion_singleCommit() {
SignalDatabase.backupMediaSnapshots.writeFullSizePendingMediaObjects(generateArchiveMediaItemSequence(count = 100))
SignalDatabase.backupMediaSnapshots.writePendingMediaEntries(generateArchiveMediaItemSequence(count = 100))
SignalDatabase.backupMediaSnapshots.commitPendingRows()
val version = SignalDatabase.backupMediaSnapshots.getCurrentSnapshotVersion()
@@ -235,15 +211,12 @@ class BackupMediaSnapshotTableTest {
fun getMediaObjectsLastSeenOnCdnBeforeSnapshotVersion_noneMarkedSeen() {
val initialCount = 100
SignalDatabase.backupMediaSnapshots.writeFullSizePendingMediaObjects(generateArchiveMediaItemSequence(count = initialCount))
SignalDatabase.backupMediaSnapshots.writeThumbnailPendingMediaObjects(generateArchiveMediaItemSequence(count = initialCount))
SignalDatabase.backupMediaSnapshots.writePendingMediaEntries(generateArchiveMediaItemSequence(count = initialCount))
SignalDatabase.backupMediaSnapshots.commitPendingRows()
val notSeenCount = SignalDatabase.backupMediaSnapshots.getMediaObjectsLastSeenOnCdnBeforeSnapshotVersion(1).count
val expectedOldCountIncludingThumbnails = initialCount * 2
assertThat(notSeenCount).isEqualTo(expectedOldCountIncludingThumbnails)
assertThat(notSeenCount).isEqualTo(initialCount)
}
@Test
@@ -251,23 +224,25 @@ class BackupMediaSnapshotTableTest {
val initialCount = 100
val markSeenCount = 25
val itemsToCommit = generateArchiveMediaItemSequence(count = initialCount)
SignalDatabase.backupMediaSnapshots.writeFullSizePendingMediaObjects(itemsToCommit)
SignalDatabase.backupMediaSnapshots.writeThumbnailPendingMediaObjects(itemsToCommit)
val fullSizeItems = generateArchiveMediaItemSequence(count = initialCount, thumbnail = false)
val thumbnailItems = generateArchiveMediaItemSequence(count = initialCount, thumbnail = true)
SignalDatabase.backupMediaSnapshots.writePendingMediaEntries(fullSizeItems)
SignalDatabase.backupMediaSnapshots.writePendingMediaEntries(thumbnailItems)
SignalDatabase.backupMediaSnapshots.commitPendingRows()
val normalIdsToMarkSeen = itemsToCommit.take(markSeenCount).map { it.mediaId }.toList()
val thumbnailIdsToMarkSeen = itemsToCommit.take(markSeenCount).map { it.thumbnailMediaId }.toList()
val allItemsToMarkSeen = normalIdsToMarkSeen + thumbnailIdsToMarkSeen
val fullSizeIdsToMarkSeen = fullSizeItems.take(markSeenCount).map { it.mediaId }.toList()
val thumbnailIdsToMarkSeen = thumbnailItems.take(markSeenCount).map { it.mediaId }.toList()
SignalDatabase.backupMediaSnapshots.markSeenOnRemote(allItemsToMarkSeen, 1)
SignalDatabase.backupMediaSnapshots.markSeenOnRemote(fullSizeIdsToMarkSeen, 1)
SignalDatabase.backupMediaSnapshots.markSeenOnRemote(thumbnailIdsToMarkSeen, 1)
val notSeenCount = SignalDatabase.backupMediaSnapshots.getMediaObjectsLastSeenOnCdnBeforeSnapshotVersion(1).count
val expectedOldCount = initialCount - markSeenCount
val expectedOldCountIncludingThumbnails = expectedOldCount * 2
val expectedOldCount = (initialCount * 2) - (markSeenCount * 2)
assertThat(notSeenCount).isEqualTo(expectedOldCountIncludingThumbnails)
assertThat(notSeenCount).isEqualTo(expectedOldCount)
}
private fun getTotalItemCount(includeThumbnails: Boolean): Int {
@@ -317,28 +292,30 @@ class BackupMediaSnapshotTableTest {
.readToSingleInt(0)
}
private fun generateArchiveMediaItemSequence(count: Int, quote: Boolean = false, contentType: String = "image/jpeg"): Sequence<ArchiveMediaItem> {
private fun generateArchiveMediaItemSequence(count: Int, thumbnail: Boolean = false): Collection<MediaEntry> {
return (1..count)
.asSequence()
.map { createArchiveMediaItem(it, quote = quote, contentType = contentType) }
.map { createArchiveMediaItem(it, thumbnail = thumbnail) }
.toList()
}
private fun createArchiveMediaItem(seed: Int, cdn: Int = 0, quote: Boolean = false, contentType: String = "image/jpeg"): ArchiveMediaItem {
return ArchiveMediaItem(
mediaId = "media_id_$seed",
thumbnailMediaId = "thumbnail_media_id_$seed",
private fun createArchiveMediaItem(seed: Int, thumbnail: Boolean = false, cdn: Int = 0): MediaEntry {
return MediaEntry(
mediaId = mediaId(seed, thumbnail),
cdn = cdn,
plaintextHash = Util.toByteArray(seed),
remoteKey = Util.toByteArray(seed),
quote = quote,
contentType = contentType
isThumbnail = thumbnail
)
}
private fun createArchiveMediaObject(seed: Int, cdn: Int = 0): ArchivedMediaObject {
private fun createArchiveMediaObject(seed: Int, thumbnail: Boolean = false, cdn: Int = 0): ArchivedMediaObject {
return ArchivedMediaObject(
mediaId = "media_id_$seed",
mediaId = mediaId(seed, thumbnail),
cdn = cdn
)
}
fun mediaId(seed: Int, thumbnail: Boolean): String {
return "media_id_${seed}_$thumbnail"
}
}

View File

@@ -9,15 +9,10 @@ import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Test
import org.signal.core.util.readToSingleObject
import org.signal.core.util.requireLongOrNull
import org.signal.core.util.select
import org.signal.core.util.update
import org.signal.libsignal.protocol.ecc.ECKeyPair
import org.signal.libsignal.protocol.kem.KEMKeyPair
import org.signal.libsignal.protocol.kem.KEMKeyType
import org.signal.libsignal.protocol.state.KyberPreKeyRecord
import org.whispersystems.signalservice.api.push.ServiceId
import org.signal.libsignal.protocol.ReusedBaseKeyException
import org.thoughtcrime.securesms.util.KyberPreKeysTestUtil.generateECPublicKey
import org.thoughtcrime.securesms.util.KyberPreKeysTestUtil.getStaleTime
import org.thoughtcrime.securesms.util.KyberPreKeysTestUtil.insertTestRecord
import org.whispersystems.signalservice.api.push.ServiceId.ACI
import org.whispersystems.signalservice.api.push.ServiceId.PNI
import java.util.UUID
@@ -142,42 +137,43 @@ class KyberPreKeyTableTest {
assertNotNull(getStaleTime(aci, 3))
}
private fun insertTestRecord(account: ServiceId, id: Int, staleTime: Long = 0, lastResort: Boolean = false) {
val kemKeyPair = KEMKeyPair.generate(KEMKeyType.KYBER_1024)
SignalDatabase.kyberPreKeys.insert(
serviceId = account,
keyId = id,
record = KyberPreKeyRecord(
id,
System.currentTimeMillis(),
kemKeyPair,
ECKeyPair.generate().privateKey.calculateSignature(kemKeyPair.publicKey.serialize())
),
lastResort = lastResort
@Test(expected = ReusedBaseKeyException::class)
fun handleMarkKyberPreKeyUsed_doesNotAllowDuplicateLastResortKeyEntries() {
insertTestRecord(aci, id = 1, staleTime = 10, lastResort = true)
val publicKey = generateECPublicKey()
SignalDatabase.kyberPreKeys.handleMarkKyberPreKeyUsed(
serviceId = aci,
kyberPreKeyId = 1,
signedPreKeyId = 1,
baseKey = publicKey
)
val count = SignalDatabase.rawDatabase
.update(KyberPreKeyTable.TABLE_NAME)
.values(KyberPreKeyTable.STALE_TIMESTAMP to staleTime)
.where("${KyberPreKeyTable.ACCOUNT_ID} = ? AND ${KyberPreKeyTable.KEY_ID} = $id", account.toAccountId())
.run()
assertEquals(1, count)
SignalDatabase.kyberPreKeys.handleMarkKyberPreKeyUsed(
serviceId = aci,
kyberPreKeyId = 1,
signedPreKeyId = 1,
baseKey = publicKey
)
}
private fun getStaleTime(account: ServiceId, id: Int): Long? {
return SignalDatabase.rawDatabase
.select(KyberPreKeyTable.STALE_TIMESTAMP)
.from(KyberPreKeyTable.TABLE_NAME)
.where("${KyberPreKeyTable.ACCOUNT_ID} = ? AND ${KyberPreKeyTable.KEY_ID} = $id", account.toAccountId())
.run()
.readToSingleObject { it.requireLongOrNull(KyberPreKeyTable.STALE_TIMESTAMP) }
}
@Test
fun handleMarkKyberPreKeyUsed_allowDuplicateNonLastResortKeyEntries() {
insertTestRecord(aci, id = 1, staleTime = 10, lastResort = false)
val publicKey = generateECPublicKey()
private fun ServiceId.toAccountId(): String {
return when (this) {
is ACI -> this.toString()
is PNI -> KyberPreKeyTable.PNI_ACCOUNT_ID
}
SignalDatabase.kyberPreKeys.handleMarkKyberPreKeyUsed(
serviceId = aci,
kyberPreKeyId = 1,
signedPreKeyId = 1,
baseKey = publicKey
)
SignalDatabase.kyberPreKeys.handleMarkKyberPreKeyUsed(
serviceId = aci,
kyberPreKeyId = 1,
signedPreKeyId = 1,
baseKey = publicKey
)
}
}

View File

@@ -0,0 +1,131 @@
package org.thoughtcrime.securesms.database
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.signal.core.util.deleteAll
import org.thoughtcrime.securesms.database.model.MessageId
import org.thoughtcrime.securesms.mms.IncomingMessage
import org.thoughtcrime.securesms.polls.PollOption
import org.thoughtcrime.securesms.polls.PollRecord
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.testing.SignalActivityRule
@RunWith(AndroidJUnit4::class)
class PollTablesTest {
@get:Rule
val harness = SignalActivityRule()
private lateinit var poll1: PollRecord
@Before
fun setUp() {
poll1 = PollRecord(
id = 1,
question = "how do you feel about unit testing?",
pollOptions = listOf(
PollOption(1, "yay", listOf(1)),
PollOption(2, "ok", emptyList()),
PollOption(3, "nay", emptyList())
),
allowMultipleVotes = false,
hasEnded = false,
authorId = 1,
messageId = 1
)
SignalDatabase.polls.writableDatabase.deleteAll(PollTables.PollTable.TABLE_NAME)
SignalDatabase.polls.writableDatabase.deleteAll(PollTables.PollOptionTable.TABLE_NAME)
SignalDatabase.polls.writableDatabase.deleteAll(PollTables.PollVoteTable.TABLE_NAME)
val message = IncomingMessage(type = MessageType.NORMAL, from = harness.others[0], sentTimeMillis = 100, serverTimeMillis = 100, receivedTimeMillis = 100)
SignalDatabase.messages.insertMessageInbox(message, SignalDatabase.threads.getOrCreateThreadIdFor(harness.others[0], isGroup = false))
}
@Test
fun givenAPollWithVoting_whenIGetPoll_thenIExpectThatPoll() {
SignalDatabase.polls.insertPoll("how do you feel about unit testing?", false, listOf("yay", "ok", "nay"), 1, 1)
SignalDatabase.polls.insertVotes(pollId = 1, pollOptionIds = listOf(1), voterId = 1, voteCount = 1, messageId = MessageId(1))
assertEquals(poll1, SignalDatabase.polls.getPoll(1))
}
@Test
fun givenAPoll_whenIGetItsOptionIds_thenIExpectAllOptionsIds() {
SignalDatabase.polls.insertPoll("how do you feel about unit testing?", false, listOf("yay", "ok", "nay"), 1, 1)
assertEquals(poll1.pollOptions.map { it.id }, SignalDatabase.polls.getPollOptionIds(1))
}
@Test
fun givenAPollAndVoter_whenIGetItsVoteCount_thenIExpectTheCorrectVoterCount() {
SignalDatabase.polls.insertPoll("how do you feel about unit testing?", false, listOf("yay", "ok", "nay"), 1, 1)
SignalDatabase.polls.insertVotes(pollId = 1, pollOptionIds = listOf(1), voterId = 1, voteCount = 1, messageId = MessageId(1))
SignalDatabase.polls.insertVotes(pollId = 1, pollOptionIds = listOf(2), voterId = 2, voteCount = 2, messageId = MessageId(1))
SignalDatabase.polls.insertVotes(pollId = 1, pollOptionIds = listOf(3), voterId = 3, voteCount = 3, messageId = MessageId(1))
assertEquals(1, SignalDatabase.polls.getCurrentPollVoteCount(1, 1))
assertEquals(2, SignalDatabase.polls.getCurrentPollVoteCount(1, 2))
assertEquals(3, SignalDatabase.polls.getCurrentPollVoteCount(1, 3))
}
@Test
fun givenMultipleRoundsOfVoting_whenIGetItsCount_thenIExpectTheMostRecentResults() {
SignalDatabase.polls.insertPoll("how do you feel about unit testing?", false, listOf("yay", "ok", "nay"), 1, 1)
SignalDatabase.polls.insertVotes(pollId = 1, pollOptionIds = listOf(2), voterId = 1, voteCount = 1, messageId = MessageId(1))
SignalDatabase.polls.insertVotes(pollId = 1, pollOptionIds = listOf(3), voterId = 1, voteCount = 2, messageId = MessageId(1))
SignalDatabase.polls.insertVotes(pollId = 1, pollOptionIds = listOf(1), voterId = 1, voteCount = 3, messageId = MessageId(1))
assertEquals(poll1, SignalDatabase.polls.getPoll(1))
}
@Test
fun givenAPoll_whenITerminateIt_thenIExpectItToEnd() {
SignalDatabase.polls.insertPoll("how do you feel about unit testing?", false, listOf("yay", "ok", "nay"), 1, 1)
SignalDatabase.polls.endPoll(1, System.currentTimeMillis())
assertEquals(true, SignalDatabase.polls.getPoll(1)!!.hasEnded)
}
@Test
fun givenAPoll_whenIIVote_thenIExpectThatVote() {
SignalDatabase.polls.insertPoll("how do you feel about unit testing?", false, listOf("yay", "ok", "nay"), 1, 1)
val poll = SignalDatabase.polls.getPoll(1)!!
val pollOption = poll.pollOptions.first()
val voteCount = SignalDatabase.polls.insertVote(poll, pollOption)
assertEquals(1, voteCount)
assertEquals(listOf(0), SignalDatabase.polls.getVotes(poll.id, false))
}
@Test
fun givenAPoll_whenIRemoveVote_thenVoteIsCleared() {
SignalDatabase.polls.insertPoll("how do you feel about unit testing?", false, listOf("yay", "ok", "nay"), 1, 1)
val poll = SignalDatabase.polls.getPoll(1)!!
val pollOption = poll.pollOptions.first()
val voteCount = SignalDatabase.polls.removeVote(poll, pollOption)
SignalDatabase.polls.markPendingAsRemoved(poll.id, Recipient.self().id.toLong(), voteCount, 1)
assertEquals(1, voteCount)
val status = SignalDatabase.polls.getPollVoteStateForGivenVote(poll.id, voteCount)
assertEquals(PollTables.VoteState.REMOVED, status)
}
@Test
fun givenAVote_whenISetPollOptionId_thenOptionIdIsUpdated() {
SignalDatabase.polls.insertPoll("how do you feel about unit testing?", false, listOf("yay", "ok", "nay"), 1, 1)
val poll = SignalDatabase.polls.getPoll(1)!!
val option = poll.pollOptions.first()
SignalDatabase.polls.insertVotes(poll.id, listOf(option.id), Recipient.self().id.toLong(), 5, MessageId(1))
SignalDatabase.polls.setPollVoteStateForGivenVote(poll.id, Recipient.self().id.toLong(), 5, 1, true)
val status = SignalDatabase.polls.getPollVoteStateForGivenVote(poll.id, 5)
assertEquals(PollTables.VoteState.ADDED, status)
}
}

View File

@@ -26,6 +26,7 @@ import org.thoughtcrime.securesms.testing.runSync
import org.thoughtcrime.securesms.testing.success
import org.whispersystems.signalservice.api.SignalServiceDataStore
import org.whispersystems.signalservice.api.SignalServiceMessageSender
import org.whispersystems.signalservice.api.account.AccountApi
import org.whispersystems.signalservice.api.archive.ArchiveApi
import org.whispersystems.signalservice.api.attachment.AttachmentApi
import org.whispersystems.signalservice.api.donations.DonationsApi
@@ -56,6 +57,7 @@ class InstrumentationApplicationDependencyProvider(val application: Application,
private val recipientCache: LiveRecipientCache
private var signalServiceMessageSender: SignalServiceMessageSender? = null
private var billingApi: BillingApi = mockk()
private var accountApi: AccountApi = mockk()
init {
runSync {
@@ -118,6 +120,8 @@ class InstrumentationApplicationDependencyProvider(val application: Application,
override fun provideBillingApi(): BillingApi = billingApi
override fun provideAccountApi(authWebSocket: SignalWebSocket.AuthenticatedWebSocket): AccountApi = accountApi
override fun provideSignalServiceNetworkAccess(): SignalServiceNetworkAccess {
return serviceNetworkAccessMock
}

View File

@@ -38,7 +38,6 @@ class BackupDeleteJobTest {
@Before
fun setUp() {
mockkObject(RemoteConfig)
every { RemoteConfig.messageBackups } returns true
every { RemoteConfig.internalUser } returns true
every { RemoteConfig.defaultMaxBackoff } returns 1000L
@@ -53,17 +52,6 @@ class BackupDeleteJobTest {
unmockkAll()
}
@Test
fun givenBackupsNotEnabled_whenIRun_thenIExpectFailure() {
every { RemoteConfig.messageBackups } returns false
val job = BackupDeleteJob()
val result = job.run()
assertThat(result.isFailure).isTrue()
}
@Test
fun givenUserNotRegistered_whenIRun_thenIExpectFailure() {
mockkObject(SignalStore) {

View File

@@ -12,10 +12,10 @@ import assertk.assertions.isTrue
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkObject
import io.mockk.unmockkAll
import io.mockk.verify
import okio.IOException
import org.junit.After
import org.junit.Before
import org.junit.Rule
@@ -24,11 +24,13 @@ import org.junit.runner.RunWith
import org.signal.core.util.billing.BillingProduct
import org.signal.core.util.billing.BillingPurchaseResult
import org.signal.core.util.billing.BillingPurchaseState
import org.signal.core.util.billing.BillingResponseCode
import org.signal.core.util.money.FiatMoney
import org.signal.donations.InAppPaymentType
import org.thoughtcrime.securesms.backup.DeletionState
import org.thoughtcrime.securesms.backup.v2.BackupRepository
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
import org.thoughtcrime.securesms.components.settings.app.backups.BackupStateObserver
import org.thoughtcrime.securesms.components.settings.app.subscription.RecurringInAppPaymentRepository
import org.thoughtcrime.securesms.database.InAppPaymentTable
import org.thoughtcrime.securesms.database.SignalDatabase
@@ -37,6 +39,7 @@ import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobmanager.Job
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.net.SignalNetwork
import org.thoughtcrime.securesms.testing.SignalActivityRule
import org.thoughtcrime.securesms.util.RemoteConfig
import org.whispersystems.signalservice.api.NetworkResult
@@ -46,6 +49,7 @@ import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription.ChargeFailure
import org.whispersystems.signalservice.api.subscriptions.SubscriberId
import org.whispersystems.signalservice.internal.push.SubscriptionsConfiguration
import org.whispersystems.signalservice.internal.push.WhoAmIResponse
import java.math.BigDecimal
import java.util.Currency
import kotlin.time.Duration.Companion.days
@@ -64,17 +68,24 @@ class BackupSubscriptionCheckJobTest {
@Before
fun setUp() {
mockkObject(RemoteConfig)
every { RemoteConfig.messageBackups } returns true
every { RemoteConfig.internalUser } returns true
coEvery { AppDependencies.billingApi.isApiAvailable() } returns true
coEvery { AppDependencies.billingApi.queryPurchases() } returns mockk()
coEvery { AppDependencies.billingApi.queryProduct() } returns null
coEvery { AppDependencies.billingApi.getApiAvailability() } returns BillingResponseCode.OK
coEvery { AppDependencies.billingApi.queryPurchases() } returns BillingPurchaseResult.Success(
purchaseState = BillingPurchaseState.PURCHASED,
purchaseToken = "test-token",
isAcknowledged = true,
isAutoRenewing = true,
purchaseTime = System.currentTimeMillis()
)
coEvery { AppDependencies.billingApi.queryProduct() } returns BillingProduct(price = FiatMoney(BigDecimal.ONE, Currency.getInstance("USD")))
SignalStore.backup.backupTier = MessageBackupTier.PAID
mockkObject(RecurringInAppPaymentRepository)
every { RecurringInAppPaymentRepository.getActiveSubscriptionSync(InAppPaymentSubscriberRecord.Type.BACKUP) } returns Result.success(
every { RecurringInAppPaymentRepository.getActiveSubscriptionSync(InAppPaymentSubscriberRecord.Type.BACKUP) } returns NetworkResult.Success(
createActiveSubscription()
)
@@ -97,6 +108,18 @@ class BackupSubscriptionCheckJobTest {
}
}
every { BackupRepository.resetInitializedStateAndAuthCredentials() } returns Unit
mockkObject(BackupStateObserver)
every { BackupStateObserver.notifyBackupStateChanged() } returns Unit
mockkObject(SignalNetwork)
every { AppDependencies.accountApi.whoAmI() } returns NetworkResult.Success(
WhoAmIResponse(
number = "+1234567890"
)
)
every { AppDependencies.donationsApi.putSubscription(any()) } returns NetworkResult.Success(Unit)
insertSubscriber()
@@ -143,7 +166,7 @@ class BackupSubscriptionCheckJobTest {
@Test
fun givenBillingApiNotAvailable_whenIRun_thenIExpectSuccessAndEarlyExit() {
coEvery { AppDependencies.billingApi.isApiAvailable() } returns false
coEvery { AppDependencies.billingApi.getApiAvailability() } returns BillingResponseCode.BILLING_UNAVAILABLE
val job = BackupSubscriptionCheckJob.create()
val result = job.run()
@@ -173,9 +196,18 @@ class BackupSubscriptionCheckJobTest {
assertEarlyExit(result)
}
@Test
fun givenPrePendingRecurringTransaction_whenIRun_thenIExpectSuccessAndEarlyExit() {
insertPrePendingInAppPayment()
val job = BackupSubscriptionCheckJob.create()
val result = job.run()
assertEarlyExit(result)
}
@Test
fun givenAPendingPayment_whenIRun_thenIExpectSuccessAndEarlyExit() {
mockProduct()
insertPendingInAppPayment()
val job = BackupSubscriptionCheckJob.create()
@@ -187,9 +219,7 @@ class BackupSubscriptionCheckJobTest {
@Test
fun givenInactiveSubscription_whenIRun_thenIExpectStateMismatchDetected() {
mockProduct()
every { RecurringInAppPaymentRepository.getActiveSubscriptionSync(InAppPaymentSubscriberRecord.Type.BACKUP) } returns Result.success(
every { RecurringInAppPaymentRepository.getActiveSubscriptionSync(InAppPaymentSubscriberRecord.Type.BACKUP) } returns NetworkResult.Success(
createActiveSubscription(isActive = false)
)
@@ -201,9 +231,9 @@ class BackupSubscriptionCheckJobTest {
}
@Test
fun givenRepositoryFailure_whenIRun_thenIExpectFailureResult() {
every { RecurringInAppPaymentRepository.getActiveSubscriptionSync(InAppPaymentSubscriberRecord.Type.BACKUP) } returns Result.failure(
RuntimeException("Network error")
fun givenAnApplicationErrorWhenAccessingTheActiveSubscription_whenIRun_thenIExpectAFailure() {
every { RecurringInAppPaymentRepository.getActiveSubscriptionSync(InAppPaymentSubscriberRecord.Type.BACKUP) } returns NetworkResult.ApplicationError(
RuntimeException("Application Error")
)
val job = BackupSubscriptionCheckJob.create()
@@ -213,8 +243,10 @@ class BackupSubscriptionCheckJobTest {
}
@Test
fun givenBillingApiReturnsAFailure_whenIRun_thenIExpectFailureResult() {
coEvery { AppDependencies.billingApi.queryPurchases() } returns BillingPurchaseResult.BillingUnavailable
fun givenANetworkErrorWhenAccessingTheActiveSubscription_whenIRun_thenIExpectAFailure() {
every { RecurringInAppPaymentRepository.getActiveSubscriptionSync(InAppPaymentSubscriberRecord.Type.BACKUP) } returns NetworkResult.NetworkError(
IOException()
)
val job = BackupSubscriptionCheckJob.create()
val result = job.run()
@@ -223,10 +255,32 @@ class BackupSubscriptionCheckJobTest {
}
@Test
fun givenPastDueSubscription_whenIRun_thenIExpectStateMismatchDetected() {
mockProduct()
fun givenAStatusCodeErrorWhenAccessingTheActiveSubscription_whenIRun_thenIExpectAMismatch() {
every { RecurringInAppPaymentRepository.getActiveSubscriptionSync(InAppPaymentSubscriberRecord.Type.BACKUP) } returns NetworkResult.StatusCodeError(
NonSuccessfulResponseCodeException(404)
)
every { RecurringInAppPaymentRepository.getActiveSubscriptionSync(InAppPaymentSubscriberRecord.Type.BACKUP) } returns Result.success(
val job = BackupSubscriptionCheckJob.create()
val result = job.run()
assertThat(result.isSuccess).isTrue()
assertThat(SignalStore.backup.subscriptionStateMismatchDetected).isTrue()
}
@Test
fun givenBillingApiReturnsAFailure_whenIRun_thenIExpectSuccessAndEarlyExit() {
coEvery { AppDependencies.billingApi.queryPurchases() } returns BillingPurchaseResult.BillingUnavailable
val job = BackupSubscriptionCheckJob.create()
val result = job.run()
assertThat(result.isSuccess).isTrue()
assertThat(SignalStore.backup.subscriptionStateMismatchDetected).isFalse()
}
@Test
fun givenPastDueSubscription_whenIRun_thenIExpectStateMismatchDetected() {
every { RecurringInAppPaymentRepository.getActiveSubscriptionSync(InAppPaymentSubscriberRecord.Type.BACKUP) } returns NetworkResult.Success(
createActiveSubscription(
isActive = false,
billingPeriodEndSeconds = System.currentTimeMillis().milliseconds.inWholeSeconds - 1.days.inWholeSeconds,
@@ -243,9 +297,7 @@ class BackupSubscriptionCheckJobTest {
@Test
fun givenCancelledSubscription_whenIRun_thenIExpectStateMismatchDetected() {
mockProduct()
every { RecurringInAppPaymentRepository.getActiveSubscriptionSync(InAppPaymentSubscriberRecord.Type.BACKUP) } returns Result.success(
every { RecurringInAppPaymentRepository.getActiveSubscriptionSync(InAppPaymentSubscriberRecord.Type.BACKUP) } returns NetworkResult.Success(
createActiveSubscription(
isActive = false,
status = "canceled",
@@ -262,9 +314,7 @@ class BackupSubscriptionCheckJobTest {
@Test
fun givenFreeBackupTier_whenIRun_thenIExpectSuccessAndEarlyExit() {
mockProduct()
every { RecurringInAppPaymentRepository.getActiveSubscriptionSync(InAppPaymentSubscriberRecord.Type.BACKUP) } returns Result.success(
every { RecurringInAppPaymentRepository.getActiveSubscriptionSync(InAppPaymentSubscriberRecord.Type.BACKUP) } returns NetworkResult.Success(
ActiveSubscription.EMPTY
)
@@ -277,25 +327,12 @@ class BackupSubscriptionCheckJobTest {
assertThat(SignalStore.backup.subscriptionStateMismatchDetected).isFalse()
}
@Test
fun givenFailedInAppPayment_whenIRun_thenIExpectStateMismatchDetected() {
mockProduct()
insertFailedInAppPayment()
val job = BackupSubscriptionCheckJob.create()
val result = job.run()
assertThat(result.isSuccess).isTrue()
assertThat(SignalStore.backup.subscriptionStateMismatchDetected).isTrue()
}
@Test
fun givenActiveSignalSubscriptionWithTokenMismatch_whenIRun_thenIExpectTokenRedemption() {
mockProduct()
mockActivePurchase()
insertSubscriber("mismatch")
every { RecurringInAppPaymentRepository.getActiveSubscriptionSync(InAppPaymentSubscriberRecord.Type.BACKUP) } returns Result.success(
every { RecurringInAppPaymentRepository.getActiveSubscriptionSync(InAppPaymentSubscriberRecord.Type.BACKUP) } returns NetworkResult.Success(
createActiveSubscription(isActive = true)
)
@@ -315,10 +352,9 @@ class BackupSubscriptionCheckJobTest {
@Test
fun givenActiveSubscriptionAndPurchaseWithoutEntitlement_whenIRun_thenIExpectRedemption() {
mockProduct()
mockActivePurchase()
every { RecurringInAppPaymentRepository.getActiveSubscriptionSync(InAppPaymentSubscriberRecord.Type.BACKUP) } returns Result.success(
every { RecurringInAppPaymentRepository.getActiveSubscriptionSync(InAppPaymentSubscriberRecord.Type.BACKUP) } returns NetworkResult.Success(
createActiveSubscription(isActive = true)
)
@@ -342,10 +378,9 @@ class BackupSubscriptionCheckJobTest {
@Test
fun givenValidActiveState_whenIRun_thenIExpectSuccessAndNoMismatch() {
mockProduct()
mockActivePurchase()
every { RecurringInAppPaymentRepository.getActiveSubscriptionSync(InAppPaymentSubscriberRecord.Type.BACKUP) } returns Result.success(
every { RecurringInAppPaymentRepository.getActiveSubscriptionSync(InAppPaymentSubscriberRecord.Type.BACKUP) } returns NetworkResult.Success(
createActiveSubscription(isActive = true)
)
@@ -360,10 +395,9 @@ class BackupSubscriptionCheckJobTest {
@Test
fun givenValidInactiveState_whenIRun_thenIExpectSuccessAndNoMismatch() {
mockProduct()
mockInactivePurchase()
every { RecurringInAppPaymentRepository.getActiveSubscriptionSync(InAppPaymentSubscriberRecord.Type.BACKUP) } returns Result.success(
every { RecurringInAppPaymentRepository.getActiveSubscriptionSync(InAppPaymentSubscriberRecord.Type.BACKUP) } returns NetworkResult.Success(
createActiveSubscription(isActive = false)
)
@@ -379,10 +413,9 @@ class BackupSubscriptionCheckJobTest {
@Test
fun givenGooglePlayBillingCanceledWithoutActiveSignalSubscription_whenIRun_thenIExpectValidCancelState() {
mockProduct()
mockCanceledPurchase()
every { RecurringInAppPaymentRepository.getActiveSubscriptionSync(InAppPaymentSubscriberRecord.Type.BACKUP) } returns Result.success(
every { RecurringInAppPaymentRepository.getActiveSubscriptionSync(InAppPaymentSubscriberRecord.Type.BACKUP) } returns NetworkResult.Success(
createActiveSubscription(isActive = false)
)
@@ -395,10 +428,9 @@ class BackupSubscriptionCheckJobTest {
@Test
fun givenGooglePlayBillingCanceledWithFailedSignalSubscription_whenIRun_thenIExpectValidCancelState() {
mockProduct()
mockCanceledPurchase()
every { RecurringInAppPaymentRepository.getActiveSubscriptionSync(InAppPaymentSubscriberRecord.Type.BACKUP) } returns Result.success(
every { RecurringInAppPaymentRepository.getActiveSubscriptionSync(InAppPaymentSubscriberRecord.Type.BACKUP) } returns NetworkResult.Success(
createActiveSubscription(isActive = true, status = "past_due", chargeFailure = ChargeFailure("test", "", "", "", ""))
)
@@ -411,11 +443,10 @@ class BackupSubscriptionCheckJobTest {
@Test
fun givenInvalidStateConfiguration_whenIRun_thenIExpectStateMismatchDetected() {
mockProduct()
mockActivePurchase()
// Create invalid state: active purchase but no active subscription, with paid tier
every { RecurringInAppPaymentRepository.getActiveSubscriptionSync(InAppPaymentSubscriberRecord.Type.BACKUP) } returns Result.success(
every { RecurringInAppPaymentRepository.getActiveSubscriptionSync(InAppPaymentSubscriberRecord.Type.BACKUP) } returns NetworkResult.Success(
createActiveSubscription(isActive = false)
)
@@ -430,10 +461,9 @@ class BackupSubscriptionCheckJobTest {
@Test
fun givenActiveSubscriptionWithMismatchedZkCredentials_whenIRun_thenIExpectCredentialRefresh() {
mockProduct()
mockActivePurchase()
every { RecurringInAppPaymentRepository.getActiveSubscriptionSync(InAppPaymentSubscriberRecord.Type.BACKUP) } returns Result.success(
every { RecurringInAppPaymentRepository.getActiveSubscriptionSync(InAppPaymentSubscriberRecord.Type.BACKUP) } returns NetworkResult.Success(
createActiveSubscription(isActive = true)
)
@@ -452,10 +482,9 @@ class BackupSubscriptionCheckJobTest {
@Test
fun givenActiveSubscriptionWithSyncedZkCredentials_whenIRun_thenIExpectNoCredentialRefresh() {
mockProduct()
mockActivePurchase()
every { RecurringInAppPaymentRepository.getActiveSubscriptionSync(InAppPaymentSubscriberRecord.Type.BACKUP) } returns Result.success(
every { RecurringInAppPaymentRepository.getActiveSubscriptionSync(InAppPaymentSubscriberRecord.Type.BACKUP) } returns NetworkResult.Success(
createActiveSubscription(isActive = true)
)
@@ -472,10 +501,9 @@ class BackupSubscriptionCheckJobTest {
@Test
fun givenActiveSubscriptionWithZkCredentialFailure_whenIRun_thenIExpectCredentialRefresh() {
mockProduct()
mockActivePurchase()
every { RecurringInAppPaymentRepository.getActiveSubscriptionSync(InAppPaymentSubscriberRecord.Type.BACKUP) } returns Result.success(
every { RecurringInAppPaymentRepository.getActiveSubscriptionSync(InAppPaymentSubscriberRecord.Type.BACKUP) } returns NetworkResult.Success(
createActiveSubscription(isActive = true)
)
@@ -493,11 +521,10 @@ class BackupSubscriptionCheckJobTest {
@Test
fun givenSubscriptionWillCancelAtPeriodEnd_whenIRun_thenIExpectValidCancelState() {
mockProduct()
mockCanceledPurchase()
// Create subscription that will cancel at period end
every { RecurringInAppPaymentRepository.getActiveSubscriptionSync(InAppPaymentSubscriberRecord.Type.BACKUP) } returns Result.success(
every { RecurringInAppPaymentRepository.getActiveSubscriptionSync(InAppPaymentSubscriberRecord.Type.BACKUP) } returns NetworkResult.Success(
createActiveSubscription(isActive = true, cancelled = true) // cancelled = true means willCancelAtPeriodEnd
)
@@ -510,11 +537,10 @@ class BackupSubscriptionCheckJobTest {
@Test
fun givenActiveSubscriptionNotWillCancelAtPeriodEnd_whenIRun_thenIExpectZkSynchronization() {
mockProduct()
mockActivePurchase()
// Create active subscription that won't cancel at period end
every { RecurringInAppPaymentRepository.getActiveSubscriptionSync(InAppPaymentSubscriberRecord.Type.BACKUP) } returns Result.success(
every { RecurringInAppPaymentRepository.getActiveSubscriptionSync(InAppPaymentSubscriberRecord.Type.BACKUP) } returns NetworkResult.Success(
createActiveSubscription(isActive = true, cancelled = false)
)
@@ -528,6 +554,37 @@ class BackupSubscriptionCheckJobTest {
verify { BackupRepository.getBackupTierWithoutDowngrade() }
}
@Test
fun givenSubscriptionWillCancelWithValidEntitlement_whenIRun_thenIExpectBackupStateNotification() {
mockCanceledPurchase()
every { AppDependencies.accountApi.whoAmI() } returns NetworkResult.Success(
WhoAmIResponse(
number = "+1234567890",
entitlements = WhoAmIResponse.Entitlements(
backup = WhoAmIResponse.BackupEntitlement(
backupLevel = 201,
expirationSeconds = System.currentTimeMillis() / 1000 + 3600 // 1 hour from now
)
)
)
)
every { RecurringInAppPaymentRepository.getActiveSubscriptionSync(InAppPaymentSubscriberRecord.Type.BACKUP) } returns NetworkResult.Success(
createActiveSubscription(
isActive = true,
cancelled = true,
chargeFailure = ChargeFailure("test", "", "", "", "")
)
)
val job = BackupSubscriptionCheckJob.create()
val result = job.run()
assertThat(result.isSuccess).isTrue()
verify { BackupStateObserver.notifyBackupStateChanged() }
}
private fun createActiveSubscription(
isActive: Boolean = true,
billingPeriodEndSeconds: Long = 2147472000,
@@ -553,15 +610,6 @@ class BackupSubscriptionCheckJobTest {
)
}
private fun mockProduct() {
coEvery { AppDependencies.billingApi.queryProduct() } returns BillingProduct(
price = FiatMoney(
BigDecimal.ONE,
Currency.getInstance("USD")
)
)
}
private fun insertSubscriber(token: String = IAP_TOKEN) {
SignalDatabase.inAppPaymentSubscribers.insertOrReplace(
InAppPaymentSubscriberRecord(
@@ -575,6 +623,16 @@ class BackupSubscriptionCheckJobTest {
)
}
private fun insertPrePendingInAppPayment() {
SignalDatabase.inAppPayments.insert(
type = InAppPaymentType.RECURRING_BACKUP,
state = InAppPaymentTable.State.TRANSACTING,
subscriberId = null,
endOfPeriod = null,
inAppPaymentData = InAppPaymentData()
)
}
private fun insertPendingInAppPayment() {
SignalDatabase.inAppPayments.insert(
type = InAppPaymentType.RECURRING_BACKUP,
@@ -593,20 +651,6 @@ class BackupSubscriptionCheckJobTest {
}
}
private fun insertFailedInAppPayment() {
SignalDatabase.inAppPayments.insert(
type = InAppPaymentType.RECURRING_BACKUP,
state = InAppPaymentTable.State.END,
subscriberId = null,
endOfPeriod = null,
inAppPaymentData = InAppPaymentData(
error = InAppPaymentData.Error(
type = InAppPaymentData.Error.Type.PAYMENT_SETUP
)
)
)
}
private fun mockActivePurchase() {
coEvery { AppDependencies.billingApi.queryPurchases() } returns BillingPurchaseResult.Success(
purchaseState = BillingPurchaseState.PURCHASED,

View File

@@ -0,0 +1,329 @@
package org.thoughtcrime.securesms.messages
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import assertk.assertThat
import assertk.assertions.isEqualTo
import io.mockk.every
import io.mockk.mockkStatic
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.thoughtcrime.securesms.database.MessageTable
import org.thoughtcrime.securesms.database.MessageType
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.MessageId
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.mms.IncomingMessage
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.testing.GroupTestingUtils
import org.thoughtcrime.securesms.testing.GroupTestingUtils.asMember
import org.thoughtcrime.securesms.testing.MessageContentFuzzer
import org.thoughtcrime.securesms.testing.SignalActivityRule
import org.thoughtcrime.securesms.util.RemoteConfig
import org.whispersystems.signalservice.api.crypto.EnvelopeMetadata
import org.whispersystems.signalservice.internal.push.DataMessage
@Suppress("ClassName")
@RunWith(AndroidJUnit4::class)
class DataMessageProcessorTest_polls {
@get:Rule
val harness = SignalActivityRule(createGroup = true)
private lateinit var alice: Recipient
private lateinit var bob: Recipient
private lateinit var charlie: Recipient
private lateinit var groupId: GroupId.V2
private lateinit var groupRecipientId: RecipientId
@Before
fun setUp() {
mockkStatic(RemoteConfig::class)
every { RemoteConfig.receivePolls } returns true
alice = Recipient.resolved(harness.others[0])
bob = Recipient.resolved(harness.others[1])
charlie = Recipient.resolved(harness.others[2])
val groupInfo = GroupTestingUtils.insertGroup(revision = 0, harness.self.asMember(), alice.asMember(), bob.asMember())
groupId = groupInfo.groupId
groupRecipientId = groupInfo.recipientId
}
@Test
fun handlePollCreate_whenIHaveAValidPollProto_createPoll() {
val insertResult = handlePollCreate(
pollCreate = DataMessage.PollCreate(question = "question?", options = listOf("a", "b", "c"), allowMultiple = false),
senderRecipient = alice,
threadRecipient = Recipient.resolved(groupRecipientId),
groupId = groupId
)
assert(insertResult != null)
val poll = SignalDatabase.polls.getPoll(insertResult!!.messageId)
assert(poll != null)
assertThat(poll!!.question).isEqualTo("question?")
assertThat(poll.pollOptions.size).isEqualTo(3)
assertThat(poll.allowMultipleVotes).isEqualTo(false)
assertThat(poll.hasEnded).isEqualTo(false)
}
@Test
fun handlePollCreate_whenSenderIsNotInGroup_dropMessage() {
val insertResult = handlePollCreate(
pollCreate = DataMessage.PollCreate(question = "question?", options = listOf("a", "b", "c"), allowMultiple = false),
senderRecipient = charlie,
threadRecipient = Recipient.resolved(groupRecipientId),
groupId = groupId
)
assert(insertResult == null)
}
@Test
fun handlePollCreate_whenTargetRecipientIsNotAGroup_dropMessage() {
val insertResult = handlePollCreate(
pollCreate = DataMessage.PollCreate(question = "question?", options = listOf("a", "b", "c"), allowMultiple = false),
senderRecipient = alice,
threadRecipient = bob,
groupId = null
)
assert(insertResult == null)
}
@Test
fun handlePollTerminate_whenIHaveValidProto_endPoll() {
val pollMessageId = insertPoll()
val insertResult = DataMessageProcessor.handlePollTerminate(
context = ApplicationProvider.getApplicationContext(),
envelope = MessageContentFuzzer.envelope(200),
message = DataMessage(pollTerminate = DataMessage.PollTerminate(targetSentTimestamp = 100)),
senderRecipient = alice,
metadata = EnvelopeMetadata(alice.requireServiceId(), null, 1, false, null, harness.self.requireServiceId()),
threadRecipient = bob,
groupId = groupId,
receivedTime = 200
)
assert(insertResult?.messageId != null)
val poll = SignalDatabase.polls.getPoll(pollMessageId)
assert(poll != null)
assert(poll!!.hasEnded)
}
@Test
fun handlePollTerminate_whenIHaveDifferentTimestamp_dropMessage() {
insertPoll()
val insertResult = DataMessageProcessor.handlePollTerminate(
context = ApplicationProvider.getApplicationContext(),
envelope = MessageContentFuzzer.envelope(200),
message = DataMessage(pollTerminate = DataMessage.PollTerminate(200)),
senderRecipient = alice,
metadata = EnvelopeMetadata(alice.requireServiceId(), null, 1, false, null, harness.self.requireServiceId()),
threadRecipient = bob,
groupId = groupId,
receivedTime = 200
)
assert(insertResult == null)
}
@Test
fun handlePollTerminate_whenMessageIsNotFromCreatorOfPoll_dropMessage() {
insertPoll()
val insertResult = DataMessageProcessor.handlePollTerminate(
context = ApplicationProvider.getApplicationContext(),
envelope = MessageContentFuzzer.envelope(200),
message = DataMessage(pollTerminate = DataMessage.PollTerminate(100)),
senderRecipient = bob,
metadata = EnvelopeMetadata(alice.requireServiceId(), null, 1, false, null, harness.self.requireServiceId()),
threadRecipient = bob,
groupId = groupId,
receivedTime = 200
)
assert(insertResult == null)
}
@Test
fun handlePollTerminate_whenPollDoesNotExist_dropMessage() {
val insertResult = DataMessageProcessor.handlePollTerminate(
context = ApplicationProvider.getApplicationContext(),
envelope = MessageContentFuzzer.envelope(200),
message = DataMessage(pollTerminate = DataMessage.PollTerminate(100)),
senderRecipient = alice,
metadata = EnvelopeMetadata(alice.requireServiceId(), null, 1, false, null, harness.self.requireServiceId()),
threadRecipient = bob,
groupId = groupId,
receivedTime = 200
)
assert(insertResult == null)
}
@Test
fun handlePollVote_whenValidPollVote_processVote() {
insertPoll()
val messageId = handlePollVote(
DataMessage.PollVote(
targetAuthorAciBinary = alice.asMember().aciBytes,
targetSentTimestamp = 100,
optionIndexes = listOf(0),
voteCount = 1
),
bob
)
assert(messageId != null)
assertThat(messageId!!.id).isEqualTo(1)
val poll = SignalDatabase.polls.getPoll(messageId.id)
assert(poll != null)
assertThat(poll!!.pollOptions[0].voterIds).isEqualTo(listOf(bob.id.toLong()))
}
@Test
fun handlePollVote_whenMultipleVoteAllowed_processAllVote() {
insertPoll()
val messageId = handlePollVote(
DataMessage.PollVote(
targetAuthorAciBinary = alice.asMember().aciBytes,
targetSentTimestamp = 100,
optionIndexes = listOf(0, 1, 2),
voteCount = 1
),
bob
)
assert(messageId != null)
val poll = SignalDatabase.polls.getPoll(messageId!!.id)
assert(poll != null)
assertThat(poll!!.pollOptions[0].voterIds).isEqualTo(listOf(bob.id.toLong()))
assertThat(poll.pollOptions[1].voterIds).isEqualTo(listOf(bob.id.toLong()))
assertThat(poll.pollOptions[2].voterIds).isEqualTo(listOf(bob.id.toLong()))
}
@Test
fun handlePollVote_whenMultipleVoteSentToSingleVotePolls_dropMessage() {
insertPoll(false)
val messageId = handlePollVote(
DataMessage.PollVote(
targetAuthorAciBinary = alice.asMember().aciBytes,
targetSentTimestamp = 100,
optionIndexes = listOf(0, 1, 2),
voteCount = 1
),
bob
)
assert(messageId == null)
}
@Test
fun handlePollVote_whenVoteCountIsNotHigher_dropMessage() {
insertPoll()
val messageId = handlePollVote(
DataMessage.PollVote(
targetAuthorAciBinary = alice.asMember().aciBytes,
targetSentTimestamp = 100,
optionIndexes = listOf(0, 1, 2),
voteCount = -1
),
bob
)
assert(messageId == null)
}
@Test
fun handlePollVote_whenVoteOptionDoesNotExist_dropMessage() {
insertPoll()
val messageId = handlePollVote(
DataMessage.PollVote(
targetAuthorAciBinary = alice.asMember().aciBytes,
targetSentTimestamp = 100,
optionIndexes = listOf(5),
voteCount = 1
),
bob
)
assert(messageId == null)
}
@Test
fun handlePollVote_whenVoterNotInGroup_dropMessage() {
insertPoll()
val messageId = handlePollVote(
DataMessage.PollVote(
targetAuthorAciBinary = alice.asMember().aciBytes,
targetSentTimestamp = 100,
optionIndexes = listOf(0, 1, 2),
voteCount = 1
),
charlie
)
assert(messageId == null)
}
@Test
fun handlePollVote_whenPollDoesNotExist_dropMessage() {
val messageId = handlePollVote(
DataMessage.PollVote(
targetAuthorAciBinary = alice.asMember().aciBytes,
targetSentTimestamp = 100,
optionIndexes = listOf(0, 1, 2),
voteCount = 1
),
bob
)
assert(messageId == null)
}
private fun handlePollCreate(pollCreate: DataMessage.PollCreate, senderRecipient: Recipient, threadRecipient: Recipient, groupId: GroupId.V2?): MessageTable.InsertResult? {
return DataMessageProcessor.handlePollCreate(
envelope = MessageContentFuzzer.envelope(100),
message = DataMessage(pollCreate = pollCreate),
senderRecipient = senderRecipient,
threadRecipient = threadRecipient,
groupId = groupId,
receivedTime = 0,
context = ApplicationProvider.getApplicationContext(),
metadata = EnvelopeMetadata(alice.requireServiceId(), null, 1, false, null, harness.self.requireServiceId())
)
}
private fun handlePollVote(pollVote: DataMessage.PollVote, senderRecipient: Recipient): MessageId? {
return DataMessageProcessor.handlePollVote(
context = ApplicationProvider.getApplicationContext(),
envelope = MessageContentFuzzer.envelope(100),
message = DataMessage(pollVote = pollVote),
senderRecipient = senderRecipient,
earlyMessageCacheEntry = null
)
}
private fun insertPoll(allowMultiple: Boolean = true): Long {
val envelope = MessageContentFuzzer.envelope(100)
val pollMessage = IncomingMessage(type = MessageType.NORMAL, from = alice.id, sentTimeMillis = envelope.timestamp!!, serverTimeMillis = envelope.serverTimestamp!!, receivedTimeMillis = 0, groupId = groupId)
val messageId = SignalDatabase.messages.insertMessageInbox(pollMessage).get()
SignalDatabase.polls.insertPoll("question?", allowMultiple, listOf("a", "b", "c"), alice.id.toLong(), messageId.messageId)
return messageId.messageId
}
}

View File

@@ -0,0 +1,79 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.messages.protocol
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.signal.libsignal.protocol.ReusedBaseKeyException
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.testing.SignalDatabaseRule
import org.thoughtcrime.securesms.util.KyberPreKeysTestUtil
import org.whispersystems.signalservice.api.push.ServiceId
class BufferedKyberPreKeyStoreTest {
@get:Rule
val harness = SignalDatabaseRule()
private lateinit var aci: ServiceId
private lateinit var testSubject: BufferedKyberPreKeyStore
private lateinit var dataStore: BufferedSignalServiceAccountDataStore
@Before
fun setUp() {
SignalStore.account.generateAciIdentityKeyIfNecessary()
aci = harness.localAci
testSubject = BufferedKyberPreKeyStore(aci)
dataStore = BufferedSignalServiceAccountDataStore(aci)
}
@Test
fun givenALastResortKey_whenIMarkKyberPreKeyUsed_thenIExpectNoIssues() {
KyberPreKeysTestUtil.insertTestRecord(aci, 1, lastResort = true)
val publicKey = KyberPreKeysTestUtil.generateECPublicKey()
testSubject.markKyberPreKeyUsed(
kyberPreKeyId = 1,
signedPreKeyId = 2,
publicKey = publicKey
)
}
@Test(expected = ReusedBaseKeyException::class)
fun givenALastResortKey_whenIMarkKyberPreKeyUsedTwice_thenIExpectException() {
KyberPreKeysTestUtil.insertTestRecord(aci, 1, lastResort = true)
val publicKey = KyberPreKeysTestUtil.generateECPublicKey()
testSubject.markKyberPreKeyUsed(
kyberPreKeyId = 1,
signedPreKeyId = 2,
publicKey = publicKey
)
testSubject.markKyberPreKeyUsed(
kyberPreKeyId = 1,
signedPreKeyId = 2,
publicKey = publicKey
)
}
@Test
fun givenAMarkedLastResortKey_whenIFlushTwice_thenIExpectNoIssues() {
KyberPreKeysTestUtil.insertTestRecord(aci, 1, lastResort = true)
val publicKey = KyberPreKeysTestUtil.generateECPublicKey()
testSubject.markKyberPreKeyUsed(
kyberPreKeyId = 1,
signedPreKeyId = 2,
publicKey = publicKey
)
testSubject.flushToDisk(dataStore)
testSubject.flushToDisk(dataStore)
}
}

View File

@@ -12,6 +12,7 @@ import org.junit.Test
import org.junit.runner.RunWith
import org.signal.core.util.billing.BillingPurchaseResult
import org.signal.core.util.billing.BillingPurchaseState
import org.signal.core.util.billing.BillingResponseCode
import org.signal.core.util.deleteAll
import org.thoughtcrime.securesms.database.InAppPaymentSubscriberTable
import org.thoughtcrime.securesms.database.SignalDatabase
@@ -94,7 +95,7 @@ class GooglePlayBillingPurchaseTokenMigrationJobTest {
)
)
coEvery { AppDependencies.billingApi.isApiAvailable() } returns false
coEvery { AppDependencies.billingApi.getApiAvailability() } returns BillingResponseCode.BILLING_UNAVAILABLE
val job = GooglePlayBillingPurchaseTokenMigrationJob()
@@ -118,7 +119,7 @@ class GooglePlayBillingPurchaseTokenMigrationJobTest {
)
)
coEvery { AppDependencies.billingApi.isApiAvailable() } returns true
coEvery { AppDependencies.billingApi.getApiAvailability() } returns BillingResponseCode.OK
coEvery { AppDependencies.billingApi.queryPurchases() } returns BillingPurchaseResult.None
val job = GooglePlayBillingPurchaseTokenMigrationJob()
@@ -143,7 +144,7 @@ class GooglePlayBillingPurchaseTokenMigrationJobTest {
)
)
coEvery { AppDependencies.billingApi.isApiAvailable() } returns true
coEvery { AppDependencies.billingApi.getApiAvailability() } returns BillingResponseCode.OK
coEvery { AppDependencies.billingApi.queryPurchases() } returns BillingPurchaseResult.Success(
purchaseState = BillingPurchaseState.PURCHASED,
purchaseToken = "purchaseToken",

View File

@@ -6,7 +6,6 @@ import org.signal.libsignal.protocol.IdentityKey
import org.signal.libsignal.protocol.IdentityKeyPair
import org.signal.libsignal.protocol.SessionBuilder
import org.signal.libsignal.protocol.SignalProtocolAddress
import org.signal.libsignal.protocol.UsePqRatchet
import org.signal.libsignal.protocol.ecc.ECKeyPair
import org.signal.libsignal.protocol.groups.state.SenderKeyRecord
import org.signal.libsignal.protocol.state.IdentityKeyStore
@@ -69,7 +68,7 @@ class BobClient(val serviceId: ServiceId, val e164: String, val identityKeyPair:
if (!aciStore.containsSession(getAliceProtocolAddress())) {
val sessionBuilder = SignalSessionBuilder(sessionLock, SessionBuilder(aciStore, getAliceProtocolAddress()))
sessionBuilder.process(getAlicePreKeyBundle(), UsePqRatchet.NO)
sessionBuilder.process(getAlicePreKeyBundle())
}
return cipher.encrypt(getAliceProtocolAddress(), getAliceUnidentifiedAccess(), envelopeContent)
@@ -78,7 +77,7 @@ class BobClient(val serviceId: ServiceId, val e164: String, val identityKeyPair:
fun decrypt(envelope: Envelope, serverDeliveredTimestamp: Long) {
val cipher = SignalServiceCipher(serviceAddress, 1, aciStore, sessionLock, SealedSenderAccessUtil.getCertificateValidator())
cipher.decrypt(envelope, serverDeliveredTimestamp, UsePqRatchet.NO)
cipher.decrypt(envelope, serverDeliveredTimestamp)
}
private fun getAliceServiceId(): ServiceId {

View File

@@ -20,8 +20,10 @@ import org.thoughtcrime.securesms.crypto.MasterSecretUtil
import org.thoughtcrime.securesms.crypto.ProfileKeyUtil
import org.thoughtcrime.securesms.database.IdentityTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.databaseprotos.RestoreDecisionState
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.dependencies.InstrumentationApplicationDependencyProvider
import org.thoughtcrime.securesms.keyvalue.NewAccount
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.profiles.ProfileName
import org.thoughtcrime.securesms.recipients.Recipient
@@ -125,6 +127,7 @@ class SignalActivityRule(private val othersCount: Int = 4, private val createGro
SignalDatabase.recipients.setProfileName(Recipient.self().id, ProfileName.fromParts("Tester", "McTesterson"))
SignalStore.settings.isMessageNotificationsEnabled = false
SignalStore.registration.restoreDecisionState = RestoreDecisionState.NewAccount
return Recipient.self()
}

View File

@@ -0,0 +1,71 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.util
import org.junit.Assert.assertEquals
import org.signal.core.util.readToSingleObject
import org.signal.core.util.requireLongOrNull
import org.signal.core.util.select
import org.signal.core.util.update
import org.signal.libsignal.protocol.ecc.ECKeyPair
import org.signal.libsignal.protocol.ecc.ECPublicKey
import org.signal.libsignal.protocol.kem.KEMKeyPair
import org.signal.libsignal.protocol.kem.KEMKeyType
import org.signal.libsignal.protocol.state.KyberPreKeyRecord
import org.thoughtcrime.securesms.database.KyberPreKeyTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.whispersystems.signalservice.api.push.ServiceId
import org.whispersystems.signalservice.api.push.ServiceId.ACI
import org.whispersystems.signalservice.api.push.ServiceId.PNI
import java.security.SecureRandom
object KyberPreKeysTestUtil {
fun insertTestRecord(account: ServiceId, id: Int, staleTime: Long = 0, lastResort: Boolean = false) {
val kemKeyPair = KEMKeyPair.generate(KEMKeyType.KYBER_1024)
SignalDatabase.kyberPreKeys.insert(
serviceId = account,
keyId = id,
record = KyberPreKeyRecord(
id,
System.currentTimeMillis(),
kemKeyPair,
ECKeyPair.generate().privateKey.calculateSignature(kemKeyPair.publicKey.serialize())
),
lastResort = lastResort
)
val count = SignalDatabase.rawDatabase
.update(KyberPreKeyTable.TABLE_NAME)
.values(KyberPreKeyTable.STALE_TIMESTAMP to staleTime)
.where("${KyberPreKeyTable.ACCOUNT_ID} = ? AND ${KyberPreKeyTable.KEY_ID} = $id", account.toAccountId())
.run()
assertEquals(1, count)
}
fun getStaleTime(account: ServiceId, id: Int): Long? {
return SignalDatabase.rawDatabase
.select(KyberPreKeyTable.STALE_TIMESTAMP)
.from(KyberPreKeyTable.TABLE_NAME)
.where("${KyberPreKeyTable.ACCOUNT_ID} = ? AND ${KyberPreKeyTable.KEY_ID} = $id", account.toAccountId())
.run()
.readToSingleObject { it.requireLongOrNull(KyberPreKeyTable.STALE_TIMESTAMP) }
}
fun generateECPublicKey(): ECPublicKey {
val byteArray = ByteArray(ECPublicKey.KEY_SIZE - 1)
SecureRandom().nextBytes(byteArray)
return ECPublicKey.fromPublicKeyBytes(byteArray)
}
private fun ServiceId.toAccountId(): String {
return when (this) {
is ACI -> this.toString()
is PNI -> KyberPreKeyTable.PNI_ACCOUNT_ID
}
}
}

View File

@@ -115,6 +115,7 @@ class ConversationElementGenerator {
null,
null,
null,
null,
-1,
null,
null,

View File

@@ -44,6 +44,8 @@ import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.groups.GroupMigrationMembershipChange
import org.thoughtcrime.securesms.linkpreview.LinkPreview
import org.thoughtcrime.securesms.mediapreview.MediaIntentFactory
import org.thoughtcrime.securesms.polls.PollOption
import org.thoughtcrime.securesms.polls.PollRecord
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.stickers.StickerLocator
@@ -336,5 +338,17 @@ class InternalConversationTestFragment : Fragment(R.layout.conversation_test_fra
override fun onUpdateSignalClicked() {
Toast.makeText(requireContext(), "Can't touch this.", Toast.LENGTH_SHORT).show()
}
override fun onViewResultsClicked(pollId: Long) {
Toast.makeText(requireContext(), "Can't touch this.", Toast.LENGTH_SHORT).show()
}
override fun onViewPollClicked(messageId: Long) {
Toast.makeText(requireContext(), "Can't touch this.", Toast.LENGTH_SHORT).show()
}
override fun onToggleVote(poll: PollRecord, pollOption: PollOption, isChecked: Boolean) {
Toast.makeText(requireContext(), "Can't touch this.", Toast.LENGTH_SHORT).show()
}
}
}

View File

@@ -697,6 +697,11 @@
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
android:exported="false"/>
<activity
android:name=".conversation.NewConversationActivityV2"
android:exported="false"
android:theme="@style/Signal.DayNight.NoActionBar" />
<activity android:name=".recipients.ui.findby.FindByActivity"
android:theme="@style/Theme.Signal.DayNight.NoActionBar"
android:windowSoftInputMode="adjustResize"
@@ -843,19 +848,12 @@
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
android:exported="false"/>
<activity android:name=".registrationv3.olddevice.TransferAccountActivity"
<activity android:name=".registration.olddevice.TransferAccountActivity"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
android:theme="@style/Theme.Signal.DayNight.NoActionBar"
android:exported="false"/>
<activity android:name=".registration.ui.RegistrationActivity"
android:launchMode="singleTask"
android:theme="@style/Theme.Signal.DayNight.NoActionBar"
android:windowSoftInputMode="stateHidden"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
android:exported="false"/>
<activity android:name=".registrationv3.ui.RegistrationActivity"
android:launchMode="singleTask"
android:theme="@style/Theme.Signal.DayNight.NoActionBar"
android:windowSoftInputMode="stateHidden|adjustResize"
@@ -930,17 +928,7 @@
android:theme="@style/TextSecure.DialogActivity"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
<activity android:name=".SmsSendtoActivity" android:exported="true">
<intent-filter>
<action android:name="android.intent.action.SENDTO" />
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="sms" />
<data android:scheme="smsto" />
<data android:scheme="mms" />
<data android:scheme="mmsto" />
</intent-filter>
<activity android:name=".SystemContactsEntrypointActivity" android:exported="true">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
@@ -991,7 +979,7 @@
android:windowSoftInputMode="stateVisible|adjustResize"
android:exported="false"/>
<activity android:name=".registrationv3.ui.restore.RemoteRestoreActivity"
<activity android:name=".registration.ui.restore.RemoteRestoreActivity"
android:theme="@style/Signal.DayNight.NoActionBar"
android:exported="false"/>

View File

@@ -299,10 +299,8 @@ public class ApplicationContext extends Application implements AppForegroundObse
}
public void checkFreeDiskSpace() {
if (RemoteConfig.messageBackups()) {
long availableBytes = BackupRepository.INSTANCE.getFreeStorageSpace().getBytes();
SignalStore.backup().setSpaceAvailableOnDiskBytes(availableBytes);
}
long availableBytes = BackupRepository.INSTANCE.getFreeStorageSpace().getBytes();
SignalStore.backup().setSpaceAvailableOnDiskBytes(availableBytes);
}
/**

View File

@@ -30,6 +30,8 @@ import org.thoughtcrime.securesms.groups.GroupId;
import org.thoughtcrime.securesms.groups.GroupMigrationMembershipChange;
import org.thoughtcrime.securesms.linkpreview.LinkPreview;
import org.thoughtcrime.securesms.mediapreview.MediaIntentFactory;
import org.thoughtcrime.securesms.polls.PollRecord;
import org.thoughtcrime.securesms.polls.PollOption;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.stickers.StickerLocator;
@@ -143,5 +145,8 @@ public interface BindableConversationItem extends Unbindable, GiphyMp4Playable,
void onDisplayMediaNoLongerAvailableSheet();
void onShowUnverifiedProfileSheet(boolean forGroup);
void onUpdateSignalClicked();
void onViewResultsClicked(long pollId);
void onViewPollClicked(long messageId);
void onToggleVote(@NonNull PollRecord poll, @NonNull PollOption pollOption, Boolean isChecked);
}
}

View File

@@ -138,16 +138,16 @@ public final class ContactSelectionListFragment extends LoggingFragment {
private TextView headerActionView;
private ContactSearchMediator contactSearchMediator;
@Nullable private NewConversationCallback newConversationCallback;
@Nullable private FindByCallback findByCallback;
@Nullable private NewCallCallback newCallCallback;
@Nullable private ScrollCallback scrollCallback;
@Nullable private OnItemLongClickListener onItemLongClickListener;
private SelectionLimits selectionLimit = SelectionLimits.NO_LIMITS;
private Set<RecipientId> currentSelection;
private boolean isMulti;
private boolean canSelectSelf;
private boolean resetPositionOnCommit = false;
@Nullable private NewConversationCallback newConversationCallback;
@Nullable private FindByCallback findByCallback;
@Nullable private NewCallCallback newCallCallback;
@Nullable private ScrollCallback scrollCallback;
@Nullable private OnItemLongClickListener onItemLongClickListener;
private SelectionLimits selectionLimit = SelectionLimits.NO_LIMITS;
private Set<RecipientId> currentSelection;
private boolean isMulti;
private boolean canSelectSelf;
private boolean resetPositionOnCommit = false;
private ListClickListener listClickListener = new ListClickListener();
@Nullable private SwipeRefreshLayout.OnRefreshListener onRefreshListener;
@@ -161,7 +161,7 @@ public final class ContactSelectionListFragment extends LoggingFragment {
}
if (context instanceof FindByCallback) {
findByCallback = (FindByCallback) context;
showFindByUsernameAndPhoneOptions((FindByCallback) context);
}
if (context instanceof NewCallCallback) {
@@ -177,11 +177,11 @@ public final class ContactSelectionListFragment extends LoggingFragment {
}
if (getParentFragment() instanceof OnContactSelectedListener) {
onContactSelectedListener = (OnContactSelectedListener) getParentFragment();
setOnContactSelectedListener((OnContactSelectedListener) getParentFragment());
}
if (context instanceof OnContactSelectedListener) {
onContactSelectedListener = (OnContactSelectedListener) context;
setOnContactSelectedListener((OnContactSelectedListener) context);
}
if (context instanceof OnSelectionLimitReachedListener) {
@@ -209,6 +209,14 @@ public final class ContactSelectionListFragment extends LoggingFragment {
}
}
public void showFindByUsernameAndPhoneOptions(@Nullable FindByCallback callback) {
this.findByCallback = callback;
}
public void setOnContactSelectedListener(@Nullable OnContactSelectedListener listener) {
this.onContactSelectedListener = listener;
}
@Override
public void onActivityCreated(Bundle icicle) {
super.onActivityCreated(icicle);
@@ -221,7 +229,7 @@ public final class ContactSelectionListFragment extends LoggingFragment {
super.onStart();
if (hasContactsPermissions(requireContext()) && !TextSecurePreferences.hasSuccessfullyRetrievedDirectory(getActivity())) {
handleContactPermissionGranted();
handleContactPermissionGranted();
} else {
requireActivity().getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_HIDDEN);
contactSearchMediator.refresh();
@@ -232,13 +240,13 @@ public final class ContactSelectionListFragment extends LoggingFragment {
public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
View view = inflater.inflate(R.layout.contact_selection_list_fragment, container, false);
emptyText = view.findViewById(android.R.id.empty);
recyclerView = view.findViewById(R.id.recycler_view);
swipeRefresh = view.findViewById(R.id.swipe_refresh);
fastScroller = view.findViewById(R.id.fast_scroller);
chipRecycler = view.findViewById(R.id.chipRecycler);
constraintLayout = view.findViewById(R.id.container);
headerActionView = view.findViewById(R.id.header_action);
emptyText = view.findViewById(android.R.id.empty);
recyclerView = view.findViewById(R.id.recycler_view);
swipeRefresh = view.findViewById(R.id.swipe_refresh);
fastScroller = view.findViewById(R.id.fast_scroller);
chipRecycler = view.findViewById(R.id.chipRecycler);
constraintLayout = view.findViewById(R.id.container);
headerActionView = view.findViewById(R.id.header_action);
final LinearLayoutManager layoutManager = new LinearLayoutManager(requireContext());
@@ -441,7 +449,7 @@ public final class ContactSelectionListFragment extends LoggingFragment {
@Override
public void onDestroyView() {
super.onDestroyView();
constraintLayout = null;
constraintLayout = null;
onRefreshListener = null;
}
@@ -723,7 +731,7 @@ public final class ContactSelectionListFragment extends LoggingFragment {
SimpleTask.run(getViewLifecycleOwner().getLifecycle(), () -> {
return UsernameRepository.fetchAciForUsername(UsernameUtil.sanitizeUsernameFromSearch(username));
}, result -> {
}, result -> {
loadingDialog.dismiss();
// TODO Could be more specific with errors
@@ -756,10 +764,10 @@ public final class ContactSelectionListFragment extends LoggingFragment {
selectedContact.getNumber(),
Optional.empty(),
allowed -> {
if (allowed) {
markContactSelected(selectedContact);
}
});
if (allowed) {
markContactSelected(selectedContact);
}
});
} else {
markContactSelected(selectedContact);
}
@@ -913,9 +921,10 @@ public final class ContactSelectionListFragment extends LoggingFragment {
builder.setQuery(contactSearchState.getQuery());
if ((newConversationCallback != null || findByCallback != null) &&
!hasContactsPermissions(requireContext()) &&
!hasContactsPermissions(requireContext()) &&
!SignalStore.uiHints().getDismissedContactsPermissionBanner() &&
!hasQuery) {
!hasQuery)
{
builder.arbitrary(ContactSelectionListAdapter.ArbitraryRepository.ArbitraryRow.FIND_CONTACTS_BANNER.getCode());
}

View File

@@ -21,8 +21,8 @@ import androidx.fragment.app.FragmentManager
import androidx.fragment.app.setFragmentResult
import org.signal.core.ui.compose.BottomSheets
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.SignalPreview
import org.thoughtcrime.securesms.compose.ComposeBottomSheetDialogFragment
import org.thoughtcrime.securesms.util.BottomSheetUtil
@@ -106,7 +106,7 @@ fun DevicePinAuthEducationSheet(
}
}
@SignalPreview
@DayNightPreviews
@Composable
fun DevicePinAuthEducationSheetPreview() {
Previews.BottomSheetPreview {

View File

@@ -30,9 +30,9 @@ import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.Scaffolds
import org.signal.core.ui.compose.SignalPreview
import org.thoughtcrime.securesms.compose.ComposeFragment
/**
@@ -113,7 +113,7 @@ fun InviteScreen(
}
}
@SignalPreview
@DayNightPreviews
@Composable
private fun InviteScreenPreview() {
Previews.Preview {

View File

@@ -18,12 +18,12 @@ import android.view.ViewTreeObserver
import android.widget.Toast
import androidx.activity.OnBackPressedCallback
import androidx.activity.SystemBarStyle
import androidx.activity.compose.BackHandler
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
@@ -37,8 +37,9 @@ import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
import androidx.compose.material3.adaptive.layout.PaneExpansionAnchor
import androidx.compose.material3.adaptive.layout.ThreePaneScaffoldRole
import androidx.compose.material3.adaptive.layout.rememberPaneExpansionState
import androidx.compose.material3.adaptive.navigation.ThreePaneScaffoldNavigator
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -48,8 +49,8 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.fragment.app.DialogFragment
import androidx.fragment.compose.AndroidFragment
import androidx.fragment.compose.rememberFragmentState
@@ -74,6 +75,7 @@ import org.signal.donations.StripeApi
import org.thoughtcrime.securesms.backup.v2.ArchiveRestoreProgress
import org.thoughtcrime.securesms.backup.v2.ui.verify.VerifyBackupKeyActivity
import org.thoughtcrime.securesms.calls.YouAreAlreadyInACallSnackbar.show
import org.thoughtcrime.securesms.calls.links.details.CallLinkDetailsActivity
import org.thoughtcrime.securesms.calls.log.CallLogFilter
import org.thoughtcrime.securesms.calls.log.CallLogFragment
import org.thoughtcrime.securesms.calls.new.NewCallActivity
@@ -87,7 +89,6 @@ import org.thoughtcrime.securesms.components.settings.app.notifications.manual.N
import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaController
import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaControllerOwner
import org.thoughtcrime.securesms.conversation.ConversationIntents
import org.thoughtcrime.securesms.conversation.v2.ConversationFragment
import org.thoughtcrime.securesms.conversation.v2.MotionEventRelay
import org.thoughtcrime.securesms.conversation.v2.ShareDataTimestampViewModel
import org.thoughtcrime.securesms.conversationlist.ConversationListArchiveFragment
@@ -100,6 +101,8 @@ import org.thoughtcrime.securesms.devicetransfer.olddevice.OldDeviceExitActivity
import org.thoughtcrime.securesms.groups.ui.creategroup.CreateGroupActivity
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.lock.v2.CreateSvrPinActivity
import org.thoughtcrime.securesms.main.DetailsScreenNavHost
import org.thoughtcrime.securesms.main.InsetsViewModelUpdater
import org.thoughtcrime.securesms.main.MainBottomChrome
import org.thoughtcrime.securesms.main.MainBottomChromeCallback
import org.thoughtcrime.securesms.main.MainBottomChromeState
@@ -118,6 +121,13 @@ import org.thoughtcrime.securesms.main.MainToolbarViewModel
import org.thoughtcrime.securesms.main.Material3OnScrollHelperBinder
import org.thoughtcrime.securesms.main.NavigationBarSpacerCompat
import org.thoughtcrime.securesms.main.SnackbarState
import org.thoughtcrime.securesms.main.callNavGraphBuilder
import org.thoughtcrime.securesms.main.chatNavGraphBuilder
import org.thoughtcrime.securesms.main.navigateToDetailLocation
import org.thoughtcrime.securesms.main.rememberDetailNavHostController
import org.thoughtcrime.securesms.main.rememberFocusRequester
import org.thoughtcrime.securesms.main.rememberMainNavigationDetailLocation
import org.thoughtcrime.securesms.main.storiesNavGraphBuilder
import org.thoughtcrime.securesms.mediasend.camerax.CameraXUtil
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionActivity
import org.thoughtcrime.securesms.megaphone.Megaphone
@@ -148,8 +158,9 @@ import org.thoughtcrime.securesms.util.Util
import org.thoughtcrime.securesms.util.viewModel
import org.thoughtcrime.securesms.window.AppPaneDragHandle
import org.thoughtcrime.securesms.window.AppScaffold
import org.thoughtcrime.securesms.window.AppScaffoldNavigator
import org.thoughtcrime.securesms.window.WindowSizeClass
import org.thoughtcrime.securesms.window.rememberAppScaffoldNavigator
import org.thoughtcrime.securesms.window.rememberThreePaneScaffoldNavigatorDelegate
import org.whispersystems.signalservice.api.websocket.WebSocketConnectionState
class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner, MainNavigator.NavigatorProvider, Material3OnScrollHelperBinder, ConversationListFragment.Callback, CallLogFragment.Callback {
@@ -297,7 +308,6 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
val mainToolbarState by toolbarViewModel.state.collectAsStateWithLifecycle()
val megaphone by mainNavigationViewModel.megaphone.collectAsStateWithLifecycle()
val mainNavigationState by mainNavigationViewModel.mainNavigationState.collectAsStateWithLifecycle()
val mainNavigationDetailLocation by mainNavigationViewModel.detailLocation.collectAsStateWithLifecycle()
LaunchedEffect(mainNavigationState.currentListLocation) {
when (mainNavigationState.currentListLocation) {
@@ -308,8 +318,12 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
}
}
val isNavigationVisible = remember(mainToolbarState.mode) {
mainToolbarState.mode == MainToolbarMode.FULL
val isNavigationVisible = mainToolbarState.mode == MainToolbarMode.FULL
val isBackHandlerEnabled = mainToolbarState.destination != MainNavigationListLocation.CHATS
BackHandler(enabled = isBackHandlerEnabled) {
mainNavigationViewModel.setFocusedPane(ThreePaneScaffoldRole.Secondary)
mainNavigationViewModel.goTo(MainNavigationListLocation.CHATS)
}
val mainBottomChromeState = remember(mainToolbarState.destination, snackbar, mainToolbarState.mode, megaphone) {
@@ -329,8 +343,79 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
MainContainer {
val wrappedNavigator = rememberNavigator(windowSizeClass, contentLayoutData, maxWidth)
val paneExpansionState = rememberPaneExpansionState()
val listPaneWidth = contentLayoutData.rememberDefaultPanePreferredWidth(maxWidth)
val halfPartitionWidth = contentLayoutData.partitionWidth / 2
val detailOffset = if (mainToolbarState.mode == MainToolbarMode.SEARCH || mainToolbarState.mode == MainToolbarMode.ACTION_MODE) 0.dp else 72.dp
val detailOnlyAnchor = PaneExpansionAnchor.Offset.fromStart(detailOffset + contentLayoutData.listPaddingStart + halfPartitionWidth)
val detailAndListAnchor = PaneExpansionAnchor.Offset.fromStart(listPaneWidth + halfPartitionWidth)
val listOnlyAnchor = PaneExpansionAnchor.Offset.fromEnd(contentLayoutData.detailPaddingEnd - halfPartitionWidth)
val paneExpansionState = rememberPaneExpansionState(
anchors = listOf(detailOnlyAnchor, detailAndListAnchor, listOnlyAnchor)
)
val mutableInteractionSource = remember { MutableInteractionSource() }
val mainNavigationDetailLocation by rememberMainNavigationDetailLocation(mainNavigationViewModel)
val chatsNavHostController = rememberDetailNavHostController(
onRequestFocus = rememberFocusRequester(
mainNavigationViewModel = mainNavigationViewModel,
currentListLocation = mainNavigationState.currentListLocation,
isTargetListLocation = { it in listOf(MainNavigationListLocation.CHATS, MainNavigationListLocation.ARCHIVE) }
)
) {
chatNavGraphBuilder()
}
val callsNavHostController = rememberDetailNavHostController(
onRequestFocus = rememberFocusRequester(
mainNavigationViewModel = mainNavigationViewModel,
currentListLocation = mainNavigationState.currentListLocation
) { it == MainNavigationListLocation.CALLS }
) {
callNavGraphBuilder(it)
}
val storiesNavHostController = rememberDetailNavHostController(
onRequestFocus = rememberFocusRequester(
mainNavigationViewModel = mainNavigationViewModel,
currentListLocation = mainNavigationState.currentListLocation
) { it == MainNavigationListLocation.STORIES }
) {
storiesNavGraphBuilder()
}
LaunchedEffect(mainNavigationDetailLocation) {
mainNavigationViewModel.clearEarlyDetailLocation()
when (mainNavigationDetailLocation) {
is MainNavigationDetailLocation.Empty -> {
when (mainNavigationState.currentListLocation) {
MainNavigationListLocation.CHATS, MainNavigationListLocation.ARCHIVE -> chatsNavHostController
MainNavigationListLocation.CALLS -> callsNavHostController
MainNavigationListLocation.STORIES -> storiesNavHostController
}.navigateToDetailLocation(mainNavigationDetailLocation)
}
is MainNavigationDetailLocation.Chats -> chatsNavHostController.navigateToDetailLocation(mainNavigationDetailLocation)
is MainNavigationDetailLocation.Calls -> callsNavHostController.navigateToDetailLocation(mainNavigationDetailLocation)
is MainNavigationDetailLocation.Stories -> storiesNavHostController.navigateToDetailLocation(mainNavigationDetailLocation)
}
}
LaunchedEffect(mainNavigationDetailLocation) {
if (paneExpansionState.currentAnchor == listOnlyAnchor && wrappedNavigator.currentDestination?.pane == ThreePaneScaffoldRole.Primary) {
paneExpansionState.animateTo(detailOnlyAnchor)
}
}
LaunchedEffect(mainNavigationState.currentListLocation) {
if (paneExpansionState.currentAnchor == detailOnlyAnchor && wrappedNavigator.currentDestination?.pane == ThreePaneScaffoldRole.Secondary) {
paneExpansionState.animateTo(listOnlyAnchor)
}
}
InsetsViewModelUpdater()
AppScaffold(
navigator = wrappedNavigator,
@@ -362,7 +447,7 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
)
}
},
listContent = {
secondaryContent = {
val listContainerColor = if (windowSizeClass.isMedium()) {
SignalTheme.colors.colorSurface1
} else {
@@ -431,36 +516,27 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
}
}
},
detailContent = {
when (val destination = mainNavigationDetailLocation) {
is MainNavigationDetailLocation.Conversation -> {
val fragmentState = key(destination) { rememberFragmentState() }
AndroidFragment(
clazz = ConversationFragment::class.java,
fragmentState = fragmentState,
arguments = requireNotNull(destination.intent.extras) { "Handed null Conversation intent arguments." },
modifier = Modifier
.padding(end = contentLayoutData.detailPaddingEnd)
.clip(contentLayoutData.shape)
.background(color = MaterialTheme.colorScheme.surface)
.fillMaxSize()
primaryContent = {
when (mainNavigationState.currentListLocation) {
MainNavigationListLocation.CHATS, MainNavigationListLocation.ARCHIVE -> {
DetailsScreenNavHost(
navHostController = chatsNavHostController,
contentLayoutData = contentLayoutData
)
}
MainNavigationDetailLocation.Empty -> {
Box(
modifier = Modifier
.padding(end = contentLayoutData.detailPaddingEnd)
.clip(contentLayoutData.shape)
.background(color = MaterialTheme.colorScheme.surface)
.fillMaxSize()
) {
Image(
painter = painterResource(R.drawable.ic_signal_logo_large),
contentDescription = null,
modifier = Modifier.align(Alignment.Center)
)
}
MainNavigationListLocation.CALLS -> {
DetailsScreenNavHost(
navHostController = callsNavHostController,
contentLayoutData = contentLayoutData
)
}
MainNavigationListLocation.STORIES -> {
DetailsScreenNavHost(
navHostController = storiesNavHostController,
contentLayoutData = contentLayoutData
)
}
}
},
@@ -509,8 +585,8 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
windowSizeClass: WindowSizeClass,
contentLayoutData: MainContentLayoutData,
maxWidth: Dp
): ThreePaneScaffoldNavigator<Any> {
val scaffoldNavigator = rememberAppScaffoldNavigator(
): AppScaffoldNavigator<Any> {
val scaffoldNavigator = rememberThreePaneScaffoldNavigatorDelegate(
isSplitPane = windowSizeClass.isSplitPane(),
horizontalPartitionSpacerSize = contentLayoutData.partitionWidth,
defaultPanePreferredWidth = contentLayoutData.rememberDefaultPanePreferredWidth(maxWidth)
@@ -521,8 +597,20 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
return remember(scaffoldNavigator, coroutine) {
mainNavigationViewModel.wrapNavigator(coroutine, scaffoldNavigator) { detailLocation ->
when (detailLocation) {
is MainNavigationDetailLocation.Conversation -> {
startActivity(detailLocation.intent)
is MainNavigationDetailLocation.Chats.Conversation -> {
startActivity(
ConversationIntents.createBuilderSync(this, detailLocation.conversationArgs.recipientId, detailLocation.conversationArgs.threadId)
.withArgs(detailLocation.conversationArgs)
.build()
)
}
is MainNavigationDetailLocation.Calls.CallLinks.CallLinkDetails -> {
startActivity(CallLinkDetailsActivity.createIntent(this, detailLocation.callLinkRoomId))
}
is MainNavigationDetailLocation.Calls.CallLinks.EditCallLinkName -> {
error("Unexpected subroute EditCallLinkName.")
}
MainNavigationDetailLocation.Empty -> Unit
@@ -744,7 +832,7 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
private fun handleConversationIntent(intent: Intent) {
if (ConversationIntents.isConversationIntent(intent)) {
mainNavigationViewModel.goTo(MainNavigationListLocation.CHATS)
mainNavigationViewModel.goTo(MainNavigationDetailLocation.Conversation(intent))
mainNavigationViewModel.goTo(MainNavigationDetailLocation.Chats.Conversation(ConversationIntents.readArgsFromBundle(intent.extras!!)))
}
}

View File

@@ -45,8 +45,8 @@ public class MainNavigator {
Disposable disposable = ConversationIntents.createBuilder(activity, recipientId, threadId)
.map(builder -> builder.withDistributionType(distributionType)
.withStartingPosition(startingPosition)
.build())
.subscribe(intent -> viewModel.goTo(new MainNavigationDetailLocation.Conversation(intent)));
.toConversationArgs())
.subscribe(args -> viewModel.goTo(new MainNavigationDetailLocation.Chats.Conversation(args)));
lifecycleDisposable.add(disposable);
}

View File

@@ -34,7 +34,6 @@ 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;
import java.util.Locale;
@@ -189,7 +188,6 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
private boolean userCanTransferOrRestore() {
return !SignalStore.registration().isRegistrationComplete() &&
RemoteConfig.restoreAfterRegistration() &&
RestoreDecisionStateUtil.isDecisionPending(SignalStore.registration().getRestoreDecisionState());
}

View File

@@ -18,9 +18,9 @@ import org.thoughtcrime.securesms.util.Rfc5724Uri;
import java.net.URISyntaxException;
public class SmsSendtoActivity extends Activity {
public class SystemContactsEntrypointActivity extends Activity {
private static final String TAG = Log.tag(SmsSendtoActivity.class);
private static final String TAG = Log.tag(SystemContactsEntrypointActivity.class);
@Override
protected void onCreate(Bundle savedInstanceState) {
@@ -32,9 +32,7 @@ public class SmsSendtoActivity extends Activity {
private Intent getNextIntent(Intent original) {
DestinationAndBody destination;
if (original.getAction().equals(Intent.ACTION_SENDTO)) {
destination = getDestinationForSendTo(original);
} else if (original.getData() != null && "content".equals(original.getData().getScheme())) {
if (original.getData() != null && "content".equals(original.getData().getScheme())) {
destination = getDestinationForSyncAdapter(original);
} else {
destination = getDestinationForView(original);
@@ -64,11 +62,6 @@ public class SmsSendtoActivity extends Activity {
return nextIntent;
}
private @NonNull DestinationAndBody getDestinationForSendTo(Intent intent) {
return new DestinationAndBody(intent.getData().getSchemeSpecificPart(),
intent.getStringExtra("sms_body"));
}
private @NonNull DestinationAndBody getDestinationForView(Intent intent) {
try {
Rfc5724Uri smsUri = new Rfc5724Uri(intent.getData().toString());

View File

@@ -1,5 +1,6 @@
package org.thoughtcrime.securesms.audio;
import android.annotation.SuppressLint;
import android.media.AudioFormat;
import android.media.AudioRecord;
import android.media.MediaCodec;
@@ -191,6 +192,7 @@ public class AudioCodec implements Recorder {
return adtsHeader;
}
@SuppressLint("MissingPermission")
private AudioRecord createAudioRecord(int bufferSize) {
return new AudioRecord(MediaRecorder.AudioSource.MIC, SAMPLE_RATE,
AudioFormat.CHANNEL_IN_MONO,

View File

@@ -18,7 +18,6 @@ import org.thoughtcrime.securesms.util.MediaUtil
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.IOException
import java.util.Optional
/**
* Renders Avatar objects into Media objects. This can involve creating a Bitmap, depending on the
@@ -132,6 +131,20 @@ object AvatarRenderer {
}
private fun createMedia(uri: Uri, size: Long): Media {
return Media(uri, MediaUtil.IMAGE_JPEG, System.currentTimeMillis(), DIMENSIONS, DIMENSIONS, size, 0, false, false, Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty())
return Media(
uri = uri,
contentType = MediaUtil.IMAGE_JPEG,
date = System.currentTimeMillis(),
width = DIMENSIONS,
height = DIMENSIONS,
size = size,
duration = 0,
isBorderless = false,
isVideoGif = false,
bucketId = null,
caption = null,
transformProperties = null,
fileName = null
)
}
}

View File

@@ -27,8 +27,8 @@ import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.TextUnitType
import androidx.compose.ui.unit.dp
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.SignalPreview
import org.signal.core.util.DimensionUnit
import org.thoughtcrime.securesms.avatar.AvatarRenderer
import org.thoughtcrime.securesms.avatar.Avatars
@@ -94,7 +94,7 @@ fun FallbackAvatarImage(
}
}
@SignalPreview
@DayNightPreviews
@Composable
fun FallbackAvatarImagePreview() {
Previews.Preview {

View File

@@ -27,7 +27,6 @@ import org.thoughtcrime.securesms.avatar.text.TextAvatarCreationFragment
import org.thoughtcrime.securesms.avatar.vector.VectorAvatarCreationFragment
import org.thoughtcrime.securesms.components.ButtonStripItemView
import org.thoughtcrime.securesms.components.recyclerview.GridDividerDecoration
import org.thoughtcrime.securesms.groups.ParcelableGroupId
import org.thoughtcrime.securesms.mediasend.AvatarSelectionActivity
import org.thoughtcrime.securesms.mediasend.Media
import org.thoughtcrime.securesms.mediasend.camerax.CameraXUtil
@@ -57,9 +56,8 @@ class AvatarPickerFragment : Fragment(R.layout.avatar_picker_fragment) {
private fun createFactory(): AvatarPickerViewModel.Factory {
val args = AvatarPickerFragmentArgs.fromBundle(requireArguments())
val groupId = ParcelableGroupId.get(args.groupId)
return AvatarPickerViewModel.Factory(AvatarPickerRepository(requireContext()), groupId, args.isNewGroup, args.groupAvatarMedia)
return AvatarPickerViewModel.Factory(AvatarPickerRepository(requireContext()), args.groupId, args.isNewGroup, args.groupAvatarMedia)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {

View File

@@ -1,16 +1,20 @@
package org.thoughtcrime.securesms.backup;
import android.Manifest;
import android.app.Notification;
import android.app.PendingIntent;
import android.content.Context;
import android.content.pm.PackageManager;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.StringRes;
import androidx.core.app.NotificationCompat;
import androidx.core.app.NotificationManagerCompat;
import androidx.core.content.ContextCompat;
import org.signal.core.util.PendingIntentFlags;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity;
import org.thoughtcrime.securesms.notifications.NotificationCancellationHelper;
@@ -26,6 +30,7 @@ public enum BackupFileIOError {
ATTACHMENT_TOO_LARGE(R.string.LocalBackupJobApi29_backup_failed, R.string.LocalBackupJobApi29_your_backup_contains_a_very_large_file),
UNKNOWN(R.string.LocalBackupJobApi29_backup_failed, R.string.LocalBackupJobApi29_tap_to_manage_backups);
private static final String TAG = Log.tag(BackupFileIOError.class);
private static final short BACKUP_FAILED_ID = 31321;
private final @StringRes int titleId;
@@ -41,6 +46,11 @@ public enum BackupFileIOError {
}
public void postNotification(@NonNull Context context) {
if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
Log.w(TAG, "postNotification: Notification permission is not granted.");
return;
}
PendingIntent pendingIntent = PendingIntent.getActivity(context, -1, AppSettingsActivity.backups(context), PendingIntentFlags.mutable());
Notification backupFailedNotification = new NotificationCompat.Builder(context, NotificationChannels.getInstance().FAILURES)
.setSmallIcon(R.drawable.ic_signal_backup)

View File

@@ -8,6 +8,8 @@ package org.thoughtcrime.securesms.backup.v2
import org.signal.core.util.ByteSize
import org.signal.core.util.bytes
import org.thoughtcrime.securesms.backup.RestoreState
import kotlin.math.max
import kotlin.math.min
/**
* In-memory view of the current state of an attachment restore process.
@@ -24,13 +26,15 @@ data class ArchiveRestoreProgressState(
val progress: Float? = when (this.restoreState) {
RestoreState.CALCULATING_MEDIA,
RestoreState.CANCELING_MEDIA -> this.completedRestoredSize.percentageOf(this.totalRestoreSize)
RestoreState.CANCELING_MEDIA -> {
max(0f, min(1f, this.completedRestoredSize.percentageOf(this.totalRestoreSize)))
}
RestoreState.RESTORING_MEDIA -> {
when (this.restoreStatus) {
RestoreStatus.NONE -> null
RestoreStatus.FINISHED -> 1f
else -> this.completedRestoredSize.percentageOf(this.totalRestoreSize)
else -> max(0f, min(1f, this.completedRestoredSize.percentageOf(this.totalRestoreSize)))
}
}

View File

@@ -72,6 +72,7 @@ import org.thoughtcrime.securesms.backup.v2.processor.RecipientArchiveProcessor
import org.thoughtcrime.securesms.backup.v2.processor.StickerArchiveProcessor
import org.thoughtcrime.securesms.backup.v2.proto.BackupDebugInfo
import org.thoughtcrime.securesms.backup.v2.proto.BackupInfo
import org.thoughtcrime.securesms.backup.v2.proto.Frame
import org.thoughtcrime.securesms.backup.v2.stream.BackupExportWriter
import org.thoughtcrime.securesms.backup.v2.stream.BackupImportReader
import org.thoughtcrime.securesms.backup.v2.stream.EncryptedBackupReader
@@ -80,6 +81,7 @@ import org.thoughtcrime.securesms.backup.v2.stream.PlainTextBackupReader
import org.thoughtcrime.securesms.backup.v2.stream.PlainTextBackupWriter
import org.thoughtcrime.securesms.backup.v2.ui.BackupAlert
import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsType
import org.thoughtcrime.securesms.backup.v2.util.ArchiveAttachmentInfo
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
import org.thoughtcrime.securesms.components.settings.app.subscription.RecurringInAppPaymentRepository
import org.thoughtcrime.securesms.crypto.AttachmentSecretProvider
@@ -107,11 +109,14 @@ import org.thoughtcrime.securesms.jobs.BackupRestoreMediaJob
import org.thoughtcrime.securesms.jobs.CancelRestoreMediaJob
import org.thoughtcrime.securesms.jobs.CreateReleaseChannelJob
import org.thoughtcrime.securesms.jobs.LocalBackupJob
import org.thoughtcrime.securesms.jobs.MultiDeviceKeysUpdateJob
import org.thoughtcrime.securesms.jobs.RequestGroupV2InfoJob
import org.thoughtcrime.securesms.jobs.ResetSvrGuessCountJob
import org.thoughtcrime.securesms.jobs.RestoreOptimizedMediaJob
import org.thoughtcrime.securesms.jobs.RetrieveProfileJob
import org.thoughtcrime.securesms.jobs.StickerPackDownloadJob
import org.thoughtcrime.securesms.jobs.StorageForcePushJob
import org.thoughtcrime.securesms.jobs.Svr2MirrorJob
import org.thoughtcrime.securesms.jobs.UploadAttachmentToArchiveJob
import org.thoughtcrime.securesms.keyvalue.BackupValues.ArchiveServiceCredentials
import org.thoughtcrime.securesms.keyvalue.KeyValueStore
@@ -130,6 +135,7 @@ import org.thoughtcrime.securesms.service.BackupProgressService
import org.thoughtcrime.securesms.storage.StorageSyncHelper
import org.thoughtcrime.securesms.util.RemoteConfig
import org.thoughtcrime.securesms.util.ServiceUtil
import org.thoughtcrime.securesms.util.TextSecurePreferences
import org.thoughtcrime.securesms.util.toMillis
import org.whispersystems.signalservice.api.AccountEntropyPool
import org.whispersystems.signalservice.api.ApplicationErrorAction
@@ -163,6 +169,7 @@ import java.io.File
import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
import java.math.BigDecimal
import java.time.ZonedDateTime
import java.util.Currency
import java.util.Locale
@@ -170,6 +177,7 @@ import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicLong
import kotlin.jvm.optionals.getOrNull
import kotlin.time.Duration
import kotlin.time.Duration.Companion.days
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
@@ -237,9 +245,22 @@ object BackupRepository {
resetInitializedStateAndAuthCredentials()
SignalStore.account.rotateAccountEntropyPool(stagedKeyRotations.aep)
SignalStore.backup.mediaRootBackupKey = stagedKeyRotations.mediaRootBackupKey
refreshMasterKeyDependents()
BackupMessagesJob.enqueue()
}
private fun refreshMasterKeyDependents() {
val jobs = buildList {
add(Svr2MirrorJob())
if (SignalStore.account.isMultiDevice) {
add(MultiDeviceKeysUpdateJob())
}
add(StorageForcePushJob())
}
AppDependencies.jobManager.addAll(jobs)
}
fun resetInitializedStateAndAuthCredentials() {
SignalStore.backup.backupsInitialized = false
SignalStore.backup.messageCredentials.clearAll()
@@ -265,6 +286,19 @@ object BackupRepository {
val messageBackupKey = SignalStore.backup.messageBackupKey
val mediaRootBackupKey = SignalStore.backup.mediaRootBackupKey
return SignalNetwork.archive.triggerBackupIdReservation(messageBackupKey, mediaRootBackupKey, SignalStore.account.requireAci())
.runIfSuccessful {
SignalStore.backup.messageCredentials.clearAll()
SignalStore.backup.mediaCredentials.clearAll()
}
}
@WorkerThread
fun triggerBackupIdReservationForRestore(): NetworkResult<Unit> {
val messageBackupKey = SignalStore.backup.messageBackupKey
return SignalNetwork.archive.triggerBackupIdReservation(messageBackupKey, null, SignalStore.account.requireAci())
.runIfSuccessful {
SignalStore.backup.messageCredentials.clearAll()
}
}
/**
@@ -542,7 +576,39 @@ object BackupRepository {
return false
}
return SignalStore.backup.hasBackupBeenUploaded && System.currentTimeMillis().milliseconds > SignalStore.backup.nextBackupFailureSheetSnoozeTime
val isRegistered = SignalStore.account.isRegistered && !TextSecurePreferences.isUnauthorizedReceived(AppDependencies.application)
if (!isRegistered) {
Log.d(TAG, "[shouldDisplayCouldNotCompleteBackupSheet] Not displaying sheet for unregistered user.")
return false
}
if (SignalStore.backup.lastBackupTime <= 0) {
Log.d(TAG, "[shouldDisplayCouldNotCompleteBackupSheet] Not displaying sheet as the last backup time is unset.")
return false
}
if (!SignalStore.backup.hasBackupBeenUploaded) {
Log.d(TAG, "[shouldDisplayCouldNotCompleteBackupSheet] Not displaying sheet as a backup has never been uploaded.")
return false
}
val now = System.currentTimeMillis().milliseconds
val lastBackupTime = SignalStore.backup.lastBackupTime.milliseconds
val nextSnoozeTime = SignalStore.backup.nextBackupFailureSnoozeTime
val isLastBackupTimeAtLeastAWeekAgo = now - 7.days > lastBackupTime
if (!isLastBackupTimeAtLeastAWeekAgo) {
Log.d(TAG, "[shouldDisplayCouldNotCompleteBackupSheet] Not displaying sheet as the last backup time is less than a week ago.")
return false
}
val isNextSnoozeTimeBeforeNow = nextSnoozeTime < now
if (!isNextSnoozeTimeBeforeNow) {
Log.d(TAG, "[shouldDisplayCouldNotCompleteBackupSheet] Not displaying sheet as the next snooze time is in the future.")
return false
}
return true
}
fun snoozeDownloadYourBackupData() {
@@ -615,7 +681,7 @@ object BackupRepository {
}
private fun shouldNotDisplayBackupFailedMessaging(): Boolean {
return !SignalStore.account.isRegistered || !RemoteConfig.messageBackups || !SignalStore.backup.areBackupsEnabled
return !SignalStore.account.isRegistered || !SignalStore.backup.areBackupsEnabled
}
/**
@@ -718,13 +784,18 @@ object BackupRepository {
append = { main.write(it) }
)
val maxBufferSize = 10_000
var totalAttachmentCount = 0
val attachmentInfos: MutableSet<ArchiveAttachmentInfo> = mutableSetOf()
export(
currentTime = System.currentTimeMillis(),
isLocal = true,
writer = writer,
progressEmitter = localBackupProgressEmitter,
cancellationSignal = cancellationSignal,
forTransfer = false
forTransfer = false,
extraFrameOperation = null
) { dbSnapshot ->
val localArchivableAttachments = dbSnapshot
.attachmentTable
@@ -760,7 +831,7 @@ object BackupRepository {
currentTime: Long,
progressEmitter: ExportProgressListener? = null,
cancellationSignal: () -> Boolean = { false },
extraExportOperations: ((SignalDatabase) -> Unit)?
extraFrameOperation: ((Frame) -> Unit)?
) {
val writer = EncryptedBackupWriter.createForSignalBackup(
key = messageBackupKey,
@@ -778,7 +849,8 @@ object BackupRepository {
forTransfer = false,
progressEmitter = progressEmitter,
cancellationSignal = cancellationSignal,
extraExportOperations = extraExportOperations
extraFrameOperation = extraFrameOperation,
endingExportOperation = null
)
}
@@ -807,7 +879,8 @@ object BackupRepository {
forTransfer = true,
progressEmitter = progressEmitter,
cancellationSignal = cancellationSignal,
extraExportOperations = null
extraFrameOperation = null,
endingExportOperation = null
)
}
@@ -821,8 +894,7 @@ object BackupRepository {
currentTime: Long = System.currentTimeMillis(),
forTransfer: Boolean = false,
progressEmitter: ExportProgressListener? = null,
cancellationSignal: () -> Boolean = { false },
extraExportOperations: ((SignalDatabase) -> Unit)? = null
cancellationSignal: () -> Boolean = { false }
) {
val writer: BackupExportWriter = if (plaintext) {
PlainTextBackupWriter(outputStream)
@@ -842,7 +914,8 @@ object BackupRepository {
forTransfer = forTransfer,
progressEmitter = progressEmitter,
cancellationSignal = cancellationSignal,
extraExportOperations = extraExportOperations
extraFrameOperation = null,
endingExportOperation = null
)
}
@@ -864,7 +937,8 @@ object BackupRepository {
forTransfer: Boolean,
progressEmitter: ExportProgressListener?,
cancellationSignal: () -> Boolean,
extraExportOperations: ((SignalDatabase) -> Unit)?
extraFrameOperation: ((Frame) -> Unit)?,
endingExportOperation: ((SignalDatabase) -> Unit)?
) {
val eventTimer = EventTimer()
val mainDbName = if (isLocal) LOCAL_MAIN_DB_SNAPSHOT_NAME else REMOTE_MAIN_DB_SNAPSHOT_NAME
@@ -902,8 +976,9 @@ object BackupRepository {
// We're using a snapshot, so the transaction is more for perf than correctness
dbSnapshot.rawWritableDatabase.withinTransaction {
progressEmitter?.onAccount()
AccountDataArchiveProcessor.export(dbSnapshot, signalStoreSnapshot) {
writer.write(it)
AccountDataArchiveProcessor.export(dbSnapshot, signalStoreSnapshot) { frame ->
writer.write(frame)
extraFrameOperation?.invoke(frame)
eventTimer.emit("account")
frameCount++
}
@@ -915,6 +990,7 @@ object BackupRepository {
progressEmitter?.onRecipient()
RecipientArchiveProcessor.export(dbSnapshot, signalStoreSnapshot, exportState, selfRecipientId, selfAci) {
writer.write(it)
extraFrameOperation?.invoke(it)
eventTimer.emit("recipient")
frameCount++
}
@@ -926,6 +1002,7 @@ object BackupRepository {
progressEmitter?.onThread()
ChatArchiveProcessor.export(dbSnapshot, exportState) { frame ->
writer.write(frame)
extraFrameOperation?.invoke(frame)
eventTimer.emit("thread")
frameCount++
}
@@ -936,6 +1013,7 @@ object BackupRepository {
progressEmitter?.onCall()
AdHocCallArchiveProcessor.export(dbSnapshot, exportState) { frame ->
writer.write(frame)
extraFrameOperation?.invoke(frame)
eventTimer.emit("call")
frameCount++
}
@@ -947,6 +1025,7 @@ object BackupRepository {
progressEmitter?.onSticker()
StickerArchiveProcessor.export(dbSnapshot) { frame ->
writer.write(frame)
extraFrameOperation?.invoke(frame)
eventTimer.emit("sticker-pack")
frameCount++
}
@@ -958,6 +1037,7 @@ object BackupRepository {
progressEmitter?.onNotificationProfile()
NotificationProfileProcessor.export(dbSnapshot, exportState) { frame ->
writer.write(frame)
extraFrameOperation?.invoke(frame)
eventTimer.emit("notification-profile")
frameCount++
}
@@ -969,6 +1049,7 @@ object BackupRepository {
progressEmitter?.onChatFolder()
ChatFolderProcessor.export(dbSnapshot, exportState) { frame ->
writer.write(frame)
extraFrameOperation?.invoke(frame)
eventTimer.emit("chat-folder")
frameCount++
}
@@ -982,6 +1063,7 @@ object BackupRepository {
progressEmitter?.onMessage(0, approximateMessageCount)
ChatItemArchiveProcessor.export(dbSnapshot, exportState, selfRecipientId, cancellationSignal) { frame ->
writer.write(frame)
extraFrameOperation?.invoke(frame)
eventTimer.emit("message")
frameCount++
@@ -997,7 +1079,7 @@ object BackupRepository {
}
}
extraExportOperations?.invoke(dbSnapshot)
endingExportOperation?.invoke(dbSnapshot)
Log.d(TAG, "[export] totalFrames: $frameCount | ${eventTimer.stop().summary}")
} finally {
@@ -1757,6 +1839,18 @@ object BackupRepository {
return RestoreTimestampResult.NotFound
}
timestampResult is NetworkResult.StatusCodeError && timestampResult.code == 401 -> {
Log.i(TAG, "Backups not enabled")
SignalStore.backup.lastBackupTime = 0L
SignalStore.backup.isBackupTimestampRestored = true
return RestoreTimestampResult.BackupsNotEnabled
}
timestampResult is NetworkResult.ApplicationError && timestampResult.getCause() is VerificationFailedException -> {
Log.w(TAG, "Entered AEP fails zk verification", timestampResult.getCause())
return RestoreTimestampResult.VerificationFailure
}
else -> {
Log.w(TAG, "Could not check for backup file.", timestampResult.getCause())
return RestoreTimestampResult.Failure
@@ -1764,11 +1858,40 @@ object BackupRepository {
}
}
fun verifyBackupKeyAssociatedWithAccount(aci: ACI, aep: AccountEntropyPool): MessageBackupTier? {
fun verifyBackupKeyAssociatedWithAccount(aci: ACI, aep: AccountEntropyPool): RestoreTimestampResult {
Log.i(TAG, "Verifying enter aep is associated with account")
var result: RestoreTimestampResult = getBackupTimestampToVerifyAepAssociatedWithAccountAndHasBackup(aci, aep)
if (result is RestoreTimestampResult.VerificationFailure) {
Log.w(TAG, "Resetting backup id reservation due to zk verification failure")
val triggerResult = SignalNetwork.archive.triggerBackupIdReservation(aep.deriveMessageBackupKey(), null, aci)
result = when {
triggerResult is NetworkResult.Success -> {
Log.i(TAG, "Reset successful, retrying aep verification")
SignalStore.backup.messageCredentials.clearAll()
getBackupTimestampToVerifyAepAssociatedWithAccountAndHasBackup(aci, aep)
}
triggerResult is NetworkResult.StatusCodeError && triggerResult.code == 429 -> {
Log.w(TAG, "Rate limited when resetting backup id, failing operation $triggerResult")
RestoreTimestampResult.RateLimited(triggerResult.retryAfter())
}
else -> {
Log.w(TAG, "Reset backup id failed, failing operation", triggerResult.getCause())
result
}
}
}
return result
}
private fun getBackupTimestampToVerifyAepAssociatedWithAccountAndHasBackup(aci: ACI, aep: AccountEntropyPool): RestoreTimestampResult {
val currentTime = System.currentTimeMillis()
val messageBackupKey = aep.deriveMessageBackupKey()
val result: NetworkResult<MessageBackupTier> = SignalNetwork.archive.getServiceCredentials(currentTime)
val result: NetworkResult<ZonedDateTime> = SignalNetwork.archive.getServiceCredentials(currentTime)
.then { result ->
val credential: ArchiveServiceCredential? = ArchiveServiceCredentials(result.messageCredentials.associateBy { it.redemptionTime }).getForCurrentTime(currentTime.milliseconds)
@@ -1783,20 +1906,41 @@ object BackupRepository {
)
}
}
.map { messageAccess ->
val zkCredential = SignalNetwork.archive.getZkCredential(aci, messageAccess)
if (zkCredential.backupLevel == BackupLevel.PAID) {
MessageBackupTier.PAID
} else {
MessageBackupTier.FREE
}
.then { messageAccess ->
SignalNetwork.archive.getBackupInfo(SignalStore.account.requireAci(), messageAccess)
.then { info -> SignalNetwork.archive.getCdnReadCredentials(info.cdn ?: RemoteConfig.backupFallbackArchiveCdn, aci, messageAccess).map { it.headers to info } }
.then { pair ->
val (cdnCredentials, info) = pair
NetworkResult.fromFetch {
AppDependencies.signalServiceMessageReceiver.getCdnLastModifiedTime(info.cdn!!, cdnCredentials, "backups/${info.backupDir}/${info.backupName}")
}
}
}
return if (result is NetworkResult.Success) {
result.result
} else {
Log.i(TAG, "Unable to verify backup key", result.getCause())
null
return when {
result is NetworkResult.Success -> {
RestoreTimestampResult.Success(result.result.toMillis())
}
result is NetworkResult.StatusCodeError && result.code == 404 -> {
Log.i(TAG, "No backup file exists")
RestoreTimestampResult.NotFound
}
result is NetworkResult.StatusCodeError && result.code == 401 -> {
Log.i(TAG, "Backups not enabled")
RestoreTimestampResult.BackupsNotEnabled
}
result is NetworkResult.ApplicationError && result.getCause() is VerificationFailedException -> {
Log.w(TAG, "Entered AEP fails zk verification", result.getCause())
RestoreTimestampResult.VerificationFailure
}
else -> {
Log.w(TAG, "Could not check for backup file.", result.getCause())
RestoreTimestampResult.Failure
}
}
}
@@ -1870,23 +2014,14 @@ object BackupRepository {
suspend fun getPaidType(): NetworkResult<MessageBackupsType.Paid> {
val productPrice: FiatMoney? = if (SignalStore.backup.backupTierInternalOverride == MessageBackupTier.PAID) {
Log.d(TAG, "Accessing price via mock subscription.")
RecurringInAppPaymentRepository.getActiveSubscriptionSync(InAppPaymentSubscriberRecord.Type.BACKUP).getOrNull()?.activeSubscription?.let {
RecurringInAppPaymentRepository.getActiveSubscriptionSync(InAppPaymentSubscriberRecord.Type.BACKUP).successOrNull()?.activeSubscription?.let {
FiatMoney.fromSignalNetworkAmount(it.amount, Currency.getInstance(it.currency))
}
} else if (AppDependencies.billingApi.isApiAvailable()) {
} else if (AppDependencies.billingApi.getApiAvailability().isSuccess) {
Log.d(TAG, "Accessing price via billing api.")
AppDependencies.billingApi.queryProduct()?.price
} else {
Log.d(TAG, "Billing API is not available on this device. Accessing price via subscription configuration.")
val configurationResult = AppDependencies.donationsService.getDonationsConfiguration(Locale.getDefault()).toNetworkResult()
val currency = Currency.getInstance(Locale.getDefault())
when (configurationResult) {
is NetworkResult.Success -> configurationResult.result.currencies[currency.currencyCode.lowercase()]?.backupSubscription[SubscriptionsConfiguration.BACKUPS_LEVEL]?.let {
FiatMoney(it, currency)
}
else -> null
}
FiatMoney(BigDecimal.ZERO, SignalStore.inAppPayments.getRecurringDonationCurrency())
}
if (productPrice == null) {
@@ -1920,22 +2055,25 @@ object BackupRepository {
* prevents early initialization with incorrect keys before we have restored them.
*/
private fun initBackupAndFetchAuth(): NetworkResult<ArchiveServiceAccessPair> {
return if (!RemoteConfig.messageBackups) {
NetworkResult.StatusCodeError(555, null, null, emptyMap(), NonSuccessfulResponseCodeException(555, "Backups disabled!"))
} else if (SignalStore.backup.backupsInitialized || SignalStore.account.isLinkedDevice) {
return if (SignalStore.backup.backupsInitialized || SignalStore.account.isLinkedDevice) {
getArchiveServiceAccessPair()
.runOnStatusCodeError(resetInitializedStateErrorAction)
.runOnApplicationError(clearAuthCredentials)
} else if (isPreRestoreDuringRegistration()) {
Log.w(TAG, "Requesting/using auth credentials in pre-restore state", Throwable())
getArchiveServiceAccessPair()
.runOnApplicationError(clearAuthCredentials)
} else {
val messageBackupKey = SignalStore.backup.messageBackupKey
val mediaRootBackupKey = SignalStore.backup.mediaRootBackupKey
return SignalNetwork.archive
.triggerBackupIdReservation(messageBackupKey, mediaRootBackupKey, SignalStore.account.requireAci())
.then { getArchiveServiceAccessPair() }
.then {
SignalStore.backup.messageCredentials.clearAll()
SignalStore.backup.mediaCredentials.clearAll()
getArchiveServiceAccessPair()
}
.then { credential -> SignalNetwork.archive.setPublicKey(SignalStore.account.requireAci(), credential.messageBackupAccess).map { credential } }
.then { credential -> SignalNetwork.archive.setPublicKey(SignalStore.account.requireAci(), credential.mediaBackupAccess).map { credential } }
.runIfSuccessful { SignalStore.backup.backupsInitialized = true }
@@ -1980,8 +2118,7 @@ object BackupRepository {
private fun isPreRestoreDuringRegistration(): Boolean {
return !SignalStore.registration.isRegistrationComplete &&
SignalStore.registration.restoreDecisionState.isDecisionPending &&
RemoteConfig.restoreAfterRegistration
SignalStore.registration.restoreDecisionState.isDecisionPending
}
private fun scheduleSyncForAccountChange() {
@@ -2076,7 +2213,7 @@ object BackupRepository {
val messageBackupKey = SignalStore.backup.messageBackupKey
Log.i(TAG, "[remoteRestore] Fetching SVRB data")
val svrBAuth = when (val result = BackupRepository.getSvrBAuth()) {
val svrBAuth = when (val result = getSvrBAuth()) {
is NetworkResult.Success -> result.result
is NetworkResult.NetworkError -> return RemoteRestoreResult.NetworkError.logW(TAG, "[remoteRestore] Network error when getting SVRB auth.", result.getCause())
is NetworkResult.StatusCodeError -> return RemoteRestoreResult.NetworkError.logW(TAG, "[remoteRestore] Status code error when getting SVRB auth.", result.getCause())
@@ -2088,20 +2225,24 @@ object BackupRepository {
SignalStore.backup.nextBackupSecretData = result.data.nextBackupSecretData
result.data.forwardSecrecyToken
}
is SvrBApi.RestoreResult.NetworkError -> {
Log.w(TAG, "[remoteRestore] Network error during SVRB.", result.exception)
return RemoteRestoreResult.NetworkError
}
is SvrBApi.RestoreResult.RestoreFailedError,
SvrBApi.RestoreResult.InvalidDataError -> {
Log.w(TAG, "[remoteRestore] Permanent SVRB error! $result")
return RemoteRestoreResult.PermanentSvrBFailure
}
SvrBApi.RestoreResult.DataMissingError,
is SvrBApi.RestoreResult.SvrError -> {
Log.w(TAG, "[remoteRestore] Failed to fetch SVRB data: $result")
return RemoteRestoreResult.Failure
}
is SvrBApi.RestoreResult.UnknownError -> {
Log.e(TAG, "[remoteRestore] Unknown SVRB result! Crashing.", result.throwable)
throw result.throwable
@@ -2326,6 +2467,9 @@ sealed interface RemoteRestoreResult {
sealed interface RestoreTimestampResult {
data class Success(val timestamp: Long) : RestoreTimestampResult
data object NotFound : RestoreTimestampResult
data object BackupsNotEnabled : RestoreTimestampResult
data object VerificationFailure : RestoreTimestampResult
data class RateLimited(val retryAfter: Duration?) : RestoreTimestampResult
data object Failure : RestoreTimestampResult
}

View File

@@ -549,7 +549,7 @@ private fun BackupMessageRecord.toBasicChatItemBuilder(selfRecipientId: Recipien
dateReceived = dateReceived
)
if (expiresInMs != null && outgoing?.sendStatus?.all { it.pending == null && it.failed == null } == true) {
if (expiresInMs != null && outgoing?.sendStatus?.all { it.pending == null && it.failed == null } == true && expireStartDate == null) {
Log.w(TAG, ExportOddities.outgoingMessageWasSentButTimerNotStarted(record.dateSent))
expireStartDate = record.dateReceived
}

View File

@@ -37,8 +37,8 @@ import androidx.core.os.bundleOf
import androidx.fragment.app.DialogFragment
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import kotlinx.parcelize.Parcelize
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.SignalPreview
import org.signal.core.ui.compose.theme.SignalTheme
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.backup.v2.BackupRepository
@@ -434,7 +434,7 @@ private fun rememberSecondaryAction(
}
}
@SignalPreview
@DayNightPreviews
@Composable
private fun BackupAlertSheetContentPreviewGeneric() {
Previews.BottomSheetPreview {
@@ -446,7 +446,7 @@ private fun BackupAlertSheetContentPreviewGeneric() {
}
}
@SignalPreview
@DayNightPreviews
@Composable
private fun BackupAlertSheetContentPreviewPayment() {
Previews.BottomSheetPreview {
@@ -458,7 +458,7 @@ private fun BackupAlertSheetContentPreviewPayment() {
}
}
@SignalPreview
@DayNightPreviews
@Composable
private fun BackupAlertSheetContentPreviewDelete() {
Previews.BottomSheetPreview {
@@ -473,7 +473,7 @@ private fun BackupAlertSheetContentPreviewDelete() {
}
}
@SignalPreview
@DayNightPreviews
@Composable
private fun BackupAlertSheetContentPreviewDiskFull() {
Previews.BottomSheetPreview {
@@ -485,7 +485,7 @@ private fun BackupAlertSheetContentPreviewDiskFull() {
}
}
@SignalPreview
@DayNightPreviews
@Composable
private fun BackupAlertSheetContentPreviewBackupFailed() {
Previews.BottomSheetPreview {
@@ -497,7 +497,7 @@ private fun BackupAlertSheetContentPreviewBackupFailed() {
}
}
@SignalPreview
@DayNightPreviews
@Composable
private fun BackupAlertSheetContentPreviewCouldNotRedeemBackup() {
Previews.BottomSheetPreview {
@@ -509,7 +509,7 @@ private fun BackupAlertSheetContentPreviewCouldNotRedeemBackup() {
}
}
@SignalPreview
@DayNightPreviews
@Composable
private fun BackupAlertSheetContentPreviewSubscriptionExpired() {
Previews.BottomSheetPreview {

View File

@@ -31,8 +31,8 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import org.signal.core.ui.compose.BottomSheets
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.SignalPreview
import org.thoughtcrime.securesms.R
import org.signal.core.ui.R as CoreUiR
@@ -201,7 +201,7 @@ private fun BackupAlertSecondaryActionButton(
}
}
@SignalPreview
@DayNightPreviews
@Composable
private fun BackupAlertBottomSheetContainerPreview() {
Previews.BottomSheetPreview {

View File

@@ -15,6 +15,7 @@ import androidx.compose.foundation.layout.widthIn
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.dimensionResource
@@ -26,9 +27,10 @@ import androidx.core.os.bundleOf
import androidx.fragment.app.setFragmentResult
import org.signal.core.ui.compose.BottomSheets
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.SignalPreview
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.app.backups.BackupStateObserver
import org.thoughtcrime.securesms.compose.ComposeBottomSheetDialogFragment
import org.thoughtcrime.securesms.jobs.BackupMessagesJob
import org.signal.core.ui.R as CoreUiR
@@ -51,15 +53,15 @@ class CreateBackupBottomSheet : ComposeBottomSheetDialogFragment() {
@Composable
override fun SheetContent() {
val isPaidTier: Boolean = remember { BackupStateObserver.getNonIOBackupState().isLikelyPaidTier() }
CreateBackupBottomSheetContent(
isPaidTier = isPaidTier,
onBackupNowClick = {
BackupMessagesJob.enqueue()
setFragmentResult(REQUEST_KEY, bundleOf(REQUEST_KEY to Result.BACKUP_STARTED))
isResultSet = true
dismissAllowingStateLoss()
},
onBackupLaterClick = {
dismissAllowingStateLoss()
}
)
}
@@ -80,8 +82,8 @@ class CreateBackupBottomSheet : ComposeBottomSheetDialogFragment() {
@Composable
private fun CreateBackupBottomSheetContent(
onBackupNowClick: () -> Unit,
onBackupLaterClick: () -> Unit
isPaidTier: Boolean,
onBackupNowClick: () -> Unit
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
@@ -106,8 +108,14 @@ private fun CreateBackupBottomSheetContent(
textAlign = TextAlign.Center
)
val body = if (isPaidTier) {
stringResource(id = R.string.CreateBackupBottomSheet__depending_on_the_size)
} else {
stringResource(id = R.string.CreateBackupBottomSheet__free_tier)
}
Text(
text = stringResource(id = R.string.CreateBackupBottomSheet__depending_on_the_size),
text = body,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
@@ -126,13 +134,24 @@ private fun CreateBackupBottomSheetContent(
}
}
@SignalPreview
@DayNightPreviews
@Composable
private fun CreateBackupBottomSheetContentPreview() {
private fun CreateBackupBottomSheetContentPaidPreview() {
Previews.BottomSheetPreview {
CreateBackupBottomSheetContent(
onBackupNowClick = {},
onBackupLaterClick = {}
isPaidTier = true,
onBackupNowClick = {}
)
}
}
@DayNightPreviews
@Composable
private fun CreateBackupBottomSheetContentFreePreview() {
Previews.BottomSheetPreview {
CreateBackupBottomSheetContent(
isPaidTier = false,
onBackupNowClick = {}
)
}
}

View File

@@ -11,9 +11,9 @@ import androidx.compose.ui.res.stringResource
import androidx.core.os.BundleCompat
import androidx.core.os.bundleOf
import androidx.fragment.app.DialogFragment
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Dialogs
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.SignalPreview
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.backup.v2.BackupRepository
import org.thoughtcrime.securesms.compose.ComposeDialogFragment
@@ -70,7 +70,7 @@ private fun DownloadYourBackupTodayDialogContent(
)
}
@SignalPreview
@DayNightPreviews
@Composable
private fun DownloadYourBackupTodayDialogContentPreview() {
Previews.Preview {

View File

@@ -18,9 +18,7 @@ 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.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
@@ -35,8 +33,8 @@ import androidx.core.os.BundleCompat
import org.signal.core.ui.R
import org.signal.core.ui.compose.BottomSheets
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.SignalPreview
import org.signal.core.util.gibiBytes
import org.signal.core.util.money.FiatMoney
import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsType
@@ -158,7 +156,7 @@ private fun SheetContent(
}
}
@SignalPreview
@DayNightPreviews
@Composable
private fun BackupAlertSheetContentPreviewMedia() {
Previews.BottomSheetPreview {

View File

@@ -12,8 +12,8 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.SignalPreview
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
import org.thoughtcrime.securesms.compose.ComposeBottomSheetDialogFragment
@@ -71,7 +71,7 @@ private fun NoManualBackupSheetContent(
}
}
@SignalPreview
@DayNightPreviews
@Composable
private fun NoManualBackupSheetContentPreview() {
Previews.BottomSheetPreview {

View File

@@ -19,8 +19,8 @@ import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.withLink
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.SignalPreview
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.backup.v2.BackupRepository
import org.thoughtcrime.securesms.components.contactsupport.ContactSupportDialogFragment
@@ -108,7 +108,7 @@ private fun NoRemoteStorageSpaceAvailableBottomSheetContent(
}
}
@SignalPreview
@DayNightPreviews
@Composable
private fun NoRemoteStorageSpaceAvailableBottomSheetContentPreview() {
Previews.BottomSheetPreview {

View File

@@ -29,8 +29,8 @@ import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.withLink
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.SignalPreview
import org.thoughtcrime.securesms.R
import org.signal.core.ui.R as CoreUiR
@@ -112,7 +112,7 @@ private fun BackupAlertText(text: AnnotatedString, inlineContent: Map<String, In
)
}
@SignalPreview
@DayNightPreviews
@Composable
fun BackupStatusRowCouldNotCompleteBackupPreview() {
Previews.Preview {
@@ -120,7 +120,7 @@ fun BackupStatusRowCouldNotCompleteBackupPreview() {
}
}
@SignalPreview
@DayNightPreviews
@Composable
fun BackupStatusRowBackupFailedPreview() {
Previews.Preview {

View File

@@ -35,8 +35,8 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.SignalPreview
import org.signal.core.util.mebiBytes
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.backup.RestoreState
@@ -309,7 +309,7 @@ private fun ArchiveRestoreProgressState.actionResource(): Int {
}
}
@SignalPreview
@DayNightPreviews
@Composable
fun BackupStatusBannerPreview() {
Previews.Preview {

View File

@@ -21,9 +21,9 @@ import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.Rows
import org.signal.core.ui.compose.SignalPreview
import org.signal.core.util.mebiBytes
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.backup.RestoreState
@@ -98,7 +98,7 @@ fun BackupStatusRow(
if (backupStatusData.restoreStatus == RestoreStatus.NOT_ENOUGH_DISK_SPACE) {
BackupAlertText(
text = stringResource(R.string.BackupStatusRow__not_enough_space, backupStatusData.remainingRestoreSize)
text = stringResource(R.string.BackupStatusRow__not_enough_space, backupStatusData.remainingRestoreSize.toUnitString())
)
Rows.TextRow(
@@ -221,7 +221,7 @@ private fun progressColor(backupStatusData: ArchiveRestoreProgressState): Color
}
}
@SignalPreview
@DayNightPreviews
@Composable
fun BackupStatusRowNormalPreview() {
Previews.Preview {
@@ -232,7 +232,7 @@ fun BackupStatusRowNormalPreview() {
}
}
@SignalPreview
@DayNightPreviews
@Composable
fun BackupStatusRowWaitingForWifiPreview() {
Previews.Preview {
@@ -242,7 +242,7 @@ fun BackupStatusRowWaitingForWifiPreview() {
}
}
@SignalPreview
@DayNightPreviews
@Composable
fun BackupStatusRowWaitingForInternetPreview() {
Previews.Preview {
@@ -252,7 +252,7 @@ fun BackupStatusRowWaitingForInternetPreview() {
}
}
@SignalPreview
@DayNightPreviews
@Composable
fun BackupStatusRowLowBatteryPreview() {
Previews.Preview {
@@ -262,7 +262,7 @@ fun BackupStatusRowLowBatteryPreview() {
}
}
@SignalPreview
@DayNightPreviews
@Composable
fun BackupStatusRowFinishedPreview() {
Previews.Preview {
@@ -273,7 +273,7 @@ fun BackupStatusRowFinishedPreview() {
}
}
@SignalPreview
@DayNightPreviews
@Composable
fun BackupStatusRowNotEnoughFreeSpacePreview() {
Previews.Preview {

View File

@@ -43,9 +43,9 @@ import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.horizontalGutters
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.fonts.MonoTypeface
import org.thoughtcrime.securesms.registrationv3.ui.restore.BackupKeyVisualTransformation
import org.thoughtcrime.securesms.registrationv3.ui.restore.attachBackupKeyAutoFillHelper
import org.thoughtcrime.securesms.registrationv3.ui.restore.backupKeyAutoFillHelper
import org.thoughtcrime.securesms.registration.ui.restore.BackupKeyVisualTransformation
import org.thoughtcrime.securesms.registration.ui.restore.attachBackupKeyAutoFillHelper
import org.thoughtcrime.securesms.registration.ui.restore.backupKeyAutoFillHelper
import org.whispersystems.signalservice.api.AccountEntropyPool
/**

View File

@@ -0,0 +1,231 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2.ui.subscription
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.res.stringResource
import com.google.android.gms.common.ConnectionResult
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Dialogs
import org.signal.core.ui.compose.Previews
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
/**
* Represents the availability status of Google Play Services on the device.
*
* Maps Google Play Services ConnectionResult codes to enum values for easier handling
* in the application. Each enum value corresponds to a specific state that determines
* what dialog or action should be presented to the user.
*
* @param code The corresponding ConnectionResult code from Google Play Services
*/
enum class GooglePlayServicesAvailability(val code: Int) {
/** An unknown code. Possibly due to an update on Google's end */
UNKNOWN(code = Int.MIN_VALUE),
/** Google Play Services is available and ready to use */
SUCCESS(code = ConnectionResult.SUCCESS),
/** Google Play Services is not installed on the device */
SERVICE_MISSING(code = ConnectionResult.SERVICE_MISSING),
/** Google Play Services is currently being updated */
SERVICE_UPDATING(code = ConnectionResult.SERVICE_UPDATING),
/** Google Play Services requires an update to a newer version */
SERVICE_VERSION_UPDATE_REQUIRED(code = ConnectionResult.SERVICE_VERSION_UPDATE_REQUIRED),
/** Google Play Services is installed but disabled by the user */
SERVICE_DISABLED(code = ConnectionResult.SERVICE_DISABLED),
/** Google Play Services installation is invalid or corrupted */
SERVICE_INVALID(code = ConnectionResult.SERVICE_INVALID);
companion object {
private val TAG = Log.tag(GooglePlayServicesAvailability::class)
/**
* Converts a Google Play Services ConnectionResult code to the corresponding enum value.
*
* @param code The ConnectionResult code from Google Play Services
* @return The matching GooglePlayServicesAvailability enum value
*/
fun fromCode(code: Int): GooglePlayServicesAvailability {
val availability = entries.firstOrNull { it.code == code } ?: UNKNOWN
if (availability == UNKNOWN) {
Log.w(TAG, "Unknown availability code: $code")
}
return availability
}
}
}
/**
* Displays a dialog based on the Google Play Services availability status.
*
* Shows different dialogs with appropriate messages and actions depending on whether
* Google Play Services is missing, updating, requires an update, is disabled, or invalid.
* When availability is SUCCESS, automatically calls onDismissRequest to dismiss any dialog.
*
* @param onDismissRequest Callback invoked when the dialog is dismissed or when SUCCESS status is received
* @param onLearnMoreClick Callback invoked when the "Learn More" action is selected
* @param onMakeServicesAvailableClick Callback invoked when an action to make services
* available is selected (e.g., install or update)
* @param googlePlayServicesAvailability The current availability status of Google Play Services
*/
@Composable
fun GooglePlayServicesAvailabilityDialog(
onDismissRequest: () -> Unit,
onLearnMoreClick: () -> Unit,
onMakeServicesAvailableClick: () -> Unit,
googlePlayServicesAvailability: GooglePlayServicesAvailability
) {
when (googlePlayServicesAvailability) {
GooglePlayServicesAvailability.SUCCESS -> {
LaunchedEffect(Unit) {
onDismissRequest()
}
}
GooglePlayServicesAvailability.SERVICE_MISSING, GooglePlayServicesAvailability.UNKNOWN -> {
ServiceMissingDialog(
onDismissRequest = onDismissRequest,
onInstallPlayServicesClick = onMakeServicesAvailableClick
)
}
GooglePlayServicesAvailability.SERVICE_UPDATING -> {
ServiceUpdatingDialog(onDismissRequest = onDismissRequest)
}
GooglePlayServicesAvailability.SERVICE_VERSION_UPDATE_REQUIRED -> {
ServiceVersionUpdateRequiredDialog(
onDismissRequest = onDismissRequest,
onUpdateClick = onMakeServicesAvailableClick
)
}
GooglePlayServicesAvailability.SERVICE_DISABLED -> {
ServiceDisabledDialog(
onDismissRequest = onDismissRequest,
onLearnMoreClick = onLearnMoreClick
)
}
GooglePlayServicesAvailability.SERVICE_INVALID -> {
ServiceInvalidDialog(
onDismissRequest = onDismissRequest,
onLearnMoreClick = onLearnMoreClick
)
}
}
}
@Composable
private fun ServiceMissingDialog(onDismissRequest: () -> Unit, onInstallPlayServicesClick: () -> Unit) {
Dialogs.SimpleAlertDialog(
title = stringResource(R.string.GooglePlayServicesAvailability__service_missing_title),
body = stringResource(R.string.GooglePlayServicesAvailability__service_missing_message),
confirm = stringResource(R.string.GooglePlayServicesAvailability__install_play_services),
dismiss = stringResource(android.R.string.cancel),
onConfirm = {},
onDeny = onInstallPlayServicesClick,
onDismiss = onDismissRequest,
onDismissRequest = onDismissRequest
)
}
@Composable
private fun ServiceUpdatingDialog(onDismissRequest: () -> Unit) {
Dialogs.SimpleAlertDialog(
title = stringResource(R.string.GooglePlayServicesAvailability__service_updating_title),
body = stringResource(R.string.GooglePlayServicesAvailability__service_updating_message),
confirm = stringResource(android.R.string.ok),
onConfirm = {},
onDismiss = onDismissRequest,
onDismissRequest = onDismissRequest
)
}
@Composable
private fun ServiceVersionUpdateRequiredDialog(onDismissRequest: () -> Unit, onUpdateClick: () -> Unit) {
Dialogs.SimpleAlertDialog(
title = stringResource(R.string.GooglePlayServicesAvailability__service_update_required_title),
body = stringResource(R.string.GooglePlayServicesAvailability__service_update_required_message),
confirm = stringResource(R.string.GooglePlayServicesAvailability__update),
dismiss = stringResource(android.R.string.cancel),
onConfirm = onUpdateClick,
onDismiss = onDismissRequest,
onDismissRequest = onDismissRequest
)
}
@Composable
private fun ServiceDisabledDialog(onDismissRequest: () -> Unit, onLearnMoreClick: () -> Unit) {
Dialogs.SimpleAlertDialog(
title = stringResource(R.string.GooglePlayServicesAvailability__service_disabled_title),
body = stringResource(R.string.GooglePlayServicesAvailability__service_disabled_message),
confirm = stringResource(android.R.string.ok),
dismiss = stringResource(R.string.GooglePlayServicesAvailability__learn_more),
onConfirm = onDismissRequest,
onDeny = onLearnMoreClick,
onDismiss = onDismissRequest,
onDismissRequest = onDismissRequest
)
}
@Composable
private fun ServiceInvalidDialog(onDismissRequest: () -> Unit, onLearnMoreClick: () -> Unit) {
Dialogs.SimpleAlertDialog(
title = stringResource(R.string.GooglePlayServicesAvailability__service_disabled_title),
body = stringResource(R.string.GooglePlayServicesAvailability__service_invalid_message),
confirm = stringResource(android.R.string.ok),
dismiss = stringResource(R.string.GooglePlayServicesAvailability__learn_more),
onConfirm = {},
onDeny = onLearnMoreClick,
onDismiss = onDismissRequest,
onDismissRequest = onDismissRequest
)
}
@DayNightPreviews
@Composable
private fun ServiceMissingDialogPreview() {
Previews.Preview {
ServiceMissingDialog({}, {})
}
}
@DayNightPreviews
@Composable
private fun ServiceUpdatingDialogPreview() {
Previews.Preview {
ServiceUpdatingDialog({})
}
}
@DayNightPreviews
@Composable
private fun ServiceVersionUpdateRequiredDialogPreview() {
Previews.Preview {
ServiceVersionUpdateRequiredDialog({}, {})
}
}
@DayNightPreviews
@Composable
private fun ServiceDisabledDialogPreview() {
Previews.Preview {
ServiceDisabledDialog({}, {})
}
}
@DayNightPreviews
@Composable
private fun ServiceInvalidDialogPreview() {
Previews.Preview {
ServiceInvalidDialog({}, {})
}
}

View File

@@ -28,9 +28,9 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
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.compose.Buttons
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.Scaffolds
import org.thoughtcrime.securesms.R
@@ -144,7 +144,7 @@ fun MessageBackupsEducationScreen(
}
}
@Preview
@DayNightPreviews
@Composable
private fun MessageBackupsEducationSheetPreview() {
Previews.Preview {
@@ -156,7 +156,7 @@ private fun MessageBackupsEducationSheetPreview() {
}
}
@Preview
@DayNightPreviews
@Composable
private fun NotableFeatureRowPreview() {
Previews.Preview {

View File

@@ -25,6 +25,7 @@ import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import com.google.android.gms.common.GoogleApiAvailability
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import kotlinx.coroutines.rx3.asFlowable
@@ -40,6 +41,7 @@ import org.thoughtcrime.securesms.compose.Nav
import org.thoughtcrime.securesms.database.InAppPaymentTable
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.PlayStoreUtil
import org.thoughtcrime.securesms.util.Util
import org.thoughtcrime.securesms.util.storage.AndroidCredentialRepository
import org.thoughtcrime.securesms.util.viewModel
@@ -63,7 +65,10 @@ class MessageBackupsFlowFragment : ComposeFragment(), InAppPaymentCheckoutDelega
}
private val viewModel: MessageBackupsFlowViewModel by viewModel {
MessageBackupsFlowViewModel(requireArguments().getSerializableCompat(TIER, MessageBackupTier::class.java))
MessageBackupsFlowViewModel(
initialTierSelection = requireArguments().getSerializableCompat(TIER, MessageBackupTier::class.java),
googlePlayApiAvailability = GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(requireContext())
)
}
private val errorHandler = InAppPaymentCheckoutDelegate.ErrorHandler()
@@ -97,6 +102,7 @@ class MessageBackupsFlowFragment : ComposeFragment(), InAppPaymentCheckoutDelega
override fun onResume() {
super.onResume()
viewModel.refreshCurrentTier()
viewModel.setGooglePlayApiAvailability(GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(requireContext()))
}
@Composable
@@ -181,12 +187,21 @@ class MessageBackupsFlowFragment : ComposeFragment(), InAppPaymentCheckoutDelega
)
},
onNextClicked = viewModel::goToNextStage,
isBillingApiAvailable = state.isBillingApiAvailable,
googlePlayServicesAvailability = state.googlePlayApiAvailability,
googlePlayBillingAvailability = state.googlePlayBillingAvailability,
onLearnMoreAboutWhyUserCanNotUpgrade = {
CommunicationActions.openBrowserLink(
requireContext(),
getString(R.string.backup_support_url)
)
},
onMakeGooglePlayServicesAvailable = {
GoogleApiAvailability.getInstance().makeGooglePlayServicesAvailable(requireActivity()).addOnSuccessListener {
viewModel.setGooglePlayApiAvailability(GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(requireContext()))
}
},
onOpenPlayStore = {
PlayStoreUtil.openPlayStoreHome(requireContext())
}
)
}

View File

@@ -6,6 +6,7 @@
package org.thoughtcrime.securesms.backup.v2.ui.subscription
import androidx.compose.runtime.Immutable
import org.signal.core.util.billing.BillingResponseCode
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
import org.thoughtcrime.securesms.components.settings.app.backups.remote.BackupKeySaveState
import org.thoughtcrime.securesms.database.InAppPaymentTable
@@ -17,7 +18,8 @@ data class MessageBackupsFlowState(
val selectedMessageBackupTier: MessageBackupTier? = SignalStore.backup.backupTier,
val currentMessageBackupTier: MessageBackupTier? = null,
val allBackupTypes: List<MessageBackupsType> = emptyList(),
val isBillingApiAvailable: Boolean = false,
val googlePlayApiAvailability: GooglePlayServicesAvailability = GooglePlayServicesAvailability.SUCCESS,
val googlePlayBillingAvailability: BillingResponseCode = BillingResponseCode.FEATURE_NOT_SUPPORTED,
val inAppPayment: InAppPaymentTable.InAppPayment? = null,
val startScreen: MessageBackupsStage,
val stage: MessageBackupsStage = startScreen,

View File

@@ -45,7 +45,6 @@ import org.thoughtcrime.securesms.jobs.InAppPaymentPurchaseTokenJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.storage.StorageSyncHelper
import org.thoughtcrime.securesms.util.RemoteConfig
import org.thoughtcrime.securesms.util.next
import org.whispersystems.signalservice.api.storage.IAPSubscriptionId
import org.whispersystems.signalservice.internal.push.SubscriptionsConfiguration
@@ -53,6 +52,7 @@ import kotlin.time.Duration.Companion.seconds
class MessageBackupsFlowViewModel(
private val initialTierSelection: MessageBackupTier?,
googlePlayApiAvailability: Int,
startScreen: MessageBackupsStage = if (SignalStore.backup.backupTier == null) MessageBackupsStage.EDUCATION else MessageBackupsStage.TYPE_SELECTION
) : ViewModel(), BackupKeyCredentialManagerHandler {
@@ -64,6 +64,7 @@ class MessageBackupsFlowViewModel(
private val internalStateFlow = MutableStateFlow(
MessageBackupsFlowState(
allBackupTypes = emptyList(),
googlePlayApiAvailability = GooglePlayServicesAvailability.fromCode(googlePlayApiAvailability),
currentMessageBackupTier = SignalStore.backup.backupTier,
selectedMessageBackupTier = resolveSelectedTier(initialTierSelection, SignalStore.backup.backupTier),
startScreen = startScreen
@@ -74,6 +75,14 @@ class MessageBackupsFlowViewModel(
val deletionState: Flow<DeletionState> = SignalStore.backup.deletionStateFlow
init {
viewModelScope.launch(SignalDispatchers.IO) {
internalStateFlow.update {
it.copy(
googlePlayBillingAvailability = AppDependencies.billingApi.getApiAvailability()
)
}
}
viewModelScope.launch {
val result = withContext(SignalDispatchers.IO) {
BackupRepository.triggerBackupIdReservation()
@@ -94,7 +103,7 @@ class MessageBackupsFlowViewModel(
val allBackupTypes: List<MessageBackupsType> = try {
withContext(SignalDispatchers.IO) {
BackupRepository.getBackupTypes(
if (!RemoteConfig.messageBackups) emptyList() else listOf(MessageBackupTier.FREE, MessageBackupTier.PAID)
listOf(MessageBackupTier.FREE, MessageBackupTier.PAID)
)
}
} catch (e: Exception) {
@@ -105,7 +114,6 @@ class MessageBackupsFlowViewModel(
internalStateFlow.update { state ->
state.copy(
allBackupTypes = allBackupTypes,
isBillingApiAvailable = AppDependencies.billingApi.isApiAvailable(),
selectedMessageBackupTier = if (state.selectedMessageBackupTier in allBackupTypes.map { it.tier }) state.selectedMessageBackupTier else allBackupTypes.firstOrNull()?.tier
)
}
@@ -158,6 +166,12 @@ class MessageBackupsFlowViewModel(
}
}
fun setGooglePlayApiAvailability(googlePlayApiAvailability: Int) {
internalStateFlow.update {
it.copy(googlePlayApiAvailability = GooglePlayServicesAvailability.fromCode(googlePlayApiAvailability))
}
}
fun refreshCurrentTier() {
val tier = SignalStore.backup.backupTier
if (tier == MessageBackupTier.PAID) {
@@ -167,7 +181,7 @@ class MessageBackupsFlowViewModel(
RecurringInAppPaymentRepository.getActiveSubscriptionSync(InAppPaymentSubscriberRecord.Type.BACKUP)
}
activeSubscription.onSuccess { subscription ->
activeSubscription.runIfSuccessful { subscription ->
if (subscription.willCancelAtPeriodEnd()) {
Log.d(TAG, "Active subscription is cancelled. Clearing tier.")
internalStateFlow.update {

View File

@@ -8,10 +8,13 @@ package org.thoughtcrime.securesms.backup.v2.ui.subscription
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
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.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -25,9 +28,9 @@ import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.Scaffolds
import org.signal.core.ui.compose.SignalPreview
import org.thoughtcrime.securesms.R
import org.signal.core.ui.R as CoreUiR
@@ -39,6 +42,8 @@ fun MessageBackupsKeyEducationScreen(
onNavigationClick: () -> Unit = {},
onNextClick: () -> Unit = {}
) {
val scrollState = rememberScrollState()
Scaffolds.Settings(
title = "",
navigationIcon = ImageVector.vectorResource(R.drawable.symbol_arrow_start_24),
@@ -48,7 +53,8 @@ fun MessageBackupsKeyEducationScreen(
modifier = Modifier
.padding(it)
.padding(horizontal = dimensionResource(CoreUiR.dimen.gutter))
.fillMaxSize(),
.fillMaxSize()
.verticalScroll(scrollState),
horizontalAlignment = Alignment.CenterHorizontally
) {
Image(
@@ -61,6 +67,7 @@ fun MessageBackupsKeyEducationScreen(
Text(
text = stringResource(R.string.MessageBackupsKeyEducationScreen__your_backup_key),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier.padding(top = 16.dp)
)
@@ -81,11 +88,16 @@ fun MessageBackupsKeyEducationScreen(
modifier = Modifier.padding(top = 16.dp)
)
Box(
Spacer(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.padding(bottom = 24.dp)
)
Box(
modifier = Modifier
.fillMaxWidth()
.padding(top = 16.dp, bottom = 24.dp)
) {
Buttons.LargeTonal(
onClick = onNextClick,
@@ -100,7 +112,7 @@ fun MessageBackupsKeyEducationScreen(
}
}
@SignalPreview
@DayNightPreviews
@Composable
private fun MessageBackupsKeyEducationScreenPreview() {
Previews.Preview {

View File

@@ -47,10 +47,10 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Dialogs
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.Scaffolds
import org.signal.core.ui.compose.SignalPreview
import org.signal.core.ui.compose.Snackbars
import org.signal.core.ui.compose.horizontalGutters
import org.signal.core.ui.compose.theme.SignalTheme
@@ -421,7 +421,7 @@ private suspend fun saveKeyToCredentialManager(
)
}
@SignalPreview
@DayNightPreviews
@Composable
private fun MessageBackupsKeyRecordScreenPreview() {
Previews.Preview {
@@ -438,7 +438,7 @@ private fun MessageBackupsKeyRecordScreenPreview() {
}
}
@SignalPreview
@DayNightPreviews
@Composable
private fun SaveKeyConfirmationDialogPreview() {
Previews.Preview {
@@ -452,7 +452,7 @@ private fun SaveKeyConfirmationDialogPreview() {
}
@OptIn(ExperimentalMaterial3Api::class)
@SignalPreview
@DayNightPreviews
@Composable
private fun CreateNewBackupKeySheetContentPreview() {
Previews.BottomSheetPreview {
@@ -462,7 +462,7 @@ private fun CreateNewBackupKeySheetContentPreview() {
}
}
@SignalPreview
@DayNightPreviews
@Composable
private fun DownloadMediaDialogPreview() {
Previews.Preview {

View File

@@ -34,9 +34,9 @@ import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import org.signal.core.ui.compose.BottomSheets
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.Scaffolds
import org.signal.core.ui.compose.SignalPreview
import org.signal.core.ui.compose.theme.SignalTheme
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.keyvalue.SignalStore
@@ -192,7 +192,7 @@ private fun BottomSheetContent(
}
}
@SignalPreview
@DayNightPreviews
@Composable
private fun MessageBackupsKeyRecordScreenPreview() {
Previews.Preview {
@@ -202,7 +202,7 @@ private fun MessageBackupsKeyRecordScreenPreview() {
}
}
@SignalPreview
@DayNightPreviews
@Composable
private fun BottomSheetContentPreview() {
Previews.BottomSheetPreview {

View File

@@ -19,8 +19,8 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.SignalPreview
import org.thoughtcrime.securesms.R
/**
@@ -58,7 +58,7 @@ fun MessageBackupsTypeFeatureRow(
}
}
@SignalPreview
@DayNightPreviews
@Composable
private fun MessageBackupsTypeFeatureRowPreview() {
Previews.Preview {

View File

@@ -21,6 +21,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -47,11 +48,12 @@ import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import kotlinx.collections.immutable.persistentListOf
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Dialogs
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.Scaffolds
import org.signal.core.ui.compose.SignalPreview
import org.signal.core.ui.compose.theme.SignalTheme
import org.signal.core.util.billing.BillingResponseCode
import org.signal.core.util.bytes
import org.signal.core.util.money.FiatMoney
import org.thoughtcrime.securesms.R
@@ -75,13 +77,16 @@ fun MessageBackupsTypeSelectionScreen(
currentBackupTier: MessageBackupTier?,
selectedBackupTier: MessageBackupTier?,
allBackupTypes: List<MessageBackupsType>,
isBillingApiAvailable: Boolean,
googlePlayServicesAvailability: GooglePlayServicesAvailability,
googlePlayBillingAvailability: BillingResponseCode,
isNextEnabled: Boolean,
onMessageBackupsTierSelected: (MessageBackupTier) -> Unit,
onNavigationClick: () -> Unit,
onReadMoreClicked: () -> Unit,
onNextClicked: () -> Unit,
onLearnMoreAboutWhyUserCanNotUpgrade: () -> Unit
onLearnMoreAboutWhyUserCanNotUpgrade: () -> Unit,
onMakeGooglePlayServicesAvailable: () -> Unit,
onOpenPlayStore: () -> Unit
) {
Scaffolds.Settings(
title = "",
@@ -160,29 +165,26 @@ fun MessageBackupsTypeSelectionScreen(
}
}
val hasCurrentBackupTier = currentBackupTier != null
var displayNotAvailableDialog by remember { mutableStateOf(false) }
val onSubscribeButtonClick = remember(isBillingApiAvailable, selectedBackupTier) {
val paidTierNotAvailableDialogState = remember { PaidTierNotAvailableDialogState() }
val onSubscribeButtonClick = remember(googlePlayServicesAvailability, googlePlayBillingAvailability, selectedBackupTier) {
{
if (selectedBackupTier == MessageBackupTier.PAID && !isBillingApiAvailable) {
displayNotAvailableDialog = true
if (selectedBackupTier == MessageBackupTier.PAID && googlePlayServicesAvailability != GooglePlayServicesAvailability.SUCCESS) {
paidTierNotAvailableDialogState.displayGooglePlayApiErrorDialog = true
} else if (selectedBackupTier == MessageBackupTier.PAID && !googlePlayBillingAvailability.isSuccess) {
paidTierNotAvailableDialogState.displayGooglePlayBillingErrorDialog = true
} else {
onNextClicked()
}
}
}
if (displayNotAvailableDialog) {
UpgradeNotAvailableDialog(
onConfirm = {
displayNotAvailableDialog = false
},
onDismiss = onLearnMoreAboutWhyUserCanNotUpgrade,
onDismissRequest = {
displayNotAvailableDialog = false
}
)
}
PaidTierNotAvailableDialogs(
state = paidTierNotAvailableDialogState,
onOpenPlayStore = onOpenPlayStore,
onLearnMoreAboutWhyUserCanNotUpgrade = onLearnMoreAboutWhyUserCanNotUpgrade,
onMakeGooglePlayServicesAvailable = onMakeGooglePlayServicesAvailable,
googlePlayServicesAvailability = googlePlayServicesAvailability
)
Buttons.LargeTonal(
onClick = onSubscribeButtonClick,
@@ -190,10 +192,12 @@ fun MessageBackupsTypeSelectionScreen(
modifier = Modifier
.testTag("subscribe-button")
.fillMaxWidth()
.padding(vertical = if (hasCurrentBackupTier) 10.dp else 16.dp)
.padding(vertical = 16.dp)
) {
val text: String = if (currentBackupTier == null) {
if (selectedBackupTier == MessageBackupTier.PAID && allBackupTypes.map { it.tier }.contains(selectedBackupTier)) {
if (selectedBackupTier == MessageBackupTier.PAID && (googlePlayServicesAvailability != GooglePlayServicesAvailability.SUCCESS || !googlePlayBillingAvailability.isSuccess)) {
stringResource(R.string.MessageBackupsTypeSelectionScreen__more_about_this_plan)
} else if (selectedBackupTier == MessageBackupTier.PAID && allBackupTypes.map { it.tier }.contains(selectedBackupTier)) {
val paidTier = allBackupTypes.first { it.tier == MessageBackupTier.PAID } as MessageBackupsType.Paid
val context = LocalContext.current
@@ -207,6 +211,8 @@ fun MessageBackupsTypeSelectionScreen(
} else {
stringResource(R.string.MessageBackupsTypeSelectionScreen__subscribe)
}
} else if (selectedBackupTier == MessageBackupTier.PAID && (googlePlayServicesAvailability != GooglePlayServicesAvailability.SUCCESS || !googlePlayBillingAvailability.isSuccess)) {
stringResource(R.string.MessageBackupsTypeSelectionScreen__more_about_this_plan)
} else {
stringResource(R.string.MessageBackupsTypeSelectionScreen__change_backup_type)
}
@@ -224,24 +230,54 @@ fun MessageBackupsTypeSelectionScreen(
}
}
@Stable
class PaidTierNotAvailableDialogState {
var displayGooglePlayBillingErrorDialog: Boolean by mutableStateOf(false)
var displayGooglePlayApiErrorDialog: Boolean by mutableStateOf(false)
}
@Composable
private fun UpgradeNotAvailableDialog(
onConfirm: () -> Unit,
onDismiss: () -> Unit,
onDismissRequest: () -> Unit
fun PaidTierNotAvailableDialogs(
state: PaidTierNotAvailableDialogState,
googlePlayServicesAvailability: GooglePlayServicesAvailability,
onLearnMoreAboutWhyUserCanNotUpgrade: () -> Unit,
onMakeGooglePlayServicesAvailable: () -> Unit,
onOpenPlayStore: () -> Unit
) {
if (state.displayGooglePlayApiErrorDialog) {
GooglePlayServicesAvailabilityDialog(
onDismissRequest = { state.displayGooglePlayApiErrorDialog = false },
googlePlayServicesAvailability = googlePlayServicesAvailability,
onLearnMoreClick = onLearnMoreAboutWhyUserCanNotUpgrade,
onMakeServicesAvailableClick = onMakeGooglePlayServicesAvailable
)
}
if (state.displayGooglePlayBillingErrorDialog) {
UserNotSignedInDialog(
onDismissRequest = { state.displayGooglePlayBillingErrorDialog = false },
onOpenPlayStore = onOpenPlayStore
)
}
}
@Composable
private fun UserNotSignedInDialog(
onDismissRequest: () -> Unit,
onOpenPlayStore: () -> Unit
) {
Dialogs.SimpleAlertDialog(
title = stringResource(R.string.MessageBackupsTypeSelectionScreen__cant_upgrade_plan),
body = stringResource(R.string.MessageBackupsTypeSelectionScreen__to_subscribe_to_signal_secure_backups),
confirm = stringResource(android.R.string.ok),
dismiss = stringResource(R.string.MessageBackupsTypeSelectionScreen__learn_more),
onConfirm = onConfirm,
onDismiss = onDismiss,
onDismissRequest = onDismissRequest
title = stringResource(R.string.GooglePlayServicesAvailability__service_disabled_title),
body = "To subscribe to Signal Secure Backups, please sign into the Google Play store.",
onConfirm = onOpenPlayStore,
onDismiss = onDismissRequest,
onDismissRequest = onDismissRequest,
confirm = "Open Play Store",
dismiss = stringResource(android.R.string.cancel)
)
}
@SignalPreview
@DayNightPreviews
@Composable
private fun MessageBackupsTypeSelectionScreenPreview() {
var selectedBackupsType by remember { mutableStateOf(MessageBackupTier.FREE) }
@@ -256,14 +292,17 @@ private fun MessageBackupsTypeSelectionScreenPreview() {
onReadMoreClicked = {},
onNextClicked = {},
onLearnMoreAboutWhyUserCanNotUpgrade = {},
onMakeGooglePlayServicesAvailable = {},
onOpenPlayStore = {},
currentBackupTier = null,
isBillingApiAvailable = true,
googlePlayServicesAvailability = GooglePlayServicesAvailability.SUCCESS,
googlePlayBillingAvailability = BillingResponseCode.OK,
isNextEnabled = true
)
}
}
@SignalPreview
@DayNightPreviews
@Composable
private fun MessageBackupsTypeSelectionScreenWithCurrentTierPreview() {
var selectedBackupsType by remember { mutableStateOf(MessageBackupTier.FREE) }
@@ -278,25 +317,16 @@ private fun MessageBackupsTypeSelectionScreenWithCurrentTierPreview() {
onReadMoreClicked = {},
onNextClicked = {},
onLearnMoreAboutWhyUserCanNotUpgrade = {},
onMakeGooglePlayServicesAvailable = {},
onOpenPlayStore = {},
currentBackupTier = MessageBackupTier.PAID,
isBillingApiAvailable = true,
googlePlayServicesAvailability = GooglePlayServicesAvailability.SUCCESS,
googlePlayBillingAvailability = BillingResponseCode.OK,
isNextEnabled = true
)
}
}
@SignalPreview
@Composable
private fun UpgradeNotAvailableDialogPreview() {
Previews.Preview {
UpgradeNotAvailableDialog(
onConfirm = {},
onDismiss = {},
onDismissRequest = {}
)
}
}
@Composable
fun MessageBackupsTypeBlock(
messageBackupsType: MessageBackupsType,
@@ -382,8 +412,12 @@ private fun getFormattedPricePerMonth(messageBackupsType: MessageBackupsType): S
return when (messageBackupsType) {
is MessageBackupsType.Free -> stringResource(id = R.string.MessageBackupsTypeSelectionScreen__free)
is MessageBackupsType.Paid -> {
val formattedAmount = FiatMoneyUtil.format(LocalContext.current.resources, messageBackupsType.pricePerMonth, FiatMoneyUtil.formatOptions().trimZerosAfterDecimal())
stringResource(id = R.string.MessageBackupsTypeSelectionScreen__s_month, formattedAmount)
if (messageBackupsType.pricePerMonth.amount == BigDecimal.ZERO) {
stringResource(R.string.MessageBackupsTypeSelectionScreen__paid)
} else {
val formattedAmount = FiatMoneyUtil.format(LocalContext.current.resources, messageBackupsType.pricePerMonth, FiatMoneyUtil.formatOptions().trimZerosAfterDecimal())
stringResource(id = R.string.MessageBackupsTypeSelectionScreen__s_month, formattedAmount)
}
}
}
}

View File

@@ -25,8 +25,8 @@ import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.withLink
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.SignalPreview
import org.signal.core.ui.compose.theme.SignalTheme
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.BiometricDeviceAuthentication
@@ -188,7 +188,7 @@ fun VerifyBackupPinScreen(
}
}
@SignalPreview
@DayNightPreviews
@Composable
private fun VerifyBackupKeyScreen() {
Previews.Preview {

View File

@@ -0,0 +1,103 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2.util
import okio.ByteString
import org.thoughtcrime.securesms.attachments.Cdn
import org.thoughtcrime.securesms.backup.v2.proto.AccountData
import org.thoughtcrime.securesms.backup.v2.proto.Chat
import org.thoughtcrime.securesms.backup.v2.proto.ChatItem
import org.thoughtcrime.securesms.backup.v2.proto.FilePointer
import org.thoughtcrime.securesms.backup.v2.proto.Frame
import org.whispersystems.signalservice.api.backup.MediaName
fun Frame.getAllReferencedArchiveAttachmentInfos(): Set<ArchiveAttachmentInfo> {
val infos: MutableSet<ArchiveAttachmentInfo> = mutableSetOf()
when {
this.account != null -> infos += this.account.getAllReferencedArchiveAttachmentInfos()
this.chat != null -> infos += this.chat.getAllReferencedArchiveAttachmentInfos()
this.chatItem != null -> infos += this.chatItem.getAllReferencedArchiveAttachmentInfos()
}
return infos.toSet()
}
private fun AccountData.getAllReferencedArchiveAttachmentInfos(): Set<ArchiveAttachmentInfo> {
val info = this.accountSettings?.defaultChatStyle?.wallpaperPhoto?.toArchiveAttachmentInfo()
return if (info != null) {
setOf(info)
} else {
emptySet()
}
}
private fun Chat.getAllReferencedArchiveAttachmentInfos(): Set<ArchiveAttachmentInfo> {
val info = this.style?.wallpaperPhoto?.toArchiveAttachmentInfo()
return if (info != null) {
setOf(info)
} else {
emptySet()
}
}
private fun ChatItem.getAllReferencedArchiveAttachmentInfos(): Set<ArchiveAttachmentInfo> {
var out: MutableSet<ArchiveAttachmentInfo>? = null
// The user could have many chat items, and most will not have attachments. To avoid allocating unnecessary sets, we do this little trick.
// (Note: emptySet() returns a constant under the hood, so that's fine)
fun appendToOutput(item: ArchiveAttachmentInfo) {
if (out == null) {
out = mutableSetOf()
}
out.add(item)
}
this.contactMessage?.contact?.avatar?.toArchiveAttachmentInfo()?.let { appendToOutput(it) }
this.directStoryReplyMessage?.textReply?.longText?.toArchiveAttachmentInfo()?.let { appendToOutput(it) }
this.standardMessage?.attachments?.mapNotNull { it.pointer?.toArchiveAttachmentInfo() }?.forEach { appendToOutput(it) }
this.standardMessage?.quote?.attachments?.mapNotNull { it.thumbnail?.pointer?.toArchiveAttachmentInfo(forQuote = true) }?.forEach { appendToOutput(it) }
this.standardMessage?.linkPreview?.mapNotNull { it.image?.toArchiveAttachmentInfo() }?.forEach { appendToOutput(it) }
this.standardMessage?.longText?.toArchiveAttachmentInfo()?.let { appendToOutput(it) }
this.stickerMessage?.sticker?.data_?.toArchiveAttachmentInfo()?.let { appendToOutput(it) }
this.viewOnceMessage?.attachment?.pointer?.toArchiveAttachmentInfo()?.let { appendToOutput(it) }
this.revisions.forEach { revision ->
revision.getAllReferencedArchiveAttachmentInfos().forEach { appendToOutput(it) }
}
return out ?: emptySet()
}
private fun FilePointer.toArchiveAttachmentInfo(forQuote: Boolean = false): ArchiveAttachmentInfo? {
if (this.locatorInfo?.key == null) {
return null
}
if (this.locatorInfo.plaintextHash == null) {
return null
}
return ArchiveAttachmentInfo(
plaintextHash = this.locatorInfo.plaintextHash,
remoteKey = this.locatorInfo.key,
cdn = this.locatorInfo.mediaTierCdnNumber ?: Cdn.CDN_0.cdnNumber,
contentType = this.contentType,
forQuote = forQuote
)
}
data class ArchiveAttachmentInfo(
val plaintextHash: ByteString,
val remoteKey: ByteString,
val cdn: Int,
val contentType: String?,
val forQuote: Boolean
) {
val fullSizeMediaName: MediaName get() = MediaName.fromPlaintextHashAndRemoteKey(plaintextHash.toByteArray(), remoteKey.toByteArray())
val thumbnailMediaName: MediaName get() = MediaName.fromPlaintextHashAndRemoteKeyForThumbnail(plaintextHash.toByteArray(), remoteKey.toByteArray())
}

View File

@@ -12,9 +12,11 @@ import com.bumptech.glide.load.Key
import com.bumptech.glide.load.engine.DiskCacheStrategy
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.Serializable
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.badges.load.BadgeSpriteTransformation
import org.thoughtcrime.securesms.components.settings.PreferenceModel
import org.thoughtcrime.securesms.serialization.UriSerializer
import org.thoughtcrime.securesms.util.ThemeUtil
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
@@ -28,12 +30,13 @@ typealias OnBadgeClicked = (Badge, Boolean, Boolean) -> Unit
*/
@Stable
@Parcelize
@Serializable
data class Badge(
val id: String,
val category: Category,
val name: String,
val description: String,
val imageUrl: Uri,
@Serializable(with = UriSerializer::class) val imageUrl: Uri,
val imageDensity: String,
val expirationTimestamp: Long,
val visible: Boolean,

View File

@@ -12,8 +12,8 @@ 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.compose.DayNightPreviews
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.SignalPreview
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.banner.Banner
import org.thoughtcrime.securesms.banner.ui.compose.Action
@@ -49,7 +49,7 @@ private fun Banner(contentPadding: PaddingValues, actionListener: (Boolean) -> U
)
}
@SignalPreview
@DayNightPreviews
@Composable
private fun BannerPreview() {
Previews.Preview {

View File

@@ -11,8 +11,8 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.fragment.app.FragmentManager
import kotlinx.coroutines.flow.flowOf
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.SignalPreview
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.banner.Banner
import org.thoughtcrime.securesms.banner.ui.compose.Action
@@ -65,7 +65,7 @@ private fun Banner(contentPadding: PaddingValues, onLearnMoreClicked: () -> Unit
)
}
@SignalPreview
@DayNightPreviews
@Composable
private fun BannerPreview() {
Previews.Preview {

View File

@@ -11,8 +11,8 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.fragment.app.FragmentManager
import kotlinx.coroutines.flow.flowOf
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.SignalPreview
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.banner.Banner
import org.thoughtcrime.securesms.banner.ui.compose.Action
@@ -56,7 +56,7 @@ private fun Banner(contentPadding: PaddingValues, onLearnMoreClicked: () -> Unit
)
}
@SignalPreview
@DayNightPreviews
@Composable
private fun BannerPreview() {
Previews.Preview {

View File

@@ -12,8 +12,8 @@ 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.compose.DayNightPreviews
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.SignalPreview
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.banner.Banner
import org.thoughtcrime.securesms.banner.ui.compose.Action
@@ -60,7 +60,7 @@ private fun Banner(contentPadding: PaddingValues, onUpdateClicked: () -> Unit =
)
}
@SignalPreview
@DayNightPreviews
@Composable
private fun BannerPreview() {
Previews.Preview {

View File

@@ -13,8 +13,8 @@ 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.compose.DayNightPreviews
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.SignalPreview
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.banner.Banner
import org.thoughtcrime.securesms.banner.ui.compose.Action
@@ -67,7 +67,7 @@ private fun Banner(contentPadding: PaddingValues, onDismissListener: () -> Unit
)
}
@SignalPreview
@DayNightPreviews
@Composable
private fun BannerPreview() {
Previews.Preview {

View File

@@ -12,8 +12,8 @@ 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.compose.DayNightPreviews
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.SignalPreview
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.banner.Banner
import org.thoughtcrime.securesms.banner.ui.compose.Action
@@ -56,7 +56,7 @@ private fun Banner(contentPadding: PaddingValues, onUpdateNow: () -> Unit = {})
)
}
@SignalPreview
@DayNightPreviews
@Composable
private fun BannerPreview() {
Previews.Preview {

View File

@@ -11,8 +11,8 @@ 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.compose.DayNightPreviews
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.SignalPreview
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.banner.Banner
import org.thoughtcrime.securesms.banner.ui.compose.Action
@@ -62,7 +62,7 @@ private fun Banner(contentPadding: PaddingValues, suggestionsSize: Int, onAddMem
)
}
@SignalPreview
@DayNightPreviews
@Composable
private fun BannerPreviewSingular() {
Previews.Preview {
@@ -70,7 +70,7 @@ private fun BannerPreviewSingular() {
}
}
@SignalPreview
@DayNightPreviews
@Composable
private fun BannerPreviewPlural() {
Previews.Preview {

View File

@@ -13,8 +13,8 @@ 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.compose.DayNightPreviews
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.SignalPreview
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.banner.Banner
import org.thoughtcrime.securesms.banner.ui.compose.Action
@@ -87,7 +87,7 @@ private fun Banner(contentPadding: PaddingValues, daysUntilExpiry: Int, onUpdate
)
}
@SignalPreview
@DayNightPreviews
@Composable
private fun BannerPreviewExpireToday() {
Previews.Preview {
@@ -98,7 +98,7 @@ private fun BannerPreviewExpireToday() {
}
}
@SignalPreview
@DayNightPreviews
@Composable
private fun BannerPreviewExpireTomorrow() {
Previews.Preview {
@@ -109,7 +109,7 @@ private fun BannerPreviewExpireTomorrow() {
}
}
@SignalPreview
@DayNightPreviews
@Composable
private fun BannerPreviewExpireLater() {
Previews.Preview {

View File

@@ -15,8 +15,8 @@ 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.compose.DayNightPreviews
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.SignalPreview
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.banner.Banner
import org.thoughtcrime.securesms.banner.ui.compose.Action
@@ -66,7 +66,7 @@ private fun Banner(contentPadding: PaddingValues, suggestionsSize: Int, onViewCl
)
}
@SignalPreview
@DayNightPreviews
@Composable
private fun BannerPreviewSingular() {
Previews.Preview {
@@ -74,7 +74,7 @@ private fun BannerPreviewSingular() {
}
}
@SignalPreview
@DayNightPreviews
@Composable
private fun BannerPreviewPlural() {
Previews.Preview {

View File

@@ -12,8 +12,8 @@ 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.compose.DayNightPreviews
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.SignalPreview
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.banner.Banner
import org.thoughtcrime.securesms.banner.ui.compose.DefaultBanner
@@ -41,7 +41,7 @@ private fun Banner(contentPadding: PaddingValues) {
)
}
@SignalPreview
@DayNightPreviews
@Composable
private fun BannerPreview() {
Previews.Preview {

View File

@@ -13,8 +13,8 @@ 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.compose.DayNightPreviews
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.SignalPreview
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.banner.Banner
import org.thoughtcrime.securesms.banner.ui.compose.Action
@@ -59,7 +59,7 @@ private fun Banner(contentPadding: PaddingValues) {
)
}
@SignalPreview
@DayNightPreviews
@Composable
private fun BannerPreview() {
Previews.Preview {

View File

@@ -11,8 +11,8 @@ 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.compose.DayNightPreviews
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.SignalPreview
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.banner.Banner
import org.thoughtcrime.securesms.banner.ui.compose.Action
@@ -65,7 +65,7 @@ private fun Banner(contentPadding: PaddingValues, usernameSyncState: UsernameSyn
)
}
@SignalPreview
@DayNightPreviews
@Composable
private fun BannerPreviewUsernameCorrupted() {
Previews.Preview {
@@ -73,7 +73,7 @@ private fun BannerPreviewUsernameCorrupted() {
}
}
@SignalPreview
@DayNightPreviews
@Composable
private fun BannerPreviewLinkCorrupted() {
Previews.Preview {

View File

@@ -34,8 +34,8 @@ import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.unit.dp
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.SignalPreview
import org.signal.core.util.isNotNullOrBlank
import org.thoughtcrime.securesms.R
@@ -195,7 +195,7 @@ enum class Importance {
}
@Composable
@SignalPreview
@DayNightPreviews
private fun BubblesOptOutPreview() {
Previews.Preview {
DefaultBanner(
@@ -212,7 +212,7 @@ private fun BubblesOptOutPreview() {
}
@Composable
@SignalPreview
@DayNightPreviews
private fun ForcedUpgradePreview() {
Previews.Preview {
DefaultBanner(
@@ -228,7 +228,7 @@ private fun ForcedUpgradePreview() {
}
@Composable
@SignalPreview
@DayNightPreviews
private fun FullyLoadedErrorPreview() {
val actions = listOf(
Action(R.string.ExpiredBuildReminder_update_now) { },

View File

@@ -26,8 +26,8 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import org.signal.core.ui.compose.BottomSheets
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.SignalPreview
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsType
import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsTypeBlock
@@ -145,7 +145,7 @@ private fun UpgradeToEnableOptimizedStorageSheetContent(
}
}
@SignalPreview
@DayNightPreviews
@Composable
private fun UpgradeToEnableOptimizedStorageSheetContentPreview() {
Previews.BottomSheetPreview {

View File

@@ -19,6 +19,7 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import com.google.android.gms.common.GoogleApiAvailability
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import kotlinx.coroutines.rx3.asFlowable
@@ -59,6 +60,7 @@ abstract class UpgradeToPaidTierBottomSheet : ComposeBottomSheetDialogFragment()
private val viewModel: MessageBackupsFlowViewModel by viewModel {
MessageBackupsFlowViewModel(
initialTierSelection = MessageBackupTier.PAID,
googlePlayApiAvailability = GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(requireContext()),
startScreen = MessageBackupsStage.TYPE_SELECTION
)
}
@@ -93,6 +95,12 @@ abstract class UpgradeToPaidTierBottomSheet : ComposeBottomSheetDialogFragment()
}
}
override fun onResume() {
super.onResume()
viewModel.refreshCurrentTier()
viewModel.setGooglePlayApiAvailability(GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(requireContext()))
}
@Composable
override fun SheetContent() {
val state by viewModel.stateFlow.collectAsStateWithLifecycle()

View File

@@ -27,8 +27,8 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import org.signal.core.ui.compose.BottomSheets
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.SignalPreview
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsType
import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsTypeBlock
@@ -143,7 +143,7 @@ private fun UpgradeToStartMediaBackupSheetContent(
}
}
@SignalPreview
@DayNightPreviews
@Composable
private fun UpgradeToStartMediaBackupSheetContentPreview() {
Previews.Preview {

View File

@@ -24,6 +24,8 @@ import java.net.URLDecoder
object CallLinks {
private const val ROOT_KEY = "key"
private const val EPOCH = "epoch"
private const val LEGACY_HTTPS_LINK_PREFIX = "https://signal.link/call#key="
private const val LEGACY_SGNL_LINK_PREFIX = "sgnl://signal.link/call#key="
private const val HTTPS_LINK_PREFIX = "https://signal.link/call/#key="
private const val SNGL_LINK_PREFIX = "sgnl://signal.link/call/#key="
@@ -60,9 +62,16 @@ object CallLinks {
}
}
private fun isPrefixedCallLink(url: String): Boolean {
return url.startsWith(HTTPS_LINK_PREFIX) ||
url.startsWith(SNGL_LINK_PREFIX) ||
url.startsWith(LEGACY_HTTPS_LINK_PREFIX) ||
url.startsWith(LEGACY_SGNL_LINK_PREFIX)
}
@JvmStatic
fun isCallLink(url: String): Boolean {
if (!url.startsWith(HTTPS_LINK_PREFIX) && !url.startsWith(SNGL_LINK_PREFIX)) {
if (!isPrefixedCallLink(url)) {
return false
}
@@ -76,7 +85,7 @@ object CallLinks {
@JvmStatic
fun parseUrl(url: String): CallLinkParseResult? {
if (!url.startsWith(HTTPS_LINK_PREFIX) && !url.startsWith(SNGL_LINK_PREFIX)) {
if (!isPrefixedCallLink(url)) {
Log.w(TAG, "Invalid url prefix.")
return null
}

View File

@@ -8,6 +8,7 @@ package org.thoughtcrime.securesms.calls.links
import android.app.Dialog
import android.os.Bundle
import android.view.WindowManager
import androidx.activity.compose.LocalOnBackPressedDispatcherOwner
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
@@ -35,11 +36,18 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.core.os.bundleOf
import androidx.fragment.app.setFragmentResult
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.viewmodel.compose.viewModel
import kotlinx.coroutines.launch
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.Scaffolds
import org.signal.core.util.BreakIteratorCompat
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.calls.links.details.CallLinkDetailsViewModel
import org.thoughtcrime.securesms.compose.ComposeDialogFragment
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId
import org.thoughtcrime.securesms.window.WindowSizeClass
class EditCallLinkNameDialogFragment : ComposeDialogFragment() {
@@ -66,61 +74,109 @@ class EditCallLinkNameDialogFragment : ComposeDialogFragment() {
@Preview
@Composable
override fun DialogContent() {
var callName by remember {
mutableStateOf(
TextFieldValue(
text = argName,
selection = TextRange(argName.length)
)
EditCallLinkNameScreen(
initialNameValue = argName,
onSaveClick = {
setFragmentResult(RESULT_KEY, bundleOf(RESULT_KEY to it))
dismiss()
},
onNavigationClick = {
dismiss()
}
)
}
}
@Composable
fun EditCallLinkNameScreen(
roomId: CallLinkRoomId
) {
val viewModel: CallLinkDetailsViewModel = viewModel {
CallLinkDetailsViewModel(roomId)
}
val backPressedDispatcherOwner = LocalOnBackPressedDispatcherOwner.current
val lifecycleScope = LocalLifecycleOwner.current.lifecycleScope
EditCallLinkNameScreen(
initialNameValue = viewModel.nameSnapshot,
onSaveClick = {
lifecycleScope.launch {
viewModel.setName(it)
backPressedDispatcherOwner?.onBackPressedDispatcher?.onBackPressed()
}
},
onNavigationClick = {
backPressedDispatcherOwner?.onBackPressedDispatcher?.onBackPressed()
},
showNavigationIcon = !WindowSizeClass.rememberWindowSizeClass().isSplitPane()
)
}
@Composable
private fun EditCallLinkNameScreen(
initialNameValue: String,
onSaveClick: (String) -> Unit,
onNavigationClick: () -> Unit,
showNavigationIcon: Boolean = true
) {
var callName by remember {
mutableStateOf(
TextFieldValue(
text = initialNameValue,
selection = TextRange(initialNameValue.length)
)
}
)
}
Scaffolds.Settings(
title = stringResource(id = R.string.EditCallLinkNameDialogFragment__edit_call_name),
onNavigationClick = this::dismiss,
navigationIcon = ImageVector.vectorResource(id = R.drawable.symbol_arrow_start_24),
navigationContentDescription = stringResource(id = R.string.Material3SearchToolbar__close)
) { paddingValues ->
val focusRequester = remember { FocusRequester() }
val breakIterator = remember { BreakIteratorCompat.getInstance() }
Scaffolds.Settings(
title = stringResource(id = R.string.EditCallLinkNameDialogFragment__edit_call_name),
onNavigationClick = onNavigationClick,
navigationIcon = if (showNavigationIcon) {
ImageVector.vectorResource(id = R.drawable.symbol_arrow_start_24)
} else {
null
},
navigationContentDescription = stringResource(id = R.string.Material3SearchToolbar__close)
) { paddingValues ->
val focusRequester = remember { FocusRequester() }
val breakIterator = remember { BreakIteratorCompat.getInstance() }
Surface(modifier = Modifier.padding(paddingValues)) {
Column(
modifier = Modifier
.padding(
horizontal = dimensionResource(id = org.signal.core.ui.R.dimen.gutter)
)
.padding(top = 20.dp, bottom = 16.dp)
) {
TextField(
value = callName,
label = {
Text(text = stringResource(id = R.string.EditCallLinkNameDialogFragment__call_name))
},
onValueChange = {
callName = it.copy(text = breakIterator.apply { setText(it.text) }.take(32).toString())
},
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester)
Surface(modifier = Modifier.padding(paddingValues)) {
Column(
modifier = Modifier
.padding(
horizontal = dimensionResource(id = org.signal.core.ui.R.dimen.gutter)
)
Spacer(modifier = Modifier.weight(1f))
Buttons.MediumTonal(
onClick = {
setFragmentResult(RESULT_KEY, bundleOf(RESULT_KEY to callName.text))
dismiss()
},
modifier = Modifier.align(End)
) {
Text(text = stringResource(id = R.string.EditCallLinkNameDialogFragment__save))
}
.padding(top = 20.dp, bottom = 16.dp)
) {
TextField(
value = callName,
label = {
Text(text = stringResource(id = R.string.EditCallLinkNameDialogFragment__call_name))
},
onValueChange = {
callName = it.copy(text = breakIterator.apply { setText(it.text) }.take(32).toString())
},
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester)
)
Spacer(modifier = Modifier.weight(1f))
Buttons.MediumTonal(
onClick = {
onSaveClick(callName.text)
},
modifier = Modifier.align(End)
) {
Text(text = stringResource(id = R.string.EditCallLinkNameDialogFragment__save))
}
}
}
LaunchedEffect(Unit) {
focusRequester.requestFocus()
}
LaunchedEffect(Unit) {
focusRequester.requestFocus()
}
}
}

View File

@@ -36,8 +36,8 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.unit.dp
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.SignalPreview
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.conversation.colors.AvatarColorPair
import org.thoughtcrime.securesms.database.CallLinkTable
@@ -49,7 +49,7 @@ import org.thoughtcrime.securesms.service.webrtc.links.SignalCallLinkState
import java.time.Instant
import org.signal.core.ui.R as CoreUiR
@SignalPreview
@DayNightPreviews
@Composable
private fun SignalCallRowPreview() {
val callLink = remember {

View File

@@ -39,10 +39,10 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import io.reactivex.rxjava3.kotlin.subscribeBy
import org.signal.core.ui.compose.BottomSheets
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Dividers
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.Rows
import org.signal.core.ui.compose.SignalPreview
import org.signal.core.util.concurrent.LifecycleDisposable
import org.signal.core.util.logging.Log
import org.signal.ringrtc.CallLinkState
@@ -325,7 +325,7 @@ private fun CreateCallLinkBottomSheetContent(
}
}
@SignalPreview
@DayNightPreviews
@Composable
private fun CreateCallLinkBottomSheetContentPreview() {
Previews.BottomSheetPreview {

View File

@@ -1,5 +1,5 @@
/**
* Copyright 2023 Signal Messenger, LLC
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
@@ -7,22 +7,68 @@ package org.thoughtcrime.securesms.calls.links.details
import android.content.Context
import android.content.Intent
import androidx.fragment.app.Fragment
import androidx.navigation.fragment.NavHostFragment
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.FragmentWrapperActivity
import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.runtime.remember
import androidx.core.os.bundleOf
import androidx.fragment.app.FragmentActivity
import org.signal.core.ui.compose.theme.SignalTheme
import org.signal.core.util.getParcelableExtraCompat
import org.thoughtcrime.securesms.calls.links.EditCallLinkNameDialogFragment
import org.thoughtcrime.securesms.main.MainNavigationDetailLocation
import org.thoughtcrime.securesms.main.MainNavigationListLocation
import org.thoughtcrime.securesms.main.MainNavigationRouter
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId
import org.thoughtcrime.securesms.util.viewModel
class CallLinkDetailsActivity : FragmentWrapperActivity() {
override fun getFragment(): Fragment = NavHostFragment.create(R.navigation.call_link_details, intent.extras!!.getBundle(BUNDLE))
class CallLinkDetailsActivity : FragmentActivity() {
companion object {
private const val BUNDLE = "bundle"
private const val ARG_ROOM_ID = "room.id"
fun createIntent(context: Context, callLinkRoomId: CallLinkRoomId): Intent {
return Intent(context, CallLinkDetailsActivity::class.java)
.putExtra(BUNDLE, CallLinkDetailsFragmentArgs.Builder(callLinkRoomId).build().toBundle())
.putExtra(ARG_ROOM_ID, callLinkRoomId)
}
}
private val roomId: CallLinkRoomId
get() = intent.getParcelableExtraCompat(ARG_ROOM_ID, CallLinkRoomId::class.java)!!
private val viewModel: CallLinkDetailsViewModel by viewModel {
CallLinkDetailsViewModel(roomId)
}
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
setContent {
SignalTheme {
CallLinkDetailsScreen(
roomId = roomId,
viewModel = viewModel,
router = remember { Router() }
)
}
}
}
private inner class Router : MainNavigationRouter {
override fun goTo(location: MainNavigationDetailLocation) {
when (location) {
is MainNavigationDetailLocation.Calls.CallLinks.EditCallLinkName -> {
EditCallLinkNameDialogFragment().apply {
arguments = bundleOf(EditCallLinkNameDialogFragment.ARG_NAME to viewModel.nameSnapshot)
}.show(supportFragmentManager, null)
}
else -> error("Unsupported route $location")
}
}
override fun goTo(location: MainNavigationListLocation) = Unit
}
}

View File

@@ -1,363 +0,0 @@
/**
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.calls.links.details
import android.content.ActivityNotFoundException
import android.content.Intent
import android.os.Bundle
import android.view.View
import android.widget.Toast
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.core.app.ActivityCompat
import androidx.core.app.ShareCompat
import androidx.fragment.app.viewModels
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import com.google.android.material.snackbar.Snackbar
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.kotlin.subscribeBy
import org.signal.core.ui.compose.Dialogs
import org.signal.core.ui.compose.Dividers
import org.signal.core.ui.compose.Rows
import org.signal.core.ui.compose.Scaffolds
import org.signal.core.ui.compose.theme.SignalTheme
import org.signal.core.util.concurrent.LifecycleDisposable
import org.signal.core.util.logging.Log
import org.signal.ringrtc.CallLinkState.Restrictions
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.calls.YouAreAlreadyInACallSnackbar.YouAreAlreadyInACallSnackbar
import org.thoughtcrime.securesms.calls.links.CallLinks
import org.thoughtcrime.securesms.calls.links.EditCallLinkNameDialogFragment
import org.thoughtcrime.securesms.calls.links.SignalCallRow
import org.thoughtcrime.securesms.compose.ComposeFragment
import org.thoughtcrime.securesms.database.CallLinkTable
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkCredentials
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId
import org.thoughtcrime.securesms.service.webrtc.links.SignalCallLinkState
import org.thoughtcrime.securesms.service.webrtc.links.UpdateCallLinkResult
import org.thoughtcrime.securesms.sharing.v2.ShareActivity
import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.Util
import java.time.Instant
/**
* Provides detailed info about a call link and allows the owner of that link
* to modify call properties.
*/
class CallLinkDetailsFragment : ComposeFragment(), CallLinkDetailsCallback {
companion object {
private val TAG = Log.tag(CallLinkDetailsFragment::class.java)
}
private val args: CallLinkDetailsFragmentArgs by navArgs()
private val viewModel: CallLinkDetailsViewModel by viewModels(factoryProducer = {
CallLinkDetailsViewModel.Factory(args.roomId)
})
private val lifecycleDisposable = LifecycleDisposable()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
lifecycleDisposable.bindTo(viewLifecycleOwner)
parentFragmentManager.setFragmentResultListener(EditCallLinkNameDialogFragment.RESULT_KEY, viewLifecycleOwner) { resultKey, bundle ->
if (bundle.containsKey(resultKey)) {
setName(bundle.getString(resultKey)!!)
}
}
}
@Composable
override fun FragmentContent() {
val state by viewModel.state.collectAsStateWithLifecycle()
val showAlreadyInACall by viewModel.showAlreadyInACall.collectAsStateWithLifecycle(false)
CallLinkDetails(
state,
showAlreadyInACall,
this
)
}
override fun onNavigationClicked() {
ActivityCompat.finishAfterTransition(requireActivity())
}
override fun onJoinClicked() {
val recipientSnapshot = viewModel.recipientSnapshot
if (recipientSnapshot != null) {
CommunicationActions.startVideoCall(this, recipientSnapshot) {
viewModel.showAlreadyInACall(true)
}
}
}
override fun onEditNameClicked() {
val name = viewModel.nameSnapshot
findNavController().navigate(
CallLinkDetailsFragmentDirections.actionCallLinkDetailsFragmentToEditCallLinkNameDialogFragment(name)
)
}
override fun onShareClicked() {
val mimeType = Intent.normalizeMimeType("text/plain")
val shareIntent = ShareCompat.IntentBuilder(requireContext())
.setText(CallLinks.url(viewModel.rootKeySnapshot, viewModel.epochSnapshot))
.setType(mimeType)
.createChooserIntent()
try {
startActivity(shareIntent)
} catch (e: ActivityNotFoundException) {
Toast.makeText(requireContext(), R.string.CreateCallLinkBottomSheetDialogFragment__failed_to_open_share_sheet, Toast.LENGTH_LONG).show()
}
}
override fun onCopyClicked() {
Util.copyToClipboard(requireContext(), CallLinks.url(viewModel.rootKeySnapshot, viewModel.epochSnapshot))
Toast.makeText(requireContext(), R.string.CreateCallLinkBottomSheetDialogFragment__copied_to_clipboard, Toast.LENGTH_LONG).show()
}
override fun onShareLinkViaSignalClicked() {
startActivity(
ShareActivity.sendSimpleText(
requireContext(),
getString(R.string.CreateCallLink__use_this_link_to_join_a_signal_call, CallLinks.url(viewModel.rootKeySnapshot, viewModel.epochSnapshot))
)
)
}
override fun onDeleteClicked() {
viewModel.setDisplayRevocationDialog(true)
}
override fun onDeleteConfirmed() {
viewModel.setDisplayRevocationDialog(false)
lifecycleDisposable += viewModel.delete().observeOn(AndroidSchedulers.mainThread()).subscribeBy(onSuccess = {
when (it) {
is UpdateCallLinkResult.Delete -> ActivityCompat.finishAfterTransition(requireActivity())
is UpdateCallLinkResult.CallLinkIsInUse -> {
Log.w(TAG, "Failed to delete in-use call link.")
toastCouldNotDeleteCallLink()
}
else -> {
Log.w(TAG, "Failed to delete call link. $it")
toastFailure()
}
}
}, onError = handleError("onDeleteClicked"))
}
override fun onDeleteCanceled() {
viewModel.setDisplayRevocationDialog(false)
}
override fun onApproveAllMembersChanged(checked: Boolean) {
lifecycleDisposable += viewModel.setApproveAllMembers(checked).observeOn(AndroidSchedulers.mainThread()).subscribeBy(onSuccess = {
if (it is UpdateCallLinkResult.Failure) {
Log.w(TAG, "Failed to change restrictions. $it")
if (it.status == 409.toShort()) {
toastCallLinkInUse()
} else {
toastFailure()
}
}
}, onError = handleError("onApproveAllMembersChanged"))
}
private fun setName(name: String) {
lifecycleDisposable += viewModel.setName(name).observeOn(AndroidSchedulers.mainThread()).subscribeBy(onSuccess = {
if (it !is UpdateCallLinkResult.Update) {
Log.w(TAG, "Failed to set name. $it")
toastFailure()
}
}, onError = handleError("setName"))
}
private fun handleError(method: String): (throwable: Throwable) -> Unit {
return {
Log.w(TAG, "Failure during $method", it)
toastFailure()
}
}
private fun toastCallLinkInUse() {
Snackbar.make(requireView(), R.string.CallLinkDetailsFragment__couldnt_update_admin_approval, Snackbar.LENGTH_LONG).show()
}
private fun toastFailure() {
Snackbar.make(requireView(), R.string.CallLinkDetailsFragment__couldnt_save_changes, Snackbar.LENGTH_LONG).show()
}
private fun toastCouldNotDeleteCallLink() {
Snackbar.make(requireView(), R.string.CallLinkDetailsFragment__couldnt_delete_call_link, Snackbar.LENGTH_LONG).show()
}
}
private interface CallLinkDetailsCallback {
fun onNavigationClicked()
fun onJoinClicked()
fun onEditNameClicked()
fun onShareClicked()
fun onCopyClicked()
fun onShareLinkViaSignalClicked()
fun onDeleteClicked()
fun onDeleteConfirmed()
fun onDeleteCanceled()
fun onApproveAllMembersChanged(checked: Boolean)
}
@Preview
@Composable
private fun CallLinkDetailsPreview() {
val callLink = remember {
val credentials = CallLinkCredentials(
byteArrayOf(1, 2, 3, 4),
byteArrayOf(0, 1, 2, 3),
byteArrayOf(3, 4, 5, 6)
)
CallLinkTable.CallLink(
recipientId = RecipientId.UNKNOWN,
roomId = CallLinkRoomId.fromBytes(byteArrayOf(1, 2, 3, 4)),
credentials = credentials,
state = SignalCallLinkState(
name = "Call Name",
revoked = false,
restrictions = Restrictions.NONE,
expiration = Instant.MAX
),
deletionTimestamp = 0L
)
}
SignalTheme(false) {
CallLinkDetails(
CallLinkDetailsState(
false,
false,
callLink
),
true,
object : CallLinkDetailsCallback {
override fun onDeleteConfirmed() = Unit
override fun onDeleteCanceled() = Unit
override fun onNavigationClicked() = Unit
override fun onJoinClicked() = Unit
override fun onEditNameClicked() = Unit
override fun onShareClicked() = Unit
override fun onCopyClicked() = Unit
override fun onShareLinkViaSignalClicked() = Unit
override fun onDeleteClicked() = Unit
override fun onApproveAllMembersChanged(checked: Boolean) = Unit
}
)
}
}
@Composable
private fun CallLinkDetails(
state: CallLinkDetailsState,
showAlreadyInACall: Boolean,
callback: CallLinkDetailsCallback
) {
Scaffolds.Settings(
title = stringResource(id = R.string.CallLinkDetailsFragment__call_details),
snackbarHost = {
YouAreAlreadyInACallSnackbar(showAlreadyInACall)
},
onNavigationClick = callback::onNavigationClicked,
navigationIcon = ImageVector.vectorResource(id = R.drawable.symbol_arrow_start_24)
) { paddingValues ->
if (state.callLink == null) {
return@Settings
}
Column(
modifier = Modifier
.padding(paddingValues)
.verticalScroll(rememberScrollState())
) {
SignalCallRow(
callLink = state.callLink,
callLinkPeekInfo = state.peekInfo,
onJoinClicked = callback::onJoinClicked,
modifier = Modifier.padding(top = 16.dp, bottom = 12.dp)
)
if (state.callLink.credentials?.adminPassBytes != null) {
Rows.TextRow(
text = stringResource(
id = if (state.callLink.state.name.isEmpty()) {
R.string.CreateCallLinkBottomSheetDialogFragment__add_call_name
} else {
R.string.CreateCallLinkBottomSheetDialogFragment__edit_call_name
}
),
onClick = callback::onEditNameClicked
)
Rows.ToggleRow(
checked = state.callLink.state.restrictions == Restrictions.ADMIN_APPROVAL,
text = stringResource(id = R.string.CallLinkDetailsFragment__require_admin_approval),
onCheckChanged = callback::onApproveAllMembersChanged,
isLoading = state.isLoadingAdminApprovalChange
)
Dividers.Default()
}
Rows.TextRow(
text = stringResource(id = R.string.CreateCallLinkBottomSheetDialogFragment__share_link_via_signal),
icon = ImageVector.vectorResource(id = R.drawable.symbol_forward_24),
onClick = callback::onShareLinkViaSignalClicked
)
Rows.TextRow(
text = stringResource(id = R.string.CreateCallLinkBottomSheetDialogFragment__copy_link),
icon = ImageVector.vectorResource(id = R.drawable.symbol_copy_android_24),
onClick = callback::onCopyClicked
)
Rows.TextRow(
text = stringResource(id = R.string.CallLinkDetailsFragment__share_link),
icon = ImageVector.vectorResource(id = R.drawable.symbol_link_24),
onClick = callback::onShareClicked
)
Rows.TextRow(
text = stringResource(id = R.string.CallLinkDetailsFragment__delete_call_link),
icon = ImageVector.vectorResource(id = R.drawable.symbol_trash_24),
foregroundTint = MaterialTheme.colorScheme.error,
onClick = callback::onDeleteClicked
)
}
if (state.displayRevocationDialog) {
Dialogs.SimpleAlertDialog(
title = stringResource(R.string.CallLinkDetailsFragment__delete_link),
body = stringResource(id = R.string.CallLinkDetailsFragment__this_link_will_no_longer_work),
confirm = stringResource(id = R.string.delete),
dismiss = stringResource(id = android.R.string.cancel),
onConfirm = callback::onDeleteConfirmed,
onDismiss = callback::onDeleteCanceled
)
}
}
}

View File

@@ -0,0 +1,357 @@
/**
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.calls.links.details
import android.content.ActivityNotFoundException
import android.content.Intent
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.unit.dp
import androidx.core.app.ShareCompat
import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.viewmodel.compose.viewModel
import kotlinx.coroutines.launch
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Dialogs
import org.signal.core.ui.compose.Dividers
import org.signal.core.ui.compose.Rows
import org.signal.core.ui.compose.Scaffolds
import org.signal.core.ui.compose.Snackbars
import org.signal.core.ui.compose.theme.SignalTheme
import org.signal.core.util.concurrent.LifecycleDisposable
import org.signal.ringrtc.CallLinkState.Restrictions
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.calls.YouAreAlreadyInACallSnackbar.YouAreAlreadyInACallSnackbar
import org.thoughtcrime.securesms.calls.links.CallLinks
import org.thoughtcrime.securesms.calls.links.SignalCallRow
import org.thoughtcrime.securesms.database.CallLinkTable
import org.thoughtcrime.securesms.main.MainNavigationDetailLocation
import org.thoughtcrime.securesms.main.MainNavigationListLocation
import org.thoughtcrime.securesms.main.MainNavigationRouter
import org.thoughtcrime.securesms.main.MainNavigationViewModel
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkCredentials
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId
import org.thoughtcrime.securesms.service.webrtc.links.SignalCallLinkState
import org.thoughtcrime.securesms.sharing.v2.ShareActivity
import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.Util
import org.thoughtcrime.securesms.window.WindowSizeClass
import java.time.Instant
@Composable
fun CallLinkDetailsScreen(
roomId: CallLinkRoomId,
viewModel: CallLinkDetailsViewModel = viewModel {
CallLinkDetailsViewModel(roomId)
},
router: MainNavigationRouter = viewModel<MainNavigationViewModel>(viewModelStoreOwner = LocalContext.current as ComponentActivity) {
error("Should already be created.")
}
) {
val activity = LocalContext.current as FragmentActivity
val callback = remember {
DefaultCallLinkDetailsCallback(
activity = activity,
viewModel = viewModel,
router = router
)
}
val state by viewModel.state.collectAsStateWithLifecycle(activity)
val showAlreadyInACall by viewModel.showAlreadyInACall.collectAsStateWithLifecycle(initialValue = false, lifecycleOwner = activity)
CallLinkDetailsScreen(
state = state,
showAlreadyInACall = showAlreadyInACall,
callback = callback,
showNavigationIcon = !WindowSizeClass.rememberWindowSizeClass().isSplitPane()
)
}
class DefaultCallLinkDetailsCallback(
private val activity: FragmentActivity,
private val viewModel: CallLinkDetailsViewModel,
private val router: MainNavigationRouter
) : CallLinkDetailsCallback {
private val lifecycleDisposable = LifecycleDisposable()
init {
lifecycleDisposable.bindTo(activity)
}
override fun onNavigationClicked() {
activity.onBackPressedDispatcher.onBackPressed()
}
override fun onJoinClicked() {
val recipientSnapshot = viewModel.recipientSnapshot
if (recipientSnapshot != null) {
CommunicationActions.startVideoCall(activity, recipientSnapshot) {
viewModel.showAlreadyInACall(true)
}
}
}
override fun onEditNameClicked() {
router.goTo(MainNavigationDetailLocation.Calls.CallLinks.EditCallLinkName(callLinkRoomId = viewModel.recipientSnapshot!!.requireCallLinkRoomId()))
}
override fun onShareClicked() {
val mimeType = Intent.normalizeMimeType("text/plain")
val shareIntent = ShareCompat.IntentBuilder(activity)
.setText(CallLinks.url(viewModel.rootKeySnapshot, viewModel.epochSnapshot))
.setType(mimeType)
.createChooserIntent()
try {
activity.startActivity(shareIntent)
} catch (e: ActivityNotFoundException) {
Toast.makeText(activity, R.string.CreateCallLinkBottomSheetDialogFragment__failed_to_open_share_sheet, Toast.LENGTH_LONG).show()
}
}
override fun onCopyClicked() {
Util.copyToClipboard(activity, CallLinks.url(viewModel.rootKeySnapshot, viewModel.epochSnapshot))
Toast.makeText(activity, R.string.CreateCallLinkBottomSheetDialogFragment__copied_to_clipboard, Toast.LENGTH_LONG).show()
}
override fun onShareLinkViaSignalClicked() {
activity.startActivity(
ShareActivity.sendSimpleText(
activity,
activity.getString(R.string.CreateCallLink__use_this_link_to_join_a_signal_call, CallLinks.url(viewModel.rootKeySnapshot, viewModel.epochSnapshot))
)
)
}
override fun onDeleteClicked() {
viewModel.setDisplayRevocationDialog(true)
}
override fun onDeleteConfirmed() {
viewModel.setDisplayRevocationDialog(false)
activity.lifecycleScope.launch {
if (viewModel.delete()) {
router.goTo(MainNavigationListLocation.CALLS)
router.goTo(MainNavigationDetailLocation.Empty)
}
}
}
override fun onDeleteCanceled() {
viewModel.setDisplayRevocationDialog(false)
}
override fun onApproveAllMembersChanged(checked: Boolean) {
activity.lifecycleScope.launch {
viewModel.setApproveAllMembers(checked)
}
}
}
interface CallLinkDetailsCallback {
fun onNavigationClicked() = Unit
fun onJoinClicked() = Unit
fun onEditNameClicked() = Unit
fun onShareClicked() = Unit
fun onCopyClicked() = Unit
fun onShareLinkViaSignalClicked() = Unit
fun onDeleteClicked() = Unit
fun onDeleteConfirmed() = Unit
fun onDeleteCanceled() = Unit
fun onApproveAllMembersChanged(checked: Boolean) = Unit
object Empty : CallLinkDetailsCallback
}
@Composable
fun CallLinkDetailsScreen(
state: CallLinkDetailsState,
showAlreadyInACall: Boolean,
callback: CallLinkDetailsCallback,
showNavigationIcon: Boolean = true
) {
Scaffolds.Settings(
title = stringResource(id = R.string.CallLinkDetailsFragment__call_details),
snackbarHost = {
YouAreAlreadyInACallSnackbar(showAlreadyInACall)
FailureSnackbar(failureSnackbar = state.failureSnackbar)
},
onNavigationClick = callback::onNavigationClicked,
navigationIcon = if (showNavigationIcon) {
ImageVector.vectorResource(id = R.drawable.symbol_arrow_start_24)
} else {
null
}
) { paddingValues ->
if (state.callLink == null) {
return@Settings
}
LazyColumn(
modifier = Modifier
.padding(paddingValues)
.fillMaxHeight()
) {
item {
SignalCallRow(
callLink = state.callLink,
callLinkPeekInfo = state.peekInfo,
onJoinClicked = callback::onJoinClicked,
modifier = Modifier.padding(top = 16.dp, bottom = 12.dp)
)
}
if (state.callLink.credentials?.adminPassBytes != null) {
item {
Rows.TextRow(
text = stringResource(
id = if (state.callLink.state.name.isEmpty()) {
R.string.CreateCallLinkBottomSheetDialogFragment__add_call_name
} else {
R.string.CreateCallLinkBottomSheetDialogFragment__edit_call_name
}
),
onClick = callback::onEditNameClicked
)
}
item {
Rows.ToggleRow(
checked = state.callLink.state.restrictions == Restrictions.ADMIN_APPROVAL,
text = stringResource(id = R.string.CallLinkDetailsFragment__require_admin_approval),
onCheckChanged = callback::onApproveAllMembersChanged,
isLoading = state.isLoadingAdminApprovalChange
)
}
item {
Dividers.Default()
}
}
item {
Rows.TextRow(
text = stringResource(id = R.string.CreateCallLinkBottomSheetDialogFragment__share_link_via_signal),
icon = ImageVector.vectorResource(id = R.drawable.symbol_forward_24),
onClick = callback::onShareLinkViaSignalClicked
)
}
item {
Rows.TextRow(
text = stringResource(id = R.string.CreateCallLinkBottomSheetDialogFragment__copy_link),
icon = ImageVector.vectorResource(id = R.drawable.symbol_copy_android_24),
onClick = callback::onCopyClicked
)
}
item {
Rows.TextRow(
text = stringResource(id = R.string.CallLinkDetailsFragment__share_link),
icon = ImageVector.vectorResource(id = R.drawable.symbol_link_24),
onClick = callback::onShareClicked
)
}
item {
Rows.TextRow(
text = stringResource(id = R.string.CallLinkDetailsFragment__delete_call_link),
icon = ImageVector.vectorResource(id = R.drawable.symbol_trash_24),
foregroundTint = MaterialTheme.colorScheme.error,
onClick = callback::onDeleteClicked
)
}
}
if (state.displayRevocationDialog) {
Dialogs.SimpleAlertDialog(
title = stringResource(R.string.CallLinkDetailsFragment__delete_link),
body = stringResource(id = R.string.CallLinkDetailsFragment__this_link_will_no_longer_work),
confirm = stringResource(id = R.string.delete),
dismiss = stringResource(id = android.R.string.cancel),
onConfirm = callback::onDeleteConfirmed,
onDismiss = callback::onDeleteCanceled
)
}
}
}
@Composable
private fun FailureSnackbar(
failureSnackbar: CallLinkDetailsState.FailureSnackbar?,
modifier: Modifier = Modifier
) {
val message: String? = when (failureSnackbar) {
CallLinkDetailsState.FailureSnackbar.COULD_NOT_DELETE_CALL_LINK -> stringResource(R.string.CallLinkDetailsFragment__couldnt_delete_call_link)
CallLinkDetailsState.FailureSnackbar.COULD_NOT_SAVE_CHANGES -> stringResource(R.string.CallLinkDetailsFragment__couldnt_save_changes)
CallLinkDetailsState.FailureSnackbar.COULD_NOT_UPDATE_ADMIN_APPROVAL -> stringResource(R.string.CallLinkDetailsFragment__couldnt_update_admin_approval)
null -> null
}
val hostState = remember { SnackbarHostState() }
Snackbars.Host(hostState, modifier = modifier)
LaunchedEffect(message) {
if (message != null) {
hostState.showSnackbar(message)
}
}
}
@DayNightPreviews
@Composable
private fun CallLinkDetailsScreenPreview() {
val callLink = remember {
val credentials = CallLinkCredentials(
byteArrayOf(1, 2, 3, 4),
byteArrayOf(0, 1, 2, 3),
byteArrayOf(3, 4, 5, 6)
)
CallLinkTable.CallLink(
recipientId = RecipientId.UNKNOWN,
roomId = CallLinkRoomId.fromBytes(byteArrayOf(1, 2, 3, 4)),
credentials = credentials,
state = SignalCallLinkState(
name = "Call Name",
revoked = false,
restrictions = Restrictions.NONE,
expiration = Instant.MAX
),
deletionTimestamp = 0L
)
}
SignalTheme {
CallLinkDetailsScreen(
CallLinkDetailsState(
false,
false,
callLink
),
true,
CallLinkDetailsCallback.Empty
)
}
}

View File

@@ -5,14 +5,19 @@
package org.thoughtcrime.securesms.calls.links.details
import androidx.compose.runtime.Immutable
import org.thoughtcrime.securesms.database.CallLinkTable
import org.thoughtcrime.securesms.service.webrtc.CallLinkPeekInfo
@Immutable
data class CallLinkDetailsState(
val displayRevocationDialog: Boolean = false,
val isLoadingAdminApprovalChange: Boolean = false,
val callLink: CallLinkTable.CallLink? = null,
val peekInfo: CallLinkPeekInfo? = null
)
val peekInfo: CallLinkPeekInfo? = null,
val failureSnackbar: FailureSnackbar? = null
) {
enum class FailureSnackbar {
COULD_NOT_DELETE_CALL_LINK,
COULD_NOT_SAVE_CHANGES,
COULD_NOT_UPDATE_ADMIN_APPROVAL
}
}

View File

@@ -7,16 +7,16 @@ package org.thoughtcrime.securesms.calls.links.details
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.kotlin.plusAssign
import io.reactivex.rxjava3.kotlin.subscribeBy
import io.reactivex.rxjava3.schedulers.Schedulers
import io.reactivex.rxjava3.subjects.BehaviorSubject
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import org.signal.core.util.logging.Log
import org.signal.ringrtc.CallLinkState
import org.thoughtcrime.securesms.calls.links.CallLinks
import org.thoughtcrime.securesms.calls.links.UpdateCallLinkRepository
@@ -24,12 +24,20 @@ import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId
import org.thoughtcrime.securesms.service.webrtc.links.UpdateCallLinkResult
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
@OptIn(ExperimentalCoroutinesApi::class)
class CallLinkDetailsViewModel(
callLinkRoomId: CallLinkRoomId,
repository: CallLinkDetailsRepository = CallLinkDetailsRepository(),
private val mutationRepository: UpdateCallLinkRepository = UpdateCallLinkRepository()
) : ViewModel() {
companion object {
private val TAG = Log.tag(CallLinkDetailsViewModel::class)
}
private val disposables = CompositeDisposable()
private val _state: MutableStateFlow<CallLinkDetailsState> = MutableStateFlow(CallLinkDetailsState())
@@ -54,7 +62,6 @@ class CallLinkDetailsViewModel(
disposables += repository.refreshCallLinkState(callLinkRoomId)
disposables += CallLinks.watchCallLink(callLinkRoomId)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribeBy { callLink ->
_state.update { it.copy(callLink = callLink) }
}
@@ -75,7 +82,6 @@ class CallLinkDetailsViewModel(
.toObservable()
}
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribeBy { callLinkPeekInfo ->
_state.update { it.copy(peekInfo = callLinkPeekInfo) }
}
@@ -94,26 +100,102 @@ class CallLinkDetailsViewModel(
_state.update { it.copy(displayRevocationDialog = displayRevocationDialog) }
}
fun setApproveAllMembers(approveAllMembers: Boolean): Single<UpdateCallLinkResult> {
val credentials = _state.value.callLink?.credentials ?: error("User cannot change the name of this call.")
return mutationRepository
.setCallRestrictions(credentials, if (approveAllMembers) CallLinkState.Restrictions.ADMIN_APPROVAL else CallLinkState.Restrictions.NONE)
.doOnSubscribe {
_state.update { it.copy(isLoadingAdminApprovalChange = true) }
}
.doFinally {
_state.update { it.copy(isLoadingAdminApprovalChange = false) }
suspend fun setApproveAllMembers(approveAllMembers: Boolean) {
val result = suspendCoroutine { continuation ->
val credentials = _state.value.callLink?.credentials ?: error("User cannot change the name of this call.")
disposables += mutationRepository
.setCallRestrictions(credentials, if (approveAllMembers) CallLinkState.Restrictions.ADMIN_APPROVAL else CallLinkState.Restrictions.NONE)
.doOnSubscribe {
_state.update { it.copy(isLoadingAdminApprovalChange = true) }
}
.doFinally {
_state.update { it.copy(isLoadingAdminApprovalChange = false) }
}
.subscribeBy(
onSuccess = { continuation.resume(Result.success(it)) },
onError = { continuation.resume(Result.failure(it)) }
)
}.getOrNull()
if (result == null) {
handleError("setApproveAllMembers")
return
}
if (result is UpdateCallLinkResult.Failure) {
Log.w(TAG, "Failed to change restrictions. $result")
if (result.status == 409.toShort()) {
toastCallLinkInUse()
} else {
toastFailure()
}
}
}
fun setName(name: String): Single<UpdateCallLinkResult> {
val credentials = _state.value.callLink?.credentials ?: error("User cannot change the name of this call.")
return mutationRepository.setCallName(credentials, name)
suspend fun setName(name: String) {
val result = suspendCoroutine { continuation ->
val credentials = _state.value.callLink?.credentials ?: error("User cannot change the name of this call.")
disposables += mutationRepository.setCallName(credentials, name)
.subscribeBy(
onSuccess = { continuation.resume(Result.success(it)) },
onError = { continuation.resume(Result.failure(it)) }
)
}.getOrNull()
if (result == null) {
handleError("setName")
} else {
if (result !is UpdateCallLinkResult.Update) {
Log.w(TAG, "Failed to set name. $name")
toastFailure()
}
}
}
fun delete(): Single<UpdateCallLinkResult> {
val credentials = _state.value.callLink?.credentials ?: error("User cannot change the name of this call.")
return mutationRepository.deleteCallLink(credentials)
suspend fun delete(): Boolean {
val result = suspendCoroutine { continuation ->
val credentials = _state.value.callLink?.credentials ?: error("User cannot change the name of this call.")
disposables += mutationRepository.deleteCallLink(credentials)
.subscribeBy(
onSuccess = { continuation.resume(Result.success(it)) },
onError = { continuation.resume(Result.failure(it)) }
)
}.getOrNull()
when (result) {
null -> handleError("delete")
is UpdateCallLinkResult.Delete -> return true
is UpdateCallLinkResult.CallLinkIsInUse -> {
Log.w(TAG, "Failed to delete in-use call link.")
toastCouldNotDeleteCallLink()
}
else -> {
Log.w(TAG, "Failed to delete call link. $result")
toastFailure()
}
}
return false
}
private fun handleError(method: String): (throwable: Throwable) -> Unit {
return {
Log.w(TAG, "Failure during $method", it)
toastFailure()
}
}
private fun toastCallLinkInUse() {
_state.update { it.copy(failureSnackbar = CallLinkDetailsState.FailureSnackbar.COULD_NOT_UPDATE_ADMIN_APPROVAL) }
}
private fun toastFailure() {
_state.update { it.copy(failureSnackbar = CallLinkDetailsState.FailureSnackbar.COULD_NOT_SAVE_CHANGES) }
}
private fun toastCouldNotDeleteCallLink() {
_state.update { it.copy(failureSnackbar = CallLinkDetailsState.FailureSnackbar.COULD_NOT_DELETE_CALL_LINK) }
}
class Factory(private val callLinkRoomId: CallLinkRoomId) : ViewModelProvider.Factory {

View File

@@ -11,12 +11,16 @@ import androidx.compose.material3.SnackbarDuration
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.appbar.AppBarLayout
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.kotlin.Flowables
import io.reactivex.rxjava3.kotlin.subscribeBy
import kotlinx.coroutines.launch
import org.signal.core.util.DimensionUnit
import org.signal.core.util.concurrent.LifecycleDisposable
import org.signal.core.util.concurrent.addTo
@@ -41,6 +45,7 @@ import org.thoughtcrime.securesms.conversationlist.chatfilter.FilterLerp
import org.thoughtcrime.securesms.conversationlist.chatfilter.FilterPullState
import org.thoughtcrime.securesms.databinding.CallLogFragmentBinding
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.main.MainNavigationDetailLocation
import org.thoughtcrime.securesms.main.MainNavigationListLocation
import org.thoughtcrime.securesms.main.MainNavigationViewModel
import org.thoughtcrime.securesms.main.MainToolbarMode
@@ -78,6 +83,7 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
private lateinit var callLogActionMode: CallLogActionMode
private val conversationUpdateTick: ConversationUpdateTick = ConversationUpdateTick(this::onTimestampTick)
private var callLogAdapter: CallLogAdapter? = null
private val backPressedCallback = OnBackPressed()
private lateinit var signalBottomActionBarController: SignalBottomActionBarController
@@ -165,16 +171,14 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
initializePullToFilter(scrollToPositionDelegate)
initializeTapToScrollToTop(scrollToPositionDelegate)
requireActivity().onBackPressedDispatcher.addCallback(
viewLifecycleOwner,
object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
if (!closeSearchIfOpen()) {
mainNavigationViewModel.onChatsSelected()
}
requireActivity().onBackPressedDispatcher.addCallback(backPressedCallback)
viewLifecycleOwner.lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.RESUMED) {
mainToolbarViewModel.state.collect {
backPressedCallback.isEnabled = it.mode == MainToolbarMode.SEARCH
}
}
)
}
if (resources.getWindowSizeClass().isCompact()) {
ViewUtil.setBottomMargin(binding.bottomActionBar, ViewUtil.getNavigationBarHeight(binding.bottomActionBar))
@@ -316,7 +320,7 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
if (viewModel.selectionStateSnapshot.isNotEmpty(binding.recycler.adapter!!.itemCount)) {
viewModel.toggleSelected(callLogRow.id)
} else {
startActivity(CallLinkDetailsActivity.createIntent(requireContext(), callLogRow.record.roomId))
mainNavigationViewModel.goTo(MainNavigationDetailLocation.Calls.CallLinks.CallLinkDetails(callLogRow.record.roomId))
}
}
@@ -482,6 +486,12 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
}
}
private inner class OnBackPressed : OnBackPressedCallback(enabled = false) {
override fun handleOnBackPressed() {
closeSearchIfOpen()
}
}
interface Callback {
fun onMultiSelectStarted()
fun onMultiSelectFinished()

View File

@@ -25,8 +25,13 @@ import org.thoughtcrime.securesms.util.ServiceUtil;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.ViewUtil;
/**
* A search input field for finding recipients.
* <p>
* In compose, use RecipientSearchField instead.
*/
public final class ContactFilterView extends FrameLayout {
private OnFilterChangedListener listener;
private OnFilterChangedListener listener;
private final EditText searchText;
private final AnimatingToggle toggle;

View File

@@ -13,8 +13,10 @@ import androidx.core.view.WindowInsetsCompat
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.main.InsetsViewModel
import org.thoughtcrime.securesms.util.ViewUtil
import org.thoughtcrime.securesms.window.WindowSizeClass.Companion.getWindowSizeClass
import kotlin.math.roundToInt
/**
* A specialized [ConstraintLayout] that sets guidelines based on the window insets provided
@@ -64,6 +66,7 @@ open class InsetAwareConstraintLayout @JvmOverloads constructor(
private var insets: WindowInsetsCompat? = null
private var windowTypes: Int = InsetAwareConstraintLayout.windowTypes
private var verticalInsetOverride: InsetsViewModel.Insets = InsetsViewModel.Insets.Zero
private val windowInsetsListener = androidx.core.view.OnApplyWindowInsetsListener { _, insets ->
this.insets = insets
@@ -127,6 +130,22 @@ open class InsetAwareConstraintLayout @JvmOverloads constructor(
}
}
fun applyInsets(insets: InsetsViewModel.Insets) {
verticalInsetOverride = insets
if (this.insets != null) {
applyInsets(this.insets!!.getInsets(windowTypes), this.insets!!.getInsets(keyboardType))
}
}
fun clearVerticalInsetOverride() {
verticalInsetOverride = InsetsViewModel.Insets.Zero
if (this.insets != null) {
applyInsets(this.insets!!.getInsets(windowTypes), this.insets!!.getInsets(keyboardType))
}
}
fun addKeyboardStateListener(listener: KeyboardStateListener) {
keyboardStateListeners += listener
}
@@ -146,8 +165,8 @@ open class InsetAwareConstraintLayout @JvmOverloads constructor(
private fun applyInsets(windowInsets: Insets, keyboardInsets: Insets) {
val isLtr = ViewUtil.isLtr(this)
val statusBar = windowInsets.top
val navigationBar = windowInsets.bottom
val statusBar = if (verticalInsetOverride == InsetsViewModel.Insets.Zero) windowInsets.top else verticalInsetOverride.statusBar.roundToInt()
val navigationBar = if (verticalInsetOverride == InsetsViewModel.Insets.Zero) windowInsets.bottom else verticalInsetOverride.navBar.roundToInt()
val parentStart = if (isLtr) windowInsets.left else windowInsets.right
val parentEnd = if (isLtr) windowInsets.right else windowInsets.left
@@ -156,7 +175,9 @@ open class InsetAwareConstraintLayout @JvmOverloads constructor(
parentStartGuideline?.setGuidelineBegin(parentStart)
parentEndGuideline?.setGuidelineEnd(parentEnd)
windowInsetsListeners.forEach { it.onApplyWindowInsets(statusBar, navigationBar, parentStart, parentEnd) }
windowInsetsListeners.forEach {
it.onApplyWindowInsets(statusBar, navigationBar, parentStart, parentEnd)
}
if (keyboardInsets.bottom > 0) {
setKeyboardHeight(keyboardInsets.bottom)

View File

@@ -0,0 +1,21 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components
import android.content.Context
import android.util.AttributeSet
import com.google.android.material.textfield.TextInputEditText
/**
* An EditText that completely disables Android's auto-fill functionality.
*/
class NoAutofillEditText : TextInputEditText {
constructor(context: Context) : super(context, null)
constructor(context: Context, attrs: AttributeSet?) : super(context, attrs)
constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr)
override fun getAutofillType(): Int = AUTOFILL_TYPE_NONE
}

View File

@@ -21,8 +21,8 @@ import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.SignalPreview
import org.signal.core.ui.compose.horizontalGutters
import org.signal.core.ui.compose.theme.SignalTheme
import org.thoughtcrime.securesms.R
@@ -88,7 +88,7 @@ fun BetaHeader(modifier: Modifier = Modifier) {
}
}
@SignalPreview
@DayNightPreviews
@Composable
fun BetaLabelPreview() {
Previews.Preview {
@@ -113,7 +113,7 @@ fun LongTextBetaLabelPreview() {
}
}
@SignalPreview
@DayNightPreviews
@Composable
fun BetaHeaderPreview() {
Previews.Preview {

View File

@@ -26,8 +26,8 @@ import androidx.compose.ui.unit.dp
import androidx.fragment.app.FragmentManager
import org.signal.core.ui.compose.BottomSheets
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.SignalPreview
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.compose.ComposeBottomSheetDialogFragment
import org.thoughtcrime.securesms.keyvalue.SignalStore
@@ -102,7 +102,7 @@ private fun Sheet(onDismiss: () -> Unit = {}) {
}
}
@SignalPreview
@DayNightPreviews
@Composable
private fun ConnectivityWarningSheetPreview() {
Previews.BottomSheetPreview {

View File

@@ -26,8 +26,8 @@ import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Completable
import io.reactivex.rxjava3.subjects.CompletableSubject
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.SignalPreview
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.compose.ComposeBottomSheetDialogFragment
import org.thoughtcrime.securesms.keyvalue.SignalStore
@@ -126,7 +126,7 @@ private fun Sheet(
}
}
@SignalPreview
@DayNightPreviews
@Composable
private fun SheetPreview() {
Previews.Preview {

View File

@@ -27,8 +27,8 @@ import androidx.core.os.bundleOf
import androidx.fragment.app.FragmentManager
import org.signal.core.ui.compose.BottomSheets
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.SignalPreview
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.compose.ComposeBottomSheetDialogFragment
import org.thoughtcrime.securesms.keyvalue.SignalStore
@@ -124,7 +124,7 @@ private fun DeviceSpecificSheet(onContinue: () -> Unit = {}, onDismiss: () -> Un
}
}
@SignalPreview
@DayNightPreviews
@Composable
private fun DeviceSpecificSheetPreview() {
Previews.BottomSheetPreview {

View File

@@ -18,7 +18,6 @@ import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Checkbox
@@ -26,6 +25,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
@@ -34,8 +34,9 @@ import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.role
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import org.signal.core.ui.compose.SignalPreview
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.theme.SignalTheme
import org.thoughtcrime.securesms.R
@@ -50,7 +51,11 @@ import org.thoughtcrime.securesms.R
fun RoundCheckbox(
checked: Boolean,
onCheckedChange: (Boolean) -> Unit,
modifier: Modifier = Modifier
modifier: Modifier = Modifier,
size: Dp = 24.dp,
enabled: Boolean = true,
outlineColor: Color = MaterialTheme.colorScheme.outline,
checkedColor: Color = MaterialTheme.colorScheme.primary
) {
val contentDescription = if (checked) {
stringResource(R.string.SignalCheckbox_accessibility_checked_description)
@@ -60,15 +65,14 @@ fun RoundCheckbox(
Box(
modifier = modifier
.padding(12.dp)
.size(24.dp)
.size(size)
.aspectRatio(1f)
.border(
width = 1.5.dp,
color = if (checked) {
MaterialTheme.colorScheme.primary
checkedColor
} else {
MaterialTheme.colorScheme.outline
outlineColor
},
shape = CircleShape
)
@@ -76,7 +80,8 @@ fun RoundCheckbox(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = { onCheckedChange(!checked) },
onClickLabel = stringResource(R.string.SignalCheckbox_accessibility_on_click_label)
onClickLabel = stringResource(R.string.SignalCheckbox_accessibility_on_click_label),
enabled = enabled
)
.semantics(mergeDescendants = true) {
this.role = Role.Checkbox
@@ -90,7 +95,7 @@ fun RoundCheckbox(
) {
Image(
imageVector = ImageVector.vectorResource(id = R.drawable.ic_check_circle_solid_24),
colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.primary),
colorFilter = ColorFilter.tint(checkedColor),
contentDescription = null,
modifier = Modifier.fillMaxSize()
)
@@ -98,13 +103,13 @@ fun RoundCheckbox(
}
}
@SignalPreview
@DayNightPreviews
@Composable
private fun RoundCheckboxCheckedPreview() = SignalTheme {
RoundCheckbox(checked = true, onCheckedChange = {})
}
@SignalPreview
@DayNightPreviews
@Composable
private fun RoundCheckboxUncheckedPreview() = SignalTheme {
RoundCheckbox(checked = false, onCheckedChange = {})

View File

@@ -0,0 +1,38 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.compose
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import org.thoughtcrime.securesms.window.WindowSizeClass
/**
* Displays the screen title for split-pane UIs on tablets and foldable devices.
*/
@Composable
fun ScreenTitlePane(
title: String,
modifier: Modifier = Modifier
) {
val windowSizeClass = WindowSizeClass.rememberWindowSizeClass()
Text(
text = title,
style = MaterialTheme.typography.headlineLarge,
color = MaterialTheme.colorScheme.onSurface,
modifier = modifier
.padding(
start = if (windowSizeClass.isExtended()) 80.dp else 20.dp,
end = 20.dp,
top = 12.dp,
bottom = 12.dp
)
)
}

View File

@@ -10,9 +10,9 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Dialogs
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.SignalPreview
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.SupportEmailUtil
@@ -86,7 +86,7 @@ fun <Reason> SendSupportEmailEffect(
}
}
@SignalPreview
@DayNightPreviews
@Composable
private fun ContactSupportDialogPreview() {
Previews.Preview {

View File

@@ -10,5 +10,6 @@ public final class EmojiStrings {
public static final String STICKER = "\u2B50";
public static final String GIFT = "\uD83C\uDF81";
public static final String CARD = "\uD83D\uDCB3";
public static final String POLL = "\uD83D\uDCCA";
public static final String FAILED_STORY = "\u2757";
}

View File

@@ -294,7 +294,7 @@ public class EmojiTextView extends AppCompatTextView {
int desiredWidth = (int) measuredTextWidth + getPaddingLeft() + getPaddingRight();
if (widthSpecMode == MeasureSpec.AT_MOST && desiredWidth < widthSpecSize) {
return MeasureSpec.makeMeasureSpec(desiredWidth + 1, MeasureSpec.EXACTLY);
return MeasureSpec.makeMeasureSpec(desiredWidth + 3, MeasureSpec.EXACTLY);
}
}
}

View File

@@ -18,8 +18,8 @@ import androidx.compose.ui.text.PlaceholderVerticalAlign
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.unit.sp
import com.google.accompanist.drawablepainter.rememberDrawablePainter
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.SignalPreview
/**
* Applies Signal or System emoji to the given content based off user settings.
@@ -71,7 +71,7 @@ fun Emojifier(
}
@Composable
@SignalPreview
@DayNightPreviews
private fun EmojifierPreview() {
Previews.Preview {
Emojifier(text = "This message has an emoji ❤\uFE0F")

View File

@@ -7,17 +7,18 @@ import androidx.navigation.NavDirections
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import io.reactivex.rxjava3.subjects.PublishSubject
import io.reactivex.rxjava3.subjects.Subject
import org.signal.core.util.getParcelableExtraCompat
import org.signal.donations.InAppPaymentType
import org.thoughtcrime.securesms.MainActivity
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.DSLSettingsActivity
import org.thoughtcrime.securesms.components.settings.app.chats.folders.CreateFoldersFragmentArgs
import org.thoughtcrime.securesms.components.settings.app.notifications.profiles.EditNotificationProfileScheduleFragmentArgs
import org.thoughtcrime.securesms.components.settings.app.routes.AppSettingsRoute
import org.thoughtcrime.securesms.components.settings.app.subscription.GooglePayComponent
import org.thoughtcrime.securesms.components.settings.app.subscription.GooglePayRepository
import org.thoughtcrime.securesms.help.HelpFragment
import org.thoughtcrime.securesms.keyvalue.SettingsValues
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.profiles.manage.UsernameEditMode
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.service.KeyCachingService
import org.thoughtcrime.securesms.util.CachedInflater
@@ -25,8 +26,7 @@ import org.thoughtcrime.securesms.util.DynamicTheme
import org.thoughtcrime.securesms.util.SignalE164Util
import org.thoughtcrime.securesms.util.navigation.safeNavigate
private const val START_LOCATION = "app.settings.start.location"
private const val START_ARGUMENTS = "app.settings.start.arguments"
private const val START_ROUTE = "app.settings.args.START_ROUTE"
private const val NOTIFICATION_CATEGORY = "android.intent.category.NOTIFICATION_PREFERENCES"
private const val STATE_WAS_CONFIGURATION_UPDATED = "app.settings.state.configuration.updated"
private const val EXTRA_PERFORM_ACTION_ON_CREATE = "extra_perform_action_on_create"
@@ -48,42 +48,41 @@ class AppSettingsActivity : DSLSettingsActivity(), GooglePayComponent {
val startingAction: NavDirections? = if (intent?.categories?.contains(NOTIFICATION_CATEGORY) == true) {
AppSettingsFragmentDirections.actionDirectToNotificationsSettingsFragment()
} else {
when (StartLocation.fromCode(intent?.getIntExtra(START_LOCATION, StartLocation.HOME.code))) {
StartLocation.HOME -> null
StartLocation.BACKUPS -> AppSettingsFragmentDirections.actionDirectToBackupsPreferenceFragment()
StartLocation.HELP -> AppSettingsFragmentDirections.actionDirectToHelpFragment()
.setStartCategoryIndex(intent.getIntExtra(HelpFragment.START_CATEGORY_INDEX, 0))
StartLocation.PROXY -> AppSettingsFragmentDirections.actionDirectToEditProxyFragment()
StartLocation.NOTIFICATIONS -> AppSettingsFragmentDirections.actionDirectToNotificationsSettingsFragment()
StartLocation.CHANGE_NUMBER -> AppSettingsFragmentDirections.actionDirectToChangeNumberFragment()
StartLocation.SUBSCRIPTIONS -> AppSettingsFragmentDirections.actionDirectToManageDonations().setDirectToCheckoutType(InAppPaymentType.RECURRING_DONATION)
StartLocation.BOOST -> AppSettingsFragmentDirections.actionDirectToManageDonations().setDirectToCheckoutType(InAppPaymentType.ONE_TIME_DONATION)
StartLocation.MANAGE_SUBSCRIPTIONS -> AppSettingsFragmentDirections.actionDirectToManageDonations()
StartLocation.NOTIFICATION_PROFILES -> AppSettingsFragmentDirections.actionDirectToNotificationProfiles()
StartLocation.CREATE_NOTIFICATION_PROFILE -> AppSettingsFragmentDirections.actionDirectToCreateNotificationProfiles()
StartLocation.NOTIFICATION_PROFILE_DETAILS -> AppSettingsFragmentDirections.actionDirectToNotificationProfileDetails(
EditNotificationProfileScheduleFragmentArgs.fromBundle(intent.getBundleExtra(START_ARGUMENTS)!!).profileId
val appSettingsRoute: AppSettingsRoute? = intent?.getParcelableExtraCompat(START_ROUTE, AppSettingsRoute::class.java)
when (appSettingsRoute) {
AppSettingsRoute.Empty -> null
AppSettingsRoute.BackupsRoute.Local -> AppSettingsFragmentDirections.actionDirectToBackupsPreferenceFragment()
is AppSettingsRoute.HelpRoute.Settings -> AppSettingsFragmentDirections.actionDirectToHelpFragment()
.setStartCategoryIndex(appSettingsRoute.startCategoryIndex)
AppSettingsRoute.DataAndStorageRoute.Proxy -> AppSettingsFragmentDirections.actionDirectToEditProxyFragment()
AppSettingsRoute.NotificationsRoute.Notifications -> AppSettingsFragmentDirections.actionDirectToNotificationsSettingsFragment()
AppSettingsRoute.ChangeNumberRoute.Start -> AppSettingsFragmentDirections.actionDirectToChangeNumberFragment()
is AppSettingsRoute.DonationsRoute.Donations -> AppSettingsFragmentDirections.actionDirectToManageDonations().setDirectToCheckoutType(appSettingsRoute.directToCheckoutType)
AppSettingsRoute.NotificationsRoute.NotificationProfiles -> AppSettingsFragmentDirections.actionDirectToNotificationProfiles()
is AppSettingsRoute.NotificationsRoute.EditProfile -> AppSettingsFragmentDirections.actionDirectToCreateNotificationProfiles()
is AppSettingsRoute.NotificationsRoute.ProfileDetails -> AppSettingsFragmentDirections.actionDirectToNotificationProfileDetails(
appSettingsRoute.profileId
)
StartLocation.PRIVACY -> AppSettingsFragmentDirections.actionDirectToPrivacy()
StartLocation.LINKED_DEVICES -> AppSettingsFragmentDirections.actionDirectToDevices()
StartLocation.USERNAME_LINK -> AppSettingsFragmentDirections.actionDirectToUsernameLinkSettings()
StartLocation.RECOVER_USERNAME -> AppSettingsFragmentDirections.actionDirectToUsernameRecovery()
StartLocation.REMOTE_BACKUPS -> AppSettingsFragmentDirections.actionDirectToRemoteBackupsSettingsFragment()
StartLocation.CHAT_FOLDERS -> AppSettingsFragmentDirections.actionDirectToChatFoldersFragment()
StartLocation.CREATE_CHAT_FOLDER -> AppSettingsFragmentDirections.actionDirectToCreateFoldersFragment(
CreateFoldersFragmentArgs.fromBundle(intent.getBundleExtra(START_ARGUMENTS)!!).folderId,
CreateFoldersFragmentArgs.fromBundle(intent.getBundleExtra(START_ARGUMENTS)!!).threadIds
AppSettingsRoute.PrivacyRoute.Privacy -> AppSettingsFragmentDirections.actionDirectToPrivacy()
AppSettingsRoute.LinkDeviceRoute.LinkDevice -> AppSettingsFragmentDirections.actionDirectToDevices()
AppSettingsRoute.UsernameLinkRoute.UsernameLink -> AppSettingsFragmentDirections.actionDirectToUsernameLinkSettings()
is AppSettingsRoute.AccountRoute.Username -> AppSettingsFragmentDirections.actionDirectToUsernameRecovery()
is AppSettingsRoute.BackupsRoute.Remote -> AppSettingsFragmentDirections.actionDirectToRemoteBackupsSettingsFragment()
AppSettingsRoute.ChatFoldersRoute.ChatFolders -> AppSettingsFragmentDirections.actionDirectToChatFoldersFragment()
is AppSettingsRoute.ChatFoldersRoute.CreateChatFolders -> AppSettingsFragmentDirections.actionDirectToCreateFoldersFragment(
appSettingsRoute.folderId,
appSettingsRoute.threadIds
)
StartLocation.BACKUPS_SETTINGS -> AppSettingsFragmentDirections.actionDirectToBackupsSettingsFragment()
StartLocation.INVITE -> AppSettingsFragmentDirections.actionDirectToInviteFragment()
StartLocation.MANAGE_STORAGE -> AppSettingsFragmentDirections.actionDirectToStoragePreferenceFragment()
AppSettingsRoute.BackupsRoute.Backups -> AppSettingsFragmentDirections.actionDirectToBackupsSettingsFragment()
AppSettingsRoute.Invite -> AppSettingsFragmentDirections.actionDirectToInviteFragment()
AppSettingsRoute.DataAndStorageRoute.DataAndStorage -> AppSettingsFragmentDirections.actionDirectToStoragePreferenceFragment()
else -> error("Unsupported start location: ${appSettingsRoute?.javaClass?.name}")
}
}
intent = intent.putExtra(START_LOCATION, StartLocation.HOME)
intent = intent.putExtra(START_ROUTE, AppSettingsRoute.Empty)
if (startingAction == null && savedInstanceState != null) {
wasConfigurationUpdated = savedInstanceState.getBoolean(STATE_WAS_CONFIGURATION_UPDATED)
@@ -148,123 +147,89 @@ class AppSettingsActivity : DSLSettingsActivity(), GooglePayComponent {
@JvmStatic
@JvmOverloads
fun home(context: Context, action: String? = null): Intent {
return getIntentForStartLocation(context, StartLocation.HOME)
return getIntentForStartLocation(context, AppSettingsRoute.Empty)
.putExtra(EXTRA_PERFORM_ACTION_ON_CREATE, action)
}
@JvmStatic
fun backups(context: Context): Intent = getIntentForStartLocation(context, StartLocation.BACKUPS)
fun backups(context: Context): Intent = getIntentForStartLocation(context, AppSettingsRoute.BackupsRoute.Local)
@JvmStatic
fun help(context: Context, startCategoryIndex: Int = 0): Intent {
return getIntentForStartLocation(context, StartLocation.HELP)
return getIntentForStartLocation(context, AppSettingsRoute.HelpRoute.Settings(startCategoryIndex = startCategoryIndex))
.putExtra(HelpFragment.START_CATEGORY_INDEX, startCategoryIndex)
}
@JvmStatic
fun proxy(context: Context): Intent = getIntentForStartLocation(context, StartLocation.PROXY)
fun proxy(context: Context): Intent = getIntentForStartLocation(context, AppSettingsRoute.DataAndStorageRoute.Proxy)
@JvmStatic
fun notifications(context: Context): Intent = getIntentForStartLocation(context, StartLocation.NOTIFICATIONS)
fun notifications(context: Context): Intent = getIntentForStartLocation(context, AppSettingsRoute.NotificationsRoute.Notifications)
@JvmStatic
fun changeNumber(context: Context): Intent = getIntentForStartLocation(context, StartLocation.CHANGE_NUMBER)
fun changeNumber(context: Context): Intent = getIntentForStartLocation(context, AppSettingsRoute.ChangeNumberRoute.Start)
@JvmStatic
fun subscriptions(context: Context): Intent = getIntentForStartLocation(context, StartLocation.SUBSCRIPTIONS)
fun subscriptions(context: Context): Intent = getIntentForStartLocation(context, AppSettingsRoute.DonationsRoute.Donations(directToCheckoutType = InAppPaymentType.RECURRING_DONATION))
@JvmStatic
fun boost(context: Context): Intent = getIntentForStartLocation(context, StartLocation.BOOST)
fun boost(context: Context): Intent = getIntentForStartLocation(context, AppSettingsRoute.DonationsRoute.Donations(directToCheckoutType = InAppPaymentType.ONE_TIME_DONATION))
@JvmStatic
fun manageSubscriptions(context: Context): Intent = getIntentForStartLocation(context, StartLocation.MANAGE_SUBSCRIPTIONS)
fun manageSubscriptions(context: Context): Intent = getIntentForStartLocation(context, AppSettingsRoute.DonationsRoute.Donations())
fun manageStorage(context: Context): Intent = getIntentForStartLocation(context, StartLocation.MANAGE_STORAGE)
fun manageStorage(context: Context): Intent = getIntentForStartLocation(context, AppSettingsRoute.DataAndStorageRoute.DataAndStorage)
@JvmStatic
fun notificationProfiles(context: Context): Intent = getIntentForStartLocation(context, StartLocation.NOTIFICATION_PROFILES)
fun notificationProfiles(context: Context): Intent = getIntentForStartLocation(context, AppSettingsRoute.NotificationsRoute.NotificationProfiles)
@JvmStatic
fun createNotificationProfile(context: Context): Intent = getIntentForStartLocation(context, StartLocation.CREATE_NOTIFICATION_PROFILE)
fun createNotificationProfile(context: Context): Intent = getIntentForStartLocation(context, AppSettingsRoute.NotificationsRoute.EditProfile())
@JvmStatic
fun privacy(context: Context): Intent = getIntentForStartLocation(context, StartLocation.PRIVACY)
fun privacy(context: Context): Intent = getIntentForStartLocation(context, AppSettingsRoute.PrivacyRoute.Privacy)
@JvmStatic
fun notificationProfileDetails(context: Context, profileId: Long): Intent {
val arguments = EditNotificationProfileScheduleFragmentArgs.Builder(profileId, false)
.build()
.toBundle()
return getIntentForStartLocation(context, StartLocation.NOTIFICATION_PROFILE_DETAILS)
.putExtra(START_ARGUMENTS, arguments)
return getIntentForStartLocation(context, AppSettingsRoute.NotificationsRoute.ProfileDetails(profileId = profileId))
}
@JvmStatic
fun linkedDevices(context: Context): Intent = getIntentForStartLocation(context, StartLocation.LINKED_DEVICES)
fun linkedDevices(context: Context): Intent = getIntentForStartLocation(context, AppSettingsRoute.LinkDeviceRoute.LinkDevice)
@JvmStatic
fun usernameLinkSettings(context: Context): Intent = getIntentForStartLocation(context, StartLocation.USERNAME_LINK)
fun usernameLinkSettings(context: Context): Intent = getIntentForStartLocation(context, AppSettingsRoute.UsernameLinkRoute.UsernameLink)
@JvmStatic
fun usernameRecovery(context: Context): Intent = getIntentForStartLocation(context, StartLocation.RECOVER_USERNAME)
fun usernameRecovery(context: Context): Intent = getIntentForStartLocation(context, AppSettingsRoute.AccountRoute.Username(mode = UsernameEditMode.RECOVERY))
@JvmStatic
fun remoteBackups(context: Context): Intent = getIntentForStartLocation(context, StartLocation.REMOTE_BACKUPS)
fun remoteBackups(context: Context): Intent = getIntentForStartLocation(context, AppSettingsRoute.BackupsRoute.Remote())
@JvmStatic
fun chatFolders(context: Context): Intent = getIntentForStartLocation(context, StartLocation.CHAT_FOLDERS)
fun chatFolders(context: Context): Intent = getIntentForStartLocation(context, AppSettingsRoute.ChatFoldersRoute.ChatFolders)
@JvmStatic
fun createChatFolder(context: Context, id: Long = -1, threadIds: LongArray?): Intent {
val arguments = CreateFoldersFragmentArgs.Builder(id, threadIds ?: longArrayOf())
.build()
.toBundle()
return getIntentForStartLocation(context, StartLocation.CREATE_CHAT_FOLDER).putExtra(START_ARGUMENTS, arguments)
return getIntentForStartLocation(
context,
AppSettingsRoute.ChatFoldersRoute.CreateChatFolders(
folderId = id,
threadIds = threadIds ?: longArrayOf()
)
)
}
@JvmStatic
fun backupsSettings(context: Context): Intent = getIntentForStartLocation(context, StartLocation.BACKUPS_SETTINGS)
fun backupsSettings(context: Context): Intent = getIntentForStartLocation(context, AppSettingsRoute.BackupsRoute.Backups)
@JvmStatic
fun invite(context: Context): Intent = getIntentForStartLocation(context, StartLocation.INVITE)
fun invite(context: Context): Intent = getIntentForStartLocation(context, AppSettingsRoute.Invite)
private fun getIntentForStartLocation(context: Context, startLocation: StartLocation): Intent {
private fun getIntentForStartLocation(context: Context, startRoute: AppSettingsRoute): Intent {
return Intent(context, AppSettingsActivity::class.java)
.putExtra(ARG_NAV_GRAPH, R.navigation.app_settings_with_change_number)
.putExtra(START_LOCATION, startLocation.code)
}
}
private enum class StartLocation(val code: Int) {
HOME(0),
BACKUPS(1),
HELP(2),
PROXY(3),
NOTIFICATIONS(4),
CHANGE_NUMBER(5),
SUBSCRIPTIONS(6),
BOOST(7),
MANAGE_SUBSCRIPTIONS(8),
NOTIFICATION_PROFILES(9),
CREATE_NOTIFICATION_PROFILE(10),
NOTIFICATION_PROFILE_DETAILS(11),
PRIVACY(12),
LINKED_DEVICES(13),
USERNAME_LINK(14),
RECOVER_USERNAME(15),
REMOTE_BACKUPS(16),
CHAT_FOLDERS(17),
CREATE_CHAT_FOLDER(18),
BACKUPS_SETTINGS(19),
INVITE(20),
MANAGE_STORAGE(21);
companion object {
fun fromCode(code: Int?): StartLocation {
return entries.find { code == it.code } ?: HOME
}
.putExtra(START_ROUTE, startRoute)
}
}
}

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