Compare commits

..

93 Commits

Author SHA1 Message Date
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
404 changed files with 13233 additions and 10597 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 = 1592
val canonicalVersionName = "7.58.0"
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")
}
}
@@ -607,6 +604,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

@@ -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

@@ -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

@@ -24,6 +24,7 @@ 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
@@ -64,10 +65,9 @@ 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.getApiAvailability() } returns BillingResponseCode.OK
coEvery { AppDependencies.billingApi.queryPurchases() } returns mockk()
coEvery { AppDependencies.billingApi.queryProduct() } returns null
@@ -143,7 +143,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()

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

@@ -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

@@ -843,19 +843,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 +923,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 +974,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

@@ -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,6 +37,8 @@ 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
@@ -48,8 +50,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 +76,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 +90,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 +102,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 +122,11 @@ 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.storiesNavGraphBuilder
import org.thoughtcrime.securesms.mediasend.camerax.CameraXUtil
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionActivity
import org.thoughtcrime.securesms.megaphone.Megaphone
@@ -297,7 +306,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 +316,11 @@ 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.goTo(MainNavigationListLocation.CHATS)
}
val mainBottomChromeState = remember(mainToolbarState.destination, snackbar, mainToolbarState.mode, megaphone) {
@@ -329,8 +340,62 @@ 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 mainNavigationViewModel.detailLocation.collectAsStateWithLifecycle(mainNavigationViewModel.earlyNavigationDetailLocationRequested ?: MainNavigationDetailLocation.Empty)
val chatsNavHostController = rememberDetailNavHostController {
chatNavGraphBuilder()
}
val callsNavHostController = rememberDetailNavHostController {
callNavGraphBuilder(it)
}
val storiesNavHostController = rememberDetailNavHostController {
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,
@@ -432,35 +497,26 @@ 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()
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
)
}
}
},
@@ -521,8 +577,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 +812,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,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
@@ -130,6 +132,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 +166,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
@@ -542,7 +546,9 @@ object BackupRepository {
return false
}
return SignalStore.backup.hasBackupBeenUploaded && System.currentTimeMillis().milliseconds > SignalStore.backup.nextBackupFailureSheetSnoozeTime
val isRegistered = SignalStore.account.isRegistered && !TextSecurePreferences.isUnauthorizedReceived(AppDependencies.application)
return SignalStore.backup.hasBackupBeenUploaded && System.currentTimeMillis().milliseconds > SignalStore.backup.nextBackupFailureSheetSnoozeTime && isRegistered
}
fun snoozeDownloadYourBackupData() {
@@ -615,7 +621,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 +724,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 +771,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 +789,8 @@ object BackupRepository {
forTransfer = false,
progressEmitter = progressEmitter,
cancellationSignal = cancellationSignal,
extraExportOperations = extraExportOperations
extraFrameOperation = extraFrameOperation,
endingExportOperation = null
)
}
@@ -807,7 +819,8 @@ object BackupRepository {
forTransfer = true,
progressEmitter = progressEmitter,
cancellationSignal = cancellationSignal,
extraExportOperations = null
extraFrameOperation = null,
endingExportOperation = null
)
}
@@ -821,8 +834,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 +854,8 @@ object BackupRepository {
forTransfer = forTransfer,
progressEmitter = progressEmitter,
cancellationSignal = cancellationSignal,
extraExportOperations = extraExportOperations
extraFrameOperation = null,
endingExportOperation = null
)
}
@@ -864,7 +877,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 +916,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 +930,7 @@ object BackupRepository {
progressEmitter?.onRecipient()
RecipientArchiveProcessor.export(dbSnapshot, signalStoreSnapshot, exportState, selfRecipientId, selfAci) {
writer.write(it)
extraFrameOperation?.invoke(it)
eventTimer.emit("recipient")
frameCount++
}
@@ -926,6 +942,7 @@ object BackupRepository {
progressEmitter?.onThread()
ChatArchiveProcessor.export(dbSnapshot, exportState) { frame ->
writer.write(frame)
extraFrameOperation?.invoke(frame)
eventTimer.emit("thread")
frameCount++
}
@@ -936,6 +953,7 @@ object BackupRepository {
progressEmitter?.onCall()
AdHocCallArchiveProcessor.export(dbSnapshot, exportState) { frame ->
writer.write(frame)
extraFrameOperation?.invoke(frame)
eventTimer.emit("call")
frameCount++
}
@@ -947,6 +965,7 @@ object BackupRepository {
progressEmitter?.onSticker()
StickerArchiveProcessor.export(dbSnapshot) { frame ->
writer.write(frame)
extraFrameOperation?.invoke(frame)
eventTimer.emit("sticker-pack")
frameCount++
}
@@ -958,6 +977,7 @@ object BackupRepository {
progressEmitter?.onNotificationProfile()
NotificationProfileProcessor.export(dbSnapshot, exportState) { frame ->
writer.write(frame)
extraFrameOperation?.invoke(frame)
eventTimer.emit("notification-profile")
frameCount++
}
@@ -969,6 +989,7 @@ object BackupRepository {
progressEmitter?.onChatFolder()
ChatFolderProcessor.export(dbSnapshot, exportState) { frame ->
writer.write(frame)
extraFrameOperation?.invoke(frame)
eventTimer.emit("chat-folder")
frameCount++
}
@@ -982,6 +1003,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 +1019,7 @@ object BackupRepository {
}
}
extraExportOperations?.invoke(dbSnapshot)
endingExportOperation?.invoke(dbSnapshot)
Log.d(TAG, "[export] totalFrames: $frameCount | ${eventTimer.stop().summary}")
} finally {
@@ -1750,7 +1772,7 @@ object BackupRepository {
return RestoreTimestampResult.Success(SignalStore.backup.lastBackupTime)
}
timestampResult is NetworkResult.StatusCodeError && timestampResult.code == 404 -> {
timestampResult is NetworkResult.StatusCodeError && (timestampResult.code == 401 || timestampResult.code == 404) -> {
Log.i(TAG, "No backup file exists")
SignalStore.backup.lastBackupTime = 0L
SignalStore.backup.isBackupTimestampRestored = true
@@ -1873,20 +1895,11 @@ object BackupRepository {
RecurringInAppPaymentRepository.getActiveSubscriptionSync(InAppPaymentSubscriberRecord.Type.BACKUP).getOrNull()?.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,15 +1933,14 @@ 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
@@ -1980,8 +1992,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 +2087,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())

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
@@ -29,6 +30,7 @@ import org.signal.core.ui.compose.Buttons
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,
@@ -128,11 +136,22 @@ private fun CreateBackupBottomSheetContent(
@SignalPreview
@Composable
private fun CreateBackupBottomSheetContentPreview() {
private fun CreateBackupBottomSheetContentPaidPreview() {
Previews.BottomSheetPreview {
CreateBackupBottomSheetContent(
onBackupNowClick = {},
onBackupLaterClick = {}
isPaidTier = true,
onBackupNowClick = {}
)
}
}
@SignalPreview
@Composable
private fun CreateBackupBottomSheetContentFreePreview() {
Previews.BottomSheetPreview {
CreateBackupBottomSheetContent(
isPaidTier = false,
onBackupNowClick = {}
)
}
}

View File

@@ -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(

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.Dialogs
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.SignalPreview
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
)
}
@SignalPreview
@Composable
private fun ServiceMissingDialogPreview() {
Previews.Preview {
ServiceMissingDialog({}, {})
}
}
@SignalPreview
@Composable
private fun ServiceUpdatingDialogPreview() {
Previews.Preview {
ServiceUpdatingDialog({})
}
}
@SignalPreview
@Composable
private fun ServiceVersionUpdateRequiredDialogPreview() {
Previews.Preview {
ServiceVersionUpdateRequiredDialog({}, {})
}
}
@SignalPreview
@Composable
private fun ServiceDisabledDialogPreview() {
Previews.Preview {
ServiceDisabledDialog({}, {})
}
}
@SignalPreview
@Composable
private fun ServiceInvalidDialogPreview() {
Previews.Preview {
ServiceInvalidDialog({}, {})
}
}

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) {

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
@@ -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,

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
@@ -52,6 +53,7 @@ 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,20 +230,50 @@ 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)
)
}
@@ -256,8 +292,11 @@ private fun MessageBackupsTypeSelectionScreenPreview() {
onReadMoreClicked = {},
onNextClicked = {},
onLearnMoreAboutWhyUserCanNotUpgrade = {},
onMakeGooglePlayServicesAvailable = {},
onOpenPlayStore = {},
currentBackupTier = null,
isBillingApiAvailable = true,
googlePlayServicesAvailability = GooglePlayServicesAvailability.SUCCESS,
googlePlayBillingAvailability = BillingResponseCode.OK,
isNextEnabled = true
)
}
@@ -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

@@ -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

@@ -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

@@ -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

@@ -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.tooling.preview.Preview
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.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)
}
}
}
@Preview
@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(false) {
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

@@ -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

@@ -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

@@ -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)
}
}
}

View File

@@ -3,7 +3,6 @@ package org.thoughtcrime.securesms.components.settings.app
import android.os.Bundle
import android.view.View
import android.widget.Toast
import androidx.annotation.IdRes
import androidx.annotation.StringRes
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
@@ -39,8 +38,9 @@ import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.fragment.app.viewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.navigation.NavDirections
import androidx.lifecycle.repeatOnLifecycle
import androidx.navigation.fragment.findNavController
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
@@ -67,6 +67,8 @@ import org.thoughtcrime.securesms.banner.ui.compose.DefaultBanner
import org.thoughtcrime.securesms.banner.ui.compose.Importance
import org.thoughtcrime.securesms.components.compose.TextWithBetaLabel
import org.thoughtcrime.securesms.components.emoji.Emojifier
import org.thoughtcrime.securesms.components.settings.app.routes.AppSettingsRoute
import org.thoughtcrime.securesms.components.settings.app.routes.AppSettingsRouter
import org.thoughtcrime.securesms.components.settings.app.subscription.BadgeImageMedium
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.completed.InAppPaymentsBottomSheetDelegate
@@ -83,9 +85,38 @@ import org.thoughtcrime.securesms.util.navigation.safeNavigate
class AppSettingsFragment : ComposeFragment(), Callbacks {
private val viewModel: AppSettingsViewModel by viewModels()
private val appSettingsRouter by viewModels<AppSettingsRouter>()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
viewLifecycleOwner.lifecycle.addObserver(InAppPaymentsBottomSheetDelegate(childFragmentManager, viewLifecycleOwner))
viewLifecycleOwner.lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.RESUMED) {
appSettingsRouter.currentRoute.collect { route ->
when (route) {
is AppSettingsRoute.BackupsRoute.Remote -> findNavController().safeNavigate(R.id.action_appSettingsFragment_to_remoteBackupsSettingsFragment)
is AppSettingsRoute.AccountRoute.Account -> findNavController().safeNavigate(R.id.action_appSettingsFragment_to_accountSettingsFragment)
is AppSettingsRoute.LinkDeviceRoute.LinkDevice -> findNavController().safeNavigate(R.id.action_appSettingsFragment_to_linkDeviceFragment)
is AppSettingsRoute.DonationsRoute.Donations -> findNavController().safeNavigate(R.id.action_appSettingsFragment_to_manageDonationsFragment)
is AppSettingsRoute.AppearanceRoute.Appearance -> findNavController().safeNavigate(R.id.action_appSettingsFragment_to_appearanceSettingsFragment)
is AppSettingsRoute.ChatsRoute.Chats -> findNavController().safeNavigate(R.id.action_appSettingsFragment_to_chatsSettingsFragment)
is AppSettingsRoute.StoriesRoute.Privacy -> findNavController().safeNavigate(AppSettingsFragmentDirections.actionAppSettingsFragmentToStoryPrivacySettings(route.titleId))
is AppSettingsRoute.NotificationsRoute.Notifications -> findNavController().safeNavigate(R.id.action_appSettingsFragment_to_notificationsSettingsFragment)
is AppSettingsRoute.PrivacyRoute.Privacy -> findNavController().safeNavigate(R.id.action_appSettingsFragment_to_privacySettingsFragment)
is AppSettingsRoute.BackupsRoute.Backups -> findNavController().safeNavigate(R.id.action_appSettingsFragment_to_backupsSettingsFragment)
is AppSettingsRoute.DataAndStorageRoute.DataAndStorage -> findNavController().safeNavigate(R.id.action_appSettingsFragment_to_dataAndStorageSettingsFragment)
is AppSettingsRoute.AppUpdates -> findNavController().safeNavigate(R.id.action_appSettingsFragment_to_appUpdatesSettingsFragment)
is AppSettingsRoute.Payments -> findNavController().safeNavigate(R.id.action_appSettingsFragment_to_paymentsActivity)
is AppSettingsRoute.HelpRoute.Settings -> findNavController().safeNavigate(R.id.action_appSettingsFragment_to_helpSettingsFragment)
is AppSettingsRoute.Invite -> findNavController().safeNavigate(R.id.action_appSettingsFragment_to_inviteFragment)
is AppSettingsRoute.InternalRoute.Internal -> findNavController().safeNavigate(R.id.action_appSettingsFragment_to_internalSettingsFragment)
is AppSettingsRoute.AccountRoute.ManageProfile -> findNavController().safeNavigate(R.id.action_appSettingsFragment_to_manageProfileActivity)
is AppSettingsRoute.UsernameLinkRoute.UsernameLink -> findNavController().safeNavigate(R.id.action_appSettingsFragment_to_usernameLinkSettingsFragment)
else -> error("Unsupported route: ${route.javaClass.name}")
}
}
}
}
}
@Composable
@@ -118,12 +149,8 @@ class AppSettingsFragment : ComposeFragment(), Callbacks {
requireActivity().finishAfterTransition()
}
override fun navigate(actionId: Int) {
findNavController().safeNavigate(actionId)
}
override fun navigate(directions: NavDirections) {
findNavController().safeNavigate(directions)
override fun navigate(route: AppSettingsRoute) {
appSettingsRouter.navigateTo(route)
}
override fun onResume() {
@@ -203,7 +230,7 @@ private fun AppSettingsContent(
BackupsWarningRow(
text = stringResource(R.string.AppSettingsFragment__renew_your_signal_backups_subscription),
onClick = {
callbacks.navigate(R.id.action_appSettingsFragment_to_remoteBackupsSettingsFragment)
callbacks.navigate(AppSettingsRoute.BackupsRoute.Remote())
}
)
@@ -219,7 +246,7 @@ private fun AppSettingsContent(
text = stringResource(R.string.AppSettingsFragment__couldnt_complete_backup),
onClick = {
BackupRepository.markBackupFailedIndicatorClicked()
callbacks.navigate(R.id.action_appSettingsFragment_to_remoteBackupsSettingsFragment)
callbacks.navigate(AppSettingsRoute.BackupsRoute.Remote())
}
)
@@ -235,7 +262,7 @@ private fun AppSettingsContent(
text = stringResource(R.string.AppSettingsFragment__couldnt_redeem_your_backups_subscription),
onClick = {
BackupRepository.markBackupAlreadyRedeemedIndicatorClicked()
callbacks.navigate(R.id.action_appSettingsFragment_to_remoteBackupsSettingsFragment)
callbacks.navigate(AppSettingsRoute.BackupsRoute.Remote())
}
)
@@ -252,7 +279,7 @@ private fun AppSettingsContent(
icon = ImageVector.vectorResource(R.drawable.symbol_error_circle_fill_24),
iconTint = MaterialTheme.colorScheme.error,
onClick = {
callbacks.navigate(R.id.action_appSettingsFragment_to_remoteBackupsSettingsFragment)
callbacks.navigate(AppSettingsRoute.BackupsRoute.Remote())
}
)
@@ -268,7 +295,7 @@ private fun AppSettingsContent(
text = stringResource(R.string.AccountSettingsFragment__account),
icon = painterResource(R.drawable.symbol_person_circle_24),
onClick = {
callbacks.navigate(R.id.action_appSettingsFragment_to_accountSettingsFragment)
callbacks.navigate(AppSettingsRoute.AccountRoute.Account)
}
)
}
@@ -278,7 +305,7 @@ private fun AppSettingsContent(
text = stringResource(R.string.preferences__linked_devices),
icon = painterResource(R.drawable.symbol_devices_24),
onClick = {
callbacks.navigate(R.id.action_appSettingsFragment_to_linkDeviceFragment)
callbacks.navigate(AppSettingsRoute.LinkDeviceRoute.LinkDevice)
},
enabled = isRegisteredAndUpToDate
)
@@ -312,7 +339,7 @@ private fun AppSettingsContent(
},
onClick = {
if (state.allowUserToGoToDonationManagementScreen) {
callbacks.navigate(R.id.action_appSettingsFragment_to_manageDonationsFragment)
callbacks.navigate(AppSettingsRoute.DonationsRoute.Donations())
} else {
CommunicationActions.openBrowserLink(context, donateUrl)
}
@@ -332,7 +359,7 @@ private fun AppSettingsContent(
text = stringResource(R.string.preferences__appearance),
icon = painterResource(R.drawable.symbol_appearance_24),
onClick = {
callbacks.navigate(R.id.action_appSettingsFragment_to_appearanceSettingsFragment)
callbacks.navigate(AppSettingsRoute.AppearanceRoute.Appearance)
}
)
}
@@ -342,9 +369,9 @@ private fun AppSettingsContent(
text = stringResource(R.string.preferences_chats__chats),
icon = painterResource(R.drawable.symbol_chat_24),
onClick = {
callbacks.navigate(R.id.action_appSettingsFragment_to_chatsSettingsFragment)
callbacks.navigate(AppSettingsRoute.ChatsRoute.Chats)
},
enabled = state.legacyLocalBackupsEnabled || isRegisteredAndUpToDate
enabled = isRegisteredAndUpToDate
)
}
@@ -353,7 +380,7 @@ private fun AppSettingsContent(
text = stringResource(R.string.preferences__stories),
icon = painterResource(R.drawable.symbol_stories_24),
onClick = {
callbacks.navigate(AppSettingsFragmentDirections.actionAppSettingsFragmentToStoryPrivacySettings(R.string.preferences__stories))
callbacks.navigate(AppSettingsRoute.StoriesRoute.Privacy(titleId = R.string.preferences__stories))
},
enabled = isRegisteredAndUpToDate
)
@@ -364,7 +391,7 @@ private fun AppSettingsContent(
text = stringResource(R.string.preferences__notifications),
icon = painterResource(R.drawable.symbol_bell_24),
onClick = {
callbacks.navigate(R.id.action_appSettingsFragment_to_notificationsSettingsFragment)
callbacks.navigate(AppSettingsRoute.NotificationsRoute.Notifications)
},
enabled = isRegisteredAndUpToDate
)
@@ -375,37 +402,35 @@ private fun AppSettingsContent(
text = stringResource(R.string.preferences__privacy),
icon = painterResource(R.drawable.symbol_lock_24),
onClick = {
callbacks.navigate(R.id.action_appSettingsFragment_to_privacySettingsFragment)
callbacks.navigate(AppSettingsRoute.PrivacyRoute.Privacy)
},
enabled = isRegisteredAndUpToDate
)
}
if (state.showBackups) {
item {
Rows.TextRow(
text = {
TextWithBetaLabel(
text = stringResource(R.string.preferences_chats__backups),
textStyle = MaterialTheme.typography.bodyLarge
)
},
icon = {
Icon(
imageVector = ImageVector.vectorResource(R.drawable.symbol_backup_24),
contentDescription = stringResource(R.string.preferences_chats__backups),
tint = MaterialTheme.colorScheme.onSurface
)
},
onClick = {
callbacks.navigate(R.id.action_appSettingsFragment_to_backupsSettingsFragment)
},
onLongClick = {
callbacks.copyRemoteBackupsSubscriberIdToClipboard()
},
enabled = isRegisteredAndUpToDate
)
}
item {
Rows.TextRow(
text = {
TextWithBetaLabel(
text = stringResource(R.string.preferences_chats__backups),
textStyle = MaterialTheme.typography.bodyLarge
)
},
icon = {
Icon(
imageVector = ImageVector.vectorResource(R.drawable.symbol_backup_24),
contentDescription = stringResource(R.string.preferences_chats__backups),
tint = MaterialTheme.colorScheme.onSurface
)
},
onClick = {
callbacks.navigate(AppSettingsRoute.BackupsRoute.Backups)
},
onLongClick = {
callbacks.copyRemoteBackupsSubscriberIdToClipboard()
},
enabled = isRegisteredAndUpToDate
)
}
item {
@@ -413,7 +438,7 @@ private fun AppSettingsContent(
text = stringResource(R.string.preferences__data_and_storage),
icon = painterResource(R.drawable.symbol_data_24),
onClick = {
callbacks.navigate(R.id.action_appSettingsFragment_to_dataAndStorageSettingsFragment)
callbacks.navigate(AppSettingsRoute.DataAndStorageRoute.DataAndStorage)
}
)
}
@@ -424,7 +449,7 @@ private fun AppSettingsContent(
text = "App updates",
icon = painterResource(R.drawable.symbol_calendar_24),
onClick = {
callbacks.navigate(R.id.action_appSettingsFragment_to_appUpdatesSettingsFragment)
callbacks.navigate(AppSettingsRoute.AppUpdates)
}
)
}
@@ -467,7 +492,7 @@ private fun AppSettingsContent(
)
},
onClick = {
callbacks.navigate(R.id.action_appSettingsFragment_to_paymentsActivity)
callbacks.navigate(AppSettingsRoute.Payments)
}
)
}
@@ -482,7 +507,7 @@ private fun AppSettingsContent(
text = stringResource(R.string.preferences__help),
icon = painterResource(R.drawable.symbol_help_24),
onClick = {
callbacks.navigate(R.id.action_appSettingsFragment_to_helpSettingsFragment)
callbacks.navigate(AppSettingsRoute.HelpRoute.Settings())
}
)
}
@@ -492,7 +517,7 @@ private fun AppSettingsContent(
text = stringResource(R.string.AppSettingsFragment__invite_your_friends),
icon = painterResource(R.drawable.symbol_invite_24),
onClick = {
callbacks.navigate(R.id.action_appSettingsFragment_to_inviteFragment)
callbacks.navigate(AppSettingsRoute.Invite)
}
)
}
@@ -506,7 +531,7 @@ private fun AppSettingsContent(
Rows.TextRow(
text = stringResource(R.string.preferences__internal_preferences),
onClick = {
callbacks.navigate(R.id.action_appSettingsFragment_to_internalSettingsFragment)
callbacks.navigate(AppSettingsRoute.InternalRoute.Internal)
}
)
}
@@ -558,7 +583,7 @@ private fun BioRow(
modifier = Modifier
.clickable(
onClick = {
callbacks.navigate(R.id.action_appSettingsFragment_to_manageProfileActivity)
callbacks.navigate(AppSettingsRoute.AccountRoute.ManageProfile)
}
)
.horizontalGutters()
@@ -632,7 +657,7 @@ private fun BioRow(
if (hasUsername) {
IconButtons.IconButton(
onClick = {
callbacks.navigate(R.id.action_appSettingsFragment_to_usernameLinkSettingsFragment)
callbacks.navigate(AppSettingsRoute.UsernameLinkRoute.UsernameLink)
},
size = 36.dp,
colors = IconButtons.iconButtonColors(
@@ -675,9 +700,7 @@ private fun AppSettingsContentPreview() {
showInternalPreferences = true,
showPayments = true,
showAppUpdates = true,
showBackups = true,
backupFailureState = BackupFailureState.OUT_OF_STORAGE_SPACE,
legacyLocalBackupsEnabled = false
backupFailureState = BackupFailureState.OUT_OF_STORAGE_SPACE
),
bannerManager = BannerManager(
banners = listOf(TestBanner())
@@ -711,8 +734,7 @@ private fun BioRowPreview() {
private interface Callbacks {
fun onNavigationClick(): Unit = error("Not implemented.")
fun navigate(@IdRes actionId: Int): Unit = error("Not implemented")
fun navigate(directions: NavDirections): Unit = error("Not implemented")
fun navigate(route: AppSettingsRoute): Unit = error("Not implemented")
fun copyDonorBadgeSubscriberIdToClipboard(): Unit = error("Not implemented")
fun copyRemoteBackupsSubscriberIdToClipboard(): Unit = error("Not implemented")
}

View File

@@ -15,9 +15,7 @@ data class AppSettingsState(
val showInternalPreferences: Boolean = RemoteConfig.internalUser,
val showPayments: Boolean = SignalStore.payments.paymentsAvailability.showPaymentsMenu(),
val showAppUpdates: Boolean = Environment.IS_NIGHTLY,
val showBackups: Boolean = RemoteConfig.messageBackups,
val backupFailureState: BackupFailureState = BackupFailureState.NONE,
val legacyLocalBackupsEnabled: Boolean
val backupFailureState: BackupFailureState = BackupFailureState.NONE
) {
fun isRegisteredAndUpToDate(): Boolean {
return !userUnregistered && !clientDeprecated

View File

@@ -14,8 +14,6 @@ import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.BackupUtil
import org.thoughtcrime.securesms.util.RemoteConfig
import org.thoughtcrime.securesms.util.TextSecurePreferences
import org.thoughtcrime.securesms.util.livedata.Store
@@ -27,8 +25,7 @@ class AppSettingsViewModel : ViewModel() {
hasExpiredGiftBadge = SignalStore.inAppPayments.getExpiredGiftBadge() != null,
allowUserToGoToDonationManagementScreen = SignalStore.inAppPayments.isLikelyASustainer() || InAppDonations.hasAtLeastOnePaymentMethodAvailable(),
userUnregistered = TextSecurePreferences.isUnauthorizedReceived(AppDependencies.application) || !SignalStore.account.isRegistered,
clientDeprecated = SignalStore.misc.isClientDeprecated,
legacyLocalBackupsEnabled = !RemoteConfig.messageBackups && SignalStore.settings.isBackupEnabled && BackupUtil.canUserAccessBackupDirectory(AppDependencies.application)
clientDeprecated = SignalStore.misc.isClientDeprecated
)
)
@@ -74,9 +71,7 @@ class AppSettingsViewModel : ViewModel() {
}
private fun getBackupFailureState(): BackupFailureState {
return if (!RemoteConfig.messageBackups) {
BackupFailureState.NONE
} else if (BackupRepository.shouldDisplayOutOfRemoteStorageSpaceUx()) {
return if (BackupRepository.shouldDisplayOutOfRemoteStorageSpaceUx()) {
BackupFailureState.OUT_OF_STORAGE_SPACE
} else if (BackupRepository.shouldDisplayBackupFailedSettingsRow()) {
BackupFailureState.BACKUP_FAILED

View File

@@ -31,10 +31,12 @@ import androidx.compose.ui.res.vectorResource
import androidx.core.app.DialogCompat
import androidx.fragment.app.viewModels
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController
import com.google.android.material.button.MaterialButton
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
import kotlinx.coroutines.launch
import org.signal.core.ui.compose.Dialogs
import org.signal.core.ui.compose.Dividers
import org.signal.core.ui.compose.Previews
@@ -112,24 +114,21 @@ class AccountSettingsFragment : ComposeFragment() {
val turnOffButton = DialogCompat.requireViewById(dialog, R.id.reminder_disable_turn_off)
val changeKeyboard = DialogCompat.requireViewById(dialog, R.id.reminder_change_keyboard) as MaterialButton
changeKeyboard.setOnClickListener {
val newType = PinKeyboardType.fromEditText(pinEditText).other
newType.applyTo(
pinEditText = pinEditText,
toggleTypeButton = changeKeyboard
)
pinEditText.typeface = Typeface.DEFAULT
dialog.lifecycleScope.launch {
viewModel.state.collect { state ->
state.pinKeyboardType.applyTo(
pinEditText = pinEditText,
toggleTypeButton = changeKeyboard
)
}
}
changeKeyboard.setOnClickListener { viewModel.togglePinKeyboardType() }
pinEditText.post {
ViewUtil.focusAndShowKeyboard(pinEditText)
}
SignalStore.pin.keyboardType.applyTo(
pinEditText = pinEditText,
toggleTypeButton = changeKeyboard
)
pinEditText.addTextChangedListener(object : SimpleTextWatcher() {
override fun onTextChanged(text: String) {
turnOffButton.isEnabled = text.length >= SvrConstants.MINIMUM_PIN_LENGTH
@@ -459,6 +458,7 @@ private fun AccountSettingsScreenPreview() {
AccountSettingsScreen(
state = AccountSettingsState(
hasPin = true,
pinKeyboardType = PinKeyboardType.NUMERIC,
hasRestoredAep = true,
pinRemindersEnabled = true,
registrationLockEnabled = true,

View File

@@ -1,7 +1,10 @@
package org.thoughtcrime.securesms.components.settings.app.account
import org.thoughtcrime.securesms.lock.v2.PinKeyboardType
data class AccountSettingsState(
val hasPin: Boolean,
val pinKeyboardType: PinKeyboardType,
val hasRestoredAep: Boolean,
val pinRemindersEnabled: Boolean,
val registrationLockEnabled: Boolean,

View File

@@ -6,7 +6,6 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.RemoteConfig
import org.thoughtcrime.securesms.util.TextSecurePreferences
class AccountSettingsViewModel : ViewModel() {
@@ -18,15 +17,22 @@ class AccountSettingsViewModel : ViewModel() {
store.update { getCurrentState() }
}
fun togglePinKeyboardType() {
store.update { previousState ->
previousState.copy(pinKeyboardType = previousState.pinKeyboardType.other)
}
}
private fun getCurrentState(): AccountSettingsState {
return AccountSettingsState(
hasPin = SignalStore.svr.hasPin() && !SignalStore.svr.hasOptedOut(),
pinKeyboardType = SignalStore.pin.keyboardType,
hasRestoredAep = SignalStore.account.restoredAccountEntropyPool,
pinRemindersEnabled = SignalStore.pin.arePinRemindersEnabled() && SignalStore.svr.hasPin(),
registrationLockEnabled = SignalStore.svr.isRegistrationLockEnabled,
userUnregistered = TextSecurePreferences.isUnauthorizedReceived(AppDependencies.application),
clientDeprecated = SignalStore.misc.isClientDeprecated,
canTransferWhileUnregistered = RemoteConfig.restoreAfterRegistration
canTransferWhileUnregistered = true
)
}
}

View File

@@ -15,11 +15,6 @@ import kotlin.time.Duration.Companion.seconds
* Describes the state of the user's selected backup tier.
*/
sealed interface BackupState {
/**
* Backups are not available on this device
*/
data object NotAvailable : BackupState
/**
* User has no active backup tier, no tier history
*/

View File

@@ -33,7 +33,6 @@ import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.InternetConnectionObserver
import org.thoughtcrime.securesms.util.RemoteConfig
import org.whispersystems.signalservice.api.NetworkResult
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription
import java.math.BigDecimal
@@ -80,16 +79,12 @@ class BackupStateObserver(
* setting initial ViewModel state values.
*/
fun getNonIOBackupState(): BackupState {
return if (RemoteConfig.messageBackups) {
val tier = SignalStore.backup.backupTier
val tier = SignalStore.backup.backupTier
if (tier != null) {
BackupState.LocalStore(tier)
} else {
BackupState.None
}
return if (tier != null) {
BackupState.LocalStore(tier)
} else {
BackupState.NotAvailable
BackupState.None
}
}
}
@@ -217,11 +212,6 @@ class BackupStateObserver(
}
private suspend fun performDatabaseBackupStateRefresh() {
if (!RemoteConfig.messageBackups) {
Log.d(TAG, "[performDatabaseBackupStateRefresh] Dropping refresh for disabled feature.")
return
}
if (!SignalStore.account.isRegistered) {
Log.d(TAG, "[performDatabaseBackupStateRefresh] Dropping refresh for unregistered user.")
return
@@ -236,11 +226,6 @@ class BackupStateObserver(
}
private suspend fun performFullBackupStateRefresh() {
if (!RemoteConfig.messageBackups) {
Log.d(TAG, "[performFullBackupStateRefresh] Dropping refresh for disabled feature.")
return
}
if (!SignalStore.account.isRegistered) {
Log.d(TAG, "[performFullBackupStateRefresh] Dropping refresh for unregistered user.")
return
@@ -315,6 +300,11 @@ class BackupStateObserver(
SignalStore.backup.subscriptionStateMismatchDetected = false
}
SignalStore.backup.backupTier == MessageBackupTier.FREE -> {
Log.i(TAG, "[getNetworkBackupState][subscriptionMismatchDetected] User is on the free tier, has no signal subscription, and has a google play subscription. Clearing mismatch.")
SignalStore.backup.subscriptionStateMismatchDetected = false
}
else -> {
Log.w(TAG, "[getNetworkBackupState][subscriptionMismatchDetected] Hit unexpected subscription mismatch state: signal:false, google:true")
return BackupState.NotFound

View File

@@ -11,12 +11,10 @@ import android.view.View
import androidx.activity.result.ActivityResultLauncher
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
@@ -94,7 +92,7 @@ class BackupsSettingsFragment : ComposeFragment() {
onNavigationClick = { requireActivity().onNavigateUp() },
onBackupsRowClick = {
when (state.backupState) {
is BackupState.Error, BackupState.NotAvailable -> Unit
is BackupState.Error -> Unit
BackupState.None -> {
checkoutLauncher.launch(null)
@@ -199,8 +197,6 @@ private fun BackupsSettingsContent(
OtherWaysToBackUpHeading()
}
BackupState.NotAvailable -> Unit
BackupState.NotFound -> {
NotFoundBackupRow(
onBackupsRowClick = onBackupsRowClick
@@ -253,12 +249,12 @@ private fun NeverEnabledBackupsRow(
onBackupsRowClick: () -> Unit = {}
) {
Rows.TextRow(
modifier = Modifier.height(IntrinsicSize.Min),
modifier = Modifier.wrapContentHeight(),
icon = {
Box(
modifier = Modifier
.fillMaxHeight()
.padding(top = 12.dp)
.align(Alignment.Top)
) {
Icon(
painter = painterResource(R.drawable.symbol_backup_24),
@@ -331,7 +327,10 @@ private fun InactiveBackupsRow(
Icon(
imageVector = ImageVector.vectorResource(R.drawable.symbol_backup_24),
contentDescription = stringResource(R.string.preferences_chats__backups),
tint = MaterialTheme.colorScheme.onSurface
tint = MaterialTheme.colorScheme.onSurface,
modifier = Modifier
.padding(top = 12.dp)
.align(Alignment.Top)
)
}
)
@@ -342,13 +341,12 @@ private fun NotFoundBackupRow(
onBackupsRowClick: () -> Unit = {}
) {
Rows.TextRow(
modifier = Modifier.height(IntrinsicSize.Min),
modifier = Modifier.wrapContentHeight(),
icon = {
Box(
contentAlignment = Alignment.TopCenter,
modifier = Modifier
.fillMaxHeight()
.padding(top = 12.dp)
.align(Alignment.Top)
) {
Icon(
painter = painterResource(R.drawable.symbol_backup_24),
@@ -379,13 +377,12 @@ private fun PendingBackupRow(
onBackupsRowClick: () -> Unit = {}
) {
Rows.TextRow(
modifier = Modifier.height(IntrinsicSize.Min),
modifier = Modifier.wrapContentHeight(),
icon = {
Box(
contentAlignment = Alignment.TopCenter,
modifier = Modifier
.fillMaxHeight()
.padding(top = 12.dp)
.align(Alignment.Top)
) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp)
@@ -430,13 +427,12 @@ private fun LocalStoreBackupRow(
onBackupsRowClick: () -> Unit
) {
Rows.TextRow(
modifier = Modifier.height(IntrinsicSize.Min),
modifier = Modifier.wrapContentHeight(),
icon = {
Box(
contentAlignment = Alignment.TopCenter,
modifier = Modifier
.fillMaxHeight()
.padding(top = 12.dp)
.align(Alignment.Top)
) {
Icon(
painter = painterResource(R.drawable.symbol_backup_24),
@@ -476,13 +472,12 @@ private fun ActiveBackupsRow(
onBackupsRowClick: () -> Unit = {}
) {
Rows.TextRow(
modifier = Modifier.height(IntrinsicSize.Min),
modifier = Modifier.wrapContentHeight(),
icon = {
Box(
contentAlignment = Alignment.TopCenter,
modifier = Modifier
.fillMaxHeight()
.padding(top = 12.dp)
.align(Alignment.Top)
) {
Icon(
painter = painterResource(R.drawable.symbol_backup_24),
@@ -501,6 +496,11 @@ private fun ActiveBackupsRow(
is MessageBackupsType.Paid -> {
val body = if (backupState is BackupState.Canceled) {
stringResource(R.string.BackupsSettingsFragment__subscription_canceled)
} else if (type.pricePerMonth.amount == BigDecimal.ZERO) {
stringResource(
R.string.BackupsSettingsFragment_renews_s,
DateUtils.formatDateWithYear(Locale.getDefault(), backupState.renewalTime.inWholeMilliseconds)
)
} else {
stringResource(
R.string.BackupsSettingsFragment_s_month_renews_s,
@@ -545,9 +545,9 @@ private fun ActiveBackupsRow(
private fun LastBackedUpText(lastBackupAt: Duration) {
val context = LocalContext.current
var lastBackupString by remember { mutableStateOf(calculateLastBackupTimeString(context, lastBackupAt)) }
var lastBackupString by remember(lastBackupAt) { mutableStateOf(calculateLastBackupTimeString(context, lastBackupAt)) }
LaunchedEffect(Unit) {
LaunchedEffect(lastBackupAt) {
while (true) {
delay(1.minutes)
lastBackupString = calculateLastBackupTimeString(context, lastBackupAt)
@@ -566,11 +566,20 @@ private fun LastBackedUpText(lastBackupAt: Duration) {
private fun calculateLastBackupTimeString(context: Context, lastBackupAt: Duration): String {
return if (lastBackupAt.inWholeMilliseconds > 0) {
DateUtils.getDatelessRelativeTimeSpanFormattedDate(
val relativeTime = DateUtils.getDatelessRelativeTimeSpanFormattedDate(
context,
Locale.getDefault(),
lastBackupAt.inWholeMilliseconds
).value
)
if (relativeTime.isRelative) {
relativeTime.value
} else {
val day = DateUtils.getDayPrecisionTimeString(context, Locale.getDefault(), lastBackupAt.inWholeMilliseconds)
val time = relativeTime.value
context.getString(R.string.RemoteBackupsSettingsFragment__s_at_s, day, time)
}
} else {
context.getString(R.string.RemoteBackupsSettingsFragment__never)
}
@@ -624,19 +633,6 @@ private fun BackupsSettingsContentPreview() {
}
}
@SignalPreview
@Composable
private fun BackupsSettingsContentNotAvailablePreview() {
Previews.Preview {
BackupsSettingsContent(
backupsSettingsState = BackupsSettingsState(
backupState = BackupState.NotAvailable,
lastBackupAt = 0.seconds
)
)
}
}
@SignalPreview
@Composable
private fun BackupsSettingsContentBackupTierInternalOverridePreview() {
@@ -703,6 +699,25 @@ private fun ActivePaidBackupsRowPreview() {
}
}
@SignalPreview
@Composable
private fun ActivePaidBackupsRowNoPricePreview() {
Previews.Preview {
ActiveBackupsRow(
backupState = BackupState.ActivePaid(
messageBackupsType = MessageBackupsType.Paid(
pricePerMonth = FiatMoney(BigDecimal.ZERO, Currency.getInstance("CAD")),
storageAllowanceBytes = 1_000_000,
mediaTtl = 30.days
),
renewalTime = 0.seconds,
price = FiatMoney(BigDecimal.valueOf(4), Currency.getInstance("CAD"))
),
lastBackupAt = 0.seconds
)
}
}
@SignalPreview
@Composable
private fun ActiveFreeBackupsRowPreview() {

View File

@@ -16,6 +16,7 @@ import androidx.compose.animation.core.tween
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement.spacedBy
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -94,7 +95,6 @@ import org.thoughtcrime.securesms.backup.DeletionState
import org.thoughtcrime.securesms.backup.RestoreState
import org.thoughtcrime.securesms.backup.v2.ArchiveRestoreProgressState
import org.thoughtcrime.securesms.backup.v2.ArchiveRestoreProgressState.RestoreStatus
import org.thoughtcrime.securesms.backup.v2.BackupFrequency
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
import org.thoughtcrime.securesms.backup.v2.ui.BackupAlert
import org.thoughtcrime.securesms.backup.v2.ui.BackupAlertBottomSheet
@@ -113,6 +113,7 @@ import org.thoughtcrime.securesms.help.HelpFragment
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.keyvalue.protos.ArchiveUploadProgressState
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.DateUtils
import org.thoughtcrime.securesms.util.navigation.safeNavigate
import org.thoughtcrime.securesms.util.viewModel
@@ -285,6 +286,14 @@ class RemoteBackupsSettingsFragment : ComposeFragment() {
override fun onIncludeDebuglogClick(newState: Boolean) {
viewModel.setIncludeDebuglog(newState)
}
override fun onMediaBackupSizeClick() {
viewModel.requestDialog(RemoteBackupsSettingsState.Dialog.FREE_TIER_MEDIA_EXPLAINER)
}
override fun onFreeTierBackupSizeLearnMore() {
CommunicationActions.openBrowserLink(requireContext(), "https://support.signal.org/hc/articles/9708267671322")
}
}
private fun displayBackupKey() {
@@ -387,6 +396,8 @@ private interface ContentCallbacks {
fun onDisplayDownloadingBackupDialog() = Unit
fun onManageStorageClick() = Unit
fun onIncludeDebuglogClick(newState: Boolean) = Unit
fun onMediaBackupSizeClick() = Unit
fun onFreeTierBackupSizeLearnMore() = Unit
object Empty : ContentCallbacks
}
@@ -508,8 +519,6 @@ private fun RemoteBackupsSettingsContent(
isRenewEnabled = backupDeleteState.isIdle()
)
}
BackupState.NotAvailable -> error("This shouldn't happen on this screen.")
}
}
@@ -634,6 +643,18 @@ private fun RemoteBackupsSettingsContent(
onResumeOverCellularClick = contentCallbacks::onRestoreUsingCellularClick
)
}
RemoteBackupsSettingsState.Dialog.FREE_TIER_MEDIA_EXPLAINER -> {
Dialogs.SimpleAlertDialog(
title = stringResource(R.string.RemoteBackupsSettingsFragment__free_tier_storage_title),
body = pluralStringResource(R.plurals.RemoteBackupsSettingsFragment__backup_frequency_dialog_body, state.freeTierMediaRetentionDays, state.freeTierMediaRetentionDays),
confirm = stringResource(android.R.string.ok),
dismiss = stringResource(R.string.RemoteBackupsSettingsFragment__learn_more),
onConfirm = {},
onDismiss = contentCallbacks::onDialogDismissed,
onDeny = contentCallbacks::onFreeTierBackupSizeLearnMore
)
}
}
val snackbarMessageId = remember(state.snackbar) {
@@ -894,6 +915,7 @@ private fun LazyListScope.appendBackupDetailsItems(
LastBackupRow(
lastBackupTimestamp = state.lastBackupTimestamp,
enabled = !state.isOutOfStorageSpace,
onRowClick = contentCallbacks::onBackupFrequencyClick,
onBackupNowClick = contentCallbacks::onBackupNowClick
)
}
@@ -901,6 +923,7 @@ private fun LazyListScope.appendBackupDetailsItems(
item {
InProgressBackupRow(
archiveUploadProgressState = backupProgress,
isPaidTier = state.tier == MessageBackupTier.PAID,
canBackupMessagesRun = state.canBackupMessagesJobRun,
canBackupUsingCellular = state.canBackUpUsingCellular,
cancelArchiveUpload = contentCallbacks::onCancelUploadClick
@@ -908,15 +931,15 @@ private fun LazyListScope.appendBackupDetailsItems(
}
}
if (state.backupState.isLikelyPaidTier()) {
item {
val sizeText = if (state.backupMediaSize < 0L) {
stringResource(R.string.RemoteBackupsSettingsFragment__calculating)
} else {
state.backupMediaSize.bytes.toUnitString()
}
item {
val sizeText = if (state.backupMediaSize < 0L) {
stringResource(R.string.RemoteBackupsSettingsFragment__calculating)
} else {
state.backupMediaSize.bytes.toUnitString()
}
Rows.TextRow(text = {
Rows.TextRow(
text = {
Column {
Text(
text = stringResource(id = R.string.RemoteBackupsSettingsFragment__backup_size),
@@ -929,27 +952,12 @@ private fun LazyListScope.appendBackupDetailsItems(
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
})
}
}
item {
Rows.TextRow(
text = {
Column {
Text(
text = stringResource(id = R.string.RemoteBackupsSettingsFragment__backup_frequency),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface
)
Text(
text = stringResource(id = R.string.RemoteBackupsSettingsFragment__daily),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
},
onClick = contentCallbacks::onBackupFrequencyClick
onClick = if (state.backupMediaSize >= 0L && state.tier == MessageBackupTier.FREE) {
{ contentCallbacks.onMediaBackupSizeClick() }
} else {
null
}
)
}
@@ -1372,6 +1380,7 @@ private fun SubscriptionMismatchMissingGooglePlayCard(
@Composable
private fun InProgressBackupRow(
archiveUploadProgressState: ArchiveUploadProgressState,
isPaidTier: Boolean,
canBackupMessagesRun: Boolean = true,
canBackupUsingCellular: Boolean = true,
cancelArchiveUpload: () -> Unit = {}
@@ -1409,7 +1418,7 @@ private fun InProgressBackupRow(
}
Text(
text = getProgressStateMessage(archiveUploadProgressState, canBackupMessagesRun, canBackupUsingCellular),
text = getProgressStateMessage(archiveUploadProgressState, isPaidTier, canBackupMessagesRun, canBackupUsingCellular),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -1447,11 +1456,11 @@ private fun ArchiveProgressIndicator(
}
@Composable
private fun getProgressStateMessage(archiveUploadProgressState: ArchiveUploadProgressState, canBackupMessagesRun: Boolean, canBackupUsingCellular: Boolean): String {
private fun getProgressStateMessage(archiveUploadProgressState: ArchiveUploadProgressState, isPaidTier: Boolean, canBackupMessagesRun: Boolean, canBackupUsingCellular: Boolean): String {
return when (archiveUploadProgressState.state) {
ArchiveUploadProgressState.State.None, ArchiveUploadProgressState.State.UserCanceled -> stringResource(R.string.RemoteBackupsSettingsFragment__processing_backup)
ArchiveUploadProgressState.State.Export -> getBackupExportPhaseProgressString(archiveUploadProgressState, canBackupMessagesRun, canBackupUsingCellular)
ArchiveUploadProgressState.State.UploadBackupFile, ArchiveUploadProgressState.State.UploadMedia -> getBackupUploadPhaseProgressString(archiveUploadProgressState)
ArchiveUploadProgressState.State.UploadBackupFile, ArchiveUploadProgressState.State.UploadMedia -> getBackupUploadPhaseProgressString(archiveUploadProgressState, isPaidTier)
}
}
@@ -1483,12 +1492,16 @@ private fun getBackupExportPhaseProgressString(state: ArchiveUploadProgressState
}
@Composable
private fun getBackupUploadPhaseProgressString(state: ArchiveUploadProgressState): String {
private fun getBackupUploadPhaseProgressString(state: ArchiveUploadProgressState, isPaidTier: Boolean): String {
val formattedTotalBytes = state.uploadBytesTotal.bytes.toUnitString()
val formattedUploadedBytes = state.uploadBytesUploaded.bytes.toUnitString()
val percent = (state.uploadProgress() * 100).toInt()
return stringResource(R.string.RemoteBackupsSettingsFragment__uploading_s_of_s_d, formattedUploadedBytes, formattedTotalBytes, percent)
return if (isPaidTier) {
stringResource(R.string.RemoteBackupsSettingsFragment__uploading_s_of_s_d, formattedUploadedBytes, formattedTotalBytes, percent)
} else {
stringResource(R.string.RemoteBackupsSettingsFragment__uploading_d, percent)
}
}
@Composable
@@ -1508,10 +1521,12 @@ private fun IncludeDebuglogRow(
private fun LastBackupRow(
lastBackupTimestamp: Long,
enabled: Boolean,
onRowClick: () -> Unit,
onBackupNowClick: () -> Unit
) {
Row(
modifier = Modifier
.clickable(onClick = onRowClick)
.padding(horizontal = dimensionResource(id = CoreUiR.dimen.gutter))
.padding(top = 16.dp, bottom = 14.dp)
) {
@@ -1705,16 +1720,6 @@ private fun BackupReadyToDownloadRow(
}
}
@Composable
private fun getTextForFrequency(backupsFrequency: BackupFrequency): String {
return when (backupsFrequency) {
BackupFrequency.DAILY -> stringResource(id = R.string.RemoteBackupsSettingsFragment__daily)
BackupFrequency.WEEKLY -> stringResource(id = R.string.RemoteBackupsSettingsFragment__weekly)
BackupFrequency.MONTHLY -> stringResource(id = R.string.RemoteBackupsSettingsFragment__monthly)
BackupFrequency.MANUAL -> stringResource(id = R.string.RemoteBackupsSettingsFragment__manually_back_up)
}
}
@SignalPreview
@Composable
private fun RemoteBackupsSettingsContentPreview() {
@@ -1965,6 +1970,7 @@ private fun LastBackupRowPreview() {
LastBackupRow(
lastBackupTimestamp = -1,
enabled = true,
onRowClick = {},
onBackupNowClick = {}
)
}
@@ -1975,18 +1981,20 @@ private fun LastBackupRowPreview() {
private fun InProgressRowPreview() {
Previews.Preview {
Column {
InProgressBackupRow(archiveUploadProgressState = ArchiveUploadProgressState())
InProgressBackupRow(archiveUploadProgressState = ArchiveUploadProgressState(), isPaidTier = true)
InProgressBackupRow(
archiveUploadProgressState = ArchiveUploadProgressState(
state = ArchiveUploadProgressState.State.Export,
backupPhase = ArchiveUploadProgressState.BackupPhase.BackupPhaseNone
)
),
isPaidTier = true
)
InProgressBackupRow(
archiveUploadProgressState = ArchiveUploadProgressState(
state = ArchiveUploadProgressState.State.Export,
backupPhase = ArchiveUploadProgressState.BackupPhase.Account
)
),
isPaidTier = true
)
InProgressBackupRow(
archiveUploadProgressState = ArchiveUploadProgressState(
@@ -1994,7 +2002,8 @@ private fun InProgressRowPreview() {
backupPhase = ArchiveUploadProgressState.BackupPhase.Message,
frameExportCount = 1,
frameTotalCount = 1
)
),
isPaidTier = true
)
InProgressBackupRow(
archiveUploadProgressState = ArchiveUploadProgressState(
@@ -2002,7 +2011,8 @@ private fun InProgressRowPreview() {
backupPhase = ArchiveUploadProgressState.BackupPhase.Message,
frameExportCount = 1000,
frameTotalCount = 100_000
)
),
isPaidTier = true
)
InProgressBackupRow(
archiveUploadProgressState = ArchiveUploadProgressState(
@@ -2010,7 +2020,8 @@ private fun InProgressRowPreview() {
backupPhase = ArchiveUploadProgressState.BackupPhase.Message,
frameExportCount = 1_000_000,
frameTotalCount = 100_000
)
),
isPaidTier = true
)
InProgressBackupRow(
archiveUploadProgressState = ArchiveUploadProgressState(
@@ -2020,7 +2031,19 @@ private fun InProgressRowPreview() {
backupFileTotalBytes = 50.mebiBytes.inWholeBytes,
mediaUploadedBytes = 0,
mediaTotalBytes = 0
)
),
isPaidTier = true
)
InProgressBackupRow(
archiveUploadProgressState = ArchiveUploadProgressState(
state = ArchiveUploadProgressState.State.UploadBackupFile,
backupPhase = ArchiveUploadProgressState.BackupPhase.BackupPhaseNone,
backupFileUploadedBytes = 10.mebiBytes.inWholeBytes,
backupFileTotalBytes = 50.mebiBytes.inWholeBytes,
mediaUploadedBytes = 0,
mediaTotalBytes = 0
),
isPaidTier = false
)
InProgressBackupRow(
archiveUploadProgressState = ArchiveUploadProgressState(
@@ -2030,7 +2053,8 @@ private fun InProgressRowPreview() {
backupFileTotalBytes = 50.mebiBytes.inWholeBytes,
mediaUploadedBytes = 100.mebiBytes.inWholeBytes,
mediaTotalBytes = 1.gibiBytes.inWholeBytes
)
),
isPaidTier = true
)
}
}

View File

@@ -31,7 +31,8 @@ data class RemoteBackupsSettingsState(
val canBackupMessagesJobRun: Boolean = false,
val backupMediaDetails: BackupMediaDetails? = null,
val showBackupCreateFailedError: Boolean = false,
val showBackupCreateCouldNotCompleteError: Boolean = false
val showBackupCreateCouldNotCompleteError: Boolean = false,
val freeTierMediaRetentionDays: Int = -1
) {
data class BackupMediaDetails(
@@ -50,7 +51,8 @@ data class RemoteBackupsSettingsState(
SUBSCRIPTION_NOT_FOUND,
SKIP_MEDIA_RESTORE_PROTECTION,
CANCEL_MEDIA_RESTORE_PROTECTION,
RESTORE_OVER_CELLULAR_PROTECTION
RESTORE_OVER_CELLULAR_PROTECTION,
FREE_TIER_MEDIA_EXPLAINER
}
enum class Snackbar {

View File

@@ -33,6 +33,9 @@ import org.thoughtcrime.securesms.backup.DeletionState
import org.thoughtcrime.securesms.backup.v2.ArchiveRestoreProgress
import org.thoughtcrime.securesms.backup.v2.ArchiveRestoreProgressState.RestoreStatus
import org.thoughtcrime.securesms.backup.v2.BackupRepository
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsType
import org.thoughtcrime.securesms.components.settings.app.backups.BackupState
import org.thoughtcrime.securesms.components.settings.app.backups.BackupStateObserver
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository
import org.thoughtcrime.securesms.database.InAppPaymentTable
@@ -47,6 +50,8 @@ import org.thoughtcrime.securesms.util.Environment
import org.thoughtcrime.securesms.util.RemoteConfig
import org.thoughtcrime.securesms.util.TextSecurePreferences
import org.whispersystems.signalservice.api.NetworkResult
import kotlin.time.Duration.Companion.days
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
/**
@@ -83,7 +88,7 @@ class RemoteBackupsSettingsViewModel : ViewModel() {
init {
viewModelScope.launch(Dispatchers.IO) {
val isBillingApiAvailable = AppDependencies.billingApi.isApiAvailable()
val isBillingApiAvailable = AppDependencies.billingApi.getApiAvailability().isSuccess
if (isBillingApiAvailable) {
_state.update {
it.copy(isPaidTierPricingAvailable = true)
@@ -163,11 +168,12 @@ class RemoteBackupsSettingsViewModel : ViewModel() {
}
}
viewModelScope.launch {
viewModelScope.launch(Dispatchers.IO) {
BackupStateObserver(viewModelScope).backupState.collect { state ->
_state.update {
it.copy(backupState = state)
}
refreshState(null)
}
}
@@ -258,8 +264,10 @@ class RemoteBackupsSettingsViewModel : ViewModel() {
private fun refreshBackupMediaSizeState() {
_state.update {
val (mediaSize, mediaRetentionDays) = getBackupMediaSize(it.tier, (it.backupState as? BackupState.WithTypeAndRenewalTime)?.messageBackupsType)
it.copy(
backupMediaSize = getBackupMediaSize(),
backupMediaSize = mediaSize,
freeTierMediaRetentionDays = mediaRetentionDays,
backupMediaDetails = if (RemoteConfig.internalUser || Environment.IS_STAGING) {
RemoteBackupsSettingsState.BackupMediaDetails(
awaitingRestore = SignalDatabase.attachments.getRemainingRestorableAttachmentSize().bytes,
@@ -287,7 +295,7 @@ class RemoteBackupsSettingsViewModel : ViewModel() {
if (paidType is NetworkResult.Success) {
val remoteStorageAllowance = paidType.result.storageAllowanceBytes.bytes
val estimatedSize = SignalDatabase.attachments.getEstimatedArchiveMediaSize().bytes
val estimatedSize = getBackupMediaSize(paidType.result.tier, paidType.result).first.bytes
if (estimatedSize + 300.mebiBytes <= remoteStorageAllowance) {
BackupRepository.clearOutOfRemoteStorageSpaceError()
@@ -303,13 +311,16 @@ class RemoteBackupsSettingsViewModel : ViewModel() {
}
}
val (mediaSize, mediaRetentionDays) = getBackupMediaSize(_state.value.tier, (_state.value.backupState as? BackupState.WithTypeAndRenewalTime)?.messageBackupsType)
_state.update {
it.copy(
tier = SignalStore.backup.backupTier,
backupsEnabled = SignalStore.backup.areBackupsEnabled,
lastBackupTimestamp = SignalStore.backup.lastBackupTime,
canBackupMessagesJobRun = BackupMessagesConstraint.isMet(AppDependencies.application),
backupMediaSize = getBackupMediaSize(),
backupMediaSize = mediaSize,
freeTierMediaRetentionDays = mediaRetentionDays,
canBackUpUsingCellular = SignalStore.backup.backupWithCellular,
canRestoreUsingCellular = SignalStore.backup.restoreWithCellular,
isOutOfStorageSpace = BackupRepository.shouldDisplayOutOfRemoteStorageSpaceUx(),
@@ -320,11 +331,39 @@ class RemoteBackupsSettingsViewModel : ViewModel() {
}
}
private fun getBackupMediaSize(): Long {
return if (SignalStore.backup.hasBackupBeenUploaded || SignalStore.backup.lastBackupTime > 0L) {
SignalDatabase.attachments.getEstimatedArchiveMediaSize()
private fun getBackupMediaSize(tier: MessageBackupTier?, messageBackupsType: MessageBackupsType?): Pair<Long, Int> {
if (tier == null) {
return -1L to 0
}
val mediaRetentionDays = if (messageBackupsType is MessageBackupsType.Free) {
messageBackupsType.mediaRetentionDays
} else {
0L
when (tier) {
MessageBackupTier.FREE -> {
when (val result = BackupRepository.getFreeType()) {
is NetworkResult.Success -> result.result.mediaRetentionDays
else -> RemoteConfig.messageQueueTime.milliseconds.inWholeDays.toInt()
}
}
MessageBackupTier.PAID -> 0
}
}
return if (SignalStore.backup.hasBackupBeenUploaded || SignalStore.backup.lastBackupTime > 0L) {
when (tier) {
MessageBackupTier.PAID -> SignalDatabase.attachments.getPaidEstimatedArchiveMediaSize() to -1
MessageBackupTier.FREE -> {
if (mediaRetentionDays > 0) {
SignalDatabase.attachments.getFreeEstimatedArchiveMediaSize(System.currentTimeMillis() - mediaRetentionDays.days.inWholeMilliseconds) to mediaRetentionDays
} else {
-1L to -1
}
}
}
} else {
0L to mediaRetentionDays
}
}
}

View File

@@ -5,10 +5,7 @@
package org.thoughtcrime.securesms.components.settings.app.changenumber
import android.os.Bundle
import android.view.View
import androidx.fragment.app.activityViewModels
import org.thoughtcrime.securesms.registration.data.network.Challenge
import org.thoughtcrime.securesms.registration.ui.captcha.CaptchaFragment
/**
@@ -16,16 +13,8 @@ import org.thoughtcrime.securesms.registration.ui.captcha.CaptchaFragment
*/
class ChangeNumberCaptchaFragment : CaptchaFragment() {
private val viewModel by activityViewModels<ChangeNumberViewModel>()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
viewModel.addPresentedChallenge(Challenge.CAPTCHA)
}
override fun handleCaptchaToken(token: String) {
viewModel.setCaptchaResponse(token)
}
override fun handleUserExit() {
viewModel.removePresentedChallenge(Challenge.CAPTCHA)
}
}

View File

@@ -23,12 +23,9 @@ import org.thoughtcrime.securesms.components.ViewBinderDelegate
import org.thoughtcrime.securesms.components.settings.app.changenumber.ChangeNumberUtil.changeNumberSuccess
import org.thoughtcrime.securesms.databinding.FragmentRegistrationLockBinding
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.lock.v2.PinKeyboardType
import org.thoughtcrime.securesms.lock.v2.SvrConstants
import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequestResult
import org.thoughtcrime.securesms.registration.fragments.RegistrationViewDelegate
import org.thoughtcrime.securesms.registration.ui.registrationlock.RegistrationLockFragment
import org.thoughtcrime.securesms.registration.ui.registrationlock.RegistrationLockFragmentArgs
import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.SupportEmailUtil
import org.thoughtcrime.securesms.util.ViewUtil
@@ -42,18 +39,17 @@ import java.util.concurrent.TimeUnit
class ChangeNumberRegistrationLockFragment : LoggingFragment(R.layout.fragment_change_number_registration_lock) {
companion object {
private val TAG = Log.tag(RegistrationLockFragment::class.java)
private val TAG = Log.tag(ChangeNumberRegistrationLockFragment::class.java)
}
private val binding: FragmentRegistrationLockBinding by ViewBinderDelegate(FragmentRegistrationLockBinding::bind)
private val binding: FragmentRegistrationLockBinding by ViewBinderDelegate(bindingFactory = { rootView ->
FragmentRegistrationLockBinding.bind(rootView.findViewById(R.id.registration_lock_content))
})
private val viewModel by activityViewModels<ChangeNumberViewModel>()
private var timeRemaining: Long = 0
private val pinEntryKeyboardType: PinKeyboardType
get() = PinKeyboardType.fromEditText(editText = binding.kbsLockPinInput)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
RegistrationViewDelegate.setDebugLogSubmitMultiTapView(view.findViewById(R.id.kbs_lock_pin_title))
@@ -69,7 +65,7 @@ class ChangeNumberRegistrationLockFragment : LoggingFragment(R.layout.fragment_c
}
)
val args: RegistrationLockFragmentArgs = RegistrationLockFragmentArgs.fromBundle(requireArguments())
val args: ChangeNumberRegistrationLockFragmentArgs = ChangeNumberRegistrationLockFragmentArgs.fromBundle(requireArguments())
timeRemaining = args.getTimeRemaining()
@@ -93,10 +89,7 @@ class ChangeNumberRegistrationLockFragment : LoggingFragment(R.layout.fragment_c
handlePinEntry()
}
binding.kbsLockKeyboardToggle.setOnClickListener {
updateKeyboard(pinEntryKeyboardType.other)
}
updateKeyboard(pinEntryKeyboardType)
binding.kbsLockKeyboardToggle.setOnClickListener { viewModel.togglePinKeyboardType() }
viewModel.liveLockedTimeRemaining.observe(viewLifecycleOwner) { t: Long -> timeRemaining = t }
@@ -123,6 +116,11 @@ class ChangeNumberRegistrationLockFragment : LoggingFragment(R.layout.fragment_c
if (state.changeNumberOutcome == ChangeNumberOutcome.VerificationCodeWorked) {
handleSuccessfulPinEntry(state.enteredPin)
}
state.pinKeyboardType.applyTo(
pinEditText = binding.kbsLockPinInput,
toggleTypeButton = binding.kbsLockKeyboardToggle
)
}
private fun handlePinEntry() {
@@ -205,7 +203,7 @@ class ChangeNumberRegistrationLockFragment : LoggingFragment(R.layout.fragment_c
private fun onIncorrectKbsRegistrationLockPin(svrTriesRemaining: Int) {
binding.kbsLockPinConfirm.cancelSpinning()
binding.kbsLockPinInput.getText().clear()
binding.kbsLockPinInput.getText()?.clear()
enableAndFocusPinEntry()
if (svrTriesRemaining == 0) {
@@ -275,13 +273,6 @@ class ChangeNumberRegistrationLockFragment : LoggingFragment(R.layout.fragment_c
ViewUtil.focusAndShowKeyboard(binding.kbsLockPinInput)
}
private fun updateKeyboard(newType: PinKeyboardType) {
newType.applyTo(
pinEditText = binding.kbsLockPinInput,
toggleTypeButton = binding.kbsLockKeyboardToggle
)
}
private fun navigateToAccountLocked() {
findNavController().safeNavigate(ChangeNumberRegistrationLockFragmentDirections.actionChangeNumberRegistrationLockToChangeNumberAccountLocked())
}

View File

@@ -5,6 +5,8 @@
package org.thoughtcrime.securesms.components.settings.app.changenumber
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.lock.v2.PinKeyboardType
import org.thoughtcrime.securesms.registration.data.network.Challenge
import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequestResult
import org.thoughtcrime.securesms.registration.ui.countrycode.Country
@@ -19,6 +21,7 @@ data class ChangeNumberState(
val number: NumberViewState = NumberViewState.INITIAL,
val enteredCode: String? = null,
val enteredPin: String = "",
val pinKeyboardType: PinKeyboardType = SignalStore.pin.keyboardType,
val oldPhoneNumber: NumberViewState = NumberViewState.INITIAL,
val sessionId: String? = null,
val changeNumberOutcome: ChangeNumberOutcome? = null,
@@ -35,10 +38,9 @@ data class ChangeNumberState(
val challengesPresented: Set<Challenge> = emptySet(),
val allowedToRequestCode: Boolean = false,
val oldCountry: Country? = null,
val newCountry: Country? = null
) {
val challengesRemaining: List<Challenge> = challengesRequested.filterNot { it in challengesPresented }
}
val newCountry: Country? = null,
val challengeInProgress: Boolean = false
)
sealed interface ChangeNumberOutcome {
data object RecoveryPasswordWorked : ChangeNumberOutcome

View File

@@ -54,8 +54,10 @@ class ChangeNumberVerifyFragment : LoggingFragment(R.layout.fragment_change_phon
private fun onStateUpdate(state: ChangeNumberState) {
if (state.challengesRequested.contains(Challenge.CAPTCHA) && state.captchaToken.isNotNullOrBlank()) {
viewModel.submitCaptchaToken(requireContext())
} else if (state.challengesRemaining.isNotEmpty()) {
handleChallenges(state.challengesRemaining)
} else if (state.challengesRequested.isNotEmpty()) {
if (!state.challengeInProgress) {
handleChallenges(state.challengesRequested)
}
} else if (state.changeNumberOutcome != null) {
handleRequestCodeResult(state.changeNumberOutcome)
} else if (!state.inProgress) {

View File

@@ -150,26 +150,18 @@ class ChangeNumberViewModel : ViewModel() {
}
}
fun togglePinKeyboardType() {
store.update { previousState ->
previousState.copy(pinKeyboardType = previousState.pinKeyboardType.other)
}
}
fun incrementIncorrectCodeAttempts() {
store.update {
it.copy(incorrectCodeAttempts = it.incorrectCodeAttempts + 1)
}
}
fun addPresentedChallenge(challenge: Challenge) {
Log.v(TAG, "addPresentedChallenge()")
store.update {
it.copy(challengesPresented = it.challengesPresented.plus(challenge))
}
}
fun removePresentedChallenge(challenge: Challenge) {
Log.v(TAG, "addPresentedChallenge()")
store.update {
it.copy(challengesPresented = it.challengesPresented.minus(challenge))
}
}
fun resetLocalSessionState() {
Log.v(TAG, "resetLocalSessionState()")
store.update {
@@ -292,7 +284,8 @@ class ChangeNumberViewModel : ViewModel() {
it.copy(
captchaToken = null,
inProgress = true,
changeNumberOutcome = null
changeNumberOutcome = null,
challengeInProgress = true
)
}
@@ -304,7 +297,8 @@ class ChangeNumberViewModel : ViewModel() {
store.update {
it.copy(
inProgress = false,
changeNumberOutcome = null
changeNumberOutcome = null,
challengeInProgress = false
)
}
return@launch
@@ -313,7 +307,7 @@ class ChangeNumberViewModel : ViewModel() {
val captchaSubmissionResult = RegistrationRepository.submitCaptchaToken(context, e164, password, sessionData.sessionId, captchaToken)
Log.d(TAG, "Captcha token submitted.")
store.update {
it.copy(inProgress = false, changeNumberOutcome = ChangeNumberOutcome.ChangeNumberRequestOutcome(captchaSubmissionResult))
it.copy(inProgress = false, changeNumberOutcome = ChangeNumberOutcome.ChangeNumberRequestOutcome(captchaSubmissionResult), challengeInProgress = false)
}
}
}
@@ -321,8 +315,6 @@ class ChangeNumberViewModel : ViewModel() {
fun requestAndSubmitPushToken(context: Context) {
Log.v(TAG, "validatePushToken()")
addPresentedChallenge(Challenge.PUSH)
val e164 = number.e164Number
viewModelScope.launch {

View File

@@ -22,7 +22,6 @@ import org.signal.core.ui.compose.Texts
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.compose.ComposeFragment
import org.thoughtcrime.securesms.compose.rememberStatusBarColorNestedScrollModifier
import org.thoughtcrime.securesms.util.RemoteConfig
import org.thoughtcrime.securesms.util.navigation.safeNavigate
/**
@@ -45,8 +44,7 @@ class ChatsSettingsFragment : ComposeFragment() {
ChatsSettingsScreen(
state = state,
callbacks = callbacks,
isRemoteBackupsAvailable = RemoteConfig.messageBackups
callbacks = callbacks
)
}
@@ -105,7 +103,6 @@ private interface ChatsSettingsCallbacks {
@Composable
private fun ChatsSettingsScreen(
isRemoteBackupsAvailable: Boolean,
state: ChatsSettingsState,
callbacks: ChatsSettingsCallbacks
) {
@@ -201,25 +198,6 @@ private fun ChatsSettingsScreen(
onCheckChanged = callbacks::onEnterKeySendsChanged
)
}
if (!isRemoteBackupsAvailable) {
item {
Dividers.Default()
}
item {
Texts.SectionHeader(stringResource(R.string.preferences_chats__backups))
}
item {
Rows.TextRow(
text = stringResource(R.string.preferences_chats__chat_backups),
label = stringResource(if (state.localBackupsEnabled) R.string.arrays__enabled else R.string.arrays__disabled),
enabled = state.localBackupsEnabled || state.isRegisteredAndUpToDate(),
onClick = callbacks::onChatBackupsClick
)
}
}
}
}
}
@@ -240,8 +218,7 @@ private fun ChatsSettingsScreenPreview() {
userUnregistered = false,
clientDeprecated = false
),
callbacks = ChatsSettingsCallbacks.Empty,
isRemoteBackupsAvailable = false
callbacks = ChatsSettingsCallbacks.Empty
)
}
}

View File

@@ -218,6 +218,14 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
}
)
clickPref(
title = DSLSettingsText.from("Data Seeding Playground"),
summary = DSLSettingsText.from("Seed conversations with media files from a folder."),
onClick = {
findNavController().safeNavigate(InternalSettingsFragmentDirections.actionInternalSettingsFragmentToDataSeedingPlaygroundFragment())
}
)
dividerPref()
sectionHeaderPref(DSLSettingsText.from("Miscellaneous"))

View File

@@ -43,11 +43,6 @@ fun InternalBackupStatsTab(stats: InternalBackupPlaygroundViewModel.StatsState,
label = "${stats.attachmentStats.totalUniqueMediaNames}"
)
Rows.TextRow(
text = "Total eligible for upload rows",
label = "${stats.attachmentStats.totalEligibleForUploadRows}"
)
Rows.TextRow(
text = "Total unique media names eligible for upload ⭐",
label = "${stats.attachmentStats.totalUniqueMediaNamesEligibleForUpload}"
@@ -73,6 +68,16 @@ fun InternalBackupStatsTab(stats: InternalBackupPlaygroundViewModel.StatsState,
label = "${stats.attachmentStats.pendingAttachmentUploadBytes} (~${stats.attachmentStats.pendingAttachmentUploadBytes.bytes.toUnitString()})"
)
Rows.TextRow(
text = "Last snapshot full-size count ⭐",
label = "${stats.attachmentStats.lastSnapshotFullSizeCount}"
)
Rows.TextRow(
text = "Last snapshot thumbnail count ⭐",
label = "${stats.attachmentStats.lastSnapshotThumbnailCount}"
)
Rows.TextRow(
text = "Uploaded attachment bytes ⭐",
label = "${stats.attachmentStats.uploadedAttachmentBytes} (~${stats.attachmentStats.uploadedAttachmentBytes.bytes.toUnitString()})"

View File

@@ -0,0 +1,352 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.internal.dataseeding
import android.app.Activity.RESULT_OK
import android.content.Intent
import android.os.Bundle
import android.widget.Toast
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.Checkbox
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.fragment.app.viewModels
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.fragment.findNavController
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.Rows
import org.signal.core.ui.compose.SignalPreview
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.compose.ComposeFragment
import org.thoughtcrime.securesms.database.model.ThreadRecord
class DataSeedingPlaygroundFragment : ComposeFragment() {
private val viewModel: DataSeedingPlaygroundViewModel by viewModels()
private lateinit var selectFolderLauncher: ActivityResultLauncher<Intent>
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
selectFolderLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == RESULT_OK) {
result.data?.data?.let { uri ->
viewModel.selectFolder(uri)
} ?: Toast.makeText(requireContext(), "No folder selected", Toast.LENGTH_SHORT).show()
}
}
}
@Composable
override fun FragmentContent() {
val context = LocalContext.current
val state by viewModel.state.collectAsStateWithLifecycle()
LaunchedEffect(Unit) {
viewModel.loadThreads()
}
Screen(
state = state,
onBack = { findNavController().popBackStack() },
onSelectFolderClicked = {
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
selectFolderLauncher.launch(intent)
},
onThreadSelectionChanged = { threadId, isSelected ->
viewModel.toggleThreadSelection(threadId, isSelected)
},
onSeedDataClicked = {
viewModel.seedData(
onComplete = {
Toast.makeText(context, "Data seeding completed!", Toast.LENGTH_SHORT).show()
},
onError = { error ->
Toast.makeText(context, "Error: $error", Toast.LENGTH_LONG).show()
}
)
}
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun Screen(
state: DataSeedingPlaygroundState,
onBack: () -> Unit = {},
onSelectFolderClicked: () -> Unit = {},
onThreadSelectionChanged: (Long, Boolean) -> Unit = { _, _ -> },
onSeedDataClicked: () -> Unit = {}
) {
var showConfirmDialog by remember { mutableStateOf(false) }
Scaffold(
topBar = {
TopAppBar(
title = {
Text("Data Seeding Playground")
},
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
painter = painterResource(R.drawable.symbol_arrow_start_24),
tint = MaterialTheme.colorScheme.onSurface,
contentDescription = null
)
}
}
)
}
) { paddingValues ->
Surface(modifier = Modifier.padding(paddingValues)) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// Folder selection section
Card(
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Text(
text = "Media Folder",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(8.dp))
if (state.selectedFolderPath.isNotEmpty()) {
Text(
text = "Selected: ${state.selectedFolderPath}",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = "Media files found: ${state.mediaFiles.size}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
} else {
Text(
text = "No folder selected",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(modifier = Modifier.height(8.dp))
Rows.TextRow(
text = "Select Media Folder",
label = "Choose a folder containing photos and videos to seed into conversations.",
onClick = onSelectFolderClicked
)
}
}
// Thread selection section
Card(
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Text(
text = "Conversation Threads (${state.selectedThreads.size} selected)",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(8.dp))
LazyColumn(
modifier = Modifier.height(300.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
items(state.threads) { thread ->
ThreadSelectionRow(
thread = thread,
isSelected = state.selectedThreads.contains(thread.threadId),
onSelectionChanged = { isSelected ->
onThreadSelectionChanged(thread.threadId, isSelected)
}
)
}
}
}
}
// Action section
if (state.mediaFiles.isNotEmpty() && state.selectedThreads.isNotEmpty()) {
Card(
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Text(
text = "Ready to Seed Data",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "This will send ${state.mediaFiles.size} media files to ${state.selectedThreads.size} conversations in a round-robin fashion.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(8.dp))
Rows.TextRow(
text = "Seed Data",
label = "Send the selected media files to the selected conversations.",
onClick = {
showConfirmDialog = true
}
)
}
}
}
}
}
}
// Confirmation dialog
if (showConfirmDialog) {
AlertDialog(
onDismissRequest = { showConfirmDialog = false },
title = { Text("Confirm Data Seeding") },
text = {
Text("Are you sure you want to send ${state.mediaFiles.size} media files to ${state.selectedThreads.size} conversations? This action cannot be undone.")
},
confirmButton = {
TextButton(
onClick = {
showConfirmDialog = false
onSeedDataClicked()
}
) {
Text("Seed Data")
}
},
dismissButton = {
TextButton(
onClick = { showConfirmDialog = false }
) {
Text("Cancel")
}
}
)
}
}
@Composable
private fun ThreadSelectionRow(
thread: ThreadRecord,
isSelected: Boolean,
onSelectionChanged: (Boolean) -> Unit
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
checked = isSelected,
onCheckedChange = onSelectionChanged
)
Column(
modifier = Modifier
.weight(1f)
.padding(start = 8.dp)
) {
Text(
text = thread.recipient.getDisplayName(LocalContext.current),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium
)
if (thread.body.isNotEmpty()) {
Text(
text = thread.body,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1
)
}
}
}
}
@SignalPreview
@Composable
fun PreviewScreen() {
Previews.Preview {
Screen(
state = DataSeedingPlaygroundState(
threads = emptyList(),
selectedThreads = emptySet(),
mediaFiles = emptyList(),
selectedFolderPath = "/storage/emulated/0/Pictures"
)
)
}
}
@SignalPreview
@Composable
fun PreviewScreenWithData() {
Previews.Preview {
Screen(
state = DataSeedingPlaygroundState(
threads = emptyList(),
selectedThreads = setOf(1L, 2L),
mediaFiles = listOf("photo1.jpg", "video1.mp4", "photo2.jpg"),
selectedFolderPath = "/storage/emulated/0/Pictures"
)
)
}
}

View File

@@ -0,0 +1,220 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.internal.dataseeding
import android.app.Application
import android.content.Context
import android.database.Cursor
import android.net.Uri
import androidx.documentfile.provider.DocumentFile
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.attachments.Attachment
import org.thoughtcrime.securesms.attachments.UriAttachment
import org.thoughtcrime.securesms.database.AttachmentTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.ThreadRecord
import org.thoughtcrime.securesms.mms.OutgoingMessage
import org.thoughtcrime.securesms.sms.MessageSender
import org.thoughtcrime.securesms.util.MediaUtil
class DataSeedingPlaygroundViewModel(application: Application) : AndroidViewModel(application) {
companion object {
private val TAG = Log.tag(DataSeedingPlaygroundViewModel::class.java)
private const val MAX_RECENT_THREADS = 10
}
private val _state = MutableStateFlow(DataSeedingPlaygroundState())
val state: StateFlow<DataSeedingPlaygroundState> = _state.asStateFlow()
fun loadThreads() {
viewModelScope.launch(Dispatchers.IO) {
try {
val threads = mutableListOf<ThreadRecord>()
val cursor: Cursor = SignalDatabase.threads.getRecentConversationList(
limit = MAX_RECENT_THREADS,
includeInactiveGroups = false,
hideV1Groups = true
)
cursor.use {
val reader = SignalDatabase.threads.readerFor(it)
var threadRecord = reader.getNext()
while (threadRecord != null) {
threads.add(threadRecord)
threadRecord = reader.getNext()
}
}
_state.value = _state.value.copy(threads = threads)
} catch (e: Exception) {
Log.w(TAG, "Failed to load threads", e)
}
}
}
fun selectFolder(uri: Uri) {
viewModelScope.launch(Dispatchers.IO) {
try {
val context = getApplication<Application>()
val documentFile = DocumentFile.fromTreeUri(context, uri)
if (documentFile != null && documentFile.isDirectory) {
val mediaFiles = findMediaFiles(documentFile)
_state.value = _state.value.copy(
selectedFolderPath = documentFile.uri.toString(),
mediaFiles = mediaFiles
)
}
} catch (e: Exception) {
Log.w(TAG, "Failed to select folder", e)
}
}
}
fun toggleThreadSelection(threadId: Long, isSelected: Boolean) {
val selectedThreads = _state.value.selectedThreads.toMutableSet()
if (isSelected) {
selectedThreads.add(threadId)
} else {
selectedThreads.remove(threadId)
}
_state.value = _state.value.copy(selectedThreads = selectedThreads)
}
@OptIn(DelicateCoroutinesApi::class)
fun seedData(onComplete: () -> Unit, onError: (String) -> Unit) {
GlobalScope.launch(Dispatchers.IO) {
try {
val context = getApplication<Application>()
val currentState = _state.value
if (currentState.mediaFiles.isEmpty()) {
withContext(Dispatchers.Main) {
onError("No media files selected")
}
return@launch
}
if (currentState.selectedThreads.isEmpty()) {
withContext(Dispatchers.Main) {
onError("No threads selected")
}
return@launch
}
val mediaFiles = currentState.mediaFiles
val threadIds = currentState.selectedThreads.toList()
var currentThreadIndex = 0
for (mediaFile in mediaFiles) {
val threadId = threadIds[currentThreadIndex % threadIds.size]
sendMediaToThread(context, mediaFile, threadId)
currentThreadIndex++
}
withContext(Dispatchers.Main) {
onComplete()
}
} catch (e: Exception) {
Log.w(TAG, "Failed to seed data", e)
withContext(Dispatchers.Main) {
onError(e.message ?: "Unknown error")
}
}
}
}
private fun findMediaFiles(directory: DocumentFile): List<String> {
val mediaFiles = mutableListOf<String>()
directory.listFiles().forEach { file ->
if (file.isFile && file.type != null) {
val mimeType = file.type!!
if (MediaUtil.isImageType(mimeType) || MediaUtil.isVideoType(mimeType)) {
mediaFiles.add(file.name ?: "unknown")
}
}
}
return mediaFiles
}
private suspend fun sendMediaToThread(context: Context, mediaFileName: String, threadId: Long) {
try {
// Find the actual file URI
val currentState = _state.value
val documentFile = DocumentFile.fromTreeUri(context, Uri.parse(currentState.selectedFolderPath))
if (documentFile != null) {
val mediaFile = documentFile.listFiles().find { it.name == mediaFileName }
if (mediaFile != null && mediaFile.uri != null) {
val recipient = SignalDatabase.threads.getRecipientForThreadId(threadId)
if (recipient != null) {
val mimeType = mediaFile.type ?: MediaUtil.getCorrectedMimeType(mediaFileName)
val attachment: Attachment = UriAttachment(
uri = mediaFile.uri,
contentType = mimeType,
transferState = AttachmentTable.TRANSFER_PROGRESS_STARTED,
size = mediaFile.length(),
fileName = mediaFileName,
voiceNote = false,
borderless = false,
videoGif = false,
quote = false,
quoteTargetContentType = null,
caption = null,
stickerLocator = null,
blurHash = null,
audioHash = null,
transformProperties = null
)
val message = OutgoingMessage(
threadRecipient = recipient,
body = "",
attachments = listOf(attachment),
sentTimeMillis = System.currentTimeMillis(),
isSecure = true
)
MessageSender.send(
context,
message,
threadId,
MessageSender.SendType.SIGNAL,
null,
null
)
}
}
}
} catch (e: Exception) {
Log.w(TAG, "Failed to send media to thread $threadId", e)
}
}
}
data class DataSeedingPlaygroundState(
val threads: List<ThreadRecord> = emptyList(),
val selectedThreads: Set<Long> = emptySet(),
val mediaFiles: List<String> = emptyList(),
val selectedFolderPath: String = ""
)

View File

@@ -0,0 +1,32 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.notifications
import android.content.Context
import android.content.Intent
import android.provider.Settings
import androidx.activity.result.contract.ActivityResultContract
import org.thoughtcrime.securesms.notifications.NotificationChannels
/**
* Activity result contract for launching the system notification channel settings screen
* for the messages notification channel.
*
* This contract allows users to configure notification priority, sound, vibration, and other
* channel-specific settings through the system's native notification settings UI.
*/
class NotificationPrioritySelectionContract : ActivityResultContract<Unit, Unit>() {
override fun createIntent(context: Context, input: Unit): Intent {
return Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS)
.putExtra(
Settings.EXTRA_CHANNEL_ID,
NotificationChannels.getInstance().messagesChannel
)
.putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName)
}
override fun parseResult(resultCode: Int, intent: Intent?) = Unit
}

View File

@@ -0,0 +1,78 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.notifications
import android.content.Context
import android.content.Intent
import android.media.RingtoneManager
import android.net.Uri
import android.provider.Settings
import androidx.activity.result.contract.ActivityResultContract
import org.signal.core.util.getParcelableExtraCompat
import org.thoughtcrime.securesms.keyvalue.SignalStore
/**
* Activity result contract for launching the system ringtone picker to select notification sounds.
*
* Supports selecting sounds for both message notifications and call ringtones through the
* Android system's ringtone picker interface.
*
* @param target Specifies whether to configure sounds for messages or calls
*/
class NotificationSoundSelectionContract(
private val target: Target
) : ActivityResultContract<Unit, Uri?>() {
/**
* Defines the type of notification sound to configure.
*/
enum class Target {
/** Message notification sounds */
MESSAGE,
/** Call ringtones */
CALL
}
override fun createIntent(context: Context, input: Unit): Intent {
return when (target) {
Target.MESSAGE -> createIntentForMessageSoundSelection()
Target.CALL -> createIntentForCallSoundSelection()
}
}
private fun createIntentForMessageSoundSelection(): Intent {
val current = SignalStore.settings.messageNotificationSound
return Intent(RingtoneManager.ACTION_RINGTONE_PICKER)
.putExtra(RingtoneManager.EXTRA_RINGTONE_SHOW_DEFAULT, true)
.putExtra(RingtoneManager.EXTRA_RINGTONE_SHOW_SILENT, true)
.putExtra(RingtoneManager.EXTRA_RINGTONE_TYPE, RingtoneManager.TYPE_NOTIFICATION)
.putExtra(
RingtoneManager.EXTRA_RINGTONE_DEFAULT_URI,
Settings.System.DEFAULT_NOTIFICATION_URI
)
.putExtra(RingtoneManager.EXTRA_RINGTONE_EXISTING_URI, current)
}
private fun createIntentForCallSoundSelection(): Intent {
val current = SignalStore.settings.callRingtone
return Intent(RingtoneManager.ACTION_RINGTONE_PICKER)
.putExtra(RingtoneManager.EXTRA_RINGTONE_SHOW_DEFAULT, true)
.putExtra(RingtoneManager.EXTRA_RINGTONE_SHOW_SILENT, true)
.putExtra(RingtoneManager.EXTRA_RINGTONE_TYPE, RingtoneManager.TYPE_RINGTONE)
.putExtra(
RingtoneManager.EXTRA_RINGTONE_DEFAULT_URI,
Settings.System.DEFAULT_RINGTONE_URI
)
.putExtra(RingtoneManager.EXTRA_RINGTONE_EXISTING_URI, current)
}
override fun parseResult(resultCode: Int, intent: Intent?): Uri? {
return intent?.getParcelableExtraCompat(RingtoneManager.EXTRA_RINGTONE_PICKED_URI, Uri::class.java)
}
}

View File

@@ -1,420 +1,615 @@
package org.thoughtcrime.securesms.components.settings.app.notifications
import android.app.Activity
import android.content.ActivityNotFoundException
import android.content.Intent
import android.graphics.ColorFilter
import android.graphics.PorterDuff
import android.graphics.PorterDuffColorFilter
import android.media.Ringtone
import android.media.RingtoneManager
import android.net.Uri
import android.os.Build
import android.provider.Settings
import android.text.TextUtils
import android.os.Bundle
import android.view.View
import android.widget.Toast
import androidx.activity.result.ActivityResultCallback
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContract
import androidx.annotation.RequiresApi
import androidx.core.content.ContextCompat
import androidx.lifecycle.ViewModelProvider
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.stringArrayResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.unit.dp
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.navigation.fragment.findNavController
import androidx.preference.PreferenceManager
import org.signal.core.util.getParcelableExtraCompat
import kotlinx.coroutines.launch
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.Scaffolds
import org.signal.core.ui.compose.SignalPreview
import org.signal.core.ui.compose.Texts
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.PromptBatterySaverDialogFragment
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.PreferenceModel
import org.thoughtcrime.securesms.components.settings.PreferenceViewHolder
import org.thoughtcrime.securesms.components.settings.RadioListPreference
import org.thoughtcrime.securesms.components.settings.RadioListPreferenceViewHolder
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.components.settings.app.routes.AppSettingsRoute
import org.thoughtcrime.securesms.components.settings.app.routes.AppSettingsRouter
import org.thoughtcrime.securesms.components.settings.models.Banner
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.compose.ComposeFragment
import org.thoughtcrime.securesms.notifications.NotificationChannels
import org.thoughtcrime.securesms.notifications.TurnOnNotificationsBottomSheet
import org.thoughtcrime.securesms.util.BottomSheetUtil
import org.thoughtcrime.securesms.util.RingtoneUtil
import org.thoughtcrime.securesms.util.ViewUtil
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import org.thoughtcrime.securesms.util.navigation.safeNavigate
import org.thoughtcrime.securesms.util.viewModel
private const val MESSAGE_SOUND_SELECT: Int = 1
private const val CALL_RINGTONE_SELECT: Int = 2
private val TAG = Log.tag(NotificationsSettingsFragment::class.java)
class NotificationsSettingsFragment : ComposeFragment() {
class NotificationsSettingsFragment : DSLSettingsFragment(R.string.preferences__notifications) {
private val viewModel: NotificationsSettingsViewModel by viewModel {
val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(requireContext())
private val repeatAlertsValues by lazy { resources.getStringArray(R.array.pref_repeat_alerts_values) }
private val repeatAlertsLabels by lazy { resources.getStringArray(R.array.pref_repeat_alerts_entries) }
NotificationsSettingsViewModel.Factory(sharedPreferences).create(NotificationsSettingsViewModel::class.java)
}
private val notificationPrivacyValues by lazy { resources.getStringArray(R.array.pref_notification_privacy_values) }
private val notificationPrivacyLabels by lazy { resources.getStringArray(R.array.pref_notification_privacy_entries) }
private val appSettingsRouter: AppSettingsRouter by viewModel {
AppSettingsRouter()
}
private val notificationPriorityValues by lazy { resources.getStringArray(R.array.pref_notification_priority_values) }
private val notificationPriorityLabels by lazy { resources.getStringArray(R.array.pref_notification_priority_entries) }
private lateinit var callbacks: DefaultNotificationsSettingsCallbacks
private val ledColorValues by lazy { resources.getStringArray(R.array.pref_led_color_values) }
private val ledColorLabels by lazy { resources.getStringArray(R.array.pref_led_color_entries) }
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
private val ledBlinkValues by lazy { resources.getStringArray(R.array.pref_led_blink_pattern_values) }
private val ledBlinkLabels by lazy { resources.getStringArray(R.array.pref_led_blink_pattern_entries) }
callbacks = DefaultNotificationsSettingsCallbacks(requireActivity(), viewModel, appSettingsRouter, DefaultNotificationsSettingsCallbacks.ActivityResultRegisterer.ForFragment(this))
private lateinit var viewModel: NotificationsSettingsViewModel
viewLifecycleOwner.lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.RESUMED) {
appSettingsRouter.currentRoute.collect {
when (it) {
AppSettingsRoute.NotificationsRoute.NotificationProfiles -> {
findNavController().safeNavigate(R.id.action_notificationsSettingsFragment_to_notificationProfilesFragment)
}
else -> error("Unexpected route: ${it.javaClass.name}")
}
}
}
}
}
override fun onResume() {
super.onResume()
viewModel.refresh()
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
if (requestCode == MESSAGE_SOUND_SELECT && resultCode == Activity.RESULT_OK && data != null) {
val uri: Uri? = data.getParcelableExtraCompat(RingtoneManager.EXTRA_RINGTONE_PICKED_URI, Uri::class.java)
viewModel.setMessageNotificationsSound(uri)
} else if (requestCode == CALL_RINGTONE_SELECT && resultCode == Activity.RESULT_OK && data != null) {
val uri: Uri? = data.getParcelableExtraCompat(RingtoneManager.EXTRA_RINGTONE_PICKED_URI, Uri::class.java)
viewModel.setCallRingtone(uri)
@Composable
override fun FragmentContent() {
val state by viewModel.state.collectAsStateWithLifecycle()
NotificationsSettingsScreen(state = state, callbacks = callbacks)
}
}
/**
* Default callbacks that package up launcher handling and other logic that was in the original fragment.
* This must be called during the creation cycle of the component it is attached to.
*/
open class DefaultNotificationsSettingsCallbacks(
val activity: FragmentActivity,
val viewModel: NotificationsSettingsViewModel,
val appSettingsRouter: AppSettingsRouter,
activityResultRegisterer: ActivityResultRegisterer = ActivityResultRegisterer.ForActivity(activity)
) : NotificationsSettingsCallbacks {
companion object {
private val TAG = Log.tag(DefaultNotificationsSettingsCallbacks::class)
}
interface ActivityResultRegisterer {
fun <I, O> registerForActivityResult(
contract: ActivityResultContract<I, O>,
callback: ActivityResultCallback<O>
): ActivityResultLauncher<I>
class ForActivity(val activity: FragmentActivity) : ActivityResultRegisterer {
override fun <I, O> registerForActivityResult(
contract: ActivityResultContract<I, O>,
callback: ActivityResultCallback<O>
): ActivityResultLauncher<I> {
return activity.registerForActivityResult(contract, callback)
}
}
class ForFragment(val fragment: Fragment) : ActivityResultRegisterer {
override fun <I, O> registerForActivityResult(
contract: ActivityResultContract<I, O>,
callback: ActivityResultCallback<O>
): ActivityResultLauncher<I> {
return fragment.registerForActivityResult(contract, callback)
}
}
}
override fun bindAdapter(adapter: MappingAdapter) {
adapter.registerFactory(
LedColorPreference::class.java,
LayoutFactory(::LedColorPreferenceViewHolder, R.layout.dsl_preference_item)
)
private val messageSoundSelectionLauncher: ActivityResultLauncher<Unit> = activityResultRegisterer.registerForActivityResult(
NotificationSoundSelectionContract(NotificationSoundSelectionContract.Target.MESSAGE),
viewModel::setMessageNotificationsSound
)
Banner.register(adapter)
private val callsSoundSelectionLauncher: ActivityResultLauncher<Unit> = activityResultRegisterer.registerForActivityResult(
NotificationSoundSelectionContract(NotificationSoundSelectionContract.Target.CALL),
viewModel::setCallRingtone
)
val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(requireContext())
val factory = NotificationsSettingsViewModel.Factory(sharedPreferences)
private val notificationPrioritySelectionLauncher: ActivityResultLauncher<Unit> = activityResultRegisterer.registerForActivityResult(
contract = NotificationPrioritySelectionContract(),
callback = {}
)
viewModel = ViewModelProvider(this, factory)[NotificationsSettingsViewModel::class.java]
override fun onTurnOnNotificationsActionClick() {
TurnOnNotificationsBottomSheet.turnOnSystemNotificationsFragment(activity).show(activity.supportFragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
}
viewModel.state.observe(viewLifecycleOwner) {
adapter.submitList(getConfiguration(it).toMappingModelList())
override fun onNavigationClick() {
activity.onBackPressedDispatcher.onBackPressed()
}
override fun setMessageNotificationsEnabled(enabled: Boolean) {
viewModel.setMessageNotificationsEnabled(enabled)
}
override fun onCustomizeClick() {
activity.let {
NotificationChannels.getInstance().openChannelSettings(it, NotificationChannels.getInstance().messagesChannel, null)
}
}
private fun getConfiguration(state: NotificationsSettingsState): DSLConfiguration {
return configure {
if (!state.messageNotificationsState.canEnableNotifications) {
customPref(
Banner.Model(
textId = R.string.NotificationSettingsFragment__to_enable_notifications,
actionId = R.string.NotificationSettingsFragment__turn_on,
onClick = {
TurnOnNotificationsBottomSheet.turnOnSystemNotificationsFragment(requireContext()).show(childFragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
}
)
)
}
sectionHeaderPref(R.string.NotificationsSettingsFragment__messages)
switchPref(
title = DSLSettingsText.from(R.string.preferences__notifications),
isEnabled = state.messageNotificationsState.canEnableNotifications,
isChecked = state.messageNotificationsState.notificationsEnabled,
onClick = {
viewModel.setMessageNotificationsEnabled(!state.messageNotificationsState.notificationsEnabled)
}
)
if (Build.VERSION.SDK_INT >= 30) {
clickPref(
title = DSLSettingsText.from(R.string.preferences__customize),
summary = DSLSettingsText.from(R.string.preferences__change_sound_and_vibration),
isEnabled = state.messageNotificationsState.notificationsEnabled,
onClick = {
NotificationChannels.getInstance().openChannelSettings(requireActivity(), NotificationChannels.getInstance().messagesChannel, null)
}
)
} else {
clickPref(
title = DSLSettingsText.from(R.string.preferences__sound),
summary = DSLSettingsText.from(getRingtoneSummary(state.messageNotificationsState.sound)),
isEnabled = state.messageNotificationsState.notificationsEnabled,
onClick = {
launchMessageSoundSelectionIntent()
}
)
switchPref(
title = DSLSettingsText.from(R.string.preferences__vibrate),
isChecked = state.messageNotificationsState.vibrateEnabled,
isEnabled = state.messageNotificationsState.notificationsEnabled,
onClick = {
viewModel.setMessageNotificationVibration(!state.messageNotificationsState.vibrateEnabled)
}
)
customPref(
LedColorPreference(
colorValues = ledColorValues,
radioListPreference = RadioListPreference(
title = DSLSettingsText.from(R.string.preferences__led_color),
listItems = ledColorLabels,
selected = ledColorValues.indexOf(state.messageNotificationsState.ledColor),
isEnabled = state.messageNotificationsState.notificationsEnabled,
onSelected = {
viewModel.setMessageNotificationLedColor(ledColorValues[it])
}
)
)
)
if (!NotificationChannels.supported()) {
radioListPref(
title = DSLSettingsText.from(R.string.preferences__pref_led_blink_title),
listItems = ledBlinkLabels,
selected = ledBlinkValues.indexOf(state.messageNotificationsState.ledBlink),
isEnabled = state.messageNotificationsState.notificationsEnabled,
onSelected = {
viewModel.setMessageNotificationLedBlink(ledBlinkValues[it])
}
)
}
}
switchPref(
title = DSLSettingsText.from(R.string.preferences_notifications__in_chat_sounds),
isChecked = state.messageNotificationsState.inChatSoundsEnabled,
isEnabled = state.messageNotificationsState.notificationsEnabled,
onClick = {
viewModel.setMessageNotificationInChatSoundsEnabled(!state.messageNotificationsState.inChatSoundsEnabled)
}
)
radioListPref(
title = DSLSettingsText.from(R.string.preferences__repeat_alerts),
listItems = repeatAlertsLabels,
selected = repeatAlertsValues.indexOf(state.messageNotificationsState.repeatAlerts.toString()),
isEnabled = state.messageNotificationsState.notificationsEnabled,
onSelected = {
viewModel.setMessageRepeatAlerts(repeatAlertsValues[it].toInt())
}
)
radioListPref(
title = DSLSettingsText.from(R.string.preferences_notifications__show),
listItems = notificationPrivacyLabels,
selected = notificationPrivacyValues.indexOf(state.messageNotificationsState.messagePrivacy),
isEnabled = state.messageNotificationsState.notificationsEnabled,
onSelected = {
viewModel.setMessageNotificationPrivacy(notificationPrivacyValues[it])
}
)
if (Build.VERSION.SDK_INT >= 23 && state.messageNotificationsState.troubleshootNotifications) {
clickPref(
title = DSLSettingsText.from(R.string.preferences_notifications__troubleshoot),
isEnabled = true,
onClick = {
PromptBatterySaverDialogFragment.show(childFragmentManager)
}
)
}
if (Build.VERSION.SDK_INT < 30) {
if (NotificationChannels.supported()) {
clickPref(
title = DSLSettingsText.from(R.string.preferences_notifications__priority),
isEnabled = state.messageNotificationsState.notificationsEnabled,
onClick = {
launchNotificationPriorityIntent()
}
)
} else {
radioListPref(
title = DSLSettingsText.from(R.string.preferences_notifications__priority),
listItems = notificationPriorityLabels,
selected = notificationPriorityValues.indexOf(state.messageNotificationsState.priority.toString()),
isEnabled = state.messageNotificationsState.notificationsEnabled,
onSelected = {
viewModel.setMessageNotificationPriority(notificationPriorityValues[it].toInt())
}
)
}
}
dividerPref()
sectionHeaderPref(R.string.NotificationsSettingsFragment__calls)
switchPref(
title = DSLSettingsText.from(R.string.preferences__notifications),
isEnabled = state.callNotificationsState.canEnableNotifications,
isChecked = state.callNotificationsState.notificationsEnabled,
onClick = {
viewModel.setCallNotificationsEnabled(!state.callNotificationsState.notificationsEnabled)
}
)
clickPref(
title = DSLSettingsText.from(R.string.preferences_notifications__ringtone),
summary = DSLSettingsText.from(getRingtoneSummary(state.callNotificationsState.ringtone)),
isEnabled = state.callNotificationsState.notificationsEnabled,
onClick = {
launchCallRingtoneSelectionIntent()
}
)
switchPref(
title = DSLSettingsText.from(R.string.preferences__vibrate),
isChecked = state.callNotificationsState.vibrateEnabled,
isEnabled = state.callNotificationsState.notificationsEnabled,
onClick = {
viewModel.setCallVibrateEnabled(!state.callNotificationsState.vibrateEnabled)
}
)
dividerPref()
sectionHeaderPref(R.string.NotificationsSettingsFragment__notification_profiles)
clickPref(
title = DSLSettingsText.from(R.string.NotificationsSettingsFragment__profiles),
summary = DSLSettingsText.from(R.string.NotificationsSettingsFragment__create_a_profile_to_receive_notifications_only_from_people_and_groups_you_choose),
onClick = {
findNavController().safeNavigate(R.id.action_notificationsSettingsFragment_to_notificationProfilesFragment)
}
)
dividerPref()
sectionHeaderPref(R.string.NotificationsSettingsFragment__notify_when)
switchPref(
title = DSLSettingsText.from(R.string.NotificationsSettingsFragment__contact_joins_signal),
isChecked = state.notifyWhenContactJoinsSignal,
onClick = {
viewModel.setNotifyWhenContactJoinsSignal(!state.notifyWhenContactJoinsSignal)
}
)
}
}
private fun getRingtoneSummary(uri: Uri): String {
return if (TextUtils.isEmpty(uri.toString())) {
getString(R.string.preferences__silent)
override fun getRingtoneSummary(uri: Uri): String {
return if (uri.toString().isBlank()) {
activity.getString(R.string.preferences__silent)
} else {
val tone: Ringtone? = RingtoneUtil.getRingtone(requireContext(), uri)
val tone: Ringtone? = RingtoneUtil.getRingtone(activity, uri)
if (tone != null) {
try {
tone.getTitle(requireContext()) ?: getString(R.string.NotificationsSettingsFragment__unknown_ringtone)
tone.getTitle(activity) ?: activity.getString(R.string.NotificationsSettingsFragment__unknown_ringtone)
} catch (e: SecurityException) {
Log.w(TAG, "Unable to get title for ringtone", e)
return getString(R.string.NotificationsSettingsFragment__unknown_ringtone)
return activity.getString(R.string.NotificationsSettingsFragment__unknown_ringtone)
}
} else {
getString(R.string.preferences__default)
activity.getString(R.string.preferences__default)
}
}
}
private fun launchMessageSoundSelectionIntent() {
val current = SignalStore.settings.messageNotificationSound
val intent = Intent(RingtoneManager.ACTION_RINGTONE_PICKER)
intent.putExtra(RingtoneManager.EXTRA_RINGTONE_SHOW_DEFAULT, true)
intent.putExtra(RingtoneManager.EXTRA_RINGTONE_SHOW_SILENT, true)
intent.putExtra(RingtoneManager.EXTRA_RINGTONE_TYPE, RingtoneManager.TYPE_NOTIFICATION)
intent.putExtra(
RingtoneManager.EXTRA_RINGTONE_DEFAULT_URI,
Settings.System.DEFAULT_NOTIFICATION_URI
)
intent.putExtra(RingtoneManager.EXTRA_RINGTONE_EXISTING_URI, current)
openRingtonePicker(intent, MESSAGE_SOUND_SELECT)
}
@RequiresApi(26)
private fun launchNotificationPriorityIntent() {
val intent = Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS)
intent.putExtra(
Settings.EXTRA_CHANNEL_ID,
NotificationChannels.getInstance().messagesChannel
)
intent.putExtra(Settings.EXTRA_APP_PACKAGE, requireContext().packageName)
startActivity(intent)
}
private fun launchCallRingtoneSelectionIntent() {
val current = SignalStore.settings.callRingtone
val intent = Intent(RingtoneManager.ACTION_RINGTONE_PICKER)
intent.putExtra(RingtoneManager.EXTRA_RINGTONE_SHOW_DEFAULT, true)
intent.putExtra(RingtoneManager.EXTRA_RINGTONE_SHOW_SILENT, true)
intent.putExtra(RingtoneManager.EXTRA_RINGTONE_TYPE, RingtoneManager.TYPE_RINGTONE)
intent.putExtra(
RingtoneManager.EXTRA_RINGTONE_DEFAULT_URI,
Settings.System.DEFAULT_RINGTONE_URI
)
intent.putExtra(RingtoneManager.EXTRA_RINGTONE_EXISTING_URI, current)
openRingtonePicker(intent, CALL_RINGTONE_SELECT)
}
@Suppress("DEPRECATION")
private fun openRingtonePicker(intent: Intent, requestCode: Int) {
override fun launchMessageSoundSelectionIntent() {
try {
startActivityForResult(intent, requestCode)
messageSoundSelectionLauncher.launch(Unit)
} catch (e: ActivityNotFoundException) {
Toast.makeText(requireContext(), R.string.NotificationSettingsFragment__failed_to_open_picker, Toast.LENGTH_LONG).show()
Toast.makeText(activity, R.string.NotificationSettingsFragment__failed_to_open_picker, Toast.LENGTH_LONG).show()
}
}
private class LedColorPreference(
val colorValues: Array<String>,
val radioListPreference: RadioListPreference
) : PreferenceModel<LedColorPreference>(
title = radioListPreference.title,
icon = radioListPreference.icon,
summary = radioListPreference.summary
override fun launchCallsSoundSelectionIntent() {
try {
callsSoundSelectionLauncher.launch(Unit)
} catch (e: ActivityNotFoundException) {
Toast.makeText(activity, R.string.NotificationSettingsFragment__failed_to_open_picker, Toast.LENGTH_LONG).show()
}
}
override fun setMessageNotificationVibration(enabled: Boolean) {
viewModel.setMessageNotificationsEnabled(enabled)
}
override fun setMessasgeNotificationLedColor(selection: String) {
viewModel.setMessageNotificationLedColor(selection)
}
override fun setMessasgeNotificationLedBlink(selection: String) {
viewModel.setMessageNotificationLedBlink(selection)
}
override fun setMessageNotificationInChatSoundsEnabled(enabled: Boolean) {
viewModel.setMessageNotificationInChatSoundsEnabled(enabled)
}
override fun setMessageRepeatAlerts(selection: String) {
viewModel.setMessageRepeatAlerts(selection.toInt())
}
override fun setMessageNotificationPrivacy(selection: String) {
viewModel.setMessageNotificationPrivacy(selection)
}
@RequiresApi(23)
override fun onTroubleshootNotificationsClick() {
PromptBatterySaverDialogFragment.show(activity.supportFragmentManager)
}
override fun launchNotificationPriorityIntent() {
notificationPrioritySelectionLauncher.launch(Unit)
}
override fun setMessageNotificationPriority(selection: String) {
viewModel.setMessageNotificationPriority(selection.toInt())
}
override fun setCallNotificationsEnabled(enabled: Boolean) {
viewModel.setCallNotificationsEnabled(enabled)
}
override fun setCallVibrateEnabled(enabled: Boolean) {
viewModel.setCallVibrateEnabled(enabled)
}
override fun onNavigationProfilesClick() {
appSettingsRouter.navigateTo(AppSettingsRoute.NotificationsRoute.NotificationProfiles)
}
override fun setNotifyWhenContactJoinsSignal(enabled: Boolean) {
viewModel.setNotifyWhenContactJoinsSignal(enabled)
}
}
interface NotificationsSettingsCallbacks {
fun onTurnOnNotificationsActionClick() = Unit
fun onNavigationClick() = Unit
fun setMessageNotificationsEnabled(enabled: Boolean) = Unit
fun onCustomizeClick() = Unit
fun getRingtoneSummary(uri: Uri): String = "Test Sound"
fun launchMessageSoundSelectionIntent(): Unit = Unit
fun launchCallsSoundSelectionIntent(): Unit = Unit
fun setMessageNotificationVibration(enabled: Boolean) = Unit
fun setMessasgeNotificationLedColor(selection: String) = Unit
fun setMessasgeNotificationLedBlink(selection: String) = Unit
fun setMessageNotificationInChatSoundsEnabled(enabled: Boolean) = Unit
fun setMessageRepeatAlerts(selection: String) = Unit
fun setMessageNotificationPrivacy(selection: String) = Unit
fun onTroubleshootNotificationsClick() = Unit
fun launchNotificationPriorityIntent() = Unit
fun setMessageNotificationPriority(selection: String) = Unit
fun setCallNotificationsEnabled(enabled: Boolean) = Unit
fun setCallVibrateEnabled(enabled: Boolean) = Unit
fun onNavigationProfilesClick() = Unit
fun setNotifyWhenContactJoinsSignal(enabled: Boolean) = Unit
object Empty : NotificationsSettingsCallbacks
}
@Composable
fun NotificationsSettingsScreen(
state: NotificationsSettingsState,
callbacks: NotificationsSettingsCallbacks,
deviceState: DeviceState = remember { DeviceState() }
) {
Scaffolds.Settings(
title = stringResource(R.string.preferences__notifications),
onNavigationClick = callbacks::onNavigationClick,
navigationIcon = ImageVector.vectorResource(R.drawable.symbol_arrow_start_24)
) {
override fun areContentsTheSame(newItem: LedColorPreference): Boolean {
return super.areContentsTheSame(newItem) && radioListPreference.areContentsTheSame(newItem.radioListPreference)
}
}
LazyColumn(
modifier = Modifier.padding(it)
) {
if (!state.messageNotificationsState.canEnableNotifications) {
item {
Banner(
text = stringResource(R.string.NotificationSettingsFragment__to_enable_notifications),
action = stringResource(R.string.NotificationSettingsFragment__turn_on),
onActionClick = callbacks::onTurnOnNotificationsActionClick
)
}
}
private class LedColorPreferenceViewHolder(itemView: View) :
PreferenceViewHolder<LedColorPreference>(itemView) {
item {
Texts.SectionHeader(stringResource(R.string.NotificationsSettingsFragment__messages))
}
val radioListPreferenceViewHolder = RadioListPreferenceViewHolder(itemView)
item {
Rows.ToggleRow(
text = stringResource(R.string.preferences__notifications),
enabled = state.messageNotificationsState.canEnableNotifications,
checked = state.messageNotificationsState.notificationsEnabled,
onCheckChanged = callbacks::setMessageNotificationsEnabled
)
}
override fun bind(model: LedColorPreference) {
super.bind(model)
radioListPreferenceViewHolder.bind(model.radioListPreference)
summaryView.visibility = View.GONE
val circleDrawable = requireNotNull(ContextCompat.getDrawable(context, R.drawable.circle_tintable))
circleDrawable.setBounds(0, 0, ViewUtil.dpToPx(20), ViewUtil.dpToPx(20))
circleDrawable.colorFilter = model.colorValues[model.radioListPreference.selected].toColorFilter()
if (ViewUtil.isLtr(itemView)) {
titleView.setCompoundDrawables(null, null, circleDrawable, null)
if (deviceState.apiLevel >= 30) {
item {
Rows.TextRow(
text = stringResource(R.string.preferences__customize),
label = stringResource(R.string.preferences__change_sound_and_vibration),
enabled = state.messageNotificationsState.notificationsEnabled,
onClick = callbacks::onCustomizeClick
)
}
} else {
titleView.setCompoundDrawables(circleDrawable, null, null, null)
}
}
item {
Rows.TextRow(
text = stringResource(R.string.preferences__sound),
label = remember(state.messageNotificationsState.sound) {
callbacks.getRingtoneSummary(state.messageNotificationsState.sound)
},
enabled = state.messageNotificationsState.notificationsEnabled,
onClick = callbacks::launchMessageSoundSelectionIntent
)
}
private fun String.toColorFilter(): ColorFilter {
val color = when (this) {
"green" -> ContextCompat.getColor(context, R.color.green_500)
"red" -> ContextCompat.getColor(context, R.color.red_500)
"blue" -> ContextCompat.getColor(context, R.color.blue_500)
"yellow" -> ContextCompat.getColor(context, R.color.yellow_500)
"cyan" -> ContextCompat.getColor(context, R.color.cyan_500)
"magenta" -> ContextCompat.getColor(context, R.color.pink_500)
"white" -> ContextCompat.getColor(context, R.color.white)
else -> ContextCompat.getColor(context, R.color.transparent)
item {
Rows.ToggleRow(
text = stringResource(R.string.preferences__vibrate),
checked = state.messageNotificationsState.vibrateEnabled,
enabled = state.messageNotificationsState.notificationsEnabled,
onCheckChanged = callbacks::setMessageNotificationsEnabled
)
}
item {
Rows.RadioListRow(
text = {
Box(
modifier = Modifier
.clip(CircleShape)
.size(24.dp)
.background(color = getLedColor(state.messageNotificationsState.ledColor))
)
Spacer(modifier = Modifier.size(10.dp))
Text(text = stringResource(R.string.preferences__led_color))
},
dialogTitle = stringResource(R.string.preferences__led_color),
labels = stringArrayResource(R.array.pref_led_color_entries),
values = stringArrayResource(R.array.pref_led_color_values),
selectedValue = state.messageNotificationsState.ledColor,
enabled = state.messageNotificationsState.notificationsEnabled,
onSelected = callbacks::setMessasgeNotificationLedColor
)
}
if (!deviceState.supportsNotificationChannels) {
item {
Rows.RadioListRow(
text = stringResource(R.string.preferences__pref_led_blink_title),
labels = stringArrayResource(R.array.pref_led_blink_pattern_entries),
values = stringArrayResource(R.array.pref_led_blink_pattern_values),
selectedValue = state.messageNotificationsState.ledBlink,
enabled = state.messageNotificationsState.notificationsEnabled,
onSelected = callbacks::setMessasgeNotificationLedBlink
)
}
}
}
return PorterDuffColorFilter(color, PorterDuff.Mode.SRC_IN)
item {
Rows.ToggleRow(
text = stringResource(R.string.preferences_notifications__in_chat_sounds),
checked = state.messageNotificationsState.inChatSoundsEnabled,
enabled = state.messageNotificationsState.notificationsEnabled,
onCheckChanged = callbacks::setMessageNotificationInChatSoundsEnabled
)
}
item {
Rows.RadioListRow(
text = stringResource(R.string.preferences__repeat_alerts),
labels = stringArrayResource(R.array.pref_repeat_alerts_entries),
values = stringArrayResource(R.array.pref_repeat_alerts_values),
selectedValue = state.messageNotificationsState.repeatAlerts.toString(),
enabled = state.messageNotificationsState.notificationsEnabled,
onSelected = callbacks::setMessageRepeatAlerts
)
}
item {
Rows.RadioListRow(
text = stringResource(R.string.preferences_notifications__show),
labels = stringArrayResource(R.array.pref_notification_privacy_entries),
values = stringArrayResource(R.array.pref_notification_privacy_values),
selectedValue = state.messageNotificationsState.messagePrivacy,
enabled = state.messageNotificationsState.notificationsEnabled,
onSelected = callbacks::setMessageNotificationPrivacy
)
}
if (deviceState.apiLevel >= 23 && state.messageNotificationsState.troubleshootNotifications) {
item {
Rows.TextRow(
text = stringResource(R.string.preferences_notifications__troubleshoot),
onClick = callbacks::onTroubleshootNotificationsClick
)
}
}
if (deviceState.apiLevel < 30) {
if (deviceState.supportsNotificationChannels) {
item {
Rows.TextRow(
text = stringResource(R.string.preferences_notifications__priority),
enabled = state.messageNotificationsState.notificationsEnabled,
onClick = callbacks::launchNotificationPriorityIntent
)
}
} else {
item {
Rows.RadioListRow(
text = stringResource(R.string.preferences_notifications__priority),
labels = stringArrayResource(R.array.pref_notification_priority_entries),
values = stringArrayResource(R.array.pref_notification_priority_values),
selectedValue = state.messageNotificationsState.priority.toString(),
enabled = state.messageNotificationsState.notificationsEnabled,
onSelected = callbacks::setMessageNotificationPriority
)
}
}
}
item {
Dividers.Default()
}
item {
Texts.SectionHeader(stringResource(R.string.NotificationsSettingsFragment__calls))
}
item {
Rows.ToggleRow(
text = stringResource(R.string.preferences__notifications),
enabled = state.callNotificationsState.canEnableNotifications,
checked = state.callNotificationsState.notificationsEnabled,
onCheckChanged = callbacks::setCallNotificationsEnabled
)
}
item {
val ringtoneSummary = remember(state.callNotificationsState.ringtone) {
callbacks.getRingtoneSummary(state.callNotificationsState.ringtone)
}
Rows.TextRow(
text = stringResource(R.string.preferences_notifications__ringtone),
label = ringtoneSummary,
enabled = state.callNotificationsState.notificationsEnabled,
onClick = callbacks::launchCallsSoundSelectionIntent
)
}
item {
Rows.ToggleRow(
text = stringResource(R.string.preferences__vibrate),
checked = state.callNotificationsState.vibrateEnabled,
enabled = state.callNotificationsState.notificationsEnabled,
onCheckChanged = callbacks::setCallVibrateEnabled
)
}
item {
Dividers.Default()
}
item {
Texts.SectionHeader(stringResource(R.string.NotificationsSettingsFragment__notification_profiles))
}
item {
Rows.TextRow(
text = stringResource(R.string.NotificationsSettingsFragment__profiles),
label = stringResource(R.string.NotificationsSettingsFragment__create_a_profile_to_receive_notifications_only_from_people_and_groups_you_choose),
onClick = callbacks::onNavigationProfilesClick
)
}
item {
Dividers.Default()
}
item {
Texts.SectionHeader(stringResource(R.string.NotificationsSettingsFragment__notify_when))
}
item {
Rows.ToggleRow(
text = stringResource(R.string.NotificationsSettingsFragment__contact_joins_signal),
checked = state.notifyWhenContactJoinsSignal,
onCheckChanged = callbacks::setNotifyWhenContactJoinsSignal
)
}
}
}
}
@Composable
private fun getLedColor(ledColorString: String): Color {
return when (ledColorString) {
"green" -> colorResource(R.color.green_500)
"red" -> colorResource(R.color.red_500)
"blue" -> colorResource(R.color.blue_500)
"yellow" -> colorResource(R.color.yellow_500)
"cyan" -> colorResource(R.color.cyan_500)
"magenta" -> colorResource(R.color.pink_500)
"white" -> colorResource(R.color.white)
else -> colorResource(R.color.transparent)
}
}
@SignalPreview
@Composable
private fun NotificationsSettingsScreenPreview() {
Previews.Preview {
NotificationsSettingsScreen(
deviceState = rememberTestDeviceState(),
state = rememberTestState(),
callbacks = NotificationsSettingsCallbacks.Empty
)
}
}
@SignalPreview
@Composable
private fun NotificationsSettingsScreenAPI21Preview() {
Previews.Preview {
NotificationsSettingsScreen(
deviceState = rememberTestDeviceState(apiLevel = 21, supportsNotificationChannels = false),
state = rememberTestState(),
callbacks = NotificationsSettingsCallbacks.Empty
)
}
}
@Composable
private fun rememberTestDeviceState(
apiLevel: Int = 35,
supportsNotificationChannels: Boolean = true
): DeviceState = remember {
DeviceState(
apiLevel = apiLevel,
supportsNotificationChannels = supportsNotificationChannels
)
}
@Composable
private fun rememberTestState(): NotificationsSettingsState = remember {
NotificationsSettingsState(
messageNotificationsState = MessageNotificationsState(
notificationsEnabled = true,
canEnableNotifications = true,
sound = Uri.EMPTY,
vibrateEnabled = true,
ledColor = "blue",
ledBlink = "",
inChatSoundsEnabled = true,
repeatAlerts = 1,
messagePrivacy = "",
priority = 1,
troubleshootNotifications = true
),
callNotificationsState = CallNotificationsState(
notificationsEnabled = true,
canEnableNotifications = true,
ringtone = Uri.EMPTY,
vibrateEnabled = true
),
notifyWhenContactJoinsSignal = true
)
}
data class DeviceState(
val apiLevel: Int = Build.VERSION.SDK_INT,
val supportsNotificationChannels: Boolean = NotificationChannels.supported()
)

View File

@@ -3,9 +3,11 @@ package org.thoughtcrime.securesms.components.settings.app.notifications
import android.content.SharedPreferences
import android.net.Uri
import android.os.Build
import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.notifications.DeviceSpecificNotificationConfig
@@ -13,13 +15,12 @@ import org.thoughtcrime.securesms.notifications.NotificationChannels
import org.thoughtcrime.securesms.notifications.SlowNotificationHeuristics
import org.thoughtcrime.securesms.preferences.widgets.NotificationPrivacyPreference
import org.thoughtcrime.securesms.util.TextSecurePreferences
import org.thoughtcrime.securesms.util.livedata.Store
class NotificationsSettingsViewModel(private val sharedPreferences: SharedPreferences) : ViewModel() {
private val store = Store(getState())
private val store = MutableStateFlow(getState())
val state: LiveData<NotificationsSettingsState> = store.stateLiveData
val state: StateFlow<NotificationsSettingsState> = store
init {
if (NotificationChannels.supported()) {

View File

@@ -0,0 +1,231 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.routes
import android.os.Parcelable
import androidx.annotation.StringRes
import kotlinx.parcelize.Parcelize
import org.signal.donations.InAppPaymentType
import org.thoughtcrime.securesms.database.model.DistributionListId
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.profiles.manage.UsernameEditMode
import org.thoughtcrime.securesms.recipients.RecipientId
/**
* Describes a route that the AppSettings screen can open. Every route listed here is displayed in
* the PRIMARY (detail) pane of the AppSettingsScreen.
*/
@Parcelize
sealed interface AppSettingsRoute : Parcelable {
/**
* Empty state, displayed when there is no current route. In this case, the "top" of our
* scaffold navigator should be the SECONDARY (list) pane.
*/
data object Empty : AppSettingsRoute
@Parcelize
sealed interface AccountRoute : AppSettingsRoute {
data object Account : AccountRoute
data object ManageProfile : AccountRoute
data object AdvancedPinSettings : AccountRoute
data object DeleteAccount : AccountRoute
data object ExportAccountData : AccountRoute
data object OldDeviceTransfer : AccountRoute
data class Username(val mode: UsernameEditMode = UsernameEditMode.NORMAL) : AccountRoute
}
data object Payments : AppSettingsRoute
data object Invite : AppSettingsRoute
data object AppUpdates : AppSettingsRoute
@Parcelize
sealed interface StoriesRoute : AppSettingsRoute {
data class Privacy(@StringRes val titleId: Int) : StoriesRoute
data object MyStory : StoriesRoute
data class PrivateStory(val distributionListId: DistributionListId) : StoriesRoute
data class GroupStory(val groupId: GroupId) : StoriesRoute
data object OnlyShareWith : StoriesRoute
data object AllExcept : StoriesRoute
data object SignalConnections : StoriesRoute
data class EditName(val distributionListId: DistributionListId, val name: String) : StoriesRoute
data class AddViewers(val distributionListId: DistributionListId) : StoriesRoute
}
@Parcelize
sealed interface UsernameLinkRoute : AppSettingsRoute {
data object UsernameLink : UsernameLinkRoute
data object QRColorPicker : UsernameLinkRoute
data object Share : UsernameLinkRoute
}
@Parcelize
sealed interface BackupsRoute : AppSettingsRoute {
data object Backups : BackupsRoute
data object Local : BackupsRoute
data class Remote(val backupLaterSelected: Boolean = false) : BackupsRoute
data object DisplayKey : BackupsRoute
}
@Parcelize
sealed interface NotificationsRoute : AppSettingsRoute {
data object Notifications : NotificationsRoute
data object NotificationProfiles : NotificationsRoute
data class EditProfile(val profileId: Long = -1L) : NotificationsRoute
data class ProfileDetails(val profileId: Long) : NotificationsRoute
data class AddAllowedMembers(val profileId: Long) : NotificationsRoute
data class SelectRecipients(val profileId: Long, val currentSelection: Array<RecipientId>? = null) : NotificationsRoute {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as SelectRecipients
if (profileId != other.profileId) return false
if (!currentSelection.contentEquals(other.currentSelection)) return false
return true
}
override fun hashCode(): Int {
var result = profileId.hashCode()
result = 31 * result + (currentSelection?.contentHashCode() ?: 0)
return result
}
}
data class EditSchedule(val profileId: Long, val createMode: Boolean) : NotificationsRoute
data class Created(val profileId: Long) : NotificationsRoute
}
@Parcelize
sealed interface DonationsRoute : AppSettingsRoute {
data class Donations(
val directToCheckoutType: InAppPaymentType = InAppPaymentType.UNKNOWN
) : DonationsRoute
data object Badges : DonationsRoute
data object Receipts : DonationsRoute
data class Receipt(val id: Long) : DonationsRoute
data object LearnMore : DonationsRoute
data object Featured : DonationsRoute
}
@Parcelize
sealed interface InternalRoute : AppSettingsRoute {
data object Internal : InternalRoute
data object DonorErrorConfiguration : InternalRoute
data object StoryDialogs : InternalRoute
data object Search : InternalRoute
data object SvrPlayground : InternalRoute
data object ChatSpringboard : InternalRoute
data object OneTimeDonationConfiguration : InternalRoute
data object TerminalDonationConfiguration : InternalRoute
data object BackupPlayground : InternalRoute
data object StorageServicePlayground : InternalRoute
data object SqlitePlayground : InternalRoute
data object ConversationTestFragment : InternalRoute
}
@Parcelize
sealed interface PrivacyRoute : AppSettingsRoute {
data object Privacy : PrivacyRoute
data object BlockedUsers : PrivacyRoute
data object Advanced : PrivacyRoute
data object ExpiringMessages : PrivacyRoute
data object PhoneNumberPrivacy : PrivacyRoute
data object ScreenLock : PrivacyRoute
}
@Parcelize
sealed interface DataAndStorageRoute : AppSettingsRoute {
data object DataAndStorage : DataAndStorageRoute
data object Storage : DataAndStorageRoute
data object Proxy : DataAndStorageRoute
}
@Parcelize
sealed interface HelpRoute : AppSettingsRoute {
data class Settings(
val startCategoryIndex: Int = 0
) : HelpRoute
data object Help : HelpRoute
data object DebugLog : HelpRoute
data object Licenses : HelpRoute
}
@Parcelize
sealed interface AppearanceRoute : AppSettingsRoute {
data object Appearance : AppearanceRoute
data object Wallpaper : AppearanceRoute
data object AppIconSelection : AppearanceRoute
data object AppIconTutorial : AppearanceRoute
}
@Parcelize
sealed interface ChatsRoute : AppSettingsRoute {
data object Chats : ChatsRoute
data object Reactions : ChatsRoute
}
@Parcelize
sealed interface ChatFoldersRoute : AppSettingsRoute {
data object ChatFolders : ChatFoldersRoute
data class CreateChatFolders(
val folderId: Long,
val threadIds: LongArray
) : ChatFoldersRoute {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as CreateChatFolders
if (folderId != other.folderId) return false
if (!threadIds.contentEquals(other.threadIds)) return false
return true
}
override fun hashCode(): Int {
var result = folderId.hashCode()
result = 31 * result + threadIds.contentHashCode()
return result
}
}
data object Education : ChatFoldersRoute
data object ChooseChats : ChatFoldersRoute
}
@Parcelize
sealed interface LinkDeviceRoute : AppSettingsRoute {
data object LinkDevice : LinkDeviceRoute
data object Finished : LinkDeviceRoute
data object LearnMore : LinkDeviceRoute
data object Education : LinkDeviceRoute
data object EditName : LinkDeviceRoute
data object Add : LinkDeviceRoute
data object Intro : LinkDeviceRoute
data object Sync : LinkDeviceRoute
}
@Parcelize
sealed interface ChangeNumberRoute : AppSettingsRoute {
data object Start : ChangeNumberRoute
data object EnterPhoneNumber : ChangeNumberRoute
data object Confirm : ChangeNumberRoute
data object CountryPicker : ChangeNumberRoute
data object Verify : ChangeNumberRoute
data object Captcha : ChangeNumberRoute
data object EnterCode : ChangeNumberRoute
data object RegistrationLock : ChangeNumberRoute
data object AccountLocked : ChangeNumberRoute
data object PinDiffers : ChangeNumberRoute
}
}

View File

@@ -0,0 +1,29 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.routes
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.launch
/**
* Router which manages what screen we are displaying in app settings. Underneath, this is a ViewModel
* that is tied to the top-level parent, so that all screens throughout the app settings can access it.
*
* This gives a single point to navigate to a new page, but assumes that the actual backstack of routes
* will be handled elsewhere. This just emits routing requests.
*/
class AppSettingsRouter() : ViewModel() {
val currentRoute = MutableSharedFlow<AppSettingsRoute>()
fun navigateTo(route: AppSettingsRoute) {
viewModelScope.launch {
currentRoute.emit(route)
}
}
}

View File

@@ -42,18 +42,16 @@ class ManageStorageSettingsViewModel : ViewModel() {
val state = store.asStateFlow()
init {
if (RemoteConfig.messageBackups) {
viewModelScope.launch(Dispatchers.IO) {
InAppPaymentsRepository.observeLatestBackupPayment()
.collectLatest { payment ->
store.update { it.copy(isPaidTierPending = payment.state == InAppPaymentTable.State.PENDING) }
}
}
viewModelScope.launch {
store.update {
it.copy(onDeviceStorageOptimizationState = getOnDeviceStorageOptimizationState())
viewModelScope.launch(Dispatchers.IO) {
InAppPaymentsRepository.observeLatestBackupPayment()
.collectLatest { payment ->
store.update { it.copy(isPaidTierPending = payment.state == InAppPaymentTable.State.PENDING) }
}
}
viewModelScope.launch {
store.update {
it.copy(onDeviceStorageOptimizationState = getOnDeviceStorageOptimizationState())
}
}
}
@@ -135,7 +133,7 @@ class ManageStorageSettingsViewModel : ViewModel() {
private suspend fun getOnDeviceStorageOptimizationState(): OnDeviceStorageOptimizationState {
return when {
!RemoteConfig.messageBackups || !SignalStore.backup.areBackupsEnabled || !AppDependencies.billingApi.isApiAvailable() || (!RemoteConfig.internalUser && !Environment.IS_STAGING) -> OnDeviceStorageOptimizationState.FEATURE_NOT_AVAILABLE
!SignalStore.backup.areBackupsEnabled || !AppDependencies.billingApi.getApiAvailability().isSuccess || (!RemoteConfig.internalUser && !Environment.IS_STAGING) -> OnDeviceStorageOptimizationState.FEATURE_NOT_AVAILABLE
SignalStore.backup.backupTier != MessageBackupTier.PAID -> OnDeviceStorageOptimizationState.REQUIRES_PAID_TIER
SignalStore.backup.optimizeStorage -> OnDeviceStorageOptimizationState.ENABLED
else -> OnDeviceStorageOptimizationState.DISABLED

View File

@@ -26,7 +26,6 @@ import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.databaseprotos.DonationErrorValue
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.RemoteConfig
/**
* Handles displaying bottom sheets for in-app payments. The current policy is to "fire and forget".
@@ -58,10 +57,7 @@ class InAppPaymentsBottomSheetDelegate(
handleLegacyTerminalDonationSheets()
handleLegacyVerifiedMonthlyDonationSheets()
handleInAppPaymentDonationSheets()
if (RemoteConfig.messageBackups) {
handleInAppPaymentBackupsSheets()
}
handleInAppPaymentBackupsSheets()
}
/**

View File

@@ -1,13 +1,17 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.errors
import android.Manifest
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
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.help.HelpFragment
@@ -18,7 +22,15 @@ import org.thoughtcrime.securesms.notifications.NotificationIds
* Donation-related push notifications.
*/
object DonationErrorNotifications {
private val TAG = Log.tag(DonationErrorNotifications::class)
fun displayErrorNotification(context: Context, donationError: DonationError) {
if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
Log.w(TAG, "Permission to post notifications is not granted.")
return
}
val parameters = DonationErrorParams.create(context, donationError, NotificationCallback)
val notification = NotificationCompat.Builder(context, NotificationChannels.getInstance().FAILURES)
.setSmallIcon(R.drawable.ic_notification)

View File

@@ -12,7 +12,6 @@ import com.google.android.material.transition.platform.MaterialContainerTransfor
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.DSLSettingsActivity
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.groups.ParcelableGroupId
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.DynamicConversationSettingsTheme
@@ -67,7 +66,7 @@ open class ConversationSettingsActivity : DSLSettingsActivity(), ConversationSet
@JvmStatic
fun forGroup(context: Context, groupId: GroupId): Intent {
val startBundle = ConversationSettingsFragmentArgs.Builder(null, ParcelableGroupId.from(groupId), null)
val startBundle = ConversationSettingsFragmentArgs.Builder(null, groupId, null)
.build()
.toBundle()
@@ -88,7 +87,7 @@ open class ConversationSettingsActivity : DSLSettingsActivity(), ConversationSet
@JvmStatic
fun forCall(context: Context, callPeer: Recipient, callMessageIds: LongArray): Intent {
val startBundleBuilder = if (callPeer.isGroup) {
ConversationSettingsFragmentArgs.Builder(null, ParcelableGroupId.from(callPeer.requireGroupId()), callMessageIds)
ConversationSettingsFragmentArgs.Builder(null, callPeer.requireGroupId(), callMessageIds)
} else {
ConversationSettingsFragmentArgs.Builder(callPeer.id, null, callMessageIds)
}

View File

@@ -69,7 +69,7 @@ import org.thoughtcrime.securesms.components.settings.conversation.preferences.U
import org.thoughtcrime.securesms.contacts.ContactSelectionDisplayMode
import org.thoughtcrime.securesms.conversation.ConversationIntents
import org.thoughtcrime.securesms.database.AttachmentTable
import org.thoughtcrime.securesms.groups.ParcelableGroupId
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.groups.ui.GroupErrors
import org.thoughtcrime.securesms.groups.ui.GroupLimitDialog
import org.thoughtcrime.securesms.groups.ui.LeaveGroupDialog
@@ -136,11 +136,11 @@ class ConversationSettingsFragment : DSLSettingsFragment(
private val viewModel by viewModels<ConversationSettingsViewModel>(
factoryProducer = {
val groupId = args.groupId as? ParcelableGroupId
val groupId = args.groupId as? GroupId
ConversationSettingsViewModel.Factory(
recipientId = args.recipientId,
groupId = ParcelableGroupId.get(groupId),
groupId = groupId,
callMessageIds = args.callMessageIds ?: longArrayOf(),
repository = ConversationSettingsRepository(requireContext()),
messageRequestRepository = MessageRequestRepository(requireContext())
@@ -210,9 +210,9 @@ class ConversationSettingsFragment : DSLSettingsFragment(
override fun onOptionsItemSelected(item: MenuItem): Boolean {
return if (item.itemId == R.id.action_edit) {
val args = ConversationSettingsFragmentArgs.fromBundle(requireArguments())
val groupId = args.groupId as ParcelableGroupId
val groupId = args.groupId as GroupId
startActivity(CreateProfileActivity.getIntentForGroupProfile(requireActivity(), requireNotNull(ParcelableGroupId.get(groupId))))
startActivity(CreateProfileActivity.getIntentForGroupProfile(requireActivity(), requireNotNull(groupId)))
true
} else {
super.onOptionsItemSelected(item)
@@ -820,7 +820,7 @@ class ConversationSettingsFragment : DSLSettingsFragment(
icon = DSLSettingsIcon.from(R.drawable.ic_lock_24),
isEnabled = !state.isDeprecatedOrUnregistered,
onClick = {
val action = ConversationSettingsFragmentDirections.actionConversationSettingsFragmentToPermissionsSettingsFragment(ParcelableGroupId.from(groupState.groupId))
val action = ConversationSettingsFragmentDirections.actionConversationSettingsFragmentToPermissionsSettingsFragment(groupState.groupId)
navController.safeNavigate(action)
}
)

View File

@@ -8,7 +8,7 @@ import org.thoughtcrime.securesms.components.settings.DSLConfiguration
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.groups.ParcelableGroupId
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.groups.ui.GroupErrors
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
@@ -23,7 +23,7 @@ class PermissionsSettingsFragment : DSLSettingsFragment(
private val viewModel: PermissionsSettingsViewModel by viewModels(
factoryProducer = {
val args = PermissionsSettingsFragmentArgs.fromBundle(requireArguments())
val groupId = requireNotNull(ParcelableGroupId.get(args.groupId as ParcelableGroupId))
val groupId = requireNotNull(args.groupId as GroupId)
val repository = PermissionsSettingsRepository(requireContext())
PermissionsSettingsViewModel.Factory(groupId, repository)

View File

@@ -6,6 +6,24 @@
package org.thoughtcrime.securesms.components.settings.models
import androidx.annotation.StringRes
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.unit.dp
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.SignalPreview
import org.signal.core.ui.compose.horizontalGutters
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.databinding.DslBannerBinding
import org.thoughtcrime.securesms.util.adapter.mapping.BindingFactory
import org.thoughtcrime.securesms.util.adapter.mapping.BindingViewHolder
@@ -42,3 +60,53 @@ object Banner {
}
}
}
/**
* Replicates the Banner DSL preference for use in compose components.
*/
@Composable
fun Banner(
text: String,
action: String,
onActionClick: () -> Unit
) {
OutlinedCard(
shape = RoundedCornerShape(18.dp),
border = BorderStroke(width = 1.dp, color = colorResource(R.color.signal_colorOutline_38)),
modifier = Modifier
.horizontalGutters()
.fillMaxWidth()
) {
Column {
Text(
text = text,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier
.padding(horizontal = 16.57.dp)
.padding(top = 16.dp, bottom = 10.dp)
)
TextButton(
onClick = onActionClick,
modifier = Modifier
.align(Alignment.End)
.padding(horizontal = 8.dp)
) {
Text(text = action)
}
}
}
}
@SignalPreview
@Composable
private fun BannerPreview() {
Previews.Preview {
Banner(
text = "Banner text will go here and probably be about something important",
action = "Action",
onActionClick = {}
)
}
}

View File

@@ -1,15 +1,19 @@
package org.thoughtcrime.securesms.components.webrtc;
import android.Manifest;
import android.app.Notification;
import android.app.PendingIntent;
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageManager;
import androidx.annotation.NonNull;
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.webrtc.v2.CallIntent;
import org.thoughtcrime.securesms.notifications.NotificationChannels;
@@ -20,12 +24,18 @@ import org.thoughtcrime.securesms.recipients.Recipient;
*/
public final class GroupCallSafetyNumberChangeNotificationUtil {
public static final String TAG = Log.tag(GroupCallSafetyNumberChangeNotificationUtil.class);
public static final String GROUP_CALLING_NOTIFICATION_TAG = "group_calling";
private GroupCallSafetyNumberChangeNotificationUtil() {
}
public static void showNotification(@NonNull Context context, @NonNull Recipient recipient) {
if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
Log.w(TAG, "showNotification: Notification permission is not granted.");
return;
}
Intent contentIntent = new Intent(context, CallIntent.getActivityClass());
contentIntent.setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP);

View File

@@ -0,0 +1,56 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.conversation
import android.net.Uri
import android.os.Parcelable
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.Serializable
import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.conversation.ConversationIntents.ConversationScreenType
import org.thoughtcrime.securesms.conversation.colors.ChatColors
import org.thoughtcrime.securesms.mediasend.Media
import org.thoughtcrime.securesms.mms.SlideFactory
import org.thoughtcrime.securesms.recipients.Recipient.Companion.resolved
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.serialization.UriSerializer
import org.thoughtcrime.securesms.stickers.StickerLocator
import org.thoughtcrime.securesms.wallpaper.ChatWallpaper
@Serializable
@Parcelize
data class ConversationArgs(
val recipientId: RecipientId,
@JvmField val threadId: Long,
val draftText: String?,
@Serializable(with = UriSerializer::class) val draftMedia: Uri?,
val draftContentType: String?,
val media: List<Media?>?,
val stickerLocator: StickerLocator?,
val isBorderless: Boolean,
val distributionType: Int,
val startingPosition: Int,
val isFirstTimeInSelfCreatedGroup: Boolean,
val isWithSearchOpen: Boolean,
val giftBadge: Badge?,
val shareDataTimestamp: Long,
val conversationScreenType: ConversationScreenType
) : Parcelable {
@IgnoredOnParcel
val draftMediaType: SlideFactory.MediaType? = SlideFactory.MediaType.from(draftContentType)
@IgnoredOnParcel
val wallpaper: ChatWallpaper?
get() = resolved(recipientId).wallpaper
@IgnoredOnParcel
val chatColors: ChatColors
get() = resolved(recipientId).chatColors
fun canInitializeFromDatabase(): Boolean {
return draftText == null && (draftMedia == null || ConversationIntents.isBubbleIntentUri(draftMedia) || ConversationIntents.isNotificationIntentUri(draftMedia)) && draftMediaType == null
}
}

View File

@@ -11,16 +11,13 @@ import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.thoughtcrime.securesms.badges.models.Badge;
import org.thoughtcrime.securesms.conversation.colors.ChatColors;
import org.thoughtcrime.securesms.conversation.v2.ConversationActivity;
import org.thoughtcrime.securesms.database.SignalDatabase;
import org.thoughtcrime.securesms.database.ThreadTable;
import org.thoughtcrime.securesms.mediasend.Media;
import org.thoughtcrime.securesms.mms.SlideFactory;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.stickers.StickerLocator;
import org.thoughtcrime.securesms.wallpaper.ChatWallpaper;
import org.whispersystems.signalservice.api.util.Preconditions;
import java.util.ArrayList;
@@ -99,6 +96,12 @@ public class ConversationIntents {
return new Builder(context, ConversationActivity.class, recipientId, threadId, ConversationScreenType.NORMAL);
}
public static @NonNull Builder createBuilderSync(@NonNull Context context, @NonNull ConversationArgs conversationArgs) {
Preconditions.checkArgument(conversationArgs.threadId > 0, "threadId is invalid");
return new Builder(context, ConversationActivity.class, conversationArgs.getRecipientId(), conversationArgs.threadId, ConversationScreenType.NORMAL)
.withArgs(conversationArgs);
}
static @Nullable Uri getIntentData(@NonNull Bundle bundle) {
return bundle.getParcelable(INTENT_DATA);
}
@@ -132,170 +135,41 @@ public class ConversationIntents {
return ACTION.equals(intent.getAction());
}
public final static class Args {
private final RecipientId recipientId;
private final long threadId;
private final String draftText;
private final Uri draftMedia;
private final String draftContentType;
private final SlideFactory.MediaType draftMediaType;
private final ArrayList<Media> media;
private final StickerLocator stickerLocator;
private final boolean isBorderless;
private final int distributionType;
private final int startingPosition;
private final boolean firstTimeInSelfCreatedGroup;
private final boolean withSearchOpen;
private final Badge giftBadge;
private final long shareDataTimestamp;
private final ConversationScreenType conversationScreenType;
public static Args from(@NonNull Bundle arguments) {
Uri intentDataUri = getIntentData(arguments);
if (isBubbleIntentUri(intentDataUri)) {
return new Args(RecipientId.from(intentDataUri.getQueryParameter(EXTRA_RECIPIENT)),
Long.parseLong(intentDataUri.getQueryParameter(EXTRA_THREAD_ID)),
null,
null,
null,
null,
null,
false,
ThreadTable.DistributionTypes.DEFAULT,
-1,
false,
false,
null,
-1L,
ConversationScreenType.BUBBLE);
}
return new Args(RecipientId.from(Objects.requireNonNull(arguments.getString(EXTRA_RECIPIENT))),
arguments.getLong(EXTRA_THREAD_ID, -1),
arguments.getString(EXTRA_TEXT),
ConversationIntents.getIntentData(arguments),
ConversationIntents.getIntentType(arguments),
arguments.getParcelableArrayList(EXTRA_MEDIA),
arguments.getParcelable(EXTRA_STICKER),
arguments.getBoolean(EXTRA_BORDERLESS, false),
arguments.getInt(EXTRA_DISTRIBUTION_TYPE, ThreadTable.DistributionTypes.DEFAULT),
arguments.getInt(EXTRA_STARTING_POSITION, -1),
arguments.getBoolean(EXTRA_FIRST_TIME_IN_SELF_CREATED_GROUP, false),
arguments.getBoolean(EXTRA_WITH_SEARCH_OPEN, false),
arguments.getParcelable(EXTRA_GIFT_BADGE),
arguments.getLong(EXTRA_SHARE_DATA_TIMESTAMP, -1L),
ConversationScreenType.from(arguments.getInt(EXTRA_CONVERSATION_TYPE, 0)));
public static ConversationArgs readArgsFromBundle(@NonNull Bundle arguments) {
Uri intentDataUri = getIntentData(arguments);
if (isBubbleIntentUri(intentDataUri)) {
return new ConversationArgs(RecipientId.from(intentDataUri.getQueryParameter(EXTRA_RECIPIENT)),
Long.parseLong(intentDataUri.getQueryParameter(EXTRA_THREAD_ID)),
null,
null,
null,
null,
null,
false,
ThreadTable.DistributionTypes.DEFAULT,
-1,
false,
false,
null,
-1L,
ConversationScreenType.BUBBLE);
}
private Args(@NonNull RecipientId recipientId,
long threadId,
@Nullable String draftText,
@Nullable Uri draftMedia,
@Nullable String draftContentType,
@Nullable ArrayList<Media> media,
@Nullable StickerLocator stickerLocator,
boolean isBorderless,
int distributionType,
int startingPosition,
boolean firstTimeInSelfCreatedGroup,
boolean withSearchOpen,
@Nullable Badge giftBadge,
long shareDataTimestamp,
@NonNull ConversationScreenType conversationScreenType)
{
this.recipientId = recipientId;
this.threadId = threadId;
this.draftText = draftText;
this.draftMedia = draftMedia;
this.draftContentType = draftContentType;
this.media = media;
this.stickerLocator = stickerLocator;
this.isBorderless = isBorderless;
this.distributionType = distributionType;
this.startingPosition = startingPosition;
this.firstTimeInSelfCreatedGroup = firstTimeInSelfCreatedGroup;
this.withSearchOpen = withSearchOpen;
this.giftBadge = giftBadge;
this.shareDataTimestamp = shareDataTimestamp;
this.conversationScreenType = conversationScreenType;
this.draftMediaType = SlideFactory.MediaType.from(draftContentType);
}
public @NonNull RecipientId getRecipientId() {
return recipientId;
}
public long getThreadId() {
return threadId;
}
public @Nullable String getDraftText() {
return draftText;
}
public @Nullable Uri getDraftMedia() {
return draftMedia;
}
public @Nullable String getDraftContentType() {
return draftContentType;
}
public @Nullable SlideFactory.MediaType getDraftMediaType() {
return draftMediaType;
}
public @Nullable ArrayList<Media> getMedia() {
return media;
}
public @Nullable StickerLocator getStickerLocator() {
return stickerLocator;
}
public int getDistributionType() {
return distributionType;
}
public int getStartingPosition() {
return startingPosition;
}
public boolean isBorderless() {
return isBorderless;
}
public boolean isFirstTimeInSelfCreatedGroup() {
return firstTimeInSelfCreatedGroup;
}
public @Nullable ChatWallpaper getWallpaper() {
return Recipient.resolved(recipientId).getWallpaper();
}
public @NonNull ChatColors getChatColors() {
return Recipient.resolved(recipientId).getChatColors();
}
public boolean isWithSearchOpen() {
return withSearchOpen;
}
public @Nullable Badge getGiftBadge() {
return giftBadge;
}
public long getShareDataTimestamp() {
return shareDataTimestamp;
}
public @NonNull ConversationScreenType getConversationScreenType() {
return conversationScreenType;
}
public boolean canInitializeFromDatabase() {
return draftText == null && (draftMedia == null || ConversationIntents.isBubbleIntentUri(draftMedia) || ConversationIntents.isNotificationIntentUri(draftMedia)) && draftMediaType == null;
}
return new ConversationArgs(RecipientId.from(Objects.requireNonNull(arguments.getString(EXTRA_RECIPIENT))),
arguments.getLong(EXTRA_THREAD_ID, -1),
arguments.getString(EXTRA_TEXT),
ConversationIntents.getIntentData(arguments),
ConversationIntents.getIntentType(arguments),
arguments.getParcelableArrayList(EXTRA_MEDIA),
arguments.getParcelable(EXTRA_STICKER),
arguments.getBoolean(EXTRA_BORDERLESS, false),
arguments.getInt(EXTRA_DISTRIBUTION_TYPE, ThreadTable.DistributionTypes.DEFAULT),
arguments.getInt(EXTRA_STARTING_POSITION, -1),
arguments.getBoolean(EXTRA_FIRST_TIME_IN_SELF_CREATED_GROUP, false),
arguments.getBoolean(EXTRA_WITH_SEARCH_OPEN, false),
arguments.getParcelable(EXTRA_GIFT_BADGE),
arguments.getLong(EXTRA_SHARE_DATA_TIMESTAMP, -1L),
ConversationScreenType.from(arguments.getInt(EXTRA_CONVERSATION_TYPE, 0)));
}
public final static class Builder {
@@ -331,6 +205,23 @@ public class ConversationIntents {
this.conversationScreenType = conversationScreenType;
}
public @NonNull Builder withArgs(@NonNull ConversationArgs args) {
draftText = args.getDraftText();
media = args.getMedia();
stickerLocator = args.getStickerLocator();
isBorderless = args.isBorderless();
distributionType = args.getDistributionType();
startingPosition = args.getStartingPosition();
dataType = args.getDraftContentType();
dataUri = args.getDraftMedia();
firstTimeInSelfCreatedGroup = args.isFirstTimeInSelfCreatedGroup();
withSearchOpen = args.isWithSearchOpen();
giftBadge = args.getGiftBadge();
shareDataTimestamp = args.getShareDataTimestamp();
return this;
}
public @NonNull Builder withDraftText(@Nullable String draftText) {
this.draftText = draftText;
return this;
@@ -391,6 +282,26 @@ public class ConversationIntents {
return this;
}
public @NonNull ConversationArgs toConversationArgs() {
return new ConversationArgs(
recipientId,
threadId,
draftText,
dataUri,
dataType,
media,
stickerLocator,
isBorderless,
distributionType,
startingPosition,
firstTimeInSelfCreatedGroup,
withSearchOpen,
giftBadge,
shareDataTimestamp,
conversationScreenType
);
}
public @NonNull Intent build() {
if (stickerLocator != null && media != null) {
throw new IllegalStateException("Cannot have both sticker and media array");

View File

@@ -18,7 +18,6 @@ import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.database.SignalDatabase;
import org.thoughtcrime.securesms.database.model.GroupRecord;
import org.thoughtcrime.securesms.groups.GroupId;
import org.thoughtcrime.securesms.groups.ParcelableGroupId;
import org.thoughtcrime.securesms.groups.ui.GroupMemberListView;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.util.BottomSheetUtil;
@@ -46,7 +45,7 @@ public final class ShowAdminsBottomSheetDialog extends BottomSheetDialogFragment
ShowAdminsBottomSheetDialog fragment = new ShowAdminsBottomSheetDialog();
Bundle args = new Bundle();
args.putParcelable(KEY_GROUP_ID, ParcelableGroupId.from(groupId));
args.putParcelable(KEY_GROUP_ID, groupId);
fragment.setArguments(args);
fragment.show(manager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG);
@@ -94,7 +93,7 @@ public final class ShowAdminsBottomSheetDialog extends BottomSheetDialogFragment
}
private GroupId getGroupId() {
return ParcelableGroupId.get(requireArguments().getParcelable(KEY_GROUP_ID));
return requireArguments().getParcelable(KEY_GROUP_ID);
}
@WorkerThread

View File

@@ -13,7 +13,7 @@ import org.signal.core.util.concurrent.SignalExecutors
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.components.location.SignalPlace
import org.thoughtcrime.securesms.components.mention.MentionAnnotation
import org.thoughtcrime.securesms.conversation.ConversationIntents
import org.thoughtcrime.securesms.conversation.ConversationArgs
import org.thoughtcrime.securesms.conversation.ConversationMessage
import org.thoughtcrime.securesms.conversation.ConversationMessage.ConversationMessageFactory
import org.thoughtcrime.securesms.conversation.MessageStyler
@@ -53,7 +53,7 @@ class DraftRepository(
private val threadTable: ThreadTable = SignalDatabase.threads,
private val draftTable: DraftTable = SignalDatabase.drafts,
private val saveDraftsExecutor: Executor = SerialMonoLifoExecutor(SignalExecutors.BOUNDED),
private val conversationArguments: ConversationIntents.Args? = null
private val conversationArguments: ConversationArgs? = null
) {
companion object {
@@ -115,7 +115,7 @@ class DraftRepository(
}
if (shareMediaList.isNotEmpty()) {
return ShareOrDraftData.StartSendMedia(shareMediaList, shareText) to null
return ShareOrDraftData.StartSendMedia(shareMediaList.filterNotNull(), shareText) to null
}
if (shareMedia != null && shareMediaType != null) {

View File

@@ -23,7 +23,6 @@ import org.thoughtcrime.securesms.mms.PartAuthority
import org.thoughtcrime.securesms.sharing.MultiShareArgs
import org.thoughtcrime.securesms.stories.Stories
import org.thoughtcrime.securesms.util.hasSharedContact
import java.util.Optional
import java.util.function.Consumer
/**
@@ -188,19 +187,19 @@ data class MultiselectForwardFragmentArgs @JvmOverloads constructor(
val uri = this.uri ?: return null
return Media(
uri,
contentType,
System.currentTimeMillis(),
width,
height,
size,
0,
borderless,
videoGif,
Optional.empty(),
Optional.ofNullable(caption),
Optional.ofNullable(transformProperties),
Optional.ofNullable(fileName)
uri = uri,
contentType = contentType,
date = System.currentTimeMillis(),
width = width,
height = height,
size = size,
duration = 0,
isBorderless = borderless,
isVideoGif = videoGif,
bucketId = null,
caption = caption,
transformProperties = transformProperties,
fileName = fileName
)
}
}

View File

@@ -20,10 +20,10 @@ import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaController
import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaControllerOwner
import org.thoughtcrime.securesms.conversation.ConversationIntents
import org.thoughtcrime.securesms.jobs.ConversationShortcutUpdateJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.ConfigurationUtil
import org.thoughtcrime.securesms.util.Debouncer
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme
import org.thoughtcrime.securesms.window.WindowSizeClass
import java.util.concurrent.TimeUnit
/**
@@ -53,7 +53,7 @@ open class ConversationActivity : PassphraseRequiredActivity(), VoiceNoteMediaCo
}
override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) {
if (SignalStore.internal.largeScreenUi) {
if (WindowSizeClass.isLargeScreenSupportEnabled()) {
startActivity(
MainActivity.clearTop(this).apply {
action = ConversationIntents.ACTION
@@ -66,6 +66,7 @@ open class ConversationActivity : PassphraseRequiredActivity(), VoiceNoteMediaCo
}
finish()
return
}
enableSavedStateHandles()

View File

@@ -154,6 +154,7 @@ import org.thoughtcrime.securesms.contactshare.SharedContactDetailsActivity
import org.thoughtcrime.securesms.conversation.AttachmentKeyboardButton
import org.thoughtcrime.securesms.conversation.BadDecryptLearnMoreDialog
import org.thoughtcrime.securesms.conversation.ConversationAdapter
import org.thoughtcrime.securesms.conversation.ConversationArgs
import org.thoughtcrime.securesms.conversation.ConversationBottomSheetCallback
import org.thoughtcrime.securesms.conversation.ConversationData
import org.thoughtcrime.securesms.conversation.ConversationHeaderView
@@ -256,6 +257,7 @@ import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.linkpreview.LinkPreview
import org.thoughtcrime.securesms.linkpreview.LinkPreviewViewModelV2
import org.thoughtcrime.securesms.longmessage.LongMessageFragment
import org.thoughtcrime.securesms.main.InsetsViewModel
import org.thoughtcrime.securesms.main.MainNavigationListLocation
import org.thoughtcrime.securesms.mediaoverview.MediaOverviewActivity
import org.thoughtcrime.securesms.mediapreview.MediaIntentFactory
@@ -348,6 +350,7 @@ import org.thoughtcrime.securesms.util.visible
import org.thoughtcrime.securesms.verify.VerifyIdentityActivity
import org.thoughtcrime.securesms.wallpaper.ChatWallpaper
import org.thoughtcrime.securesms.wallpaper.ChatWallpaperDimLevelUtil
import org.thoughtcrime.securesms.window.WindowSizeClass
import org.thoughtcrime.securesms.window.WindowSizeClass.Companion.getWindowSizeClass
import java.time.Instant
import java.time.LocalDateTime
@@ -390,8 +393,8 @@ class ConversationFragment :
private const val IS_SCROLLED_TO_BOTTOM_THRESHOLD: Int = 2
}
private val args: ConversationIntents.Args by lazy {
ConversationIntents.Args.from(requireArguments())
private val args: ConversationArgs by lazy {
ConversationIntents.readArgsFromBundle(requireArguments())
}
private val conversationRecipientRepository: ConversationRecipientRepository by lazy {
@@ -482,6 +485,8 @@ class ConversationFragment :
private val shareDataTimestampViewModel: ShareDataTimestampViewModel by activityViewModels()
private val insetsViewModel: InsetsViewModel by activityViewModels()
private val inlineQueryController: InlineQueryResultsControllerV2 by lazy {
InlineQueryResultsControllerV2(
this,
@@ -594,8 +599,21 @@ class ConversationFragment :
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
binding.toolbar.isBackInvokedCallbackEnabled = false
binding.root.setApplyRootInsets(!resources.getWindowSizeClass().isSplitPane())
binding.root.setUseWindowTypes(!resources.getWindowSizeClass().isSplitPane())
if (WindowSizeClass.isLargeScreenSupportEnabled()) {
viewLifecycleOwner.lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.RESUMED) {
binding.root.clearVerticalInsetOverride()
if (!resources.getWindowSizeClass().isSplitPane()) {
insetsViewModel.insets.collect {
binding.root.applyInsets(it)
}
}
}
}
}
binding.root.setApplyRootInsets(!WindowSizeClass.isLargeScreenSupportEnabled())
binding.root.setUseWindowTypes(!WindowSizeClass.isLargeScreenSupportEnabled())
disposables.bindTo(viewLifecycleOwner)
@@ -1329,19 +1347,19 @@ class ConversationFragment :
} else {
val mimeType = MediaUtil.getMimeType(requireContext(), uri) ?: mediaType.toFallbackMimeType()
val media = Media(
uri,
mimeType,
0,
width,
height,
0,
0,
borderless,
videoGif,
Optional.empty(),
Optional.empty(),
Optional.of(AttachmentTable.TransformProperties.forSentMediaQuality(SignalStore.settings.sentMediaQuality.code)),
Optional.empty()
uri = uri,
contentType = mimeType,
date = 0,
width = width,
height = height,
size = 0,
duration = 0,
isBorderless = borderless,
isVideoGif = videoGif,
bucketId = null,
caption = null,
transformProperties = AttachmentTable.TransformProperties.forSentMediaQuality(SignalStore.settings.sentMediaQuality.code),
fileName = null
)
conversationActivityResultContracts.launchMediaEditor(listOf(media), recipientId, composeText.textTrimmed)
}
@@ -1600,6 +1618,7 @@ class ConversationFragment :
composeText.setDraftText(data.text)
inputPanel.clickOnComposeInput()
}
is ShareOrDraftData.SetLocation -> attachmentManager.setLocation(data.location, MediaConstraints.getPushMediaConstraints())
is ShareOrDraftData.SetEditMessage -> {
composeText.setDraftText(data.draftText)
@@ -3218,9 +3237,13 @@ class ConversationFragment :
override fun onItemLongClick(itemView: View, item: MultiselectPart) {
Log.d(TAG, "onItemLongClick")
if (actionMode != null) { return }
if (actionMode != null) {
return
}
if (item.getMessageRecord().isInMemoryMessageRecord) { return }
if (item.getMessageRecord().isInMemoryMessageRecord) {
return
}
val messageRecord = item.getMessageRecord()
val recipient = viewModel.recipientSnapshot ?: return
@@ -3788,11 +3811,11 @@ class ConversationFragment :
val slides: List<Slide> = result.nonUploadedMedia.mapNotNull {
when {
MediaUtil.isVideoType(it.contentType) -> VideoSlide(requireContext(), it.uri, it.size, it.isVideoGif, it.width, it.height, it.caption.orNull(), it.transformProperties.orNull())
MediaUtil.isGif(it.contentType) -> GifSlide(requireContext(), it.uri, it.size, it.width, it.height, it.isBorderless, it.caption.orNull())
MediaUtil.isImageType(it.contentType) -> ImageSlide(requireContext(), it.uri, it.contentType, it.size, it.width, it.height, it.isBorderless, it.caption.orNull(), null, it.transformProperties.orNull())
MediaUtil.isVideoType(it.contentType) -> VideoSlide(requireContext(), it.uri, it.size, it.isVideoGif, it.width, it.height, it.caption, it.transformProperties)
MediaUtil.isGif(it.contentType) -> GifSlide(requireContext(), it.uri, it.size, it.width, it.height, it.isBorderless, it.caption)
MediaUtil.isImageType(it.contentType) -> ImageSlide(requireContext(), it.uri, it.contentType, it.size, it.width, it.height, it.isBorderless, it.caption, null, it.transformProperties)
MediaUtil.isDocumentType(it.contentType) -> {
DocumentSlide(requireContext(), it.uri, it.contentType, it.size, it.fileName.orNull())
DocumentSlide(requireContext(), it.uri, it.contentType!!, it.size, it.fileName)
}
else -> {

View File

@@ -9,7 +9,7 @@ import android.content.Intent
import android.os.Bundle
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import org.thoughtcrime.securesms.conversation.ConversationIntents
import org.thoughtcrime.securesms.conversation.ConversationArgs
import org.thoughtcrime.securesms.util.delegate
/**
@@ -33,7 +33,7 @@ class ShareDataTimestampViewModel(
}
}
fun setTimestampFromConversationArgs(args: ConversationIntents.Args) {
fun setTimestampFromConversationArgs(args: ConversationArgs) {
timestamp = args.shareDataTimestamp
}
}

View File

@@ -24,7 +24,6 @@ import org.thoughtcrime.securesms.database.model.MmsMessageRecord
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.messagerequests.MessageRequestRepository
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.RemoteConfig
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel
private typealias ConversationElement = MappingModel<*>
@@ -125,7 +124,7 @@ class ConversationDataSource(
records = MessageDataFetcher.updateModelsWithData(records, extraData).toMutableList()
stopwatch.split("models")
if (RemoteConfig.messageBackups && ArchiveRestoreProgress.state.activelyRestoring()) {
if (ArchiveRestoreProgress.state.activelyRestoring()) {
BackupRestoreManager.prioritizeAttachmentsIfNeeded(records)
stopwatch.split("restore")
}

View File

@@ -18,7 +18,6 @@ package org.thoughtcrime.securesms.conversationlist;
import android.annotation.SuppressLint;
import android.content.Context;
import android.content.Intent;
import android.content.res.ColorStateList;
import android.content.res.Configuration;
import android.content.res.Resources;
@@ -115,7 +114,7 @@ import org.thoughtcrime.securesms.contacts.paged.ContactSearchData;
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey;
import org.thoughtcrime.securesms.contacts.paged.ContactSearchMediator;
import org.thoughtcrime.securesms.contacts.paged.ContactSearchState;
import org.thoughtcrime.securesms.conversation.ConversationIntents;
import org.thoughtcrime.securesms.conversation.ConversationArgs;
import org.thoughtcrime.securesms.conversation.ConversationUpdateTick;
import org.thoughtcrime.securesms.conversationlist.chatfilter.ConversationFilterRequest;
import org.thoughtcrime.securesms.conversationlist.chatfilter.ConversationFilterSource;
@@ -417,10 +416,9 @@ public class ConversationListFragment extends MainFragment implements Conversati
lifecycleDisposable.add(mainNavigationViewModel.getDetailLocationObservable()
.subscribeOn(AndroidSchedulers.mainThread())
.subscribe(location -> {
if (location instanceof MainNavigationDetailLocation.Conversation) {
Intent intent = ((MainNavigationDetailLocation.Conversation) location).getIntent();
ConversationIntents.Args args = ConversationIntents.Args.from(Objects.requireNonNull(intent.getExtras()));
long threadId = args.getThreadId();
if (location instanceof MainNavigationDetailLocation.Chats.Conversation) {
ConversationArgs args = ((MainNavigationDetailLocation.Chats.Conversation) location).getConversationArgs();
long threadId = args.threadId;
defaultAdapter.setActiveThreadId(threadId);
}

View File

@@ -37,8 +37,9 @@ fun Fragment.listenToEventBusWhileResumed(
.collectLatest {
if (resources.getWindowSizeClass().isCompact()) {
when (it) {
is MainNavigationDetailLocation.Conversation -> unsubscribe()
is MainNavigationDetailLocation.Chats.Conversation -> unsubscribe()
MainNavigationDetailLocation.Empty -> subscribe()
else -> Unit
}
} else {
subscribe()

View File

@@ -196,7 +196,6 @@ class ConversationListFilterPullView @JvmOverloads constructor(
}
fun openImmediate() {
println("openImmediate from $state")
if (state == FilterPullState.CLOSED) {
setState(FilterPullState.OPEN_APEX, source)
setState(FilterPullState.OPENING, source)

View File

@@ -24,6 +24,7 @@ import org.whispersystems.signalservice.api.crypto.SealedSenderAccess;
import org.whispersystems.signalservice.api.crypto.UnidentifiedAccess;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.Iterator;
@@ -185,8 +186,15 @@ public class SealedSenderAccessUtil {
private static CertificateValidator buildCertificateValidator() {
try {
ECPublicKey unidentifiedSenderTrustRoot = new ECPublicKey(Base64.decode(BuildConfig.UNIDENTIFIED_SENDER_TRUST_ROOT));
return new CertificateValidator(unidentifiedSenderTrustRoot);
String[] base64Strings = BuildConfig.UNIDENTIFIED_SENDER_TRUST_ROOTS;
ArrayList<ECPublicKey> roots = new ArrayList<>(base64Strings.length);
for (String base64String: base64Strings) {
ECPublicKey unidentifiedSenderTrustRoot = new ECPublicKey(Base64.decode(base64String));
roots.add(unidentifiedSenderTrustRoot);
}
return new CertificateValidator(roots);
} catch (InvalidKeyException | IOException e) {
throw new AssertionError(e);
}

View File

@@ -30,6 +30,7 @@ import com.bumptech.glide.Glide
import com.fasterxml.jackson.annotation.JsonProperty
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.Serializable
import okio.ByteString
import okio.ByteString.Companion.toByteString
import org.json.JSONArray
@@ -73,6 +74,7 @@ import org.thoughtcrime.securesms.attachments.DatabaseAttachment
import org.thoughtcrime.securesms.attachments.LocalStickerAttachment
import org.thoughtcrime.securesms.attachments.WallpaperAttachment
import org.thoughtcrime.securesms.audio.AudioHash
import org.thoughtcrime.securesms.backup.v2.ArchivedMediaObject
import org.thoughtcrime.securesms.backup.v2.exporters.ChatItemArchiveExporter
import org.thoughtcrime.securesms.backup.v2.proto.BackupDebugInfo
import org.thoughtcrime.securesms.blurhash.BlurHash
@@ -429,34 +431,27 @@ class AttachmentTable(
}
/**
* Returns a cursor (with just the plaintextHash+remoteKey+archive_cdn) for all full-size attachments that are slated to be included in the current archive upload.
* Used for snapshotting data in [BackupMediaSnapshotTable].
* Returns a list that has any permanently-failed thumbnails removed.
*/
fun getFullSizeAttachmentsThatWillBeIncludedInArchive(): Cursor {
return readableDatabase
.select(DATA_HASH_END, REMOTE_KEY, ARCHIVE_CDN, QUOTE, CONTENT_TYPE)
.from("$TABLE_NAME LEFT JOIN ${MessageTable.TABLE_NAME} ON $TABLE_NAME.$MESSAGE_ID = ${MessageTable.TABLE_NAME}.${MessageTable.ID}")
.where(buildAttachmentsThatNeedUploadQuery(transferStateFilter = "$ARCHIVE_TRANSFER_STATE != ${ArchiveTransferState.PERMANENT_FAILURE.value}"))
.run()
}
fun filterPermanentlyFailedThumbnails(entries: Set<BackupMediaSnapshotTable.MediaEntry>): Set<BackupMediaSnapshotTable.MediaEntry> {
val entriesByMediaName: MutableMap<String, BackupMediaSnapshotTable.MediaEntry> = entries
.associateBy { MediaName.fromPlaintextHashAndRemoteKeyForThumbnail(it.plaintextHash, it.remoteKey).name }
.toMutableMap()
/**
* Returns a cursor (with just the plaintextHash+remoteKey+archive_cdn) for all thumbnail attachments that are slated to be included in the current archive upload.
* Used for snapshotting data in [BackupMediaSnapshotTable].
*/
fun getThumbnailAttachmentsThatWillBeIncludedInArchive(): Cursor {
return readableDatabase
.select(DATA_HASH_END, REMOTE_KEY, ARCHIVE_CDN, QUOTE, CONTENT_TYPE)
.from("$TABLE_NAME LEFT JOIN ${MessageTable.TABLE_NAME} ON $TABLE_NAME.$MESSAGE_ID = ${MessageTable.TABLE_NAME}.${MessageTable.ID}")
.where(
"""
${buildAttachmentsThatNeedUploadQuery(transferStateFilter = "$ARCHIVE_THUMBNAIL_TRANSFER_STATE != ${ArchiveTransferState.PERMANENT_FAILURE.value}")} AND
$QUOTE = 0 AND
($CONTENT_TYPE LIKE 'image/%' OR $CONTENT_TYPE LIKE 'video/%') AND
$CONTENT_TYPE != 'image/svg+xml'
"""
)
readableDatabase
.select(DATA_HASH_END, REMOTE_KEY)
.from(TABLE_NAME)
.where("$DATA_HASH_END NOT NULL AND $REMOTE_KEY NOT NULL AND $ARCHIVE_THUMBNAIL_TRANSFER_STATE = ${ArchiveTransferState.PERMANENT_FAILURE.value}")
.run()
.forEach { cursor ->
val hashEnd = cursor.requireNonNullString(DATA_HASH_END)
val remoteKey = cursor.requireNonNullString(REMOTE_KEY)
val thumbnailMediaName = MediaName.fromPlaintextHashAndRemoteKeyForThumbnail(Base64.decode(hashEnd), Base64.decode(remoteKey)).name
entriesByMediaName.remove(thumbnailMediaName)
}
return entriesByMediaName.values.toSet()
}
fun hasData(attachmentId: AttachmentId): Boolean {
@@ -564,6 +559,25 @@ class AttachmentTable(
.flatten()
}
fun getLocalArchivableAttachment(plaintextHash: String, remoteKey: String): LocalArchivableAttachment? {
return readableDatabase
.select(*PROJECTION)
.from(TABLE_NAME)
.where("$DATA_HASH_END = ? AND $REMOTE_KEY = ?")
.orderBy("$ID DESC")
.limit(1)
.run()
.readToSingleObject {
LocalArchivableAttachment(
file = File(it.requireNonNullString(DATA_FILE)),
random = it.requireNonNullBlob(DATA_RANDOM),
size = it.requireLong(DATA_SIZE),
remoteKey = Base64.decode(it.requireNonNullString(REMOTE_KEY)),
plaintextHash = Base64.decode(it.requireNonNullString(DATA_HASH_END))
)
}
}
fun getLocalArchivableAttachments(): List<LocalArchivableAttachment> {
return readableDatabase
.select(*PROJECTION)
@@ -2995,28 +3009,40 @@ class AttachmentTable(
.readToList { AttachmentId(it.requireLong(ID)) }
}
fun getEstimatedArchiveMediaSize(): Long {
val estimatedThumbnailCount = readableDatabase
.select("COUNT(*)")
.from(
"""
(
SELECT DISTINCT $DATA_HASH_END, $REMOTE_KEY
FROM $TABLE_NAME INNER JOIN ${MessageTable.TABLE_NAME} AS m ON $TABLE_NAME.$MESSAGE_ID = m.${MessageTable.ID}
WHERE
$DATA_FILE NOT NULL AND
$DATA_HASH_END NOT NULL AND
$REMOTE_KEY NOT NULL AND
$TRANSFER_STATE = $TRANSFER_PROGRESS_DONE AND
$ARCHIVE_TRANSFER_STATE != ${ArchiveTransferState.PERMANENT_FAILURE.value} AND
($CONTENT_TYPE LIKE 'image/%' OR $CONTENT_TYPE LIKE 'video/%') AND
$CONTENT_TYPE != 'image/svg+xml' AND
${getMessageDoesNotExpireWithinTimeoutClause(tablePrefix = "m")}
fun getPaidEstimatedArchiveMediaSize(): Long {
return getEstimatedArchiveMediaSize()
}
fun getFreeEstimatedArchiveMediaSize(afterTimestamp: Long): Long {
return getEstimatedArchiveMediaSize(afterTimestamp)
}
private fun getEstimatedArchiveMediaSize(afterTimestamp: Long = 0L): Long {
val estimatedThumbnailCount = if (afterTimestamp == 0L) {
readableDatabase
.select("COUNT(*)")
.from(
"""
(
SELECT DISTINCT $DATA_HASH_END, $REMOTE_KEY
FROM $TABLE_NAME INNER JOIN ${MessageTable.TABLE_NAME} AS m ON $TABLE_NAME.$MESSAGE_ID = m.${MessageTable.ID}
WHERE
$DATA_FILE NOT NULL AND
$DATA_HASH_END NOT NULL AND
$REMOTE_KEY NOT NULL AND
$TRANSFER_STATE = $TRANSFER_PROGRESS_DONE AND
$ARCHIVE_TRANSFER_STATE != ${ArchiveTransferState.PERMANENT_FAILURE.value} AND
($CONTENT_TYPE LIKE 'image/%' OR $CONTENT_TYPE LIKE 'video/%') AND
$CONTENT_TYPE != 'image/svg+xml' AND
${getMessageDoesNotExpireWithinTimeoutClause(tablePrefix = "m")}
)
"""
)
"""
)
.run()
.readToSingleLong(0L)
.run()
.readToSingleLong(0L)
} else {
0
}
val uploadedAttachmentBytes = readableDatabase
.rawQuery(
@@ -3031,6 +3057,7 @@ class AttachmentTable(
$REMOTE_KEY NOT NULL AND
$TRANSFER_STATE = $TRANSFER_PROGRESS_DONE AND
$ARCHIVE_TRANSFER_STATE != ${ArchiveTransferState.PERMANENT_FAILURE.value} AND
${if (afterTimestamp > 0) "m.${MessageTable.DATE_RECEIVED} >= $afterTimestamp AND" else ""}
${getMessageDoesNotExpireWithinTimeoutClause(tablePrefix = "m")}
)
"""
@@ -3174,6 +3201,32 @@ class AttachmentTable(
}
}
fun getMediaObjectsThatCantBeFound(objects: Set<ArchivedMediaObject>): Set<ArchivedMediaObject> {
if (objects.isEmpty()) {
return emptySet()
}
val objectsByMediaId: MutableMap<String, ArchivedMediaObject> = objects.associateBy { it.mediaId }.toMutableMap()
readableDatabase
.select(*PROJECTION)
.from(TABLE_NAME)
.where("$REMOTE_KEY NOT NULL AND $DATA_HASH_END NOT NULL")
.groupBy("$DATA_HASH_END, $REMOTE_KEY")
.run()
.forEach { cursor ->
val remoteKey = Base64.decode(cursor.requireNonNullString(REMOTE_KEY))
val plaintextHash = Base64.decode(cursor.requireNonNullString(DATA_HASH_END))
val mediaId = MediaName.fromPlaintextHashAndRemoteKey(plaintextHash, remoteKey).toMediaId(SignalStore.backup.mediaRootBackupKey).encode()
val mediaIdThumbnail = MediaName.fromPlaintextHashAndRemoteKeyForThumbnail(plaintextHash, remoteKey).toMediaId(SignalStore.backup.mediaRootBackupKey).encode()
objectsByMediaId.remove(mediaId)
objectsByMediaId.remove(mediaIdThumbnail)
}
return objectsByMediaId.values.toSet()
}
/**
* Important: This is an expensive query that involves iterating over every row in the table. Only call this for debug stuff!
*/
@@ -3186,10 +3239,9 @@ class AttachmentTable(
.select(*PROJECTION)
.from(TABLE_NAME)
.where("$REMOTE_KEY NOT NULL AND $DATA_HASH_END NOT NULL")
.groupBy(DATA_HASH_END)
.groupBy("$DATA_HASH_END, $REMOTE_KEY")
.run()
.forEach { cursor ->
val remoteKey = Base64.decode(cursor.requireNonNullString(REMOTE_KEY))
val plaintextHash = Base64.decode(cursor.requireNonNullString(DATA_HASH_END))
val mediaId = MediaName.fromPlaintextHashAndRemoteKey(plaintextHash, remoteKey).toMediaId(SignalStore.backup.mediaRootBackupKey).value.toByteString()
@@ -3212,7 +3264,6 @@ class AttachmentTable(
fun debugGetAttachmentStats(): DebugAttachmentStats {
val totalAttachmentRows = readableDatabase.count().from(TABLE_NAME).run().readToSingleLong(0)
val totalEligibleForUploadRows = getFullSizeAttachmentsThatWillBeIncludedInArchive().count
val totalUniqueDataFiles = readableDatabase.select("COUNT(DISTINCT $DATA_FILE)").from(TABLE_NAME).run().readToSingleLong(0)
val totalUniqueMediaNames = readableDatabase.query("SELECT COUNT(*) FROM (SELECT DISTINCT $DATA_HASH_END, $REMOTE_KEY FROM $TABLE_NAME WHERE $DATA_HASH_END NOT NULL AND $REMOTE_KEY NOT NULL)").readToSingleLong(0)
@@ -3282,15 +3333,19 @@ class AttachmentTable(
val uploadedThumbnailCount = archiveStatusMediaNameThumbnailCounts.getOrDefault(ArchiveTransferState.FINISHED, 0L)
val uploadedThumbnailBytes = uploadedThumbnailCount * RemoteConfig.backupMaxThumbnailFileSize.inWholeBytes
val lastSnapshotFullSizeCount = SignalDatabase.backupMediaSnapshots.debugGetFullSizeAttachmentCountForMostRecentSnapshot()
val lastSnapshotThumbnailCount = SignalDatabase.backupMediaSnapshots.debugGetThumbnailAttachmentCountForMostRecentSnapshot()
return DebugAttachmentStats(
totalAttachmentRows = totalAttachmentRows,
totalEligibleForUploadRows = totalEligibleForUploadRows.toLong(),
totalUniqueMediaNamesEligibleForUpload = totalUniqueMediaNamesEligibleForUpload,
totalUniqueDataFiles = totalUniqueDataFiles,
totalUniqueMediaNames = totalUniqueMediaNames,
archiveStatusMediaNameCounts = archiveStatusMediaNameCounts,
mediaNamesWithThumbnailsCount = uniqueEligibleMediaNamesWithThumbnailsCount,
archiveStatusMediaNameThumbnailCounts = archiveStatusMediaNameThumbnailCounts,
lastSnapshotFullSizeCount = lastSnapshotFullSizeCount.toLong(),
lastSnapshotThumbnailCount = lastSnapshotThumbnailCount.toLong(),
pendingAttachmentUploadBytes = pendingAttachmentUploadBytes,
uploadedAttachmentBytes = uploadedAttachmentBytes,
uploadedThumbnailBytes = uploadedThumbnailBytes
@@ -3493,6 +3548,7 @@ class AttachmentTable(
val random: ByteArray
)
@Serializable
@Parcelize
data class TransformProperties(
@JsonProperty("skipTransform")
@@ -3699,13 +3755,14 @@ class AttachmentTable(
data class DebugAttachmentStats(
val totalAttachmentRows: Long = 0L,
val totalEligibleForUploadRows: Long = 0L,
val totalUniqueMediaNamesEligibleForUpload: Long = 0L,
val totalUniqueDataFiles: Long = 0L,
val totalUniqueMediaNames: Long = 0L,
val archiveStatusMediaNameCounts: Map<ArchiveTransferState, Long> = emptyMap(),
val mediaNamesWithThumbnailsCount: Long = 0L,
val archiveStatusMediaNameThumbnailCounts: Map<ArchiveTransferState, Long> = emptyMap(),
val lastSnapshotFullSizeCount: Long = 0L,
val lastSnapshotThumbnailCount: Long = 0L,
val pendingAttachmentUploadBytes: Long = 0L,
val uploadedAttachmentBytes: Long = 0L,
val uploadedThumbnailBytes: Long = 0L
@@ -3719,12 +3776,13 @@ class AttachmentTable(
fun prettyString(): String {
return buildString {
appendLine("Total attachment rows: $totalAttachmentRows")
appendLine("Total eligible for upload rows: $totalEligibleForUploadRows")
appendLine("Total unique media names eligible for upload: $totalUniqueMediaNamesEligibleForUpload")
appendLine("Total unique data files: $totalUniqueDataFiles")
appendLine("Total unique media names: $totalUniqueMediaNames")
appendLine("Media names with thumbnails count: $mediaNamesWithThumbnailsCount")
appendLine("Pending attachment upload bytes: $pendingAttachmentUploadBytes")
appendLine("Last snapshot full-size count: $lastSnapshotFullSizeCount")
appendLine("Last snapshot thumbnail count : $lastSnapshotFullSizeCount")
appendLine("Uploaded attachment bytes: $uploadedAttachmentBytes")
appendLine("Uploaded thumbnail bytes: $uploadedThumbnailBytes")
appendLine("Total upload count: $totalUploadCount")
@@ -3748,10 +3806,11 @@ class AttachmentTable(
fun shortPrettyString(): String {
return buildString {
appendLine("Total eligible for upload rows: $totalEligibleForUploadRows")
appendLine("Total unique media names eligible for upload: $totalUniqueMediaNamesEligibleForUpload")
appendLine("Total unique data files: $totalUniqueDataFiles")
appendLine("Total unique media names: $totalUniqueMediaNames")
appendLine("Last snapshot full-size count: $lastSnapshotFullSizeCount")
appendLine("Last snapshot thumbnail count : $lastSnapshotFullSizeCount")
appendLine("Pending attachment upload bytes: $pendingAttachmentUploadBytes")
if (archiveStatusMediaNameCounts.isNotEmpty()) {

View File

@@ -10,9 +10,12 @@ import android.database.Cursor
import androidx.annotation.VisibleForTesting
import androidx.core.content.contentValuesOf
import org.signal.core.util.SqlUtil
import org.signal.core.util.count
import org.signal.core.util.delete
import org.signal.core.util.forEach
import org.signal.core.util.readToList
import org.signal.core.util.readToSet
import org.signal.core.util.readToSingleInt
import org.signal.core.util.readToSingleLong
import org.signal.core.util.requireBoolean
import org.signal.core.util.requireInt
@@ -128,33 +131,43 @@ class BackupMediaSnapshotTable(context: Context, database: SignalDatabase) : Dat
}
/**
* Writes the set of full-size media items that are slated to be referenced in the next backup, updating their pending sync time.
* Writes a set of [MediaEntry] that are slated to be referenced in the next backup, updating their pending sync time.
*/
fun writeFullSizePendingMediaObjects(mediaObjects: Sequence<ArchiveMediaItem>) {
mediaObjects
.chunked(SqlUtil.MAX_QUERY_ARGS)
.forEach { chunk ->
writePendingMediaObjectsChunk(
chunk.map { MediaEntry(it.mediaId, it.cdn, it.plaintextHash, it.remoteKey, isThumbnail = false) }
)
}
fun writePendingMediaEntries(entries: Collection<MediaEntry>) {
if (entries.isEmpty()) {
return
}
val values = entries.map {
contentValuesOf(
MEDIA_ID to it.mediaId,
CDN to it.cdn,
PLAINTEXT_HASH to it.plaintextHash,
REMOTE_KEY to it.remoteKey,
IS_THUMBNAIL to it.isThumbnail.toInt(),
SNAPSHOT_VERSION to UNKNOWN_VERSION,
IS_PENDING to 1
)
}
SqlUtil.buildBulkInsert(TABLE_NAME, arrayOf(MEDIA_ID, CDN, PLAINTEXT_HASH, REMOTE_KEY, IS_THUMBNAIL, SNAPSHOT_VERSION, IS_PENDING), values).forEach { query ->
writableDatabase.execSQL(
query.where +
"""
ON CONFLICT($MEDIA_ID) DO UPDATE SET
$CDN = excluded.$CDN,
$PLAINTEXT_HASH = excluded.$PLAINTEXT_HASH,
$REMOTE_KEY = excluded.$REMOTE_KEY,
$IS_THUMBNAIL = excluded.$IS_THUMBNAIL,
$IS_PENDING = excluded.$IS_PENDING
""",
query.whereArgs
)
}
}
/**
* Writes the set of thumbnail media items that are slated to be referenced in the next backup, updating their pending sync time.
*/
fun writeThumbnailPendingMediaObjects(mediaObjects: Sequence<ArchiveMediaItem>) {
mediaObjects
.chunked(SqlUtil.MAX_QUERY_ARGS)
.forEach { chunk ->
writePendingMediaObjectsChunk(
chunk.map { MediaEntry(it.thumbnailMediaId, it.cdn, it.plaintextHash, it.remoteKey, isThumbnail = true) }
)
}
}
/**
* Commits all pending entries (written via [writePendingMediaObjects]) to have a concrete [SNAPSHOT_VERSION]. The version will be 1 higher than the previous
* Commits all pending entries (written via [writePendingMediaEntries]) to have a concrete [SNAPSHOT_VERSION]. The version will be 1 higher than the previous
* snapshot version.
*/
fun commitPendingRows() {
@@ -214,6 +227,8 @@ class BackupMediaSnapshotTable(context: Context, database: SignalDatabase) : Dat
return emptySet()
}
val objectsByMediaId: MutableMap<String, ArchivedMediaObject> = objects.associateBy { it.mediaId }.toMutableMap()
val queries: List<SqlUtil.Query> = SqlUtil.buildCollectionQuery(
column = MEDIA_ID,
values = objects.map { it.mediaId },
@@ -221,20 +236,19 @@ class BackupMediaSnapshotTable(context: Context, database: SignalDatabase) : Dat
prefix = "$SNAPSHOT_VERSION = $MAX_VERSION AND "
)
val foundObjects: MutableSet<String> = mutableSetOf()
for (query in queries) {
foundObjects += readableDatabase
readableDatabase
.select(MEDIA_ID, CDN)
.from(TABLE_NAME)
.where(query.where, query.whereArgs)
.run()
.readToSet {
it.requireNonNullString(MEDIA_ID)
.forEach {
val mediaId = it.requireNonNullString(MEDIA_ID)
objectsByMediaId.remove(mediaId)
}
}
return objects.filterNot { foundObjects.contains(it.mediaId) }.toSet()
return objectsByMediaId.values.toSet()
}
fun getMediaEntriesForObjects(objects: List<ArchivedMediaObject>): Set<MediaEntry> {
@@ -324,37 +338,22 @@ class BackupMediaSnapshotTable(context: Context, database: SignalDatabase) : Dat
.run()
}
private fun writePendingMediaObjectsChunk(chunk: List<MediaEntry>) {
if (chunk.isEmpty()) {
return
}
fun debugGetFullSizeAttachmentCountForMostRecentSnapshot(): Int {
return readableDatabase
.count()
.from(TABLE_NAME)
.where("$IS_THUMBNAIL = 0 AND $SNAPSHOT_VERSION = $MAX_VERSION")
.run()
.readToSingleInt()
}
val values = chunk.map {
contentValuesOf(
MEDIA_ID to it.mediaId,
CDN to it.cdn,
PLAINTEXT_HASH to it.plaintextHash,
REMOTE_KEY to it.remoteKey,
IS_THUMBNAIL to it.isThumbnail.toInt(),
SNAPSHOT_VERSION to UNKNOWN_VERSION,
IS_PENDING to 1
)
}
val query = SqlUtil.buildSingleBulkInsert(TABLE_NAME, arrayOf(MEDIA_ID, CDN, PLAINTEXT_HASH, REMOTE_KEY, IS_THUMBNAIL, SNAPSHOT_VERSION, IS_PENDING), values)
writableDatabase.execSQL(
query.where +
"""
ON CONFLICT($MEDIA_ID) DO UPDATE SET
$CDN = excluded.$CDN,
$PLAINTEXT_HASH = excluded.$PLAINTEXT_HASH,
$REMOTE_KEY = excluded.$REMOTE_KEY,
$IS_THUMBNAIL = excluded.$IS_THUMBNAIL,
$IS_PENDING = excluded.$IS_PENDING
""",
query.whereArgs
)
fun debugGetThumbnailAttachmentCountForMostRecentSnapshot(): Int {
return readableDatabase
.count()
.from(TABLE_NAME)
.where("$IS_THUMBNAIL != 0 AND $SNAPSHOT_VERSION = $MAX_VERSION")
.run()
.readToSingleInt()
}
class ArchiveMediaItem(

View File

@@ -93,21 +93,26 @@ class EmojiSearchTable(context: Context, databaseHelper: SignalDatabase) : Datab
/**
* Deletes the content of the current search index and replaces it with the new one.
*/
fun setSearchIndex(searchIndex: List<EmojiSearchData>) {
val db = databaseHelper.signalReadableDatabase
db.withinTransaction {
fun setSearchIndex(
localizedSearchIndex: List<EmojiSearchData>,
englishSearchIndex: List<EmojiSearchData>
) {
databaseHelper.signalReadableDatabase.withinTransaction { db ->
db.delete(TABLE_NAME, null, null)
db.insert(localizedSearchIndex)
db.insert(englishSearchIndex)
}
}
for (searchData in searchIndex) {
for (label in searchData.tags) {
val values = contentValuesOf(
LABEL to label,
EMOJI to searchData.emoji,
RANK to if (searchData.rank == 0) Int.MAX_VALUE else searchData.rank
)
db.insert(TABLE_NAME, null, values)
}
private fun SQLiteDatabase.insert(searchIndex: List<EmojiSearchData>) {
for (searchData in searchIndex) {
for (label in searchData.tags) {
val values = contentValuesOf(
LABEL to label,
EMOJI to searchData.emoji,
RANK to if (searchData.rank == 0) Int.MAX_VALUE else searchData.rank
)
insert(TABLE_NAME, null, values)
}
}
}

View File

@@ -3745,9 +3745,9 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
}
if (blockedGroupIds.isNotEmpty()) {
val groupIds: List<GroupId.V1> = blockedGroupIds.mapNotNull { raw ->
val groupIds: List<GroupId.V1> = blockedGroupIds.filterNotNull().mapNotNull { raw ->
try {
GroupId.v1(raw)
raw?.let { GroupId.v1(it) }
} catch (e: BadGroupIdException) {
Log.w(TAG, "[applyBlockedUpdate] Bad GV1 ID!")
null

View File

@@ -306,7 +306,11 @@ object SignalDatabaseMigrations {
fun migrate(context: Application, db: SignalSqliteDatabase, oldVersion: Int, newVersion: Int) {
val initialForeignKeyState = db.areForeignKeyConstraintsEnabled()
val eligibleMigrations = migrations.filter { (version, _) -> version > oldVersion && version <= newVersion }
val eligibleMigrations = if (newVersion < 0) {
migrations.filter { (version, _) -> version > oldVersion }
} else {
migrations.filter { (version, _) -> version > oldVersion && version <= newVersion }
}
for (migrationData in eligibleMigrations) {
val (version, migration) = migrationData

View File

@@ -174,10 +174,9 @@ public class ApplicationDependencyProvider implements AppDependencies.Provider {
keysApi,
Optional.of(new SecurityEventListener(context)),
SignalExecutors.newCachedBoundedExecutor("signal-messages", ThreadUtil.PRIORITY_IMPORTANT_BACKGROUND_THREAD, 1, 16, 30),
ByteUnit.KILOBYTES.toBytes(256),
RemoteConfig.maxEnvelopeSizeBytes(),
RemoteConfig::useMessageSendRestFallback,
RemoteConfig.usePqRatchet(),
RemoteConfig.internalUser() ? Optional.of(ByteUnit.KILOBYTES.toBytes(96)) : Optional.empty());
RemoteConfig.usePqRatchet());
}
@Override
@@ -276,7 +275,7 @@ public class ApplicationDependencyProvider implements AppDependencies.Provider {
public @NonNull Network provideLibsignalNetwork(@NonNull SignalServiceConfiguration config) {
Network network = new Network(BuildConfig.LIBSIGNAL_NET_ENV, StandardUserAgentInterceptor.USER_AGENT);
LibSignalNetworkExtensions.applyConfiguration(network, config);
LibSignalNetworkExtensions.buildAndSetRemoteConfig(network, RemoteConfig.libsignalEnforceMinTlsVersion());
network.setRemoteConfig(RemoteConfig.getLibsignalConfigs());
return network;
}
@@ -511,7 +510,7 @@ public class ApplicationDependencyProvider implements AppDependencies.Provider {
@Override
public @NonNull BillingApi provideBillingApi() {
return BillingFactory.create(GooglePlayBillingDependencies.INSTANCE, RemoteConfig.messageBackups() && Environment.Backups.supportsGooglePlayBilling());
return BillingFactory.create(GooglePlayBillingDependencies.INSTANCE, Environment.Backups.supportsGooglePlayBilling());
}
@Override

View File

@@ -44,12 +44,7 @@ final class NewDeviceServerTask implements ServerTask {
DataRestoreConstraint.setRestoringData(true);
SQLiteDatabase database = SignalDatabase.getBackupDatabase();
String passphrase;
if (RemoteConfig.restoreAfterRegistration()) {
passphrase = SignalStore.account().getAccountEntropyPool().getValue();
} else {
passphrase = "deadbeef";
}
String passphrase = SignalStore.account().getAccountEntropyPool().getValue();
BackupPassphrase.set(context, passphrase);
FullBackupImporter.importFile(context,
@@ -57,7 +52,7 @@ final class NewDeviceServerTask implements ServerTask {
database,
inputStream,
passphrase,
RemoteConfig.restoreAfterRegistration());
true);
SignalDatabase.runPostBackupRestoreTasks(database);
NotificationChannels.getInstance().restoreContactNotificationChannels();

View File

@@ -16,8 +16,8 @@ import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobs.ReclaimUsernameAndLinkJob
import org.thoughtcrime.securesms.keyvalue.Completed
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.registration.data.RegistrationRepository
import org.thoughtcrime.securesms.registration.util.RegistrationUtil
import org.thoughtcrime.securesms.registrationv3.data.RegistrationRepository
class NewDeviceTransferViewModel : ViewModel() {
fun onRestoreComplete(context: Context, onComplete: () -> Unit) {

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