mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-13 13:33:20 +01:00
Compare commits
195 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b9abe9c119 | ||
|
|
cb1605bf23 | ||
|
|
dcc533ef49 | ||
|
|
cdafe47c9a | ||
|
|
365ad54f10 | ||
|
|
ded8c99ce2 | ||
|
|
b1d7da5320 | ||
|
|
467fa11a17 | ||
|
|
3346497a25 | ||
|
|
6ea0e176c9 | ||
|
|
8ea443cde1 | ||
|
|
c2d0d80b9f | ||
|
|
cbe72307a0 | ||
|
|
e57b47ec82 | ||
|
|
518bf04e1d | ||
|
|
a430e9b3d3 | ||
|
|
75ce72ee83 | ||
|
|
5d60ab35de | ||
|
|
33f9369883 | ||
|
|
7d1abf0f7c | ||
|
|
17d1061204 | ||
|
|
feb37eea2d | ||
|
|
6bde2fd20a | ||
|
|
7b25cc399d | ||
|
|
525175f04a | ||
|
|
a2aabeaad2 | ||
|
|
cdfcdcc3b7 | ||
|
|
56244ad873 | ||
|
|
e6399517ee | ||
|
|
1c3223f551 | ||
|
|
f4f2976907 | ||
|
|
76f65198bb | ||
|
|
971bcf4f41 | ||
|
|
b49074a786 | ||
|
|
eea89d3b62 | ||
|
|
3f7b73cf5e | ||
|
|
cbc547d322 | ||
|
|
c9a59a7417 | ||
|
|
f8eaa96412 | ||
|
|
33a7f55fa3 | ||
|
|
7cbee2e5f4 | ||
|
|
56725f0f5c | ||
|
|
7b6c2fa729 | ||
|
|
7b34dc75b3 | ||
|
|
8d3d86372f | ||
|
|
7de9218b80 | ||
|
|
f9ddba5aed | ||
|
|
9ab1996f4a | ||
|
|
c7666626a1 | ||
|
|
9a6c869bb5 | ||
|
|
ac86140133 | ||
|
|
534756c833 | ||
|
|
0f35eb7f7b | ||
|
|
a5cca5b0fd | ||
|
|
6e8f982e7b | ||
|
|
a14517fceb | ||
|
|
fe17e01ff5 | ||
|
|
585fb3eea8 | ||
|
|
3e07834c20 | ||
|
|
14cc0f12a6 | ||
|
|
1d403d3dee | ||
|
|
d36a4232be | ||
|
|
5b8750a84f | ||
|
|
0323cb5d98 | ||
|
|
f4369f90e0 | ||
|
|
8b19cbb603 | ||
|
|
aa3a797e19 | ||
|
|
827ceafffb | ||
|
|
cf1afb739f | ||
|
|
b9fe377afd | ||
|
|
a381697949 | ||
|
|
2d87078495 | ||
|
|
1b9695cb98 | ||
|
|
5324290fab | ||
|
|
b8e4ffb5ae | ||
|
|
67a693107e | ||
|
|
e08b86cda6 | ||
|
|
92bab9fb20 | ||
|
|
e7502f08ce | ||
|
|
3a530022fc | ||
|
|
2c8144b32f | ||
|
|
87535a917a | ||
|
|
76448f5426 | ||
|
|
019df97a22 | ||
|
|
51897bb74f | ||
|
|
5f3b4056e9 | ||
|
|
73a3c21716 | ||
|
|
a37209d8ba | ||
|
|
415021eedf | ||
|
|
ea6d512cc8 | ||
|
|
fba6673907 | ||
|
|
faba4682ed | ||
|
|
71b92f03bc | ||
|
|
d4a1cb0bfb | ||
|
|
e16ca2b2d2 | ||
|
|
77e678e05c | ||
|
|
efe0e3b816 | ||
|
|
6c497e131a | ||
|
|
ccb8c1b1b9 | ||
|
|
4aa965144d | ||
|
|
786bcc3da7 | ||
|
|
4447b29e6c | ||
|
|
3ebbb94a1a | ||
|
|
64a7cdafa8 | ||
|
|
c3350c0bb0 | ||
|
|
e2be1e0c79 | ||
|
|
228a993237 | ||
|
|
04923487c4 | ||
|
|
9777aa411c | ||
|
|
d0c1e93b3c | ||
|
|
9b517a14cb | ||
|
|
369085e162 | ||
|
|
93815a0504 | ||
|
|
b88097a6ae | ||
|
|
120cc9c521 | ||
|
|
58304a0fb6 | ||
|
|
6e867d678c | ||
|
|
8b2f58e0e7 | ||
|
|
6976ac7d44 | ||
|
|
8dc2077ad0 | ||
|
|
52fa86046b | ||
|
|
3352ebaa06 | ||
|
|
cbfdc4b57a | ||
|
|
c5753b96ff | ||
|
|
f39ad24cc1 | ||
|
|
6b6877bae7 | ||
|
|
930254da7b | ||
|
|
3df2fa53e8 | ||
|
|
c901639ce8 | ||
|
|
9e1cec7a60 | ||
|
|
9269c66d1e | ||
|
|
fd999be41a | ||
|
|
146a5f5701 | ||
|
|
d49ef1dd7d | ||
|
|
49c5fead39 | ||
|
|
9c705f3a45 | ||
|
|
bea204ab82 | ||
|
|
9350438866 | ||
|
|
4d827adc8b | ||
|
|
9f839b75fb | ||
|
|
c0482e8247 | ||
|
|
17f27f45fc | ||
|
|
2401e33222 | ||
|
|
4345179a1d | ||
|
|
5aa6fc78ee | ||
|
|
e0a86ead58 | ||
|
|
169d0fa964 | ||
|
|
c5397bc7d2 | ||
|
|
43f6e0ad8e | ||
|
|
736811393f | ||
|
|
957ddc82b5 | ||
|
|
16d6e98355 | ||
|
|
2a90809ba3 | ||
|
|
0713a88ddb | ||
|
|
c78b47fbe3 | ||
|
|
5807cbc9e9 | ||
|
|
6d90330e86 | ||
|
|
862bab55af | ||
|
|
7235a3730c | ||
|
|
c24993960d | ||
|
|
7f429dc769 | ||
|
|
a575626abb | ||
|
|
0b71b1837c | ||
|
|
f0df1b99e5 | ||
|
|
23b7ea90a1 | ||
|
|
53a6b0c719 | ||
|
|
bf3135b2d0 | ||
|
|
897461b594 | ||
|
|
63800306a0 | ||
|
|
b649b8c943 | ||
|
|
2c0aa40c61 | ||
|
|
2eb4f650d8 | ||
|
|
7af811eb3f | ||
|
|
d7f43c436e | ||
|
|
2792b9e676 | ||
|
|
bdf2ef5a05 | ||
|
|
23b5a3dcb0 | ||
|
|
909ea6b925 | ||
|
|
a5922c31b1 | ||
|
|
d8758bcc4e | ||
|
|
f88181cc82 | ||
|
|
c3f1036686 | ||
|
|
96292cd4a1 | ||
|
|
81f6035027 | ||
|
|
52005cf62c | ||
|
|
f5effa5be9 | ||
|
|
cae7906f04 | ||
|
|
7ea8cc6b0a | ||
|
|
8669a3d6e0 | ||
|
|
cb3bc91865 | ||
|
|
1a0c4b8135 | ||
|
|
6a456a288d | ||
|
|
901a81fb74 | ||
|
|
b1b99855b2 | ||
|
|
c6f0b4cf83 |
@@ -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 = 1603
|
||||
val canonicalVersionName = "7.61.3"
|
||||
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 {
|
||||
@@ -293,6 +293,7 @@ android {
|
||||
manifestPlaceholders["mapsKey"] = getMapsKey()
|
||||
|
||||
buildConfigField("String", "BUILD_VARIANT_TYPE", "\"Debug\"")
|
||||
buildConfigField("boolean", "LINK_DEVICE_UX_ENABLED", "true")
|
||||
}
|
||||
|
||||
getByName("release") {
|
||||
@@ -318,7 +319,6 @@ android {
|
||||
isMinifyEnabled = false
|
||||
matchingFallbacks += "debug"
|
||||
buildConfigField("String", "BUILD_VARIANT_TYPE", "\"Spinner\"")
|
||||
buildConfigField("boolean", "LINK_DEVICE_UX_ENABLED", "true")
|
||||
}
|
||||
|
||||
create("perf") {
|
||||
@@ -378,7 +378,7 @@ 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")
|
||||
buildConfigField("boolean", "LINK_DEVICE_UX_ENABLED", "true")
|
||||
}
|
||||
|
||||
create("prod") {
|
||||
@@ -405,7 +405,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 +417,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 +428,6 @@ android {
|
||||
|
||||
buildConfigField("boolean", "MANAGES_APP_UPDATES", "true")
|
||||
buildConfigField("String", "BUILD_ENVIRONMENT_TYPE", "\"Backup\"")
|
||||
buildConfigField("boolean", "MESSAGE_BACKUP_RESTORE_ENABLED", "true")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -511,7 +509,6 @@ dependencies {
|
||||
implementation(project(":core-ui"))
|
||||
|
||||
implementation(libs.androidx.fragment.ktx)
|
||||
implementation(libs.androidx.fragment.compose)
|
||||
implementation(libs.androidx.appcompat) {
|
||||
version {
|
||||
strictly("1.6.1")
|
||||
@@ -607,6 +604,7 @@ dependencies {
|
||||
implementation(libs.rxdogtag)
|
||||
implementation(libs.androidx.credentials)
|
||||
implementation(libs.androidx.credentials.compat)
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
|
||||
implementation(project(":billing"))
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -32,6 +31,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.R
|
||||
@@ -43,7 +43,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
|
||||
|
||||
@@ -65,11 +64,9 @@ class MessageBackupsCheckoutActivityTest {
|
||||
@Before
|
||||
fun setUp() {
|
||||
every { AppDependencies.billingApi.getBillingPurchaseResults() } returns purchaseResults
|
||||
coEvery { AppDependencies.billingApi.getApiAvailability() } returns BillingResponseCode.OK
|
||||
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
|
||||
|
||||
@@ -36,6 +36,8 @@ import org.thoughtcrime.securesms.groups.GroupId
|
||||
import org.thoughtcrime.securesms.groups.GroupMigrationMembershipChange
|
||||
import org.thoughtcrime.securesms.linkpreview.LinkPreview
|
||||
import org.thoughtcrime.securesms.mediapreview.MediaIntentFactory
|
||||
import org.thoughtcrime.securesms.polls.PollOption
|
||||
import org.thoughtcrime.securesms.polls.PollRecord
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.stickers.StickerLocator
|
||||
@@ -348,5 +350,11 @@ class V2ConversationItemShapeTest {
|
||||
override fun onShowUnverifiedProfileSheet(forGroup: Boolean) = Unit
|
||||
|
||||
override fun onUpdateSignalClicked() = Unit
|
||||
|
||||
override fun onViewResultsClicked(pollId: Long) = Unit
|
||||
|
||||
override fun onViewPollClicked(messageId: Long) = Unit
|
||||
|
||||
override fun onToggleVote(poll: PollRecord, pollOption: PollOption, isChecked: Boolean) = Unit
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import org.junit.Ignore
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.signal.core.util.Base64
|
||||
import org.signal.core.util.Base64.decodeBase64OrThrow
|
||||
import org.signal.core.util.copyTo
|
||||
import org.signal.core.util.stream.NullOutputStream
|
||||
@@ -37,6 +38,7 @@ import org.whispersystems.signalservice.api.crypto.AttachmentCipherOutputStream
|
||||
import org.whispersystems.signalservice.api.crypto.NoCipherOutputStream
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentPointer
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentRemoteId
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.File
|
||||
import java.util.Optional
|
||||
@@ -326,6 +328,92 @@ class AttachmentTableTest {
|
||||
assertThat(attachments).isEmpty()
|
||||
}
|
||||
|
||||
/**
|
||||
* There's a race condition where the following was happening:
|
||||
*
|
||||
* 1. Receive attachment A
|
||||
* 2. Download attachment A
|
||||
* 3. Enqueue copy to archive job for A (old media name)
|
||||
* 4. Receive attachment B that is identical to A
|
||||
* 5. Dedupe B with A's data file but update A to match B's "newer" remote key
|
||||
* 6. Enqueue copy to archive job for B (new media name)
|
||||
* 7. Copy to archive for A succeeds for old media name, updating A and B to FINISHED
|
||||
* 8. Copy to archive for B for new media name early aborts because B is already marked FINISHED
|
||||
*
|
||||
* THe problem is Step 7 because it's marking attachments as archived but under the old media and not the new media name.
|
||||
*
|
||||
* This tests recreates the flow but ensures Step 7 doesn't mark A and B as finished so that Step 8 will not early abort and copy
|
||||
* B over with the new media name.
|
||||
*/
|
||||
@Test
|
||||
fun givenAnDuplicateAttachmentPriorToCopyToArchive_whenICopyFirstAttachmentToArchive_thenIDoNotExpectBothAttachmentsToChangeArchiveStateToFinished() {
|
||||
val data = byteArrayOf(1, 2, 3, 4, 5)
|
||||
|
||||
val attachment1 = createAttachmentPointer("remote-key-1".toByteArray(), data.size)
|
||||
val attachment2 = createAttachmentPointer("remote-key-2".toByteArray(), data.size)
|
||||
|
||||
// Insert Message 1
|
||||
val message1Result = SignalDatabase.messages.insertMessageInbox(createIncomingMessage(serverTime = 0.days, attachment = attachment1)).get()
|
||||
val message1Id = message1Result.messageId
|
||||
val attachment1Id = message1Result.insertedAttachments!![attachment1]!!
|
||||
// AttachmentDownloadJob#onAdded
|
||||
SignalDatabase.attachments.setTransferState(message1Id, attachment1Id, AttachmentTable.TRANSFER_PROGRESS_STARTED)
|
||||
|
||||
// Insert Message 2
|
||||
val message2Result = SignalDatabase.messages.insertMessageInbox(createIncomingMessage(serverTime = 1.days, attachment = attachment2)).get()
|
||||
val message2Id = message2Result.messageId
|
||||
val attachment2Id = message2Result.insertedAttachments!![attachment2]!!
|
||||
// AttachmentDownloadJob#onAdded
|
||||
SignalDatabase.attachments.setTransferState(message2Id, attachment2Id, AttachmentTable.TRANSFER_PROGRESS_STARTED)
|
||||
|
||||
// Finalize Attachment 1 download
|
||||
SignalDatabase.attachments.finalizeAttachmentAfterDownload(message1Id, attachment1Id, ByteArrayInputStream(data))
|
||||
// CopyAttachmentToArchiveJob#onAdded
|
||||
SignalDatabase.attachments.setArchiveTransferState(attachment1Id, AttachmentTable.ArchiveTransferState.COPY_PENDING)
|
||||
|
||||
// Verify Attachment 1 data matches original Attachment 1 data from insert
|
||||
var dbAttachment1 = SignalDatabase.attachments.getAttachment(attachment1Id)!!
|
||||
assertThat(dbAttachment1.archiveTransferState).isEqualTo(AttachmentTable.ArchiveTransferState.COPY_PENDING)
|
||||
assertThat(dbAttachment1.remoteKey).isEqualTo(Base64.encodeWithPadding("remote-key-1".toByteArray()))
|
||||
|
||||
val attachment1InitialRemoteKey = dbAttachment1.remoteKey!!
|
||||
val attachment1InitialPlaintextHash = dbAttachment1.dataHash!!
|
||||
|
||||
// Finalize Attachment 2
|
||||
SignalDatabase.attachments.finalizeAttachmentAfterDownload(message2Id, attachment2Id, ByteArrayInputStream(data))
|
||||
|
||||
// Verify Attachment 1 data matches Attachment 2 data from insert and dedupe in finalize
|
||||
dbAttachment1 = SignalDatabase.attachments.getAttachment(attachment1Id)!!
|
||||
var dbAttachment2 = SignalDatabase.attachments.getAttachment(attachment2Id)!!
|
||||
assertThat(dbAttachment1.archiveTransferState).isEqualTo(AttachmentTable.ArchiveTransferState.NONE)
|
||||
assertThat(dbAttachment2.archiveTransferState).isEqualTo(AttachmentTable.ArchiveTransferState.NONE)
|
||||
assertThat(dbAttachment1.remoteKey).isEqualTo(dbAttachment2.remoteKey)
|
||||
assertThat(dbAttachment1.dataHash).isEqualTo(dbAttachment2.dataHash)
|
||||
|
||||
val attachment2InitialRemoteKey = dbAttachment2.remoteKey!!
|
||||
val attachment2InitialPlaintextHash = dbAttachment2.dataHash!!
|
||||
|
||||
// "Finish" Copy to Archive for Attachment 1
|
||||
SignalDatabase.attachments.setArchiveTransferState(attachment1Id, attachment1InitialRemoteKey, attachment1InitialPlaintextHash, AttachmentTable.ArchiveTransferState.FINISHED)
|
||||
|
||||
dbAttachment1 = SignalDatabase.attachments.getAttachment(attachment1Id)!!
|
||||
dbAttachment2 = SignalDatabase.attachments.getAttachment(attachment2Id)!!
|
||||
|
||||
// Verify Attachment 1 and 2 are not updated as FINISHED since Attachment 1's media name parts have changed
|
||||
assertThat(dbAttachment1.archiveTransferState).isEqualTo(AttachmentTable.ArchiveTransferState.NONE)
|
||||
assertThat(dbAttachment2.archiveTransferState).isEqualTo(AttachmentTable.ArchiveTransferState.NONE)
|
||||
|
||||
// "Finish" Copy to Archive for Attachment 2
|
||||
SignalDatabase.attachments.setArchiveTransferState(attachment2Id, attachment2InitialRemoteKey, attachment2InitialPlaintextHash, AttachmentTable.ArchiveTransferState.FINISHED)
|
||||
|
||||
dbAttachment1 = SignalDatabase.attachments.getAttachment(attachment1Id)!!
|
||||
dbAttachment2 = SignalDatabase.attachments.getAttachment(attachment2Id)!!
|
||||
|
||||
// Verify Attachment 1 and 2 are updated as FINISHED
|
||||
assertThat(dbAttachment1.archiveTransferState).isEqualTo(AttachmentTable.ArchiveTransferState.FINISHED)
|
||||
assertThat(dbAttachment2.archiveTransferState).isEqualTo(AttachmentTable.ArchiveTransferState.FINISHED)
|
||||
}
|
||||
|
||||
private fun createIncomingMessage(
|
||||
serverTime: Duration,
|
||||
attachment: Attachment,
|
||||
@@ -343,7 +431,7 @@ class AttachmentTableTest {
|
||||
)
|
||||
}
|
||||
|
||||
private fun createAttachmentPointer(key: ByteArray, digest: ByteArray, size: Int): Attachment {
|
||||
private fun createAttachmentPointer(key: ByteArray, size: Int): Attachment {
|
||||
return PointerAttachment.forPointer(
|
||||
pointer = Optional.of(
|
||||
SignalServiceAttachmentPointer(
|
||||
@@ -355,7 +443,7 @@ class AttachmentTableTest {
|
||||
preview = Optional.empty(),
|
||||
width = 2,
|
||||
height = 2,
|
||||
digest = Optional.of(digest),
|
||||
digest = Optional.of(byteArrayOf()),
|
||||
incrementalDigest = Optional.empty(),
|
||||
incrementalMacChunkSize = 0,
|
||||
fileName = Optional.of("file.jpg"),
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,15 +9,10 @@ import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
import org.signal.core.util.readToSingleObject
|
||||
import org.signal.core.util.requireLongOrNull
|
||||
import org.signal.core.util.select
|
||||
import org.signal.core.util.update
|
||||
import org.signal.libsignal.protocol.ecc.ECKeyPair
|
||||
import org.signal.libsignal.protocol.kem.KEMKeyPair
|
||||
import org.signal.libsignal.protocol.kem.KEMKeyType
|
||||
import org.signal.libsignal.protocol.state.KyberPreKeyRecord
|
||||
import org.whispersystems.signalservice.api.push.ServiceId
|
||||
import org.signal.libsignal.protocol.ReusedBaseKeyException
|
||||
import org.thoughtcrime.securesms.util.KyberPreKeysTestUtil.generateECPublicKey
|
||||
import org.thoughtcrime.securesms.util.KyberPreKeysTestUtil.getStaleTime
|
||||
import org.thoughtcrime.securesms.util.KyberPreKeysTestUtil.insertTestRecord
|
||||
import org.whispersystems.signalservice.api.push.ServiceId.ACI
|
||||
import org.whispersystems.signalservice.api.push.ServiceId.PNI
|
||||
import java.util.UUID
|
||||
@@ -142,42 +137,43 @@ class KyberPreKeyTableTest {
|
||||
assertNotNull(getStaleTime(aci, 3))
|
||||
}
|
||||
|
||||
private fun insertTestRecord(account: ServiceId, id: Int, staleTime: Long = 0, lastResort: Boolean = false) {
|
||||
val kemKeyPair = KEMKeyPair.generate(KEMKeyType.KYBER_1024)
|
||||
SignalDatabase.kyberPreKeys.insert(
|
||||
serviceId = account,
|
||||
keyId = id,
|
||||
record = KyberPreKeyRecord(
|
||||
id,
|
||||
System.currentTimeMillis(),
|
||||
kemKeyPair,
|
||||
ECKeyPair.generate().privateKey.calculateSignature(kemKeyPair.publicKey.serialize())
|
||||
),
|
||||
lastResort = lastResort
|
||||
@Test(expected = ReusedBaseKeyException::class)
|
||||
fun handleMarkKyberPreKeyUsed_doesNotAllowDuplicateLastResortKeyEntries() {
|
||||
insertTestRecord(aci, id = 1, staleTime = 10, lastResort = true)
|
||||
val publicKey = generateECPublicKey()
|
||||
|
||||
SignalDatabase.kyberPreKeys.handleMarkKyberPreKeyUsed(
|
||||
serviceId = aci,
|
||||
kyberPreKeyId = 1,
|
||||
signedPreKeyId = 1,
|
||||
baseKey = publicKey
|
||||
)
|
||||
|
||||
val count = SignalDatabase.rawDatabase
|
||||
.update(KyberPreKeyTable.TABLE_NAME)
|
||||
.values(KyberPreKeyTable.STALE_TIMESTAMP to staleTime)
|
||||
.where("${KyberPreKeyTable.ACCOUNT_ID} = ? AND ${KyberPreKeyTable.KEY_ID} = $id", account.toAccountId())
|
||||
.run()
|
||||
|
||||
assertEquals(1, count)
|
||||
SignalDatabase.kyberPreKeys.handleMarkKyberPreKeyUsed(
|
||||
serviceId = aci,
|
||||
kyberPreKeyId = 1,
|
||||
signedPreKeyId = 1,
|
||||
baseKey = publicKey
|
||||
)
|
||||
}
|
||||
|
||||
private fun getStaleTime(account: ServiceId, id: Int): Long? {
|
||||
return SignalDatabase.rawDatabase
|
||||
.select(KyberPreKeyTable.STALE_TIMESTAMP)
|
||||
.from(KyberPreKeyTable.TABLE_NAME)
|
||||
.where("${KyberPreKeyTable.ACCOUNT_ID} = ? AND ${KyberPreKeyTable.KEY_ID} = $id", account.toAccountId())
|
||||
.run()
|
||||
.readToSingleObject { it.requireLongOrNull(KyberPreKeyTable.STALE_TIMESTAMP) }
|
||||
}
|
||||
@Test
|
||||
fun handleMarkKyberPreKeyUsed_allowDuplicateNonLastResortKeyEntries() {
|
||||
insertTestRecord(aci, id = 1, staleTime = 10, lastResort = false)
|
||||
val publicKey = generateECPublicKey()
|
||||
|
||||
private fun ServiceId.toAccountId(): String {
|
||||
return when (this) {
|
||||
is ACI -> this.toString()
|
||||
is PNI -> KyberPreKeyTable.PNI_ACCOUNT_ID
|
||||
}
|
||||
SignalDatabase.kyberPreKeys.handleMarkKyberPreKeyUsed(
|
||||
serviceId = aci,
|
||||
kyberPreKeyId = 1,
|
||||
signedPreKeyId = 1,
|
||||
baseKey = publicKey
|
||||
)
|
||||
|
||||
SignalDatabase.kyberPreKeys.handleMarkKyberPreKeyUsed(
|
||||
serviceId = aci,
|
||||
kyberPreKeyId = 1,
|
||||
signedPreKeyId = 1,
|
||||
baseKey = publicKey
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
package org.thoughtcrime.securesms.database
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.signal.core.util.deleteAll
|
||||
import org.thoughtcrime.securesms.database.model.MessageId
|
||||
import org.thoughtcrime.securesms.mms.IncomingMessage
|
||||
import org.thoughtcrime.securesms.polls.PollOption
|
||||
import org.thoughtcrime.securesms.polls.PollRecord
|
||||
import org.thoughtcrime.securesms.polls.Voter
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.testing.SignalActivityRule
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class PollTablesTest {
|
||||
|
||||
@get:Rule
|
||||
val harness = SignalActivityRule()
|
||||
|
||||
private lateinit var poll1: PollRecord
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
poll1 = PollRecord(
|
||||
id = 1,
|
||||
question = "how do you feel about unit testing?",
|
||||
pollOptions = listOf(
|
||||
PollOption(1, "yay", listOf(Voter(1, 1))),
|
||||
PollOption(2, "ok", emptyList()),
|
||||
PollOption(3, "nay", emptyList())
|
||||
),
|
||||
allowMultipleVotes = false,
|
||||
hasEnded = false,
|
||||
authorId = 1,
|
||||
messageId = 1
|
||||
)
|
||||
|
||||
SignalDatabase.polls.writableDatabase.deleteAll(PollTables.PollTable.TABLE_NAME)
|
||||
SignalDatabase.polls.writableDatabase.deleteAll(PollTables.PollOptionTable.TABLE_NAME)
|
||||
SignalDatabase.polls.writableDatabase.deleteAll(PollTables.PollVoteTable.TABLE_NAME)
|
||||
|
||||
val message = IncomingMessage(type = MessageType.NORMAL, from = harness.others[0], sentTimeMillis = 100, serverTimeMillis = 100, receivedTimeMillis = 100)
|
||||
SignalDatabase.messages.insertMessageInbox(message, SignalDatabase.threads.getOrCreateThreadIdFor(harness.others[0], isGroup = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenAPollWithVoting_whenIGetPoll_thenIExpectThatPoll() {
|
||||
SignalDatabase.polls.insertPoll("how do you feel about unit testing?", false, listOf("yay", "ok", "nay"), 1, 1)
|
||||
SignalDatabase.polls.insertVotes(pollId = 1, pollOptionIds = listOf(1), voterId = 1, voteCount = 1, messageId = MessageId(1))
|
||||
|
||||
assertEquals(poll1, SignalDatabase.polls.getPoll(1))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenAPoll_whenIGetItsOptionIds_thenIExpectAllOptionsIds() {
|
||||
SignalDatabase.polls.insertPoll("how do you feel about unit testing?", false, listOf("yay", "ok", "nay"), 1, 1)
|
||||
assertEquals(poll1.pollOptions.map { it.id }, SignalDatabase.polls.getPollOptionIds(1))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenAPollAndVoter_whenIGetItsVoteCount_thenIExpectTheCorrectVoterCount() {
|
||||
SignalDatabase.polls.insertPoll("how do you feel about unit testing?", false, listOf("yay", "ok", "nay"), 1, 1)
|
||||
SignalDatabase.polls.insertVotes(pollId = 1, pollOptionIds = listOf(1), voterId = 1, voteCount = 1, messageId = MessageId(1))
|
||||
SignalDatabase.polls.insertVotes(pollId = 1, pollOptionIds = listOf(2), voterId = 2, voteCount = 2, messageId = MessageId(1))
|
||||
SignalDatabase.polls.insertVotes(pollId = 1, pollOptionIds = listOf(3), voterId = 3, voteCount = 3, messageId = MessageId(1))
|
||||
|
||||
assertEquals(1, SignalDatabase.polls.getCurrentPollVoteCount(1, 1))
|
||||
assertEquals(2, SignalDatabase.polls.getCurrentPollVoteCount(1, 2))
|
||||
assertEquals(3, SignalDatabase.polls.getCurrentPollVoteCount(1, 3))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenMultipleRoundsOfVoting_whenIGetItsCount_thenIExpectTheMostRecentResults() {
|
||||
SignalDatabase.polls.insertPoll("how do you feel about unit testing?", false, listOf("yay", "ok", "nay"), 1, 1)
|
||||
SignalDatabase.polls.insertVotes(pollId = 1, pollOptionIds = listOf(2), voterId = 1, voteCount = 1, messageId = MessageId(1))
|
||||
SignalDatabase.polls.insertVotes(pollId = 1, pollOptionIds = listOf(3), voterId = 1, voteCount = 2, messageId = MessageId(1))
|
||||
SignalDatabase.polls.insertVotes(pollId = 1, pollOptionIds = listOf(1), voterId = 1, voteCount = 3, messageId = MessageId(1))
|
||||
|
||||
assertEquals(listOf(Voter(1, 3)), SignalDatabase.polls.getPoll(1)!!.pollOptions[0].voters)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenAPoll_whenITerminateIt_thenIExpectItToEnd() {
|
||||
SignalDatabase.polls.insertPoll("how do you feel about unit testing?", false, listOf("yay", "ok", "nay"), 1, 1)
|
||||
SignalDatabase.polls.endPoll(1, System.currentTimeMillis())
|
||||
|
||||
assertEquals(true, SignalDatabase.polls.getPoll(1)!!.hasEnded)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenAPoll_whenIIVote_thenIExpectThatVote() {
|
||||
SignalDatabase.polls.insertPoll("how do you feel about unit testing?", false, listOf("yay", "ok", "nay"), 1, 1)
|
||||
val poll = SignalDatabase.polls.getPoll(1)!!
|
||||
val pollOption = poll.pollOptions.first()
|
||||
|
||||
val voteCount = SignalDatabase.polls.insertVote(poll, pollOption)
|
||||
|
||||
assertEquals(1, voteCount)
|
||||
assertEquals(listOf(0), SignalDatabase.polls.getVotes(poll.id, false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenAPoll_whenIRemoveVote_thenVoteIsCleared() {
|
||||
SignalDatabase.polls.insertPoll("how do you feel about unit testing?", false, listOf("yay", "ok", "nay"), 1, 1)
|
||||
val poll = SignalDatabase.polls.getPoll(1)!!
|
||||
val pollOption = poll.pollOptions.first()
|
||||
|
||||
val voteCount = SignalDatabase.polls.removeVote(poll, pollOption)
|
||||
SignalDatabase.polls.markPendingAsRemoved(poll.id, Recipient.self().id.toLong(), voteCount, 1)
|
||||
|
||||
assertEquals(1, voteCount)
|
||||
val status = SignalDatabase.polls.getPollVoteStateForGivenVote(poll.id, voteCount)
|
||||
assertEquals(PollTables.VoteState.REMOVED, status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenAVote_whenISetPollOptionId_thenOptionIdIsUpdated() {
|
||||
SignalDatabase.polls.insertPoll("how do you feel about unit testing?", false, listOf("yay", "ok", "nay"), 1, 1)
|
||||
val poll = SignalDatabase.polls.getPoll(1)!!
|
||||
val option = poll.pollOptions.first()
|
||||
|
||||
SignalDatabase.polls.insertVotes(poll.id, listOf(option.id), Recipient.self().id.toLong(), 5, MessageId(1))
|
||||
SignalDatabase.polls.setPollVoteStateForGivenVote(poll.id, Recipient.self().id.toLong(), 5, 1, true)
|
||||
val status = SignalDatabase.polls.getPollVoteStateForGivenVote(poll.id, 5)
|
||||
|
||||
assertEquals(PollTables.VoteState.ADDED, status)
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ import org.thoughtcrime.securesms.testing.runSync
|
||||
import org.thoughtcrime.securesms.testing.success
|
||||
import org.whispersystems.signalservice.api.SignalServiceDataStore
|
||||
import org.whispersystems.signalservice.api.SignalServiceMessageSender
|
||||
import org.whispersystems.signalservice.api.account.AccountApi
|
||||
import org.whispersystems.signalservice.api.archive.ArchiveApi
|
||||
import org.whispersystems.signalservice.api.attachment.AttachmentApi
|
||||
import org.whispersystems.signalservice.api.donations.DonationsApi
|
||||
@@ -56,6 +57,7 @@ class InstrumentationApplicationDependencyProvider(val application: Application,
|
||||
private val recipientCache: LiveRecipientCache
|
||||
private var signalServiceMessageSender: SignalServiceMessageSender? = null
|
||||
private var billingApi: BillingApi = mockk()
|
||||
private var accountApi: AccountApi = mockk()
|
||||
|
||||
init {
|
||||
runSync {
|
||||
@@ -118,6 +120,8 @@ class InstrumentationApplicationDependencyProvider(val application: Application,
|
||||
|
||||
override fun provideBillingApi(): BillingApi = billingApi
|
||||
|
||||
override fun provideAccountApi(authWebSocket: SignalWebSocket.AuthenticatedWebSocket): AccountApi = accountApi
|
||||
|
||||
override fun provideSignalServiceNetworkAccess(): SignalServiceNetworkAccess {
|
||||
return serviceNetworkAccessMock
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -12,10 +12,10 @@ import assertk.assertions.isTrue
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkObject
|
||||
import io.mockk.unmockkAll
|
||||
import io.mockk.verify
|
||||
import okio.IOException
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
@@ -24,11 +24,13 @@ import org.junit.runner.RunWith
|
||||
import org.signal.core.util.billing.BillingProduct
|
||||
import org.signal.core.util.billing.BillingPurchaseResult
|
||||
import org.signal.core.util.billing.BillingPurchaseState
|
||||
import org.signal.core.util.billing.BillingResponseCode
|
||||
import org.signal.core.util.money.FiatMoney
|
||||
import org.signal.donations.InAppPaymentType
|
||||
import org.thoughtcrime.securesms.backup.DeletionState
|
||||
import org.thoughtcrime.securesms.backup.v2.BackupRepository
|
||||
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
|
||||
import org.thoughtcrime.securesms.components.settings.app.backups.BackupStateObserver
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.RecurringInAppPaymentRepository
|
||||
import org.thoughtcrime.securesms.database.InAppPaymentTable
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
@@ -37,6 +39,7 @@ import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.jobmanager.Job
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.net.SignalNetwork
|
||||
import org.thoughtcrime.securesms.testing.SignalActivityRule
|
||||
import org.thoughtcrime.securesms.util.RemoteConfig
|
||||
import org.whispersystems.signalservice.api.NetworkResult
|
||||
@@ -46,6 +49,7 @@ import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription
|
||||
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription.ChargeFailure
|
||||
import org.whispersystems.signalservice.api.subscriptions.SubscriberId
|
||||
import org.whispersystems.signalservice.internal.push.SubscriptionsConfiguration
|
||||
import org.whispersystems.signalservice.internal.push.WhoAmIResponse
|
||||
import java.math.BigDecimal
|
||||
import java.util.Currency
|
||||
import kotlin.time.Duration.Companion.days
|
||||
@@ -64,17 +68,24 @@ class BackupSubscriptionCheckJobTest {
|
||||
@Before
|
||||
fun setUp() {
|
||||
mockkObject(RemoteConfig)
|
||||
every { RemoteConfig.messageBackups } returns true
|
||||
every { RemoteConfig.internalUser } returns true
|
||||
|
||||
coEvery { AppDependencies.billingApi.isApiAvailable() } returns true
|
||||
coEvery { AppDependencies.billingApi.queryPurchases() } returns mockk()
|
||||
coEvery { AppDependencies.billingApi.queryProduct() } returns null
|
||||
coEvery { AppDependencies.billingApi.getApiAvailability() } returns BillingResponseCode.OK
|
||||
|
||||
coEvery { AppDependencies.billingApi.queryPurchases() } returns BillingPurchaseResult.Success(
|
||||
purchaseState = BillingPurchaseState.PURCHASED,
|
||||
purchaseToken = "test-token",
|
||||
isAcknowledged = true,
|
||||
isAutoRenewing = true,
|
||||
purchaseTime = System.currentTimeMillis()
|
||||
)
|
||||
|
||||
coEvery { AppDependencies.billingApi.queryProduct() } returns BillingProduct(price = FiatMoney(BigDecimal.ONE, Currency.getInstance("USD")))
|
||||
|
||||
SignalStore.backup.backupTier = MessageBackupTier.PAID
|
||||
|
||||
mockkObject(RecurringInAppPaymentRepository)
|
||||
every { RecurringInAppPaymentRepository.getActiveSubscriptionSync(InAppPaymentSubscriberRecord.Type.BACKUP) } returns Result.success(
|
||||
every { RecurringInAppPaymentRepository.getActiveSubscriptionSync(InAppPaymentSubscriberRecord.Type.BACKUP) } returns NetworkResult.Success(
|
||||
createActiveSubscription()
|
||||
)
|
||||
|
||||
@@ -97,6 +108,18 @@ class BackupSubscriptionCheckJobTest {
|
||||
}
|
||||
}
|
||||
|
||||
every { BackupRepository.resetInitializedStateAndAuthCredentials() } returns Unit
|
||||
|
||||
mockkObject(BackupStateObserver)
|
||||
every { BackupStateObserver.notifyBackupStateChanged() } returns Unit
|
||||
|
||||
mockkObject(SignalNetwork)
|
||||
every { AppDependencies.accountApi.whoAmI() } returns NetworkResult.Success(
|
||||
WhoAmIResponse(
|
||||
number = "+1234567890"
|
||||
)
|
||||
)
|
||||
|
||||
every { AppDependencies.donationsApi.putSubscription(any()) } returns NetworkResult.Success(Unit)
|
||||
|
||||
insertSubscriber()
|
||||
@@ -143,7 +166,7 @@ class BackupSubscriptionCheckJobTest {
|
||||
|
||||
@Test
|
||||
fun givenBillingApiNotAvailable_whenIRun_thenIExpectSuccessAndEarlyExit() {
|
||||
coEvery { AppDependencies.billingApi.isApiAvailable() } returns false
|
||||
coEvery { AppDependencies.billingApi.getApiAvailability() } returns BillingResponseCode.BILLING_UNAVAILABLE
|
||||
|
||||
val job = BackupSubscriptionCheckJob.create()
|
||||
val result = job.run()
|
||||
@@ -173,9 +196,18 @@ class BackupSubscriptionCheckJobTest {
|
||||
assertEarlyExit(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenPrePendingRecurringTransaction_whenIRun_thenIExpectSuccessAndEarlyExit() {
|
||||
insertPrePendingInAppPayment()
|
||||
|
||||
val job = BackupSubscriptionCheckJob.create()
|
||||
val result = job.run()
|
||||
|
||||
assertEarlyExit(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenAPendingPayment_whenIRun_thenIExpectSuccessAndEarlyExit() {
|
||||
mockProduct()
|
||||
insertPendingInAppPayment()
|
||||
|
||||
val job = BackupSubscriptionCheckJob.create()
|
||||
@@ -187,9 +219,7 @@ class BackupSubscriptionCheckJobTest {
|
||||
|
||||
@Test
|
||||
fun givenInactiveSubscription_whenIRun_thenIExpectStateMismatchDetected() {
|
||||
mockProduct()
|
||||
|
||||
every { RecurringInAppPaymentRepository.getActiveSubscriptionSync(InAppPaymentSubscriberRecord.Type.BACKUP) } returns Result.success(
|
||||
every { RecurringInAppPaymentRepository.getActiveSubscriptionSync(InAppPaymentSubscriberRecord.Type.BACKUP) } returns NetworkResult.Success(
|
||||
createActiveSubscription(isActive = false)
|
||||
)
|
||||
|
||||
@@ -201,9 +231,9 @@ class BackupSubscriptionCheckJobTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenRepositoryFailure_whenIRun_thenIExpectFailureResult() {
|
||||
every { RecurringInAppPaymentRepository.getActiveSubscriptionSync(InAppPaymentSubscriberRecord.Type.BACKUP) } returns Result.failure(
|
||||
RuntimeException("Network error")
|
||||
fun givenAnApplicationErrorWhenAccessingTheActiveSubscription_whenIRun_thenIExpectAFailure() {
|
||||
every { RecurringInAppPaymentRepository.getActiveSubscriptionSync(InAppPaymentSubscriberRecord.Type.BACKUP) } returns NetworkResult.ApplicationError(
|
||||
RuntimeException("Application Error")
|
||||
)
|
||||
|
||||
val job = BackupSubscriptionCheckJob.create()
|
||||
@@ -213,8 +243,10 @@ class BackupSubscriptionCheckJobTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenBillingApiReturnsAFailure_whenIRun_thenIExpectFailureResult() {
|
||||
coEvery { AppDependencies.billingApi.queryPurchases() } returns BillingPurchaseResult.BillingUnavailable
|
||||
fun givenANetworkErrorWhenAccessingTheActiveSubscription_whenIRun_thenIExpectAFailure() {
|
||||
every { RecurringInAppPaymentRepository.getActiveSubscriptionSync(InAppPaymentSubscriberRecord.Type.BACKUP) } returns NetworkResult.NetworkError(
|
||||
IOException()
|
||||
)
|
||||
|
||||
val job = BackupSubscriptionCheckJob.create()
|
||||
val result = job.run()
|
||||
@@ -223,10 +255,32 @@ class BackupSubscriptionCheckJobTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenPastDueSubscription_whenIRun_thenIExpectStateMismatchDetected() {
|
||||
mockProduct()
|
||||
fun givenAStatusCodeErrorWhenAccessingTheActiveSubscription_whenIRun_thenIExpectAMismatch() {
|
||||
every { RecurringInAppPaymentRepository.getActiveSubscriptionSync(InAppPaymentSubscriberRecord.Type.BACKUP) } returns NetworkResult.StatusCodeError(
|
||||
NonSuccessfulResponseCodeException(404)
|
||||
)
|
||||
|
||||
every { RecurringInAppPaymentRepository.getActiveSubscriptionSync(InAppPaymentSubscriberRecord.Type.BACKUP) } returns Result.success(
|
||||
val job = BackupSubscriptionCheckJob.create()
|
||||
val result = job.run()
|
||||
|
||||
assertThat(result.isSuccess).isTrue()
|
||||
assertThat(SignalStore.backup.subscriptionStateMismatchDetected).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenBillingApiReturnsAFailure_whenIRun_thenIExpectSuccessAndEarlyExit() {
|
||||
coEvery { AppDependencies.billingApi.queryPurchases() } returns BillingPurchaseResult.BillingUnavailable
|
||||
|
||||
val job = BackupSubscriptionCheckJob.create()
|
||||
val result = job.run()
|
||||
|
||||
assertThat(result.isSuccess).isTrue()
|
||||
assertThat(SignalStore.backup.subscriptionStateMismatchDetected).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenPastDueSubscription_whenIRun_thenIExpectStateMismatchDetected() {
|
||||
every { RecurringInAppPaymentRepository.getActiveSubscriptionSync(InAppPaymentSubscriberRecord.Type.BACKUP) } returns NetworkResult.Success(
|
||||
createActiveSubscription(
|
||||
isActive = false,
|
||||
billingPeriodEndSeconds = System.currentTimeMillis().milliseconds.inWholeSeconds - 1.days.inWholeSeconds,
|
||||
@@ -243,9 +297,7 @@ class BackupSubscriptionCheckJobTest {
|
||||
|
||||
@Test
|
||||
fun givenCancelledSubscription_whenIRun_thenIExpectStateMismatchDetected() {
|
||||
mockProduct()
|
||||
|
||||
every { RecurringInAppPaymentRepository.getActiveSubscriptionSync(InAppPaymentSubscriberRecord.Type.BACKUP) } returns Result.success(
|
||||
every { RecurringInAppPaymentRepository.getActiveSubscriptionSync(InAppPaymentSubscriberRecord.Type.BACKUP) } returns NetworkResult.Success(
|
||||
createActiveSubscription(
|
||||
isActive = false,
|
||||
status = "canceled",
|
||||
@@ -262,9 +314,7 @@ class BackupSubscriptionCheckJobTest {
|
||||
|
||||
@Test
|
||||
fun givenFreeBackupTier_whenIRun_thenIExpectSuccessAndEarlyExit() {
|
||||
mockProduct()
|
||||
|
||||
every { RecurringInAppPaymentRepository.getActiveSubscriptionSync(InAppPaymentSubscriberRecord.Type.BACKUP) } returns Result.success(
|
||||
every { RecurringInAppPaymentRepository.getActiveSubscriptionSync(InAppPaymentSubscriberRecord.Type.BACKUP) } returns NetworkResult.Success(
|
||||
ActiveSubscription.EMPTY
|
||||
)
|
||||
|
||||
@@ -277,25 +327,12 @@ class BackupSubscriptionCheckJobTest {
|
||||
assertThat(SignalStore.backup.subscriptionStateMismatchDetected).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenFailedInAppPayment_whenIRun_thenIExpectStateMismatchDetected() {
|
||||
mockProduct()
|
||||
insertFailedInAppPayment()
|
||||
|
||||
val job = BackupSubscriptionCheckJob.create()
|
||||
val result = job.run()
|
||||
|
||||
assertThat(result.isSuccess).isTrue()
|
||||
assertThat(SignalStore.backup.subscriptionStateMismatchDetected).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenActiveSignalSubscriptionWithTokenMismatch_whenIRun_thenIExpectTokenRedemption() {
|
||||
mockProduct()
|
||||
mockActivePurchase()
|
||||
insertSubscriber("mismatch")
|
||||
|
||||
every { RecurringInAppPaymentRepository.getActiveSubscriptionSync(InAppPaymentSubscriberRecord.Type.BACKUP) } returns Result.success(
|
||||
every { RecurringInAppPaymentRepository.getActiveSubscriptionSync(InAppPaymentSubscriberRecord.Type.BACKUP) } returns NetworkResult.Success(
|
||||
createActiveSubscription(isActive = true)
|
||||
)
|
||||
|
||||
@@ -315,10 +352,9 @@ class BackupSubscriptionCheckJobTest {
|
||||
|
||||
@Test
|
||||
fun givenActiveSubscriptionAndPurchaseWithoutEntitlement_whenIRun_thenIExpectRedemption() {
|
||||
mockProduct()
|
||||
mockActivePurchase()
|
||||
|
||||
every { RecurringInAppPaymentRepository.getActiveSubscriptionSync(InAppPaymentSubscriberRecord.Type.BACKUP) } returns Result.success(
|
||||
every { RecurringInAppPaymentRepository.getActiveSubscriptionSync(InAppPaymentSubscriberRecord.Type.BACKUP) } returns NetworkResult.Success(
|
||||
createActiveSubscription(isActive = true)
|
||||
)
|
||||
|
||||
@@ -342,10 +378,9 @@ class BackupSubscriptionCheckJobTest {
|
||||
|
||||
@Test
|
||||
fun givenValidActiveState_whenIRun_thenIExpectSuccessAndNoMismatch() {
|
||||
mockProduct()
|
||||
mockActivePurchase()
|
||||
|
||||
every { RecurringInAppPaymentRepository.getActiveSubscriptionSync(InAppPaymentSubscriberRecord.Type.BACKUP) } returns Result.success(
|
||||
every { RecurringInAppPaymentRepository.getActiveSubscriptionSync(InAppPaymentSubscriberRecord.Type.BACKUP) } returns NetworkResult.Success(
|
||||
createActiveSubscription(isActive = true)
|
||||
)
|
||||
|
||||
@@ -360,10 +395,9 @@ class BackupSubscriptionCheckJobTest {
|
||||
|
||||
@Test
|
||||
fun givenValidInactiveState_whenIRun_thenIExpectSuccessAndNoMismatch() {
|
||||
mockProduct()
|
||||
mockInactivePurchase()
|
||||
|
||||
every { RecurringInAppPaymentRepository.getActiveSubscriptionSync(InAppPaymentSubscriberRecord.Type.BACKUP) } returns Result.success(
|
||||
every { RecurringInAppPaymentRepository.getActiveSubscriptionSync(InAppPaymentSubscriberRecord.Type.BACKUP) } returns NetworkResult.Success(
|
||||
createActiveSubscription(isActive = false)
|
||||
)
|
||||
|
||||
@@ -379,10 +413,9 @@ class BackupSubscriptionCheckJobTest {
|
||||
|
||||
@Test
|
||||
fun givenGooglePlayBillingCanceledWithoutActiveSignalSubscription_whenIRun_thenIExpectValidCancelState() {
|
||||
mockProduct()
|
||||
mockCanceledPurchase()
|
||||
|
||||
every { RecurringInAppPaymentRepository.getActiveSubscriptionSync(InAppPaymentSubscriberRecord.Type.BACKUP) } returns Result.success(
|
||||
every { RecurringInAppPaymentRepository.getActiveSubscriptionSync(InAppPaymentSubscriberRecord.Type.BACKUP) } returns NetworkResult.Success(
|
||||
createActiveSubscription(isActive = false)
|
||||
)
|
||||
|
||||
@@ -395,10 +428,9 @@ class BackupSubscriptionCheckJobTest {
|
||||
|
||||
@Test
|
||||
fun givenGooglePlayBillingCanceledWithFailedSignalSubscription_whenIRun_thenIExpectValidCancelState() {
|
||||
mockProduct()
|
||||
mockCanceledPurchase()
|
||||
|
||||
every { RecurringInAppPaymentRepository.getActiveSubscriptionSync(InAppPaymentSubscriberRecord.Type.BACKUP) } returns Result.success(
|
||||
every { RecurringInAppPaymentRepository.getActiveSubscriptionSync(InAppPaymentSubscriberRecord.Type.BACKUP) } returns NetworkResult.Success(
|
||||
createActiveSubscription(isActive = true, status = "past_due", chargeFailure = ChargeFailure("test", "", "", "", ""))
|
||||
)
|
||||
|
||||
@@ -411,11 +443,10 @@ class BackupSubscriptionCheckJobTest {
|
||||
|
||||
@Test
|
||||
fun givenInvalidStateConfiguration_whenIRun_thenIExpectStateMismatchDetected() {
|
||||
mockProduct()
|
||||
mockActivePurchase()
|
||||
|
||||
// Create invalid state: active purchase but no active subscription, with paid tier
|
||||
every { RecurringInAppPaymentRepository.getActiveSubscriptionSync(InAppPaymentSubscriberRecord.Type.BACKUP) } returns Result.success(
|
||||
every { RecurringInAppPaymentRepository.getActiveSubscriptionSync(InAppPaymentSubscriberRecord.Type.BACKUP) } returns NetworkResult.Success(
|
||||
createActiveSubscription(isActive = false)
|
||||
)
|
||||
|
||||
@@ -430,10 +461,9 @@ class BackupSubscriptionCheckJobTest {
|
||||
|
||||
@Test
|
||||
fun givenActiveSubscriptionWithMismatchedZkCredentials_whenIRun_thenIExpectCredentialRefresh() {
|
||||
mockProduct()
|
||||
mockActivePurchase()
|
||||
|
||||
every { RecurringInAppPaymentRepository.getActiveSubscriptionSync(InAppPaymentSubscriberRecord.Type.BACKUP) } returns Result.success(
|
||||
every { RecurringInAppPaymentRepository.getActiveSubscriptionSync(InAppPaymentSubscriberRecord.Type.BACKUP) } returns NetworkResult.Success(
|
||||
createActiveSubscription(isActive = true)
|
||||
)
|
||||
|
||||
@@ -452,10 +482,9 @@ class BackupSubscriptionCheckJobTest {
|
||||
|
||||
@Test
|
||||
fun givenActiveSubscriptionWithSyncedZkCredentials_whenIRun_thenIExpectNoCredentialRefresh() {
|
||||
mockProduct()
|
||||
mockActivePurchase()
|
||||
|
||||
every { RecurringInAppPaymentRepository.getActiveSubscriptionSync(InAppPaymentSubscriberRecord.Type.BACKUP) } returns Result.success(
|
||||
every { RecurringInAppPaymentRepository.getActiveSubscriptionSync(InAppPaymentSubscriberRecord.Type.BACKUP) } returns NetworkResult.Success(
|
||||
createActiveSubscription(isActive = true)
|
||||
)
|
||||
|
||||
@@ -472,10 +501,9 @@ class BackupSubscriptionCheckJobTest {
|
||||
|
||||
@Test
|
||||
fun givenActiveSubscriptionWithZkCredentialFailure_whenIRun_thenIExpectCredentialRefresh() {
|
||||
mockProduct()
|
||||
mockActivePurchase()
|
||||
|
||||
every { RecurringInAppPaymentRepository.getActiveSubscriptionSync(InAppPaymentSubscriberRecord.Type.BACKUP) } returns Result.success(
|
||||
every { RecurringInAppPaymentRepository.getActiveSubscriptionSync(InAppPaymentSubscriberRecord.Type.BACKUP) } returns NetworkResult.Success(
|
||||
createActiveSubscription(isActive = true)
|
||||
)
|
||||
|
||||
@@ -493,11 +521,10 @@ class BackupSubscriptionCheckJobTest {
|
||||
|
||||
@Test
|
||||
fun givenSubscriptionWillCancelAtPeriodEnd_whenIRun_thenIExpectValidCancelState() {
|
||||
mockProduct()
|
||||
mockCanceledPurchase()
|
||||
|
||||
// Create subscription that will cancel at period end
|
||||
every { RecurringInAppPaymentRepository.getActiveSubscriptionSync(InAppPaymentSubscriberRecord.Type.BACKUP) } returns Result.success(
|
||||
every { RecurringInAppPaymentRepository.getActiveSubscriptionSync(InAppPaymentSubscriberRecord.Type.BACKUP) } returns NetworkResult.Success(
|
||||
createActiveSubscription(isActive = true, cancelled = true) // cancelled = true means willCancelAtPeriodEnd
|
||||
)
|
||||
|
||||
@@ -510,11 +537,10 @@ class BackupSubscriptionCheckJobTest {
|
||||
|
||||
@Test
|
||||
fun givenActiveSubscriptionNotWillCancelAtPeriodEnd_whenIRun_thenIExpectZkSynchronization() {
|
||||
mockProduct()
|
||||
mockActivePurchase()
|
||||
|
||||
// Create active subscription that won't cancel at period end
|
||||
every { RecurringInAppPaymentRepository.getActiveSubscriptionSync(InAppPaymentSubscriberRecord.Type.BACKUP) } returns Result.success(
|
||||
every { RecurringInAppPaymentRepository.getActiveSubscriptionSync(InAppPaymentSubscriberRecord.Type.BACKUP) } returns NetworkResult.Success(
|
||||
createActiveSubscription(isActive = true, cancelled = false)
|
||||
)
|
||||
|
||||
@@ -528,6 +554,37 @@ class BackupSubscriptionCheckJobTest {
|
||||
verify { BackupRepository.getBackupTierWithoutDowngrade() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenSubscriptionWillCancelWithValidEntitlement_whenIRun_thenIExpectBackupStateNotification() {
|
||||
mockCanceledPurchase()
|
||||
|
||||
every { AppDependencies.accountApi.whoAmI() } returns NetworkResult.Success(
|
||||
WhoAmIResponse(
|
||||
number = "+1234567890",
|
||||
entitlements = WhoAmIResponse.Entitlements(
|
||||
backup = WhoAmIResponse.BackupEntitlement(
|
||||
backupLevel = 201,
|
||||
expirationSeconds = System.currentTimeMillis() / 1000 + 3600 // 1 hour from now
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
every { RecurringInAppPaymentRepository.getActiveSubscriptionSync(InAppPaymentSubscriberRecord.Type.BACKUP) } returns NetworkResult.Success(
|
||||
createActiveSubscription(
|
||||
isActive = true,
|
||||
cancelled = true,
|
||||
chargeFailure = ChargeFailure("test", "", "", "", "")
|
||||
)
|
||||
)
|
||||
|
||||
val job = BackupSubscriptionCheckJob.create()
|
||||
val result = job.run()
|
||||
|
||||
assertThat(result.isSuccess).isTrue()
|
||||
verify { BackupStateObserver.notifyBackupStateChanged() }
|
||||
}
|
||||
|
||||
private fun createActiveSubscription(
|
||||
isActive: Boolean = true,
|
||||
billingPeriodEndSeconds: Long = 2147472000,
|
||||
@@ -553,15 +610,6 @@ class BackupSubscriptionCheckJobTest {
|
||||
)
|
||||
}
|
||||
|
||||
private fun mockProduct() {
|
||||
coEvery { AppDependencies.billingApi.queryProduct() } returns BillingProduct(
|
||||
price = FiatMoney(
|
||||
BigDecimal.ONE,
|
||||
Currency.getInstance("USD")
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun insertSubscriber(token: String = IAP_TOKEN) {
|
||||
SignalDatabase.inAppPaymentSubscribers.insertOrReplace(
|
||||
InAppPaymentSubscriberRecord(
|
||||
@@ -575,6 +623,16 @@ class BackupSubscriptionCheckJobTest {
|
||||
)
|
||||
}
|
||||
|
||||
private fun insertPrePendingInAppPayment() {
|
||||
SignalDatabase.inAppPayments.insert(
|
||||
type = InAppPaymentType.RECURRING_BACKUP,
|
||||
state = InAppPaymentTable.State.TRANSACTING,
|
||||
subscriberId = null,
|
||||
endOfPeriod = null,
|
||||
inAppPaymentData = InAppPaymentData()
|
||||
)
|
||||
}
|
||||
|
||||
private fun insertPendingInAppPayment() {
|
||||
SignalDatabase.inAppPayments.insert(
|
||||
type = InAppPaymentType.RECURRING_BACKUP,
|
||||
@@ -593,20 +651,6 @@ class BackupSubscriptionCheckJobTest {
|
||||
}
|
||||
}
|
||||
|
||||
private fun insertFailedInAppPayment() {
|
||||
SignalDatabase.inAppPayments.insert(
|
||||
type = InAppPaymentType.RECURRING_BACKUP,
|
||||
state = InAppPaymentTable.State.END,
|
||||
subscriberId = null,
|
||||
endOfPeriod = null,
|
||||
inAppPaymentData = InAppPaymentData(
|
||||
error = InAppPaymentData.Error(
|
||||
type = InAppPaymentData.Error.Type.PAYMENT_SETUP
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun mockActivePurchase() {
|
||||
coEvery { AppDependencies.billingApi.queryPurchases() } returns BillingPurchaseResult.Success(
|
||||
purchaseState = BillingPurchaseState.PURCHASED,
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
package org.thoughtcrime.securesms.messages
|
||||
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.isEqualTo
|
||||
import io.mockk.every
|
||||
import io.mockk.mockkStatic
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.thoughtcrime.securesms.database.MessageTable
|
||||
import org.thoughtcrime.securesms.database.MessageType
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.model.MessageId
|
||||
import org.thoughtcrime.securesms.groups.GroupId
|
||||
import org.thoughtcrime.securesms.mms.IncomingMessage
|
||||
import org.thoughtcrime.securesms.polls.Voter
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.testing.GroupTestingUtils
|
||||
import org.thoughtcrime.securesms.testing.GroupTestingUtils.asMember
|
||||
import org.thoughtcrime.securesms.testing.MessageContentFuzzer
|
||||
import org.thoughtcrime.securesms.testing.SignalActivityRule
|
||||
import org.thoughtcrime.securesms.util.RemoteConfig
|
||||
import org.whispersystems.signalservice.api.crypto.EnvelopeMetadata
|
||||
import org.whispersystems.signalservice.internal.push.DataMessage
|
||||
|
||||
@Suppress("ClassName")
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class DataMessageProcessorTest_polls {
|
||||
|
||||
@get:Rule
|
||||
val harness = SignalActivityRule(createGroup = true)
|
||||
|
||||
private lateinit var alice: Recipient
|
||||
private lateinit var bob: Recipient
|
||||
private lateinit var charlie: Recipient
|
||||
private lateinit var groupId: GroupId.V2
|
||||
private lateinit var groupRecipientId: RecipientId
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
mockkStatic(RemoteConfig::class)
|
||||
|
||||
every { RemoteConfig.receivePolls } returns true
|
||||
|
||||
alice = Recipient.resolved(harness.others[0])
|
||||
bob = Recipient.resolved(harness.others[1])
|
||||
charlie = Recipient.resolved(harness.others[2])
|
||||
|
||||
val groupInfo = GroupTestingUtils.insertGroup(revision = 0, harness.self.asMember(), alice.asMember(), bob.asMember())
|
||||
groupId = groupInfo.groupId
|
||||
groupRecipientId = groupInfo.recipientId
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handlePollCreate_whenIHaveAValidPollProto_createPoll() {
|
||||
val insertResult = handlePollCreate(
|
||||
pollCreate = DataMessage.PollCreate(question = "question?", options = listOf("a", "b", "c"), allowMultiple = false),
|
||||
senderRecipient = alice,
|
||||
threadRecipient = Recipient.resolved(groupRecipientId),
|
||||
groupId = groupId
|
||||
)
|
||||
|
||||
assert(insertResult != null)
|
||||
val poll = SignalDatabase.polls.getPoll(insertResult!!.messageId)
|
||||
assert(poll != null)
|
||||
assertThat(poll!!.question).isEqualTo("question?")
|
||||
assertThat(poll.pollOptions.size).isEqualTo(3)
|
||||
assertThat(poll.allowMultipleVotes).isEqualTo(false)
|
||||
assertThat(poll.hasEnded).isEqualTo(false)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handlePollCreate_whenSenderIsNotInGroup_dropMessage() {
|
||||
val insertResult = handlePollCreate(
|
||||
pollCreate = DataMessage.PollCreate(question = "question?", options = listOf("a", "b", "c"), allowMultiple = false),
|
||||
senderRecipient = charlie,
|
||||
threadRecipient = Recipient.resolved(groupRecipientId),
|
||||
groupId = groupId
|
||||
)
|
||||
|
||||
assert(insertResult == null)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handlePollCreate_whenTargetRecipientIsNotAGroup_dropMessage() {
|
||||
val insertResult = handlePollCreate(
|
||||
pollCreate = DataMessage.PollCreate(question = "question?", options = listOf("a", "b", "c"), allowMultiple = false),
|
||||
senderRecipient = alice,
|
||||
threadRecipient = bob,
|
||||
groupId = null
|
||||
)
|
||||
|
||||
assert(insertResult == null)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handlePollTerminate_whenIHaveValidProto_endPoll() {
|
||||
val pollMessageId = insertPoll()
|
||||
|
||||
val insertResult = DataMessageProcessor.handlePollTerminate(
|
||||
context = ApplicationProvider.getApplicationContext(),
|
||||
envelope = MessageContentFuzzer.envelope(200),
|
||||
message = DataMessage(pollTerminate = DataMessage.PollTerminate(targetSentTimestamp = 100)),
|
||||
senderRecipient = alice,
|
||||
metadata = EnvelopeMetadata(alice.requireServiceId(), null, 1, false, null, harness.self.requireServiceId()),
|
||||
threadRecipient = bob,
|
||||
groupId = groupId,
|
||||
receivedTime = 200
|
||||
)
|
||||
|
||||
assert(insertResult?.messageId != null)
|
||||
val poll = SignalDatabase.polls.getPoll(pollMessageId)
|
||||
assert(poll != null)
|
||||
assert(poll!!.hasEnded)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handlePollTerminate_whenIHaveDifferentTimestamp_dropMessage() {
|
||||
insertPoll()
|
||||
|
||||
val insertResult = DataMessageProcessor.handlePollTerminate(
|
||||
context = ApplicationProvider.getApplicationContext(),
|
||||
envelope = MessageContentFuzzer.envelope(200),
|
||||
message = DataMessage(pollTerminate = DataMessage.PollTerminate(200)),
|
||||
senderRecipient = alice,
|
||||
metadata = EnvelopeMetadata(alice.requireServiceId(), null, 1, false, null, harness.self.requireServiceId()),
|
||||
threadRecipient = bob,
|
||||
groupId = groupId,
|
||||
receivedTime = 200
|
||||
)
|
||||
|
||||
assert(insertResult == null)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handlePollTerminate_whenMessageIsNotFromCreatorOfPoll_dropMessage() {
|
||||
insertPoll()
|
||||
|
||||
val insertResult = DataMessageProcessor.handlePollTerminate(
|
||||
context = ApplicationProvider.getApplicationContext(),
|
||||
envelope = MessageContentFuzzer.envelope(200),
|
||||
message = DataMessage(pollTerminate = DataMessage.PollTerminate(100)),
|
||||
senderRecipient = bob,
|
||||
metadata = EnvelopeMetadata(alice.requireServiceId(), null, 1, false, null, harness.self.requireServiceId()),
|
||||
threadRecipient = bob,
|
||||
groupId = groupId,
|
||||
receivedTime = 200
|
||||
)
|
||||
|
||||
assert(insertResult == null)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handlePollTerminate_whenPollDoesNotExist_dropMessage() {
|
||||
val insertResult = DataMessageProcessor.handlePollTerminate(
|
||||
context = ApplicationProvider.getApplicationContext(),
|
||||
envelope = MessageContentFuzzer.envelope(200),
|
||||
message = DataMessage(pollTerminate = DataMessage.PollTerminate(100)),
|
||||
senderRecipient = alice,
|
||||
metadata = EnvelopeMetadata(alice.requireServiceId(), null, 1, false, null, harness.self.requireServiceId()),
|
||||
threadRecipient = bob,
|
||||
groupId = groupId,
|
||||
receivedTime = 200
|
||||
)
|
||||
|
||||
assert(insertResult == null)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handlePollVote_whenValidPollVote_processVote() {
|
||||
insertPoll()
|
||||
|
||||
val messageId = handlePollVote(
|
||||
DataMessage.PollVote(
|
||||
targetAuthorAciBinary = alice.asMember().aciBytes,
|
||||
targetSentTimestamp = 100,
|
||||
optionIndexes = listOf(0),
|
||||
voteCount = 1
|
||||
),
|
||||
bob
|
||||
)
|
||||
|
||||
assert(messageId != null)
|
||||
assertThat(messageId!!.id).isEqualTo(1)
|
||||
val poll = SignalDatabase.polls.getPoll(messageId.id)
|
||||
assert(poll != null)
|
||||
assertThat(poll!!.pollOptions[0].voters).isEqualTo(listOf(Voter(bob.id.toLong(), 1)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handlePollVote_whenMultipleVoteAllowed_processAllVote() {
|
||||
insertPoll()
|
||||
|
||||
val messageId = handlePollVote(
|
||||
DataMessage.PollVote(
|
||||
targetAuthorAciBinary = alice.asMember().aciBytes,
|
||||
targetSentTimestamp = 100,
|
||||
optionIndexes = listOf(0, 1, 2),
|
||||
voteCount = 1
|
||||
),
|
||||
bob
|
||||
)
|
||||
|
||||
assert(messageId != null)
|
||||
val poll = SignalDatabase.polls.getPoll(messageId!!.id)
|
||||
assert(poll != null)
|
||||
assertThat(poll!!.pollOptions[0].voters).isEqualTo(listOf(Voter(bob.id.toLong(), 1)))
|
||||
assertThat(poll.pollOptions[1].voters).isEqualTo(listOf(Voter(bob.id.toLong(), 1)))
|
||||
assertThat(poll.pollOptions[2].voters).isEqualTo(listOf(Voter(bob.id.toLong(), 1)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handlePollVote_whenMultipleVoteSentToSingleVotePolls_dropMessage() {
|
||||
insertPoll(false)
|
||||
|
||||
val messageId = handlePollVote(
|
||||
DataMessage.PollVote(
|
||||
targetAuthorAciBinary = alice.asMember().aciBytes,
|
||||
targetSentTimestamp = 100,
|
||||
optionIndexes = listOf(0, 1, 2),
|
||||
voteCount = 1
|
||||
),
|
||||
bob
|
||||
)
|
||||
|
||||
assert(messageId == null)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handlePollVote_whenVoteCountIsNotHigher_dropMessage() {
|
||||
insertPoll()
|
||||
|
||||
val messageId = handlePollVote(
|
||||
DataMessage.PollVote(
|
||||
targetAuthorAciBinary = alice.asMember().aciBytes,
|
||||
targetSentTimestamp = 100,
|
||||
optionIndexes = listOf(0, 1, 2),
|
||||
voteCount = -1
|
||||
),
|
||||
bob
|
||||
)
|
||||
|
||||
assert(messageId == null)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handlePollVote_whenVoteOptionDoesNotExist_dropMessage() {
|
||||
insertPoll()
|
||||
|
||||
val messageId = handlePollVote(
|
||||
DataMessage.PollVote(
|
||||
targetAuthorAciBinary = alice.asMember().aciBytes,
|
||||
targetSentTimestamp = 100,
|
||||
optionIndexes = listOf(5),
|
||||
voteCount = 1
|
||||
),
|
||||
bob
|
||||
)
|
||||
|
||||
assert(messageId == null)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handlePollVote_whenVoterNotInGroup_dropMessage() {
|
||||
insertPoll()
|
||||
|
||||
val messageId = handlePollVote(
|
||||
DataMessage.PollVote(
|
||||
targetAuthorAciBinary = alice.asMember().aciBytes,
|
||||
targetSentTimestamp = 100,
|
||||
optionIndexes = listOf(0, 1, 2),
|
||||
voteCount = 1
|
||||
|
||||
),
|
||||
charlie
|
||||
)
|
||||
|
||||
assert(messageId == null)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handlePollVote_whenPollDoesNotExist_dropMessage() {
|
||||
val messageId = handlePollVote(
|
||||
DataMessage.PollVote(
|
||||
targetAuthorAciBinary = alice.asMember().aciBytes,
|
||||
targetSentTimestamp = 100,
|
||||
optionIndexes = listOf(0, 1, 2),
|
||||
voteCount = 1
|
||||
),
|
||||
bob
|
||||
)
|
||||
|
||||
assert(messageId == null)
|
||||
}
|
||||
|
||||
private fun handlePollCreate(pollCreate: DataMessage.PollCreate, senderRecipient: Recipient, threadRecipient: Recipient, groupId: GroupId.V2?): MessageTable.InsertResult? {
|
||||
return DataMessageProcessor.handlePollCreate(
|
||||
envelope = MessageContentFuzzer.envelope(100),
|
||||
message = DataMessage(pollCreate = pollCreate),
|
||||
senderRecipient = senderRecipient,
|
||||
threadRecipient = threadRecipient,
|
||||
groupId = groupId,
|
||||
receivedTime = 0,
|
||||
context = ApplicationProvider.getApplicationContext(),
|
||||
metadata = EnvelopeMetadata(alice.requireServiceId(), null, 1, false, null, harness.self.requireServiceId())
|
||||
)
|
||||
}
|
||||
|
||||
private fun handlePollVote(pollVote: DataMessage.PollVote, senderRecipient: Recipient): MessageId? {
|
||||
return DataMessageProcessor.handlePollVote(
|
||||
context = ApplicationProvider.getApplicationContext(),
|
||||
envelope = MessageContentFuzzer.envelope(100),
|
||||
message = DataMessage(pollVote = pollVote),
|
||||
senderRecipient = senderRecipient,
|
||||
earlyMessageCacheEntry = null
|
||||
)
|
||||
}
|
||||
|
||||
private fun insertPoll(allowMultiple: Boolean = true): Long {
|
||||
val envelope = MessageContentFuzzer.envelope(100)
|
||||
val pollMessage = IncomingMessage(type = MessageType.NORMAL, from = alice.id, sentTimeMillis = envelope.timestamp!!, serverTimeMillis = envelope.serverTimestamp!!, receivedTimeMillis = 0, groupId = groupId)
|
||||
val messageId = SignalDatabase.messages.insertMessageInbox(pollMessage).get()
|
||||
SignalDatabase.polls.insertPoll("question?", allowMultiple, listOf("a", "b", "c"), alice.id.toLong(), messageId.messageId)
|
||||
return messageId.messageId
|
||||
}
|
||||
}
|
||||
@@ -44,7 +44,7 @@ class MessageHelper(private val harness: SignalActivityRule, var startTime: Long
|
||||
init {
|
||||
val threadIdSlot = slot<Long>()
|
||||
mockkStatic(ThreadUpdateJob::class)
|
||||
every { ThreadUpdateJob.enqueue(capture(threadIdSlot)) } answers {
|
||||
every { ThreadUpdateJob.enqueue(capture(threadIdSlot), any()) } answers {
|
||||
SignalDatabase.threads.update(threadIdSlot.captured, false)
|
||||
}
|
||||
}
|
||||
@@ -148,7 +148,7 @@ class MessageHelper(private val harness: SignalActivityRule, var startTime: Long
|
||||
.groupChangeUpdate(GroupsV2UpdateMessageConverter.translateDecryptedChange(SignalStore.account.getServiceIds(), decryptedGroupV2Context))
|
||||
.build()
|
||||
|
||||
val outgoingMessage = OutgoingMessage.groupUpdateMessage(groupRecipient, updateDescription, startTime)
|
||||
val outgoingMessage = OutgoingMessage.groupUpdateMessage(groupRecipient, updateDescription, startTime, false)
|
||||
|
||||
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(groupRecipient)
|
||||
val messageId = SignalDatabase.messages.insertMessageOutbox(outgoingMessage, threadId, false, null).messageId
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.messages.protocol
|
||||
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.signal.libsignal.protocol.ReusedBaseKeyException
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.testing.SignalDatabaseRule
|
||||
import org.thoughtcrime.securesms.util.KyberPreKeysTestUtil
|
||||
import org.whispersystems.signalservice.api.push.ServiceId
|
||||
|
||||
class BufferedKyberPreKeyStoreTest {
|
||||
|
||||
@get:Rule
|
||||
val harness = SignalDatabaseRule()
|
||||
|
||||
private lateinit var aci: ServiceId
|
||||
private lateinit var testSubject: BufferedKyberPreKeyStore
|
||||
private lateinit var dataStore: BufferedSignalServiceAccountDataStore
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
SignalStore.account.generateAciIdentityKeyIfNecessary()
|
||||
|
||||
aci = harness.localAci
|
||||
testSubject = BufferedKyberPreKeyStore(aci)
|
||||
dataStore = BufferedSignalServiceAccountDataStore(aci)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenALastResortKey_whenIMarkKyberPreKeyUsed_thenIExpectNoIssues() {
|
||||
KyberPreKeysTestUtil.insertTestRecord(aci, 1, lastResort = true)
|
||||
val publicKey = KyberPreKeysTestUtil.generateECPublicKey()
|
||||
|
||||
testSubject.markKyberPreKeyUsed(
|
||||
kyberPreKeyId = 1,
|
||||
signedPreKeyId = 2,
|
||||
publicKey = publicKey
|
||||
)
|
||||
}
|
||||
|
||||
@Test(expected = ReusedBaseKeyException::class)
|
||||
fun givenALastResortKey_whenIMarkKyberPreKeyUsedTwice_thenIExpectException() {
|
||||
KyberPreKeysTestUtil.insertTestRecord(aci, 1, lastResort = true)
|
||||
val publicKey = KyberPreKeysTestUtil.generateECPublicKey()
|
||||
|
||||
testSubject.markKyberPreKeyUsed(
|
||||
kyberPreKeyId = 1,
|
||||
signedPreKeyId = 2,
|
||||
publicKey = publicKey
|
||||
)
|
||||
|
||||
testSubject.markKyberPreKeyUsed(
|
||||
kyberPreKeyId = 1,
|
||||
signedPreKeyId = 2,
|
||||
publicKey = publicKey
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenAMarkedLastResortKey_whenIFlushTwice_thenIExpectNoIssues() {
|
||||
KyberPreKeysTestUtil.insertTestRecord(aci, 1, lastResort = true)
|
||||
val publicKey = KyberPreKeysTestUtil.generateECPublicKey()
|
||||
|
||||
testSubject.markKyberPreKeyUsed(
|
||||
kyberPreKeyId = 1,
|
||||
signedPreKeyId = 2,
|
||||
publicKey = publicKey
|
||||
)
|
||||
|
||||
testSubject.flushToDisk(dataStore)
|
||||
testSubject.flushToDisk(dataStore)
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -6,7 +6,6 @@ import org.signal.libsignal.protocol.IdentityKey
|
||||
import org.signal.libsignal.protocol.IdentityKeyPair
|
||||
import org.signal.libsignal.protocol.SessionBuilder
|
||||
import org.signal.libsignal.protocol.SignalProtocolAddress
|
||||
import org.signal.libsignal.protocol.UsePqRatchet
|
||||
import org.signal.libsignal.protocol.ecc.ECKeyPair
|
||||
import org.signal.libsignal.protocol.groups.state.SenderKeyRecord
|
||||
import org.signal.libsignal.protocol.state.IdentityKeyStore
|
||||
@@ -69,7 +68,7 @@ class BobClient(val serviceId: ServiceId, val e164: String, val identityKeyPair:
|
||||
|
||||
if (!aciStore.containsSession(getAliceProtocolAddress())) {
|
||||
val sessionBuilder = SignalSessionBuilder(sessionLock, SessionBuilder(aciStore, getAliceProtocolAddress()))
|
||||
sessionBuilder.process(getAlicePreKeyBundle(), UsePqRatchet.NO)
|
||||
sessionBuilder.process(getAlicePreKeyBundle())
|
||||
}
|
||||
|
||||
return cipher.encrypt(getAliceProtocolAddress(), getAliceUnidentifiedAccess(), envelopeContent)
|
||||
@@ -78,7 +77,7 @@ class BobClient(val serviceId: ServiceId, val e164: String, val identityKeyPair:
|
||||
|
||||
fun decrypt(envelope: Envelope, serverDeliveredTimestamp: Long) {
|
||||
val cipher = SignalServiceCipher(serviceAddress, 1, aciStore, sessionLock, SealedSenderAccessUtil.getCertificateValidator())
|
||||
cipher.decrypt(envelope, serverDeliveredTimestamp, UsePqRatchet.NO)
|
||||
cipher.decrypt(envelope, serverDeliveredTimestamp)
|
||||
}
|
||||
|
||||
private fun getAliceServiceId(): ServiceId {
|
||||
|
||||
@@ -15,13 +15,14 @@ import org.signal.libsignal.protocol.IdentityKey
|
||||
import org.signal.libsignal.protocol.IdentityKeyPair
|
||||
import org.signal.libsignal.protocol.SignalProtocolAddress
|
||||
import org.thoughtcrime.securesms.SignalInstrumentationApplicationContext
|
||||
import org.thoughtcrime.securesms.crypto.IdentityKeyUtil
|
||||
import org.thoughtcrime.securesms.crypto.MasterSecretUtil
|
||||
import org.thoughtcrime.securesms.crypto.ProfileKeyUtil
|
||||
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 +126,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()
|
||||
}
|
||||
@@ -145,7 +147,7 @@ class SignalActivityRule(private val othersCount: Int = 4, private val createGro
|
||||
SignalDatabase.recipients.setCapabilities(recipientId, SignalServiceProfile.Capabilities(true, true))
|
||||
SignalDatabase.recipients.setProfileSharing(recipientId, true)
|
||||
SignalDatabase.recipients.markRegistered(recipientId, aci)
|
||||
val otherIdentity = IdentityKeyUtil.generateIdentityKeyPair()
|
||||
val otherIdentity = IdentityKeyPair.generate()
|
||||
AppDependencies.protocolStore.aci().saveIdentity(SignalProtocolAddress(aci.toString(), 1), otherIdentity.publicKey)
|
||||
others += recipientId
|
||||
othersKeys += otherIdentity
|
||||
@@ -158,7 +160,7 @@ class SignalActivityRule(private val othersCount: Int = 4, private val createGro
|
||||
return androidx.test.core.app.launchActivity(Intent(context, T::class.java).apply(initIntent))
|
||||
}
|
||||
|
||||
fun changeIdentityKey(recipient: Recipient, identityKey: IdentityKey = IdentityKeyUtil.generateIdentityKeyPair().publicKey) {
|
||||
fun changeIdentityKey(recipient: Recipient, identityKey: IdentityKey = IdentityKeyPair.generate().publicKey) {
|
||||
AppDependencies.protocolStore.aci().saveIdentity(SignalProtocolAddress(recipient.requireServiceId().toString(), 0), identityKey)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.util
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.signal.core.util.readToSingleObject
|
||||
import org.signal.core.util.requireLongOrNull
|
||||
import org.signal.core.util.select
|
||||
import org.signal.core.util.update
|
||||
import org.signal.libsignal.protocol.ecc.ECKeyPair
|
||||
import org.signal.libsignal.protocol.ecc.ECPublicKey
|
||||
import org.signal.libsignal.protocol.kem.KEMKeyPair
|
||||
import org.signal.libsignal.protocol.kem.KEMKeyType
|
||||
import org.signal.libsignal.protocol.state.KyberPreKeyRecord
|
||||
import org.thoughtcrime.securesms.database.KyberPreKeyTable
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.whispersystems.signalservice.api.push.ServiceId
|
||||
import org.whispersystems.signalservice.api.push.ServiceId.ACI
|
||||
import org.whispersystems.signalservice.api.push.ServiceId.PNI
|
||||
import java.security.SecureRandom
|
||||
|
||||
object KyberPreKeysTestUtil {
|
||||
fun insertTestRecord(account: ServiceId, id: Int, staleTime: Long = 0, lastResort: Boolean = false) {
|
||||
val kemKeyPair = KEMKeyPair.generate(KEMKeyType.KYBER_1024)
|
||||
SignalDatabase.kyberPreKeys.insert(
|
||||
serviceId = account,
|
||||
keyId = id,
|
||||
record = KyberPreKeyRecord(
|
||||
id,
|
||||
System.currentTimeMillis(),
|
||||
kemKeyPair,
|
||||
ECKeyPair.generate().privateKey.calculateSignature(kemKeyPair.publicKey.serialize())
|
||||
),
|
||||
lastResort = lastResort
|
||||
)
|
||||
|
||||
val count = SignalDatabase.rawDatabase
|
||||
.update(KyberPreKeyTable.TABLE_NAME)
|
||||
.values(KyberPreKeyTable.STALE_TIMESTAMP to staleTime)
|
||||
.where("${KyberPreKeyTable.ACCOUNT_ID} = ? AND ${KyberPreKeyTable.KEY_ID} = $id", account.toAccountId())
|
||||
.run()
|
||||
|
||||
assertEquals(1, count)
|
||||
}
|
||||
|
||||
fun getStaleTime(account: ServiceId, id: Int): Long? {
|
||||
return SignalDatabase.rawDatabase
|
||||
.select(KyberPreKeyTable.STALE_TIMESTAMP)
|
||||
.from(KyberPreKeyTable.TABLE_NAME)
|
||||
.where("${KyberPreKeyTable.ACCOUNT_ID} = ? AND ${KyberPreKeyTable.KEY_ID} = $id", account.toAccountId())
|
||||
.run()
|
||||
.readToSingleObject { it.requireLongOrNull(KyberPreKeyTable.STALE_TIMESTAMP) }
|
||||
}
|
||||
|
||||
fun generateECPublicKey(): ECPublicKey {
|
||||
val byteArray = ByteArray(ECPublicKey.KEY_SIZE - 1)
|
||||
SecureRandom().nextBytes(byteArray)
|
||||
|
||||
return ECPublicKey.fromPublicKeyBytes(byteArray)
|
||||
}
|
||||
|
||||
private fun ServiceId.toAccountId(): String {
|
||||
return when (this) {
|
||||
is ACI -> this.toString()
|
||||
is PNI -> KyberPreKeyTable.PNI_ACCOUNT_ID
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,8 @@ import android.app.Application
|
||||
import android.content.SharedPreferences
|
||||
import android.preference.PreferenceManager
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.signal.libsignal.protocol.IdentityKeyPair
|
||||
import org.signal.libsignal.protocol.SignalProtocolAddress
|
||||
import org.thoughtcrime.securesms.crypto.IdentityKeyUtil
|
||||
import org.thoughtcrime.securesms.crypto.MasterSecretUtil
|
||||
import org.thoughtcrime.securesms.crypto.ProfileKeyUtil
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
@@ -96,7 +96,7 @@ object TestUsers {
|
||||
SignalDatabase.recipients.setCapabilities(recipientId, SignalServiceProfile.Capabilities(true, true))
|
||||
SignalDatabase.recipients.setProfileSharing(recipientId, true)
|
||||
SignalDatabase.recipients.markRegistered(recipientId, aci)
|
||||
val otherIdentity = IdentityKeyUtil.generateIdentityKeyPair()
|
||||
val otherIdentity = IdentityKeyPair.generate()
|
||||
AppDependencies.protocolStore.aci().saveIdentity(SignalProtocolAddress(aci.toString(), 1), otherIdentity.publicKey)
|
||||
|
||||
others += recipientId
|
||||
|
||||
@@ -115,6 +115,7 @@ class ConversationElementGenerator {
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
-1,
|
||||
null,
|
||||
null,
|
||||
|
||||
@@ -44,6 +44,8 @@ import org.thoughtcrime.securesms.groups.GroupId
|
||||
import org.thoughtcrime.securesms.groups.GroupMigrationMembershipChange
|
||||
import org.thoughtcrime.securesms.linkpreview.LinkPreview
|
||||
import org.thoughtcrime.securesms.mediapreview.MediaIntentFactory
|
||||
import org.thoughtcrime.securesms.polls.PollOption
|
||||
import org.thoughtcrime.securesms.polls.PollRecord
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.stickers.StickerLocator
|
||||
@@ -336,5 +338,17 @@ class InternalConversationTestFragment : Fragment(R.layout.conversation_test_fra
|
||||
override fun onUpdateSignalClicked() {
|
||||
Toast.makeText(requireContext(), "Can't touch this.", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
override fun onViewResultsClicked(pollId: Long) {
|
||||
Toast.makeText(requireContext(), "Can't touch this.", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
override fun onViewPollClicked(messageId: Long) {
|
||||
Toast.makeText(requireContext(), "Can't touch this.", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
override fun onToggleVote(poll: PollRecord, pollOption: PollOption, isChecked: Boolean) {
|
||||
Toast.makeText(requireContext(), "Can't touch this.", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,6 +129,10 @@
|
||||
<meta-data android:name="firebase_messaging_auto_init_enabled" android:value="false" />
|
||||
<meta-data android:name="android.webkit.WebView.MetricsOptOut" android:value="true" />
|
||||
|
||||
<meta-data
|
||||
android:name="android.telephony.PROPERTY_SATELLITE_DATA_OPTIMIZED"
|
||||
android:value="org.thoughtcrime.securesms" />
|
||||
|
||||
<activity android:name=".components.webrtc.v2.WebRtcCallActivity"
|
||||
android:theme="@style/TextSecure.DarkTheme.WebRTCCall"
|
||||
android:excludeFromRecents="true"
|
||||
@@ -697,6 +701,11 @@
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
|
||||
android:exported="false"/>
|
||||
|
||||
<activity
|
||||
android:name=".conversation.NewConversationActivityV2"
|
||||
android:exported="false"
|
||||
android:theme="@style/Signal.DayNight.NoActionBar" />
|
||||
|
||||
<activity android:name=".recipients.ui.findby.FindByActivity"
|
||||
android:theme="@style/Theme.Signal.DayNight.NoActionBar"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
@@ -843,19 +852,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 +932,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 +983,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"/>
|
||||
|
||||
|
||||
Binary file not shown.
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -492,7 +490,7 @@ public class ApplicationContext extends Application implements AppForegroundObse
|
||||
}
|
||||
|
||||
private void ensureProfileUploaded() {
|
||||
if (SignalStore.account().isRegistered() && !SignalStore.registration().hasUploadedProfile() && !Recipient.self().getProfileName().isEmpty()) {
|
||||
if (SignalStore.account().isRegistered() && !SignalStore.registration().hasUploadedProfile() && !Recipient.self().getProfileName().isEmpty() && SignalStore.account().isPrimaryDevice()) {
|
||||
Log.w(TAG, "User has a profile, but has not uploaded one. Uploading now.");
|
||||
AppDependencies.getJobManager().add(new ProfileUploadJob());
|
||||
}
|
||||
|
||||
@@ -30,6 +30,8 @@ import org.thoughtcrime.securesms.groups.GroupId;
|
||||
import org.thoughtcrime.securesms.groups.GroupMigrationMembershipChange;
|
||||
import org.thoughtcrime.securesms.linkpreview.LinkPreview;
|
||||
import org.thoughtcrime.securesms.mediapreview.MediaIntentFactory;
|
||||
import org.thoughtcrime.securesms.polls.PollRecord;
|
||||
import org.thoughtcrime.securesms.polls.PollOption;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.stickers.StickerLocator;
|
||||
@@ -143,5 +145,8 @@ public interface BindableConversationItem extends Unbindable, GiphyMp4Playable,
|
||||
void onDisplayMediaNoLongerAvailableSheet();
|
||||
void onShowUnverifiedProfileSheet(boolean forGroup);
|
||||
void onUpdateSignalClicked();
|
||||
void onViewResultsClicked(long pollId);
|
||||
void onViewPollClicked(long messageId);
|
||||
void onToggleVote(@NonNull PollRecord poll, @NonNull PollOption pollOption, Boolean isChecked);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.components.ContactFilterView;
|
||||
import org.thoughtcrime.securesms.contacts.ContactSelectionDisplayMode;
|
||||
import org.thoughtcrime.securesms.contacts.paged.ChatType;
|
||||
import org.thoughtcrime.securesms.contacts.selection.ContactSelectionArguments;
|
||||
import org.thoughtcrime.securesms.contacts.sync.ContactDiscovery;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme;
|
||||
@@ -69,9 +70,9 @@ public abstract class ContactSelectionActivity extends PassphraseRequiredActivit
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle icicle, boolean ready) {
|
||||
if (!getIntent().hasExtra(ContactSelectionListFragment.DISPLAY_MODE)) {
|
||||
if (!getIntent().hasExtra(ContactSelectionArguments.DISPLAY_MODE)) {
|
||||
int displayMode = ContactSelectionDisplayMode.FLAG_PUSH | ContactSelectionDisplayMode.FLAG_ACTIVE_GROUPS | ContactSelectionDisplayMode.FLAG_INACTIVE_GROUPS | ContactSelectionDisplayMode.FLAG_SELF;
|
||||
getIntent().putExtra(ContactSelectionListFragment.DISPLAY_MODE, displayMode);
|
||||
getIntent().putExtra(ContactSelectionArguments.DISPLAY_MODE, displayMode);
|
||||
}
|
||||
|
||||
setContentView(getIntent().getIntExtra(EXTRA_LAYOUT_RES_ID, R.layout.contact_selection_activity));
|
||||
|
||||
@@ -20,7 +20,6 @@ package org.thoughtcrime.securesms;
|
||||
import android.Manifest;
|
||||
import android.annotation.SuppressLint;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.graphics.Rect;
|
||||
import android.os.AsyncTask;
|
||||
import android.os.Bundle;
|
||||
@@ -66,6 +65,7 @@ import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey;
|
||||
import org.thoughtcrime.securesms.contacts.paged.ContactSearchMediator;
|
||||
import org.thoughtcrime.securesms.contacts.paged.ContactSearchSortOrder;
|
||||
import org.thoughtcrime.securesms.contacts.paged.ContactSearchState;
|
||||
import org.thoughtcrime.securesms.contacts.selection.ContactSelectionArguments;
|
||||
import org.thoughtcrime.securesms.contacts.sync.ContactDiscovery;
|
||||
import org.thoughtcrime.securesms.database.RecipientTable;
|
||||
import org.thoughtcrime.securesms.groups.SelectionLimits;
|
||||
@@ -86,7 +86,6 @@ import org.thoughtcrime.securesms.util.views.SimpleProgressDialog;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
@@ -97,7 +96,7 @@ import io.reactivex.rxjava3.disposables.Disposable;
|
||||
import kotlin.Unit;
|
||||
|
||||
/**
|
||||
* Fragment for selecting a one or more contacts from a list.
|
||||
* Fragment for selecting one or more contacts from a list.
|
||||
*
|
||||
* @author Moxie Marlinspike
|
||||
*/
|
||||
@@ -110,17 +109,7 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
|
||||
public static final int NO_LIMIT = Integer.MAX_VALUE;
|
||||
|
||||
public static final String DISPLAY_MODE = "display_mode";
|
||||
public static final String REFRESHABLE = "refreshable";
|
||||
public static final String RECENTS = "recents";
|
||||
public static final String SELECTION_LIMITS = "selection_limits";
|
||||
public static final String CURRENT_SELECTION = "current_selection";
|
||||
public static final String HIDE_COUNT = "hide_count";
|
||||
public static final String CAN_SELECT_SELF = "can_select_self";
|
||||
public static final String DISPLAY_CHIPS = "display_chips";
|
||||
public static final String RV_PADDING_BOTTOM = "recycler_view_padding_bottom";
|
||||
public static final String RV_CLIP = "recycler_view_clipping";
|
||||
public static final String INCLUDE_CHAT_TYPES = "include_chat_types";
|
||||
private ContactSelectionArguments fragmentArgs;
|
||||
|
||||
private ConstraintLayout constraintLayout;
|
||||
private TextView emptyText;
|
||||
@@ -138,16 +127,16 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
private TextView headerActionView;
|
||||
private ContactSearchMediator contactSearchMediator;
|
||||
|
||||
@Nullable private NewConversationCallback newConversationCallback;
|
||||
@Nullable private FindByCallback findByCallback;
|
||||
@Nullable private NewCallCallback newCallCallback;
|
||||
@Nullable private ScrollCallback scrollCallback;
|
||||
@Nullable private OnItemLongClickListener onItemLongClickListener;
|
||||
private SelectionLimits selectionLimit = SelectionLimits.NO_LIMITS;
|
||||
private Set<RecipientId> currentSelection;
|
||||
private boolean isMulti;
|
||||
private boolean canSelectSelf;
|
||||
private boolean resetPositionOnCommit = false;
|
||||
@Nullable private NewConversationCallback newConversationCallback;
|
||||
@Nullable private FindByCallback findByCallback;
|
||||
@Nullable private NewCallCallback newCallCallback;
|
||||
@Nullable private ScrollCallback scrollCallback;
|
||||
@Nullable private OnItemLongClickListener onItemLongClickListener;
|
||||
private SelectionLimits selectionLimit = SelectionLimits.NO_LIMITS;
|
||||
private Set<RecipientId> currentSelection;
|
||||
private boolean isMulti;
|
||||
private boolean canSelectSelf;
|
||||
private boolean resetPositionOnCommit = false;
|
||||
|
||||
private ListClickListener listClickListener = new ListClickListener();
|
||||
@Nullable private SwipeRefreshLayout.OnRefreshListener onRefreshListener;
|
||||
@@ -157,11 +146,11 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
super.onAttach(context);
|
||||
|
||||
if (context instanceof NewConversationCallback) {
|
||||
newConversationCallback = (NewConversationCallback) context;
|
||||
setNewConversationCallback((NewConversationCallback) context);
|
||||
}
|
||||
|
||||
if (context instanceof FindByCallback) {
|
||||
findByCallback = (FindByCallback) context;
|
||||
setFindByCallback((FindByCallback) context);
|
||||
}
|
||||
|
||||
if (context instanceof NewCallCallback) {
|
||||
@@ -177,11 +166,11 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
}
|
||||
|
||||
if (getParentFragment() instanceof OnContactSelectedListener) {
|
||||
onContactSelectedListener = (OnContactSelectedListener) getParentFragment();
|
||||
setOnContactSelectedListener((OnContactSelectedListener) getParentFragment());
|
||||
}
|
||||
|
||||
if (context instanceof OnContactSelectedListener) {
|
||||
onContactSelectedListener = (OnContactSelectedListener) context;
|
||||
setOnContactSelectedListener((OnContactSelectedListener) context);
|
||||
}
|
||||
|
||||
if (context instanceof OnSelectionLimitReachedListener) {
|
||||
@@ -209,6 +198,18 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
}
|
||||
}
|
||||
|
||||
public void setNewConversationCallback(@Nullable NewConversationCallback callback) {
|
||||
this.newConversationCallback = callback;
|
||||
}
|
||||
|
||||
public void setFindByCallback(@Nullable FindByCallback callback) {
|
||||
this.findByCallback = callback;
|
||||
}
|
||||
|
||||
public void setOnContactSelectedListener(@Nullable OnContactSelectedListener listener) {
|
||||
this.onContactSelectedListener = listener;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onActivityCreated(Bundle icicle) {
|
||||
super.onActivityCreated(icicle);
|
||||
@@ -221,7 +222,7 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
super.onStart();
|
||||
|
||||
if (hasContactsPermissions(requireContext()) && !TextSecurePreferences.hasSuccessfullyRetrievedDirectory(getActivity())) {
|
||||
handleContactPermissionGranted();
|
||||
handleContactPermissionGranted();
|
||||
} else {
|
||||
requireActivity().getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_HIDDEN);
|
||||
contactSearchMediator.refresh();
|
||||
@@ -232,13 +233,13 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
|
||||
View view = inflater.inflate(R.layout.contact_selection_list_fragment, container, false);
|
||||
|
||||
emptyText = view.findViewById(android.R.id.empty);
|
||||
recyclerView = view.findViewById(R.id.recycler_view);
|
||||
swipeRefresh = view.findViewById(R.id.swipe_refresh);
|
||||
fastScroller = view.findViewById(R.id.fast_scroller);
|
||||
chipRecycler = view.findViewById(R.id.chipRecycler);
|
||||
constraintLayout = view.findViewById(R.id.container);
|
||||
headerActionView = view.findViewById(R.id.header_action);
|
||||
emptyText = view.findViewById(android.R.id.empty);
|
||||
recyclerView = view.findViewById(R.id.recycler_view);
|
||||
swipeRefresh = view.findViewById(R.id.swipe_refresh);
|
||||
fastScroller = view.findViewById(R.id.fast_scroller);
|
||||
chipRecycler = view.findViewById(R.id.chipRecycler);
|
||||
constraintLayout = view.findViewById(R.id.container);
|
||||
headerActionView = view.findViewById(R.id.header_action);
|
||||
|
||||
final LinearLayoutManager layoutManager = new LinearLayoutManager(requireContext());
|
||||
|
||||
@@ -267,28 +268,20 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
|
||||
lifecycleDisposable.add(disposable);
|
||||
|
||||
Intent intent = requireActivity().getIntent();
|
||||
Bundle arguments = safeArguments();
|
||||
fragmentArgs = ContactSelectionArguments.fromBundle(safeArguments(), requireActivity().getIntent());
|
||||
|
||||
int recyclerViewPadBottom = arguments.getInt(RV_PADDING_BOTTOM, intent.getIntExtra(RV_PADDING_BOTTOM, -1));
|
||||
boolean recyclerViewClipping = arguments.getBoolean(RV_CLIP, intent.getBooleanExtra(RV_CLIP, true));
|
||||
|
||||
if (recyclerViewPadBottom != -1) {
|
||||
ViewUtil.setPaddingBottom(recyclerView, recyclerViewPadBottom);
|
||||
if (fragmentArgs.getRecyclerPadBottom() != -1) {
|
||||
ViewUtil.setPaddingBottom(recyclerView, fragmentArgs.getRecyclerPadBottom());
|
||||
}
|
||||
|
||||
recyclerView.setClipToPadding(recyclerViewClipping);
|
||||
recyclerView.setClipToPadding(fragmentArgs.getRecyclerChildClipping());
|
||||
|
||||
boolean isRefreshable = arguments.getBoolean(REFRESHABLE, intent.getBooleanExtra(REFRESHABLE, true));
|
||||
swipeRefresh.setNestedScrollingEnabled(isRefreshable);
|
||||
swipeRefresh.setEnabled(isRefreshable);
|
||||
swipeRefresh.setNestedScrollingEnabled(fragmentArgs.isRefreshable());
|
||||
swipeRefresh.setEnabled(fragmentArgs.isRefreshable());
|
||||
|
||||
selectionLimit = arguments.getParcelable(SELECTION_LIMITS);
|
||||
if (selectionLimit == null) {
|
||||
selectionLimit = intent.getParcelableExtra(SELECTION_LIMITS);
|
||||
}
|
||||
isMulti = selectionLimit != null;
|
||||
canSelectSelf = arguments.getBoolean(CAN_SELECT_SELF, intent.getBooleanExtra(CAN_SELECT_SELF, !isMulti));
|
||||
selectionLimit = fragmentArgs.getSelectionLimits();
|
||||
isMulti = selectionLimit != null;
|
||||
canSelectSelf = fragmentArgs.getCanSelectSelf();
|
||||
|
||||
if (!isMulti) {
|
||||
selectionLimit = SelectionLimits.NO_LIMITS;
|
||||
@@ -441,7 +434,7 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
@Override
|
||||
public void onDestroyView() {
|
||||
super.onDestroyView();
|
||||
constraintLayout = null;
|
||||
constraintLayout = null;
|
||||
onRefreshListener = null;
|
||||
}
|
||||
|
||||
@@ -486,13 +479,7 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
}
|
||||
|
||||
private Set<RecipientId> getCurrentSelection() {
|
||||
List<RecipientId> currentSelection = safeArguments().getParcelableArrayList(CURRENT_SELECTION);
|
||||
if (currentSelection == null) {
|
||||
currentSelection = requireActivity().getIntent().getParcelableArrayListExtra(CURRENT_SELECTION);
|
||||
}
|
||||
|
||||
return currentSelection == null ? Collections.emptySet()
|
||||
: Collections.unmodifiableSet(new HashSet<>(currentSelection));
|
||||
return Set.copyOf(fragmentArgs.getCurrentSelection());
|
||||
}
|
||||
|
||||
public boolean isMulti() {
|
||||
@@ -609,7 +596,7 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
}
|
||||
|
||||
private boolean shouldDisplayRecents() {
|
||||
return safeArguments().getBoolean(RECENTS, requireActivity().getIntent().getBooleanExtra(RECENTS, false));
|
||||
return fragmentArgs.getIncludeRecents();
|
||||
}
|
||||
|
||||
@SuppressLint("StaticFieldLeak")
|
||||
@@ -723,7 +710,7 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
|
||||
SimpleTask.run(getViewLifecycleOwner().getLifecycle(), () -> {
|
||||
return UsernameRepository.fetchAciForUsername(UsernameUtil.sanitizeUsernameFromSearch(username));
|
||||
}, result -> {
|
||||
}, result -> {
|
||||
loadingDialog.dismiss();
|
||||
|
||||
// TODO Could be more specific with errors
|
||||
@@ -756,10 +743,10 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
selectedContact.getNumber(),
|
||||
Optional.empty(),
|
||||
allowed -> {
|
||||
if (allowed) {
|
||||
markContactSelected(selectedContact);
|
||||
}
|
||||
});
|
||||
if (allowed) {
|
||||
markContactSelected(selectedContact);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
markContactSelected(selectedContact);
|
||||
}
|
||||
@@ -861,7 +848,7 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
}
|
||||
|
||||
private void setChipGroupVisibility(int visibility) {
|
||||
if (!safeArguments().getBoolean(DISPLAY_CHIPS, requireActivity().getIntent().getBooleanExtra(DISPLAY_CHIPS, true))) {
|
||||
if (!fragmentArgs.getDisplayChips()) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -888,9 +875,9 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
}
|
||||
|
||||
private @NonNull ContactSearchConfiguration mapStateToConfiguration(@NonNull ContactSearchState contactSearchState) {
|
||||
int displayMode = safeArguments().getInt(DISPLAY_MODE, requireActivity().getIntent().getIntExtra(DISPLAY_MODE, ContactSelectionDisplayMode.FLAG_ALL));
|
||||
int displayMode = fragmentArgs.getDisplayMode();
|
||||
|
||||
boolean includeRecents = safeArguments().getBoolean(RECENTS, requireActivity().getIntent().getBooleanExtra(RECENTS, false));
|
||||
boolean includeRecents = fragmentArgs.getIncludeRecents();
|
||||
boolean includePushContacts = flagSet(displayMode, ContactSelectionDisplayMode.FLAG_PUSH);
|
||||
boolean includeSmsContacts = flagSet(displayMode, ContactSelectionDisplayMode.FLAG_SMS);
|
||||
boolean includeActiveGroups = flagSet(displayMode, ContactSelectionDisplayMode.FLAG_ACTIVE_GROUPS);
|
||||
@@ -902,7 +889,7 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
boolean includeGroupsAfterContacts = flagSet(displayMode, ContactSelectionDisplayMode.FLAG_GROUPS_AFTER_CONTACTS);
|
||||
boolean blocked = flagSet(displayMode, ContactSelectionDisplayMode.FLAG_BLOCK);
|
||||
boolean includeGroupMembers = flagSet(displayMode, ContactSelectionDisplayMode.FLAG_GROUP_MEMBERS);
|
||||
boolean includeChatTypes = safeArguments().getBoolean(INCLUDE_CHAT_TYPES);
|
||||
boolean includeChatTypes = fragmentArgs.getIncludeChatTypes();
|
||||
boolean hasQuery = !TextUtils.isEmpty(contactSearchState.getQuery());
|
||||
|
||||
ContactSearchConfiguration.TransportType transportType = resolveTransportType(includePushContacts, includeSmsContacts);
|
||||
@@ -913,18 +900,22 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
builder.setQuery(contactSearchState.getQuery());
|
||||
|
||||
if ((newConversationCallback != null || findByCallback != null) &&
|
||||
!hasContactsPermissions(requireContext()) &&
|
||||
!hasContactsPermissions(requireContext()) &&
|
||||
!SignalStore.uiHints().getDismissedContactsPermissionBanner() &&
|
||||
!hasQuery) {
|
||||
!hasQuery)
|
||||
{
|
||||
builder.arbitrary(ContactSelectionListAdapter.ArbitraryRepository.ArbitraryRow.FIND_CONTACTS_BANNER.getCode());
|
||||
}
|
||||
|
||||
if (newConversationCallback != null && !hasQuery) {
|
||||
if (fragmentArgs.getEnableCreateNewGroup() && !hasQuery) {
|
||||
builder.arbitrary(ContactSelectionListAdapter.ArbitraryRepository.ArbitraryRow.NEW_GROUP.getCode());
|
||||
}
|
||||
|
||||
if (findByCallback != null && !hasQuery) {
|
||||
if (fragmentArgs.getEnableFindByUsername() && !hasQuery) {
|
||||
builder.arbitrary(ContactSelectionListAdapter.ArbitraryRepository.ArbitraryRow.FIND_BY_USERNAME.getCode());
|
||||
}
|
||||
|
||||
if (fragmentArgs.getEnableFindByPhoneNumber() && !hasQuery) {
|
||||
builder.arbitrary(ContactSelectionListAdapter.ArbitraryRepository.ArbitraryRow.FIND_BY_PHONE_NUMBER.getCode());
|
||||
}
|
||||
|
||||
|
||||
@@ -21,8 +21,8 @@ import androidx.fragment.app.FragmentManager
|
||||
import androidx.fragment.app.setFragmentResult
|
||||
import org.signal.core.ui.compose.BottomSheets
|
||||
import org.signal.core.ui.compose.Buttons
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.SignalPreview
|
||||
import org.thoughtcrime.securesms.compose.ComposeBottomSheetDialogFragment
|
||||
import org.thoughtcrime.securesms.util.BottomSheetUtil
|
||||
|
||||
@@ -106,7 +106,7 @@ fun DevicePinAuthEducationSheet(
|
||||
}
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
fun DevicePinAuthEducationSheetPreview() {
|
||||
Previews.BottomSheetPreview {
|
||||
|
||||
@@ -30,9 +30,9 @@ import androidx.compose.ui.text.TextRange
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.Scaffolds
|
||||
import org.signal.core.ui.compose.SignalPreview
|
||||
import org.thoughtcrime.securesms.compose.ComposeFragment
|
||||
|
||||
/**
|
||||
@@ -113,7 +113,7 @@ fun InviteScreen(
|
||||
}
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun InviteScreenPreview() {
|
||||
Previews.Preview {
|
||||
|
||||
@@ -18,12 +18,12 @@ import android.view.ViewTreeObserver
|
||||
import android.widget.Toast
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.activity.SystemBarStyle
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.activity.viewModels
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -37,8 +37,9 @@ import androidx.compose.foundation.layout.systemBarsPadding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.SnackbarDuration
|
||||
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
|
||||
import androidx.compose.material3.adaptive.layout.PaneExpansionAnchor
|
||||
import androidx.compose.material3.adaptive.layout.ThreePaneScaffoldRole
|
||||
import androidx.compose.material3.adaptive.layout.rememberPaneExpansionState
|
||||
import androidx.compose.material3.adaptive.navigation.ThreePaneScaffoldNavigator
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -48,8 +49,8 @@ import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.fragment.app.DialogFragment
|
||||
import androidx.fragment.compose.AndroidFragment
|
||||
import androidx.fragment.compose.rememberFragmentState
|
||||
@@ -74,6 +75,7 @@ import org.signal.donations.StripeApi
|
||||
import org.thoughtcrime.securesms.backup.v2.ArchiveRestoreProgress
|
||||
import org.thoughtcrime.securesms.backup.v2.ui.verify.VerifyBackupKeyActivity
|
||||
import org.thoughtcrime.securesms.calls.YouAreAlreadyInACallSnackbar.show
|
||||
import org.thoughtcrime.securesms.calls.links.details.CallLinkDetailsActivity
|
||||
import org.thoughtcrime.securesms.calls.log.CallLogFilter
|
||||
import org.thoughtcrime.securesms.calls.log.CallLogFragment
|
||||
import org.thoughtcrime.securesms.calls.new.NewCallActivity
|
||||
@@ -87,7 +89,6 @@ import org.thoughtcrime.securesms.components.settings.app.notifications.manual.N
|
||||
import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaController
|
||||
import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaControllerOwner
|
||||
import org.thoughtcrime.securesms.conversation.ConversationIntents
|
||||
import org.thoughtcrime.securesms.conversation.v2.ConversationFragment
|
||||
import org.thoughtcrime.securesms.conversation.v2.MotionEventRelay
|
||||
import org.thoughtcrime.securesms.conversation.v2.ShareDataTimestampViewModel
|
||||
import org.thoughtcrime.securesms.conversationlist.ConversationListArchiveFragment
|
||||
@@ -100,6 +101,7 @@ 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.MainBottomChrome
|
||||
import org.thoughtcrime.securesms.main.MainBottomChromeCallback
|
||||
import org.thoughtcrime.securesms.main.MainBottomChromeState
|
||||
@@ -118,6 +120,13 @@ import org.thoughtcrime.securesms.main.MainToolbarViewModel
|
||||
import org.thoughtcrime.securesms.main.Material3OnScrollHelperBinder
|
||||
import org.thoughtcrime.securesms.main.NavigationBarSpacerCompat
|
||||
import org.thoughtcrime.securesms.main.SnackbarState
|
||||
import org.thoughtcrime.securesms.main.callNavGraphBuilder
|
||||
import org.thoughtcrime.securesms.main.chatNavGraphBuilder
|
||||
import org.thoughtcrime.securesms.main.navigateToDetailLocation
|
||||
import org.thoughtcrime.securesms.main.rememberDetailNavHostController
|
||||
import org.thoughtcrime.securesms.main.rememberFocusRequester
|
||||
import org.thoughtcrime.securesms.main.rememberMainNavigationDetailLocation
|
||||
import org.thoughtcrime.securesms.main.storiesNavGraphBuilder
|
||||
import org.thoughtcrime.securesms.mediasend.camerax.CameraXUtil
|
||||
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionActivity
|
||||
import org.thoughtcrime.securesms.megaphone.Megaphone
|
||||
@@ -148,8 +157,9 @@ import org.thoughtcrime.securesms.util.Util
|
||||
import org.thoughtcrime.securesms.util.viewModel
|
||||
import org.thoughtcrime.securesms.window.AppPaneDragHandle
|
||||
import org.thoughtcrime.securesms.window.AppScaffold
|
||||
import org.thoughtcrime.securesms.window.AppScaffoldNavigator
|
||||
import org.thoughtcrime.securesms.window.WindowSizeClass
|
||||
import org.thoughtcrime.securesms.window.rememberAppScaffoldNavigator
|
||||
import org.thoughtcrime.securesms.window.rememberThreePaneScaffoldNavigatorDelegate
|
||||
import org.whispersystems.signalservice.api.websocket.WebSocketConnectionState
|
||||
|
||||
class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner, MainNavigator.NavigatorProvider, Material3OnScrollHelperBinder, ConversationListFragment.Callback, CallLogFragment.Callback {
|
||||
@@ -297,7 +307,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 +317,12 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
|
||||
}
|
||||
}
|
||||
|
||||
val isNavigationVisible = remember(mainToolbarState.mode) {
|
||||
mainToolbarState.mode == MainToolbarMode.FULL
|
||||
val isNavigationVisible = mainToolbarState.mode == MainToolbarMode.FULL
|
||||
val isBackHandlerEnabled = mainToolbarState.destination != MainNavigationListLocation.CHATS
|
||||
|
||||
BackHandler(enabled = isBackHandlerEnabled) {
|
||||
mainNavigationViewModel.setFocusedPane(ThreePaneScaffoldRole.Secondary)
|
||||
mainNavigationViewModel.goTo(MainNavigationListLocation.CHATS)
|
||||
}
|
||||
|
||||
val mainBottomChromeState = remember(mainToolbarState.destination, snackbar, mainToolbarState.mode, megaphone) {
|
||||
@@ -329,8 +342,77 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
|
||||
|
||||
MainContainer {
|
||||
val wrappedNavigator = rememberNavigator(windowSizeClass, contentLayoutData, maxWidth)
|
||||
val paneExpansionState = rememberPaneExpansionState()
|
||||
val listPaneWidth = contentLayoutData.rememberDefaultPanePreferredWidth(maxWidth)
|
||||
val halfPartitionWidth = contentLayoutData.partitionWidth / 2
|
||||
|
||||
val detailOffset = if (mainToolbarState.mode == MainToolbarMode.SEARCH || mainToolbarState.mode == MainToolbarMode.ACTION_MODE) 0.dp else 72.dp
|
||||
val detailOnlyAnchor = PaneExpansionAnchor.Offset.fromStart(detailOffset + contentLayoutData.listPaddingStart + halfPartitionWidth)
|
||||
val detailAndListAnchor = PaneExpansionAnchor.Offset.fromStart(listPaneWidth + halfPartitionWidth)
|
||||
val listOnlyAnchor = PaneExpansionAnchor.Offset.fromEnd(contentLayoutData.detailPaddingEnd - halfPartitionWidth)
|
||||
|
||||
val paneExpansionState = rememberPaneExpansionState(
|
||||
anchors = listOf(detailOnlyAnchor, detailAndListAnchor, listOnlyAnchor)
|
||||
)
|
||||
|
||||
val mutableInteractionSource = remember { MutableInteractionSource() }
|
||||
val mainNavigationDetailLocation by rememberMainNavigationDetailLocation(mainNavigationViewModel)
|
||||
|
||||
val chatsNavHostController = rememberDetailNavHostController(
|
||||
onRequestFocus = rememberFocusRequester(
|
||||
mainNavigationViewModel = mainNavigationViewModel,
|
||||
currentListLocation = mainNavigationState.currentListLocation,
|
||||
isTargetListLocation = { it in listOf(MainNavigationListLocation.CHATS, MainNavigationListLocation.ARCHIVE) }
|
||||
)
|
||||
) {
|
||||
chatNavGraphBuilder()
|
||||
}
|
||||
|
||||
val callsNavHostController = rememberDetailNavHostController(
|
||||
onRequestFocus = rememberFocusRequester(
|
||||
mainNavigationViewModel = mainNavigationViewModel,
|
||||
currentListLocation = mainNavigationState.currentListLocation
|
||||
) { it == MainNavigationListLocation.CALLS }
|
||||
) {
|
||||
callNavGraphBuilder(it)
|
||||
}
|
||||
|
||||
val storiesNavHostController = rememberDetailNavHostController(
|
||||
onRequestFocus = rememberFocusRequester(
|
||||
mainNavigationViewModel = mainNavigationViewModel,
|
||||
currentListLocation = mainNavigationState.currentListLocation
|
||||
) { it == MainNavigationListLocation.STORIES }
|
||||
) {
|
||||
storiesNavGraphBuilder()
|
||||
}
|
||||
|
||||
LaunchedEffect(mainNavigationDetailLocation) {
|
||||
mainNavigationViewModel.clearEarlyDetailLocation()
|
||||
when (mainNavigationDetailLocation) {
|
||||
is MainNavigationDetailLocation.Empty -> {
|
||||
when (mainNavigationState.currentListLocation) {
|
||||
MainNavigationListLocation.CHATS, MainNavigationListLocation.ARCHIVE -> chatsNavHostController
|
||||
MainNavigationListLocation.CALLS -> callsNavHostController
|
||||
MainNavigationListLocation.STORIES -> storiesNavHostController
|
||||
}.navigateToDetailLocation(mainNavigationDetailLocation)
|
||||
}
|
||||
|
||||
is MainNavigationDetailLocation.Chats -> chatsNavHostController.navigateToDetailLocation(mainNavigationDetailLocation)
|
||||
is MainNavigationDetailLocation.Calls -> callsNavHostController.navigateToDetailLocation(mainNavigationDetailLocation)
|
||||
is MainNavigationDetailLocation.Stories -> storiesNavHostController.navigateToDetailLocation(mainNavigationDetailLocation)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(mainNavigationDetailLocation) {
|
||||
if (paneExpansionState.currentAnchor == listOnlyAnchor && wrappedNavigator.currentDestination?.pane == ThreePaneScaffoldRole.Primary) {
|
||||
paneExpansionState.animateTo(detailOnlyAnchor)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(mainNavigationState.currentListLocation) {
|
||||
if (paneExpansionState.currentAnchor == detailOnlyAnchor && wrappedNavigator.currentDestination?.pane == ThreePaneScaffoldRole.Secondary) {
|
||||
paneExpansionState.animateTo(listOnlyAnchor)
|
||||
}
|
||||
}
|
||||
|
||||
AppScaffold(
|
||||
navigator = wrappedNavigator,
|
||||
@@ -362,7 +444,7 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
|
||||
)
|
||||
}
|
||||
},
|
||||
listContent = {
|
||||
secondaryContent = {
|
||||
val listContainerColor = if (windowSizeClass.isMedium()) {
|
||||
SignalTheme.colors.colorSurface1
|
||||
} else {
|
||||
@@ -431,36 +513,27 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
|
||||
}
|
||||
}
|
||||
},
|
||||
detailContent = {
|
||||
when (val destination = mainNavigationDetailLocation) {
|
||||
is MainNavigationDetailLocation.Conversation -> {
|
||||
val fragmentState = key(destination) { rememberFragmentState() }
|
||||
AndroidFragment(
|
||||
clazz = ConversationFragment::class.java,
|
||||
fragmentState = fragmentState,
|
||||
arguments = requireNotNull(destination.intent.extras) { "Handed null Conversation intent arguments." },
|
||||
modifier = Modifier
|
||||
.padding(end = contentLayoutData.detailPaddingEnd)
|
||||
.clip(contentLayoutData.shape)
|
||||
.background(color = MaterialTheme.colorScheme.surface)
|
||||
.fillMaxSize()
|
||||
primaryContent = {
|
||||
when (mainNavigationState.currentListLocation) {
|
||||
MainNavigationListLocation.CHATS, MainNavigationListLocation.ARCHIVE -> {
|
||||
DetailsScreenNavHost(
|
||||
navHostController = chatsNavHostController,
|
||||
contentLayoutData = contentLayoutData
|
||||
)
|
||||
}
|
||||
|
||||
MainNavigationDetailLocation.Empty -> {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(end = contentLayoutData.detailPaddingEnd)
|
||||
.clip(contentLayoutData.shape)
|
||||
.background(color = MaterialTheme.colorScheme.surface)
|
||||
.fillMaxSize()
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.ic_signal_logo_large),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.align(Alignment.Center)
|
||||
)
|
||||
}
|
||||
MainNavigationListLocation.CALLS -> {
|
||||
DetailsScreenNavHost(
|
||||
navHostController = callsNavHostController,
|
||||
contentLayoutData = contentLayoutData
|
||||
)
|
||||
}
|
||||
|
||||
MainNavigationListLocation.STORIES -> {
|
||||
DetailsScreenNavHost(
|
||||
navHostController = storiesNavHostController,
|
||||
contentLayoutData = contentLayoutData
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -509,8 +582,8 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
|
||||
windowSizeClass: WindowSizeClass,
|
||||
contentLayoutData: MainContentLayoutData,
|
||||
maxWidth: Dp
|
||||
): ThreePaneScaffoldNavigator<Any> {
|
||||
val scaffoldNavigator = rememberAppScaffoldNavigator(
|
||||
): AppScaffoldNavigator<Any> {
|
||||
val scaffoldNavigator = rememberThreePaneScaffoldNavigatorDelegate(
|
||||
isSplitPane = windowSizeClass.isSplitPane(),
|
||||
horizontalPartitionSpacerSize = contentLayoutData.partitionWidth,
|
||||
defaultPanePreferredWidth = contentLayoutData.rememberDefaultPanePreferredWidth(maxWidth)
|
||||
@@ -521,8 +594,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 +829,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!!)))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -197,7 +195,8 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
|
||||
return !SignalStore.registration().isRegistrationComplete() &&
|
||||
!SignalStore.svr().hasPin() &&
|
||||
!SignalStore.svr().lastPinCreateFailed() &&
|
||||
!SignalStore.svr().hasOptedOut();
|
||||
!SignalStore.svr().hasOptedOut() &&
|
||||
SignalStore.account().isPrimaryDevice();
|
||||
}
|
||||
|
||||
private boolean userMustSetProfileName() {
|
||||
|
||||
@@ -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());
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,8 +27,8 @@ import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.TextUnitType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.SignalPreview
|
||||
import org.signal.core.util.DimensionUnit
|
||||
import org.thoughtcrime.securesms.avatar.AvatarRenderer
|
||||
import org.thoughtcrime.securesms.avatar.Avatars
|
||||
@@ -94,7 +94,7 @@ fun FallbackAvatarImage(
|
||||
}
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
fun FallbackAvatarImagePreview() {
|
||||
Previews.Preview {
|
||||
|
||||
@@ -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?) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -33,6 +33,7 @@ import org.thoughtcrime.securesms.database.EmojiSearchTable;
|
||||
import org.thoughtcrime.securesms.database.GroupReceiptTable;
|
||||
import org.thoughtcrime.securesms.database.KeyValueDatabase;
|
||||
import org.thoughtcrime.securesms.database.KyberPreKeyTable;
|
||||
import org.thoughtcrime.securesms.database.LastResortKeyTupleTable;
|
||||
import org.thoughtcrime.securesms.database.MentionTable;
|
||||
import org.thoughtcrime.securesms.database.MessageTable;
|
||||
import org.thoughtcrime.securesms.database.OneTimePreKeyTable;
|
||||
@@ -90,6 +91,7 @@ public class FullBackupExporter extends FullBackupBase {
|
||||
SignedPreKeyTable.TABLE_NAME,
|
||||
OneTimePreKeyTable.TABLE_NAME,
|
||||
KyberPreKeyTable.TABLE_NAME,
|
||||
LastResortKeyTupleTable.TABLE_NAME,
|
||||
SessionTable.TABLE_NAME,
|
||||
SearchTable.FTS_TABLE_NAME,
|
||||
EmojiSearchTable.TABLE_NAME,
|
||||
|
||||
@@ -30,6 +30,7 @@ import org.thoughtcrime.securesms.database.AttachmentTable;
|
||||
import org.thoughtcrime.securesms.database.EmojiSearchTable;
|
||||
import org.thoughtcrime.securesms.database.KeyValueDatabase;
|
||||
import org.thoughtcrime.securesms.database.KyberPreKeyTable;
|
||||
import org.thoughtcrime.securesms.database.LastResortKeyTupleTable;
|
||||
import org.thoughtcrime.securesms.database.OneTimePreKeyTable;
|
||||
import org.thoughtcrime.securesms.database.SearchTable;
|
||||
import org.thoughtcrime.securesms.database.SignedPreKeyTable;
|
||||
@@ -70,7 +71,7 @@ public class FullBackupImporter extends FullBackupBase {
|
||||
@SuppressWarnings("unused")
|
||||
private static final String TAG = Log.tag(FullBackupImporter.class);
|
||||
|
||||
private static final Set<String> KEY_TABLES = SetsKt.setOf(KyberPreKeyTable.TABLE_NAME, OneTimePreKeyTable.TABLE_NAME, SignedPreKeyTable.TABLE_NAME);
|
||||
private static final Set<String> KEY_TABLES = SetsKt.setOf(KyberPreKeyTable.TABLE_NAME, LastResortKeyTupleTable.TABLE_NAME, OneTimePreKeyTable.TABLE_NAME, SignedPreKeyTable.TABLE_NAME);
|
||||
|
||||
public static boolean validatePassphrase(@NonNull Context context,
|
||||
@NonNull Uri uri,
|
||||
|
||||
@@ -119,6 +119,10 @@ object ExportSkips {
|
||||
return log(sentTimestamp, "Failed to parse thread merge event.")
|
||||
}
|
||||
|
||||
fun pollTerminateIsEmpty(sentTimestamp: Long): String {
|
||||
return log(sentTimestamp, "Poll terminate update was empty.")
|
||||
}
|
||||
|
||||
fun individualChatUpdateInWrongTypeOfChat(sentTimestamp: Long): String {
|
||||
return log(sentTimestamp, "A chat update that only makes sense for individual chats was found in a different kind of chat.")
|
||||
}
|
||||
@@ -199,6 +203,10 @@ object ExportOddities {
|
||||
* These represent situations where we will skip importing a data frame due to the data being invalid.
|
||||
*/
|
||||
object ImportSkips {
|
||||
fun recipientWithoutId(): String {
|
||||
return log(0, " No aci, pni, or e164 available for recipient")
|
||||
}
|
||||
|
||||
fun fromRecipientNotFound(sentTimestamp: Long): String {
|
||||
return log(sentTimestamp, "Failed to find the fromRecipient for the message.")
|
||||
}
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -72,6 +72,7 @@ import org.thoughtcrime.securesms.backup.v2.processor.RecipientArchiveProcessor
|
||||
import org.thoughtcrime.securesms.backup.v2.processor.StickerArchiveProcessor
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.BackupDebugInfo
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.BackupInfo
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.Frame
|
||||
import org.thoughtcrime.securesms.backup.v2.stream.BackupExportWriter
|
||||
import org.thoughtcrime.securesms.backup.v2.stream.BackupImportReader
|
||||
import org.thoughtcrime.securesms.backup.v2.stream.EncryptedBackupReader
|
||||
@@ -80,6 +81,7 @@ import org.thoughtcrime.securesms.backup.v2.stream.PlainTextBackupReader
|
||||
import org.thoughtcrime.securesms.backup.v2.stream.PlainTextBackupWriter
|
||||
import org.thoughtcrime.securesms.backup.v2.ui.BackupAlert
|
||||
import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsType
|
||||
import org.thoughtcrime.securesms.backup.v2.util.ArchiveAttachmentInfo
|
||||
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.RecurringInAppPaymentRepository
|
||||
import org.thoughtcrime.securesms.crypto.AttachmentSecretProvider
|
||||
@@ -107,11 +109,14 @@ import org.thoughtcrime.securesms.jobs.BackupRestoreMediaJob
|
||||
import org.thoughtcrime.securesms.jobs.CancelRestoreMediaJob
|
||||
import org.thoughtcrime.securesms.jobs.CreateReleaseChannelJob
|
||||
import org.thoughtcrime.securesms.jobs.LocalBackupJob
|
||||
import org.thoughtcrime.securesms.jobs.MultiDeviceKeysUpdateJob
|
||||
import org.thoughtcrime.securesms.jobs.RequestGroupV2InfoJob
|
||||
import org.thoughtcrime.securesms.jobs.ResetSvrGuessCountJob
|
||||
import org.thoughtcrime.securesms.jobs.RestoreOptimizedMediaJob
|
||||
import org.thoughtcrime.securesms.jobs.RetrieveProfileJob
|
||||
import org.thoughtcrime.securesms.jobs.StickerPackDownloadJob
|
||||
import org.thoughtcrime.securesms.jobs.StorageForcePushJob
|
||||
import org.thoughtcrime.securesms.jobs.Svr2MirrorJob
|
||||
import org.thoughtcrime.securesms.jobs.UploadAttachmentToArchiveJob
|
||||
import org.thoughtcrime.securesms.keyvalue.BackupValues.ArchiveServiceCredentials
|
||||
import org.thoughtcrime.securesms.keyvalue.KeyValueStore
|
||||
@@ -130,6 +135,7 @@ import org.thoughtcrime.securesms.service.BackupProgressService
|
||||
import org.thoughtcrime.securesms.storage.StorageSyncHelper
|
||||
import org.thoughtcrime.securesms.util.RemoteConfig
|
||||
import org.thoughtcrime.securesms.util.ServiceUtil
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences
|
||||
import org.thoughtcrime.securesms.util.toMillis
|
||||
import org.whispersystems.signalservice.api.AccountEntropyPool
|
||||
import org.whispersystems.signalservice.api.ApplicationErrorAction
|
||||
@@ -163,6 +169,7 @@ import java.io.File
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
import java.math.BigDecimal
|
||||
import java.time.ZonedDateTime
|
||||
import java.util.Currency
|
||||
import java.util.Locale
|
||||
@@ -170,6 +177,7 @@ import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
import kotlin.jvm.optionals.getOrNull
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.days
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
@@ -237,9 +245,22 @@ object BackupRepository {
|
||||
resetInitializedStateAndAuthCredentials()
|
||||
SignalStore.account.rotateAccountEntropyPool(stagedKeyRotations.aep)
|
||||
SignalStore.backup.mediaRootBackupKey = stagedKeyRotations.mediaRootBackupKey
|
||||
refreshMasterKeyDependents()
|
||||
BackupMessagesJob.enqueue()
|
||||
}
|
||||
|
||||
private fun refreshMasterKeyDependents() {
|
||||
val jobs = buildList {
|
||||
add(Svr2MirrorJob())
|
||||
if (SignalStore.account.isMultiDevice) {
|
||||
add(MultiDeviceKeysUpdateJob())
|
||||
}
|
||||
add(StorageForcePushJob())
|
||||
}
|
||||
|
||||
AppDependencies.jobManager.addAll(jobs)
|
||||
}
|
||||
|
||||
fun resetInitializedStateAndAuthCredentials() {
|
||||
SignalStore.backup.backupsInitialized = false
|
||||
SignalStore.backup.messageCredentials.clearAll()
|
||||
@@ -265,6 +286,19 @@ object BackupRepository {
|
||||
val messageBackupKey = SignalStore.backup.messageBackupKey
|
||||
val mediaRootBackupKey = SignalStore.backup.mediaRootBackupKey
|
||||
return SignalNetwork.archive.triggerBackupIdReservation(messageBackupKey, mediaRootBackupKey, SignalStore.account.requireAci())
|
||||
.runIfSuccessful {
|
||||
SignalStore.backup.messageCredentials.clearAll()
|
||||
SignalStore.backup.mediaCredentials.clearAll()
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
fun triggerBackupIdReservationForRestore(): NetworkResult<Unit> {
|
||||
val messageBackupKey = SignalStore.backup.messageBackupKey
|
||||
return SignalNetwork.archive.triggerBackupIdReservation(messageBackupKey, null, SignalStore.account.requireAci())
|
||||
.runIfSuccessful {
|
||||
SignalStore.backup.messageCredentials.clearAll()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -542,7 +576,39 @@ object BackupRepository {
|
||||
return false
|
||||
}
|
||||
|
||||
return SignalStore.backup.hasBackupBeenUploaded && System.currentTimeMillis().milliseconds > SignalStore.backup.nextBackupFailureSheetSnoozeTime
|
||||
val isRegistered = SignalStore.account.isRegistered && !TextSecurePreferences.isUnauthorizedReceived(AppDependencies.application)
|
||||
if (!isRegistered) {
|
||||
Log.d(TAG, "[shouldDisplayCouldNotCompleteBackupSheet] Not displaying sheet for unregistered user.")
|
||||
return false
|
||||
}
|
||||
|
||||
if (SignalStore.backup.lastBackupTime <= 0) {
|
||||
Log.d(TAG, "[shouldDisplayCouldNotCompleteBackupSheet] Not displaying sheet as the last backup time is unset.")
|
||||
return false
|
||||
}
|
||||
|
||||
if (!SignalStore.backup.hasBackupBeenUploaded) {
|
||||
Log.d(TAG, "[shouldDisplayCouldNotCompleteBackupSheet] Not displaying sheet as a backup has never been uploaded.")
|
||||
return false
|
||||
}
|
||||
|
||||
val now = System.currentTimeMillis().milliseconds
|
||||
val lastBackupTime = SignalStore.backup.lastBackupTime.milliseconds
|
||||
val nextSnoozeTime = SignalStore.backup.nextBackupFailureSnoozeTime
|
||||
|
||||
val isLastBackupTimeAtLeastAWeekAgo = now - 7.days > lastBackupTime
|
||||
if (!isLastBackupTimeAtLeastAWeekAgo) {
|
||||
Log.d(TAG, "[shouldDisplayCouldNotCompleteBackupSheet] Not displaying sheet as the last backup time is less than a week ago.")
|
||||
return false
|
||||
}
|
||||
|
||||
val isNextSnoozeTimeBeforeNow = nextSnoozeTime < now
|
||||
if (!isNextSnoozeTimeBeforeNow) {
|
||||
Log.d(TAG, "[shouldDisplayCouldNotCompleteBackupSheet] Not displaying sheet as the next snooze time is in the future.")
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
fun snoozeDownloadYourBackupData() {
|
||||
@@ -615,7 +681,7 @@ object BackupRepository {
|
||||
}
|
||||
|
||||
private fun shouldNotDisplayBackupFailedMessaging(): Boolean {
|
||||
return !SignalStore.account.isRegistered || !RemoteConfig.messageBackups || !SignalStore.backup.areBackupsEnabled
|
||||
return !SignalStore.account.isRegistered || !SignalStore.backup.areBackupsEnabled
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -718,13 +784,18 @@ object BackupRepository {
|
||||
append = { main.write(it) }
|
||||
)
|
||||
|
||||
val maxBufferSize = 10_000
|
||||
var totalAttachmentCount = 0
|
||||
val attachmentInfos: MutableSet<ArchiveAttachmentInfo> = mutableSetOf()
|
||||
|
||||
export(
|
||||
currentTime = System.currentTimeMillis(),
|
||||
isLocal = true,
|
||||
writer = writer,
|
||||
progressEmitter = localBackupProgressEmitter,
|
||||
cancellationSignal = cancellationSignal,
|
||||
forTransfer = false
|
||||
forTransfer = false,
|
||||
extraFrameOperation = null
|
||||
) { dbSnapshot ->
|
||||
val localArchivableAttachments = dbSnapshot
|
||||
.attachmentTable
|
||||
@@ -760,7 +831,7 @@ object BackupRepository {
|
||||
currentTime: Long,
|
||||
progressEmitter: ExportProgressListener? = null,
|
||||
cancellationSignal: () -> Boolean = { false },
|
||||
extraExportOperations: ((SignalDatabase) -> Unit)?
|
||||
extraFrameOperation: ((Frame) -> Unit)?
|
||||
) {
|
||||
val writer = EncryptedBackupWriter.createForSignalBackup(
|
||||
key = messageBackupKey,
|
||||
@@ -778,7 +849,8 @@ object BackupRepository {
|
||||
forTransfer = false,
|
||||
progressEmitter = progressEmitter,
|
||||
cancellationSignal = cancellationSignal,
|
||||
extraExportOperations = extraExportOperations
|
||||
extraFrameOperation = extraFrameOperation,
|
||||
endingExportOperation = null
|
||||
)
|
||||
}
|
||||
|
||||
@@ -807,7 +879,8 @@ object BackupRepository {
|
||||
forTransfer = true,
|
||||
progressEmitter = progressEmitter,
|
||||
cancellationSignal = cancellationSignal,
|
||||
extraExportOperations = null
|
||||
extraFrameOperation = null,
|
||||
endingExportOperation = null
|
||||
)
|
||||
}
|
||||
|
||||
@@ -821,8 +894,7 @@ object BackupRepository {
|
||||
currentTime: Long = System.currentTimeMillis(),
|
||||
forTransfer: Boolean = false,
|
||||
progressEmitter: ExportProgressListener? = null,
|
||||
cancellationSignal: () -> Boolean = { false },
|
||||
extraExportOperations: ((SignalDatabase) -> Unit)? = null
|
||||
cancellationSignal: () -> Boolean = { false }
|
||||
) {
|
||||
val writer: BackupExportWriter = if (plaintext) {
|
||||
PlainTextBackupWriter(outputStream)
|
||||
@@ -842,7 +914,8 @@ object BackupRepository {
|
||||
forTransfer = forTransfer,
|
||||
progressEmitter = progressEmitter,
|
||||
cancellationSignal = cancellationSignal,
|
||||
extraExportOperations = extraExportOperations
|
||||
extraFrameOperation = null,
|
||||
endingExportOperation = null
|
||||
)
|
||||
}
|
||||
|
||||
@@ -864,7 +937,8 @@ object BackupRepository {
|
||||
forTransfer: Boolean,
|
||||
progressEmitter: ExportProgressListener?,
|
||||
cancellationSignal: () -> Boolean,
|
||||
extraExportOperations: ((SignalDatabase) -> Unit)?
|
||||
extraFrameOperation: ((Frame) -> Unit)?,
|
||||
endingExportOperation: ((SignalDatabase) -> Unit)?
|
||||
) {
|
||||
val eventTimer = EventTimer()
|
||||
val mainDbName = if (isLocal) LOCAL_MAIN_DB_SNAPSHOT_NAME else REMOTE_MAIN_DB_SNAPSHOT_NAME
|
||||
@@ -902,8 +976,9 @@ object BackupRepository {
|
||||
// We're using a snapshot, so the transaction is more for perf than correctness
|
||||
dbSnapshot.rawWritableDatabase.withinTransaction {
|
||||
progressEmitter?.onAccount()
|
||||
AccountDataArchiveProcessor.export(dbSnapshot, signalStoreSnapshot) {
|
||||
writer.write(it)
|
||||
AccountDataArchiveProcessor.export(dbSnapshot, signalStoreSnapshot) { frame ->
|
||||
writer.write(frame)
|
||||
extraFrameOperation?.invoke(frame)
|
||||
eventTimer.emit("account")
|
||||
frameCount++
|
||||
}
|
||||
@@ -915,6 +990,7 @@ object BackupRepository {
|
||||
progressEmitter?.onRecipient()
|
||||
RecipientArchiveProcessor.export(dbSnapshot, signalStoreSnapshot, exportState, selfRecipientId, selfAci) {
|
||||
writer.write(it)
|
||||
extraFrameOperation?.invoke(it)
|
||||
eventTimer.emit("recipient")
|
||||
frameCount++
|
||||
}
|
||||
@@ -926,6 +1002,7 @@ object BackupRepository {
|
||||
progressEmitter?.onThread()
|
||||
ChatArchiveProcessor.export(dbSnapshot, exportState) { frame ->
|
||||
writer.write(frame)
|
||||
extraFrameOperation?.invoke(frame)
|
||||
eventTimer.emit("thread")
|
||||
frameCount++
|
||||
}
|
||||
@@ -936,6 +1013,7 @@ object BackupRepository {
|
||||
progressEmitter?.onCall()
|
||||
AdHocCallArchiveProcessor.export(dbSnapshot, exportState) { frame ->
|
||||
writer.write(frame)
|
||||
extraFrameOperation?.invoke(frame)
|
||||
eventTimer.emit("call")
|
||||
frameCount++
|
||||
}
|
||||
@@ -947,6 +1025,7 @@ object BackupRepository {
|
||||
progressEmitter?.onSticker()
|
||||
StickerArchiveProcessor.export(dbSnapshot) { frame ->
|
||||
writer.write(frame)
|
||||
extraFrameOperation?.invoke(frame)
|
||||
eventTimer.emit("sticker-pack")
|
||||
frameCount++
|
||||
}
|
||||
@@ -958,6 +1037,7 @@ object BackupRepository {
|
||||
progressEmitter?.onNotificationProfile()
|
||||
NotificationProfileProcessor.export(dbSnapshot, exportState) { frame ->
|
||||
writer.write(frame)
|
||||
extraFrameOperation?.invoke(frame)
|
||||
eventTimer.emit("notification-profile")
|
||||
frameCount++
|
||||
}
|
||||
@@ -969,6 +1049,7 @@ object BackupRepository {
|
||||
progressEmitter?.onChatFolder()
|
||||
ChatFolderProcessor.export(dbSnapshot, exportState) { frame ->
|
||||
writer.write(frame)
|
||||
extraFrameOperation?.invoke(frame)
|
||||
eventTimer.emit("chat-folder")
|
||||
frameCount++
|
||||
}
|
||||
@@ -982,6 +1063,7 @@ object BackupRepository {
|
||||
progressEmitter?.onMessage(0, approximateMessageCount)
|
||||
ChatItemArchiveProcessor.export(dbSnapshot, exportState, selfRecipientId, cancellationSignal) { frame ->
|
||||
writer.write(frame)
|
||||
extraFrameOperation?.invoke(frame)
|
||||
eventTimer.emit("message")
|
||||
frameCount++
|
||||
|
||||
@@ -997,7 +1079,7 @@ object BackupRepository {
|
||||
}
|
||||
}
|
||||
|
||||
extraExportOperations?.invoke(dbSnapshot)
|
||||
endingExportOperation?.invoke(dbSnapshot)
|
||||
|
||||
Log.d(TAG, "[export] totalFrames: $frameCount | ${eventTimer.stop().summary}")
|
||||
} finally {
|
||||
@@ -1299,6 +1381,9 @@ object BackupRepository {
|
||||
|
||||
stopwatch.split("frames")
|
||||
|
||||
Log.d(TAG, "[import] Remove duplicate messages...")
|
||||
SignalDatabase.messages.removeDuplicatesPostBackupRestore()
|
||||
|
||||
Log.d(TAG, "[import] Rebuilding FTS index...")
|
||||
SignalDatabase.messageSearch.rebuildIndex()
|
||||
|
||||
@@ -1757,6 +1842,18 @@ object BackupRepository {
|
||||
return RestoreTimestampResult.NotFound
|
||||
}
|
||||
|
||||
timestampResult is NetworkResult.StatusCodeError && timestampResult.code == 401 -> {
|
||||
Log.i(TAG, "Backups not enabled")
|
||||
SignalStore.backup.lastBackupTime = 0L
|
||||
SignalStore.backup.isBackupTimestampRestored = true
|
||||
return RestoreTimestampResult.BackupsNotEnabled
|
||||
}
|
||||
|
||||
timestampResult is NetworkResult.ApplicationError && timestampResult.getCause() is VerificationFailedException -> {
|
||||
Log.w(TAG, "Entered AEP fails zk verification", timestampResult.getCause())
|
||||
return RestoreTimestampResult.VerificationFailure
|
||||
}
|
||||
|
||||
else -> {
|
||||
Log.w(TAG, "Could not check for backup file.", timestampResult.getCause())
|
||||
return RestoreTimestampResult.Failure
|
||||
@@ -1764,11 +1861,40 @@ object BackupRepository {
|
||||
}
|
||||
}
|
||||
|
||||
fun verifyBackupKeyAssociatedWithAccount(aci: ACI, aep: AccountEntropyPool): MessageBackupTier? {
|
||||
fun verifyBackupKeyAssociatedWithAccount(aci: ACI, aep: AccountEntropyPool): RestoreTimestampResult {
|
||||
Log.i(TAG, "Verifying enter aep is associated with account")
|
||||
var result: RestoreTimestampResult = getBackupTimestampToVerifyAepAssociatedWithAccountAndHasBackup(aci, aep)
|
||||
|
||||
if (result is RestoreTimestampResult.VerificationFailure) {
|
||||
Log.w(TAG, "Resetting backup id reservation due to zk verification failure")
|
||||
val triggerResult = SignalNetwork.archive.triggerBackupIdReservation(aep.deriveMessageBackupKey(), null, aci)
|
||||
result = when {
|
||||
triggerResult is NetworkResult.Success -> {
|
||||
Log.i(TAG, "Reset successful, retrying aep verification")
|
||||
SignalStore.backup.messageCredentials.clearAll()
|
||||
getBackupTimestampToVerifyAepAssociatedWithAccountAndHasBackup(aci, aep)
|
||||
}
|
||||
|
||||
triggerResult is NetworkResult.StatusCodeError && triggerResult.code == 429 -> {
|
||||
Log.w(TAG, "Rate limited when resetting backup id, failing operation $triggerResult")
|
||||
RestoreTimestampResult.RateLimited(triggerResult.retryAfter())
|
||||
}
|
||||
|
||||
else -> {
|
||||
Log.w(TAG, "Reset backup id failed, failing operation", triggerResult.getCause())
|
||||
result
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
private fun getBackupTimestampToVerifyAepAssociatedWithAccountAndHasBackup(aci: ACI, aep: AccountEntropyPool): RestoreTimestampResult {
|
||||
val currentTime = System.currentTimeMillis()
|
||||
val messageBackupKey = aep.deriveMessageBackupKey()
|
||||
|
||||
val result: NetworkResult<MessageBackupTier> = SignalNetwork.archive.getServiceCredentials(currentTime)
|
||||
val result: NetworkResult<ZonedDateTime> = SignalNetwork.archive.getServiceCredentials(currentTime)
|
||||
.then { result ->
|
||||
val credential: ArchiveServiceCredential? = ArchiveServiceCredentials(result.messageCredentials.associateBy { it.redemptionTime }).getForCurrentTime(currentTime.milliseconds)
|
||||
|
||||
@@ -1783,20 +1909,41 @@ object BackupRepository {
|
||||
)
|
||||
}
|
||||
}
|
||||
.map { messageAccess ->
|
||||
val zkCredential = SignalNetwork.archive.getZkCredential(aci, messageAccess)
|
||||
if (zkCredential.backupLevel == BackupLevel.PAID) {
|
||||
MessageBackupTier.PAID
|
||||
} else {
|
||||
MessageBackupTier.FREE
|
||||
}
|
||||
.then { messageAccess ->
|
||||
SignalNetwork.archive.getBackupInfo(SignalStore.account.requireAci(), messageAccess)
|
||||
.then { info -> SignalNetwork.archive.getCdnReadCredentials(info.cdn ?: RemoteConfig.backupFallbackArchiveCdn, aci, messageAccess).map { it.headers to info } }
|
||||
.then { pair ->
|
||||
val (cdnCredentials, info) = pair
|
||||
NetworkResult.fromFetch {
|
||||
AppDependencies.signalServiceMessageReceiver.getCdnLastModifiedTime(info.cdn!!, cdnCredentials, "backups/${info.backupDir}/${info.backupName}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return if (result is NetworkResult.Success) {
|
||||
result.result
|
||||
} else {
|
||||
Log.i(TAG, "Unable to verify backup key", result.getCause())
|
||||
null
|
||||
return when {
|
||||
result is NetworkResult.Success -> {
|
||||
RestoreTimestampResult.Success(result.result.toMillis())
|
||||
}
|
||||
|
||||
result is NetworkResult.StatusCodeError && result.code == 404 -> {
|
||||
Log.i(TAG, "No backup file exists")
|
||||
RestoreTimestampResult.NotFound
|
||||
}
|
||||
|
||||
result is NetworkResult.StatusCodeError && result.code == 401 -> {
|
||||
Log.i(TAG, "Backups not enabled")
|
||||
RestoreTimestampResult.BackupsNotEnabled
|
||||
}
|
||||
|
||||
result is NetworkResult.ApplicationError && result.getCause() is VerificationFailedException -> {
|
||||
Log.w(TAG, "Entered AEP fails zk verification", result.getCause())
|
||||
RestoreTimestampResult.VerificationFailure
|
||||
}
|
||||
|
||||
else -> {
|
||||
Log.w(TAG, "Could not check for backup file.", result.getCause())
|
||||
RestoreTimestampResult.Failure
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1870,23 +2017,14 @@ object BackupRepository {
|
||||
suspend fun getPaidType(): NetworkResult<MessageBackupsType.Paid> {
|
||||
val productPrice: FiatMoney? = if (SignalStore.backup.backupTierInternalOverride == MessageBackupTier.PAID) {
|
||||
Log.d(TAG, "Accessing price via mock subscription.")
|
||||
RecurringInAppPaymentRepository.getActiveSubscriptionSync(InAppPaymentSubscriberRecord.Type.BACKUP).getOrNull()?.activeSubscription?.let {
|
||||
RecurringInAppPaymentRepository.getActiveSubscriptionSync(InAppPaymentSubscriberRecord.Type.BACKUP).successOrNull()?.activeSubscription?.let {
|
||||
FiatMoney.fromSignalNetworkAmount(it.amount, Currency.getInstance(it.currency))
|
||||
}
|
||||
} else if (AppDependencies.billingApi.isApiAvailable()) {
|
||||
} else if (AppDependencies.billingApi.getApiAvailability().isSuccess) {
|
||||
Log.d(TAG, "Accessing price via billing api.")
|
||||
AppDependencies.billingApi.queryProduct()?.price
|
||||
} else {
|
||||
Log.d(TAG, "Billing API is not available on this device. Accessing price via subscription configuration.")
|
||||
val configurationResult = AppDependencies.donationsService.getDonationsConfiguration(Locale.getDefault()).toNetworkResult()
|
||||
val currency = Currency.getInstance(Locale.getDefault())
|
||||
|
||||
when (configurationResult) {
|
||||
is NetworkResult.Success -> configurationResult.result.currencies[currency.currencyCode.lowercase()]?.backupSubscription[SubscriptionsConfiguration.BACKUPS_LEVEL]?.let {
|
||||
FiatMoney(it, currency)
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
FiatMoney(BigDecimal.ZERO, SignalStore.inAppPayments.getRecurringDonationCurrency())
|
||||
}
|
||||
|
||||
if (productPrice == null) {
|
||||
@@ -1920,22 +2058,25 @@ object BackupRepository {
|
||||
* prevents early initialization with incorrect keys before we have restored them.
|
||||
*/
|
||||
private fun initBackupAndFetchAuth(): NetworkResult<ArchiveServiceAccessPair> {
|
||||
return if (!RemoteConfig.messageBackups) {
|
||||
NetworkResult.StatusCodeError(555, null, null, emptyMap(), NonSuccessfulResponseCodeException(555, "Backups disabled!"))
|
||||
} else if (SignalStore.backup.backupsInitialized || SignalStore.account.isLinkedDevice) {
|
||||
return if (SignalStore.backup.backupsInitialized || SignalStore.account.isLinkedDevice) {
|
||||
getArchiveServiceAccessPair()
|
||||
.runOnStatusCodeError(resetInitializedStateErrorAction)
|
||||
.runOnApplicationError(clearAuthCredentials)
|
||||
} else if (isPreRestoreDuringRegistration()) {
|
||||
Log.w(TAG, "Requesting/using auth credentials in pre-restore state", Throwable())
|
||||
getArchiveServiceAccessPair()
|
||||
.runOnApplicationError(clearAuthCredentials)
|
||||
} else {
|
||||
val messageBackupKey = SignalStore.backup.messageBackupKey
|
||||
val mediaRootBackupKey = SignalStore.backup.mediaRootBackupKey
|
||||
|
||||
return SignalNetwork.archive
|
||||
.triggerBackupIdReservation(messageBackupKey, mediaRootBackupKey, SignalStore.account.requireAci())
|
||||
.then { getArchiveServiceAccessPair() }
|
||||
.then {
|
||||
SignalStore.backup.messageCredentials.clearAll()
|
||||
SignalStore.backup.mediaCredentials.clearAll()
|
||||
getArchiveServiceAccessPair()
|
||||
}
|
||||
.then { credential -> SignalNetwork.archive.setPublicKey(SignalStore.account.requireAci(), credential.messageBackupAccess).map { credential } }
|
||||
.then { credential -> SignalNetwork.archive.setPublicKey(SignalStore.account.requireAci(), credential.mediaBackupAccess).map { credential } }
|
||||
.runIfSuccessful { SignalStore.backup.backupsInitialized = true }
|
||||
@@ -1980,8 +2121,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 +2216,7 @@ object BackupRepository {
|
||||
val messageBackupKey = SignalStore.backup.messageBackupKey
|
||||
|
||||
Log.i(TAG, "[remoteRestore] Fetching SVRB data")
|
||||
val svrBAuth = when (val result = BackupRepository.getSvrBAuth()) {
|
||||
val svrBAuth = when (val result = getSvrBAuth()) {
|
||||
is NetworkResult.Success -> result.result
|
||||
is NetworkResult.NetworkError -> return RemoteRestoreResult.NetworkError.logW(TAG, "[remoteRestore] Network error when getting SVRB auth.", result.getCause())
|
||||
is NetworkResult.StatusCodeError -> return RemoteRestoreResult.NetworkError.logW(TAG, "[remoteRestore] Status code error when getting SVRB auth.", result.getCause())
|
||||
@@ -2088,20 +2228,24 @@ object BackupRepository {
|
||||
SignalStore.backup.nextBackupSecretData = result.data.nextBackupSecretData
|
||||
result.data.forwardSecrecyToken
|
||||
}
|
||||
|
||||
is SvrBApi.RestoreResult.NetworkError -> {
|
||||
Log.w(TAG, "[remoteRestore] Network error during SVRB.", result.exception)
|
||||
return RemoteRestoreResult.NetworkError
|
||||
}
|
||||
|
||||
is SvrBApi.RestoreResult.RestoreFailedError,
|
||||
SvrBApi.RestoreResult.InvalidDataError -> {
|
||||
Log.w(TAG, "[remoteRestore] Permanent SVRB error! $result")
|
||||
return RemoteRestoreResult.PermanentSvrBFailure
|
||||
}
|
||||
|
||||
SvrBApi.RestoreResult.DataMissingError,
|
||||
is SvrBApi.RestoreResult.SvrError -> {
|
||||
Log.w(TAG, "[remoteRestore] Failed to fetch SVRB data: $result")
|
||||
return RemoteRestoreResult.Failure
|
||||
}
|
||||
|
||||
is SvrBApi.RestoreResult.UnknownError -> {
|
||||
Log.e(TAG, "[remoteRestore] Unknown SVRB result! Crashing.", result.throwable)
|
||||
throw result.throwable
|
||||
@@ -2326,6 +2470,9 @@ sealed interface RemoteRestoreResult {
|
||||
sealed interface RestoreTimestampResult {
|
||||
data class Success(val timestamp: Long) : RestoreTimestampResult
|
||||
data object NotFound : RestoreTimestampResult
|
||||
data object BackupsNotEnabled : RestoreTimestampResult
|
||||
data object VerificationFailure : RestoreTimestampResult
|
||||
data class RateLimited(val retryAfter: Duration?) : RestoreTimestampResult
|
||||
data object Failure : RestoreTimestampResult
|
||||
}
|
||||
|
||||
|
||||
@@ -50,6 +50,8 @@ import org.thoughtcrime.securesms.backup.v2.proto.IndividualCall
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.LearnedProfileChatUpdate
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.MessageAttachment
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.PaymentNotification
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.Poll
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.PollTerminateUpdate
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.ProfileChangeChatUpdate
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.Quote
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.Reaction
|
||||
@@ -93,6 +95,7 @@ import org.thoughtcrime.securesms.mms.PartAuthority
|
||||
import org.thoughtcrime.securesms.mms.QuoteModel
|
||||
import org.thoughtcrime.securesms.payments.FailureReason
|
||||
import org.thoughtcrime.securesms.payments.State
|
||||
import org.thoughtcrime.securesms.polls.PollRecord
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.util.JsonUtils
|
||||
import org.thoughtcrime.securesms.util.MediaUtil
|
||||
@@ -371,6 +374,22 @@ class ChatItemArchiveExporter(
|
||||
transformTimer.emit("story")
|
||||
}
|
||||
|
||||
MessageTypes.isPollTerminate(record.type) -> {
|
||||
val pollTerminateUpdate = record.toRemotePollTerminateUpdate()
|
||||
if (pollTerminateUpdate == null) {
|
||||
Log.w(TAG, ExportSkips.pollTerminateIsEmpty(record.dateSent))
|
||||
continue
|
||||
}
|
||||
builder.updateMessage = ChatUpdateMessage(pollTerminate = pollTerminateUpdate)
|
||||
transformTimer.emit("poll-terminate")
|
||||
}
|
||||
|
||||
extraData.pollsById[record.id] != null -> {
|
||||
val poll = extraData.pollsById[record.id]!!
|
||||
builder.poll = poll.toRemotePollMessage()
|
||||
transformTimer.emit("poll")
|
||||
}
|
||||
|
||||
else -> {
|
||||
val attachments = extraData.attachmentsById[record.id]
|
||||
val sticker = attachments?.firstOrNull { dbAttachment -> dbAttachment.isSticker }
|
||||
@@ -471,16 +490,24 @@ class ChatItemArchiveExporter(
|
||||
}
|
||||
}
|
||||
|
||||
val pollsFuture = executor.submitTyped {
|
||||
extraDataTimer.timeEvent("polls") {
|
||||
db.pollTable.getPollsForMessages(messageIds)
|
||||
}
|
||||
}
|
||||
|
||||
val mentionsResult = mentionsFuture.get()
|
||||
val reactionsResult = reactionsFuture.get()
|
||||
val attachmentsResult = attachmentsFuture.get()
|
||||
val groupReceiptsResult = groupReceiptsFuture.get()
|
||||
val pollsResult = pollsFuture.get()
|
||||
|
||||
return ExtraMessageData(
|
||||
mentionsById = mentionsResult,
|
||||
reactionsById = reactionsResult,
|
||||
attachmentsById = attachmentsResult,
|
||||
groupReceiptsById = groupReceiptsResult
|
||||
groupReceiptsById = groupReceiptsResult,
|
||||
pollsById = pollsResult
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -549,7 +576,7 @@ private fun BackupMessageRecord.toBasicChatItemBuilder(selfRecipientId: Recipien
|
||||
dateReceived = dateReceived
|
||||
)
|
||||
|
||||
if (expiresInMs != null && outgoing?.sendStatus?.all { it.pending == null && it.failed == null } == true) {
|
||||
if (expiresInMs != null && outgoing?.sendStatus?.all { it.pending == null && it.failed == null } == true && expireStartDate == null) {
|
||||
Log.w(TAG, ExportOddities.outgoingMessageWasSentButTimerNotStarted(record.dateSent))
|
||||
expireStartDate = record.dateReceived
|
||||
}
|
||||
@@ -783,6 +810,14 @@ private fun BackupMessageRecord.toRemotePaymentNotificationUpdate(db: SignalData
|
||||
}
|
||||
}
|
||||
|
||||
private fun BackupMessageRecord.toRemotePollTerminateUpdate(): PollTerminateUpdate? {
|
||||
val pollTerminate = this.messageExtras?.pollTerminate ?: return null
|
||||
return PollTerminateUpdate(
|
||||
targetSentTimestamp = pollTerminate.targetTimestamp,
|
||||
question = pollTerminate.question
|
||||
)
|
||||
}
|
||||
|
||||
private fun BackupMessageRecord.toRemoteSharedContact(attachments: List<DatabaseAttachment>?): Contact? {
|
||||
if (this.sharedContacts.isNullOrEmpty()) {
|
||||
return null
|
||||
@@ -1131,6 +1166,25 @@ private fun BackupMessageRecord.toRemoteGiftBadgeUpdate(): BackupGiftBadge? {
|
||||
)
|
||||
}
|
||||
|
||||
private fun PollRecord.toRemotePollMessage(): Poll {
|
||||
return Poll(
|
||||
question = this.question,
|
||||
allowMultiple = this.allowMultipleVotes,
|
||||
hasEnded = this.hasEnded,
|
||||
options = this.pollOptions.map { option ->
|
||||
Poll.PollOption(
|
||||
option = option.text,
|
||||
votes = option.voters.map { voter ->
|
||||
Poll.PollOption.PollVote(
|
||||
voterId = voter.id,
|
||||
voteCount = voter.voteCount
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun DatabaseAttachment.toRemoteStickerMessage(sentTimestamp: Long, reactions: List<ReactionRecord>?): StickerMessage? {
|
||||
val stickerLocator = this.stickerLocator!!
|
||||
|
||||
@@ -1491,7 +1545,8 @@ private fun Long.isDirectionlessType(): Boolean {
|
||||
MessageTypes.isGroupCall(this) ||
|
||||
MessageTypes.isGroupUpdate(this) ||
|
||||
MessageTypes.isGroupV1MigrationEvent(this) ||
|
||||
MessageTypes.isGroupQuit(this)
|
||||
MessageTypes.isGroupQuit(this) ||
|
||||
MessageTypes.isPollTerminate(this)
|
||||
}
|
||||
|
||||
private fun Long.isIdentityVerifyType(): Boolean {
|
||||
@@ -1522,7 +1577,8 @@ private fun ChatItem.validateChatItem(exportState: ExportState): ChatItem? {
|
||||
this.paymentNotification == null &&
|
||||
this.giftBadge == null &&
|
||||
this.viewOnceMessage == null &&
|
||||
this.directStoryReplyMessage == null
|
||||
this.directStoryReplyMessage == null &&
|
||||
this.poll == null
|
||||
) {
|
||||
Log.w(TAG, ExportSkips.emptyChatItem(this.dateSent))
|
||||
return null
|
||||
@@ -1693,7 +1749,8 @@ private data class ExtraMessageData(
|
||||
val mentionsById: Map<Long, List<Mention>>,
|
||||
val reactionsById: Map<Long, List<ReactionRecord>>,
|
||||
val attachmentsById: Map<Long, List<DatabaseAttachment>>,
|
||||
val groupReceiptsById: Map<Long, List<GroupReceiptTable.GroupReceiptInfo>>
|
||||
val groupReceiptsById: Map<Long, List<GroupReceiptTable.GroupReceiptInfo>>,
|
||||
val pollsById: Map<Long, PollRecord>
|
||||
)
|
||||
|
||||
private enum class Direction {
|
||||
|
||||
@@ -62,6 +62,7 @@ import org.thoughtcrime.securesms.database.model.databaseprotos.GV2UpdateDescrip
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.MessageExtras
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.PaymentTombstone
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.PollTerminate
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.ProfileChangeDetails
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.SessionSwitchoverEvent
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.ThreadMergeEvent
|
||||
@@ -72,6 +73,7 @@ import org.thoughtcrime.securesms.payments.Direction
|
||||
import org.thoughtcrime.securesms.payments.FailureReason
|
||||
import org.thoughtcrime.securesms.payments.State
|
||||
import org.thoughtcrime.securesms.payments.proto.PaymentMetaData
|
||||
import org.thoughtcrime.securesms.polls.Voter
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.stickers.StickerLocator
|
||||
@@ -304,6 +306,21 @@ class ChatItemArchiveImporter(
|
||||
)
|
||||
db.insert(CallTable.TABLE_NAME, SQLiteDatabase.CONFLICT_IGNORE, values)
|
||||
}
|
||||
} else if (this.updateMessage.pollTerminate != null) {
|
||||
followUps += { endPollMessageId ->
|
||||
val pollMessageId = SignalDatabase.messages.getMessageFor(updateMessage.pollTerminate.targetSentTimestamp, fromRecipientId)?.id ?: -1
|
||||
val pollId = SignalDatabase.polls.getPollId(pollMessageId)
|
||||
|
||||
val messageExtras = MessageExtras(pollTerminate = PollTerminate(question = updateMessage.pollTerminate.question, messageId = pollMessageId, targetTimestamp = updateMessage.pollTerminate.targetSentTimestamp))
|
||||
db.update(MessageTable.TABLE_NAME)
|
||||
.values(MessageTable.MESSAGE_EXTRAS to messageExtras.encode())
|
||||
.where("${MessageTable.ID} = ?", endPollMessageId)
|
||||
.run()
|
||||
|
||||
if (pollId != null) {
|
||||
SignalDatabase.polls.endPoll(pollId = pollId, endingMessageId = endPollMessageId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -459,6 +476,35 @@ class ChatItemArchiveImporter(
|
||||
}
|
||||
}
|
||||
|
||||
if (this.poll != null) {
|
||||
contentValues.put(MessageTable.BODY, poll.question)
|
||||
contentValues.put(MessageTable.VOTES_LAST_SEEN, System.currentTimeMillis())
|
||||
|
||||
followUps += { messageRowId ->
|
||||
val pollId = SignalDatabase.polls.insertPoll(
|
||||
question = poll.question,
|
||||
allowMultipleVotes = poll.allowMultiple,
|
||||
options = poll.options.map { it.option },
|
||||
authorId = fromRecipientId.toLong(),
|
||||
messageId = messageRowId
|
||||
)
|
||||
|
||||
val localOptionIds = SignalDatabase.polls.getPollOptionIds(pollId)
|
||||
poll.options.forEachIndexed { index, option ->
|
||||
val localVoterIds = option.votes.map { importState.remoteToLocalRecipientId[it.voterId]?.toLong() }
|
||||
val voteCounts = option.votes.map { it.voteCount }
|
||||
val localVoters = localVoterIds.mapIndexedNotNull { index, id -> id?.let { Voter(id = id, voteCount = voteCounts[index]) } }
|
||||
SignalDatabase.polls.addPollVotes(pollId = pollId, optionId = localOptionIds[index], voters = localVoters)
|
||||
}
|
||||
|
||||
if (poll.hasEnded) {
|
||||
// At this point, we don't know what message ended the poll. Instead, we set it to -1 to indicate that it
|
||||
// is ended and will update endingMessageId when we process the poll terminate message (if it exists).
|
||||
SignalDatabase.polls.endPoll(pollId = pollId, endingMessageId = -1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val followUp: ((Long) -> Unit)? = if (followUps.isNotEmpty()) {
|
||||
{ messageId ->
|
||||
followUps.forEach { it(messageId) }
|
||||
@@ -774,6 +820,9 @@ class ChatItemArchiveImporter(
|
||||
val messageExtras = MessageExtras(profileChangeDetails = profileChangeDetails).encode()
|
||||
put(MessageTable.MESSAGE_EXTRAS, messageExtras)
|
||||
}
|
||||
updateMessage.pollTerminate != null -> {
|
||||
typeFlags = MessageTypes.SPECIAL_TYPE_POLL_TERMINATE or (getAsLong(MessageTable.TYPE) and MessageTypes.BASE_TYPE_MASK.inv())
|
||||
}
|
||||
updateMessage.sessionSwitchover != null -> {
|
||||
typeFlags = MessageTypes.SESSION_SWITCHOVER_TYPE or (getAsLong(MessageTable.TYPE) and MessageTypes.BASE_TYPE_MASK.inv())
|
||||
val sessionSwitchoverDetails = SessionSwitchoverEvent(e164 = updateMessage.sessionSwitchover.e164.toString()).encode()
|
||||
|
||||
@@ -8,8 +8,10 @@ package org.thoughtcrime.securesms.backup.v2.importer
|
||||
import androidx.core.content.contentValuesOf
|
||||
import org.signal.core.util.Base64
|
||||
import org.signal.core.util.insertInto
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.core.util.toInt
|
||||
import org.signal.core.util.update
|
||||
import org.thoughtcrime.securesms.backup.v2.ImportSkips
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.Contact
|
||||
import org.thoughtcrime.securesms.backup.v2.util.toLocal
|
||||
import org.thoughtcrime.securesms.database.IdentityTable
|
||||
@@ -28,14 +30,22 @@ import org.whispersystems.signalservice.api.push.ServiceId.PNI
|
||||
* Handles the importing of [Contact] models into the local database.
|
||||
*/
|
||||
object ContactArchiveImporter {
|
||||
fun import(contact: Contact): RecipientId {
|
||||
private val TAG = Log.tag(ContactArchiveImporter::class)
|
||||
|
||||
fun import(contact: Contact): RecipientId? {
|
||||
val aci = ACI.parseOrNull(contact.aci?.toByteArray())
|
||||
val pni = PNI.parseOrNull(contact.pni?.toByteArray())
|
||||
val e164 = contact.formattedE164
|
||||
|
||||
if (aci == null && pni == null && e164 == null) {
|
||||
Log.w(TAG, ImportSkips.recipientWithoutId())
|
||||
return null
|
||||
}
|
||||
|
||||
val id = SignalDatabase.recipients.getAndPossiblyMergePnpVerified(
|
||||
aci = aci,
|
||||
pni = pni,
|
||||
e164 = contact.formattedE164
|
||||
e164 = e164
|
||||
)
|
||||
|
||||
val profileKey = contact.profileKey?.toByteArray()
|
||||
|
||||
@@ -98,7 +98,7 @@ object RecipientArchiveProcessor {
|
||||
}
|
||||
|
||||
fun import(recipient: ArchiveRecipient, importState: ImportState) {
|
||||
val newId = when {
|
||||
val newId: RecipientId? = when {
|
||||
recipient.contact != null -> ContactArchiveImporter.import(recipient.contact)
|
||||
recipient.group != null -> GroupArchiveImporter.import(recipient.group)
|
||||
recipient.distributionList != null -> DistributionListArchiveImporter.import(recipient.distributionList, importState)
|
||||
|
||||
@@ -37,8 +37,8 @@ import androidx.core.os.bundleOf
|
||||
import androidx.fragment.app.DialogFragment
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.SignalPreview
|
||||
import org.signal.core.ui.compose.theme.SignalTheme
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.backup.v2.BackupRepository
|
||||
@@ -434,7 +434,7 @@ private fun rememberSecondaryAction(
|
||||
}
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun BackupAlertSheetContentPreviewGeneric() {
|
||||
Previews.BottomSheetPreview {
|
||||
@@ -446,7 +446,7 @@ private fun BackupAlertSheetContentPreviewGeneric() {
|
||||
}
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun BackupAlertSheetContentPreviewPayment() {
|
||||
Previews.BottomSheetPreview {
|
||||
@@ -458,7 +458,7 @@ private fun BackupAlertSheetContentPreviewPayment() {
|
||||
}
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun BackupAlertSheetContentPreviewDelete() {
|
||||
Previews.BottomSheetPreview {
|
||||
@@ -473,7 +473,7 @@ private fun BackupAlertSheetContentPreviewDelete() {
|
||||
}
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun BackupAlertSheetContentPreviewDiskFull() {
|
||||
Previews.BottomSheetPreview {
|
||||
@@ -485,7 +485,7 @@ private fun BackupAlertSheetContentPreviewDiskFull() {
|
||||
}
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun BackupAlertSheetContentPreviewBackupFailed() {
|
||||
Previews.BottomSheetPreview {
|
||||
@@ -497,7 +497,7 @@ private fun BackupAlertSheetContentPreviewBackupFailed() {
|
||||
}
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun BackupAlertSheetContentPreviewCouldNotRedeemBackup() {
|
||||
Previews.BottomSheetPreview {
|
||||
@@ -509,7 +509,7 @@ private fun BackupAlertSheetContentPreviewCouldNotRedeemBackup() {
|
||||
}
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun BackupAlertSheetContentPreviewSubscriptionExpired() {
|
||||
Previews.BottomSheetPreview {
|
||||
|
||||
@@ -31,8 +31,8 @@ import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.signal.core.ui.compose.BottomSheets
|
||||
import org.signal.core.ui.compose.Buttons
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.SignalPreview
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.signal.core.ui.R as CoreUiR
|
||||
|
||||
@@ -201,7 +201,7 @@ private fun BackupAlertSecondaryActionButton(
|
||||
}
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun BackupAlertBottomSheetContainerPreview() {
|
||||
Previews.BottomSheetPreview {
|
||||
|
||||
@@ -15,6 +15,7 @@ import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.dimensionResource
|
||||
@@ -26,9 +27,10 @@ import androidx.core.os.bundleOf
|
||||
import androidx.fragment.app.setFragmentResult
|
||||
import org.signal.core.ui.compose.BottomSheets
|
||||
import org.signal.core.ui.compose.Buttons
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.SignalPreview
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.app.backups.BackupStateObserver
|
||||
import org.thoughtcrime.securesms.compose.ComposeBottomSheetDialogFragment
|
||||
import org.thoughtcrime.securesms.jobs.BackupMessagesJob
|
||||
import org.signal.core.ui.R as CoreUiR
|
||||
@@ -51,15 +53,15 @@ class CreateBackupBottomSheet : ComposeBottomSheetDialogFragment() {
|
||||
|
||||
@Composable
|
||||
override fun SheetContent() {
|
||||
val isPaidTier: Boolean = remember { BackupStateObserver.getNonIOBackupState().isLikelyPaidTier() }
|
||||
|
||||
CreateBackupBottomSheetContent(
|
||||
isPaidTier = isPaidTier,
|
||||
onBackupNowClick = {
|
||||
BackupMessagesJob.enqueue()
|
||||
setFragmentResult(REQUEST_KEY, bundleOf(REQUEST_KEY to Result.BACKUP_STARTED))
|
||||
isResultSet = true
|
||||
dismissAllowingStateLoss()
|
||||
},
|
||||
onBackupLaterClick = {
|
||||
dismissAllowingStateLoss()
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -80,8 +82,8 @@ class CreateBackupBottomSheet : ComposeBottomSheetDialogFragment() {
|
||||
|
||||
@Composable
|
||||
private fun CreateBackupBottomSheetContent(
|
||||
onBackupNowClick: () -> Unit,
|
||||
onBackupLaterClick: () -> Unit
|
||||
isPaidTier: Boolean,
|
||||
onBackupNowClick: () -> Unit
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
@@ -106,8 +108,14 @@ private fun CreateBackupBottomSheetContent(
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
val body = if (isPaidTier) {
|
||||
stringResource(id = R.string.CreateBackupBottomSheet__depending_on_the_size)
|
||||
} else {
|
||||
stringResource(id = R.string.CreateBackupBottomSheet__free_tier)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = stringResource(id = R.string.CreateBackupBottomSheet__depending_on_the_size),
|
||||
text = body,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
@@ -126,13 +134,24 @@ private fun CreateBackupBottomSheetContent(
|
||||
}
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun CreateBackupBottomSheetContentPreview() {
|
||||
private fun CreateBackupBottomSheetContentPaidPreview() {
|
||||
Previews.BottomSheetPreview {
|
||||
CreateBackupBottomSheetContent(
|
||||
onBackupNowClick = {},
|
||||
onBackupLaterClick = {}
|
||||
isPaidTier = true,
|
||||
onBackupNowClick = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun CreateBackupBottomSheetContentFreePreview() {
|
||||
Previews.BottomSheetPreview {
|
||||
CreateBackupBottomSheetContent(
|
||||
isPaidTier = false,
|
||||
onBackupNowClick = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,9 +11,9 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.core.os.BundleCompat
|
||||
import androidx.core.os.bundleOf
|
||||
import androidx.fragment.app.DialogFragment
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Dialogs
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.SignalPreview
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.backup.v2.BackupRepository
|
||||
import org.thoughtcrime.securesms.compose.ComposeDialogFragment
|
||||
@@ -70,7 +70,7 @@ private fun DownloadYourBackupTodayDialogContent(
|
||||
)
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun DownloadYourBackupTodayDialogContentPreview() {
|
||||
Previews.Preview {
|
||||
|
||||
@@ -18,9 +18,7 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
@@ -35,8 +33,8 @@ import androidx.core.os.BundleCompat
|
||||
import org.signal.core.ui.R
|
||||
import org.signal.core.ui.compose.BottomSheets
|
||||
import org.signal.core.ui.compose.Buttons
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.SignalPreview
|
||||
import org.signal.core.util.gibiBytes
|
||||
import org.signal.core.util.money.FiatMoney
|
||||
import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsType
|
||||
@@ -158,7 +156,7 @@ private fun SheetContent(
|
||||
}
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun BackupAlertSheetContentPreviewMedia() {
|
||||
Previews.BottomSheetPreview {
|
||||
|
||||
@@ -12,8 +12,8 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.SignalPreview
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
|
||||
import org.thoughtcrime.securesms.compose.ComposeBottomSheetDialogFragment
|
||||
@@ -71,7 +71,7 @@ private fun NoManualBackupSheetContent(
|
||||
}
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun NoManualBackupSheetContentPreview() {
|
||||
Previews.BottomSheetPreview {
|
||||
|
||||
@@ -19,8 +19,8 @@ import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.withLink
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.SignalPreview
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.backup.v2.BackupRepository
|
||||
import org.thoughtcrime.securesms.components.contactsupport.ContactSupportDialogFragment
|
||||
@@ -108,7 +108,7 @@ private fun NoRemoteStorageSpaceAvailableBottomSheetContent(
|
||||
}
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun NoRemoteStorageSpaceAvailableBottomSheetContentPreview() {
|
||||
Previews.BottomSheetPreview {
|
||||
|
||||
@@ -29,8 +29,8 @@ import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.withLink
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.SignalPreview
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.signal.core.ui.R as CoreUiR
|
||||
|
||||
@@ -112,7 +112,7 @@ private fun BackupAlertText(text: AnnotatedString, inlineContent: Map<String, In
|
||||
)
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
fun BackupStatusRowCouldNotCompleteBackupPreview() {
|
||||
Previews.Preview {
|
||||
@@ -120,7 +120,7 @@ fun BackupStatusRowCouldNotCompleteBackupPreview() {
|
||||
}
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
fun BackupStatusRowBackupFailedPreview() {
|
||||
Previews.Preview {
|
||||
|
||||
@@ -35,8 +35,8 @@ import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.signal.core.ui.compose.Buttons
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.SignalPreview
|
||||
import org.signal.core.util.mebiBytes
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.backup.RestoreState
|
||||
@@ -309,7 +309,7 @@ private fun ArchiveRestoreProgressState.actionResource(): Int {
|
||||
}
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
fun BackupStatusBannerPreview() {
|
||||
Previews.Preview {
|
||||
|
||||
@@ -21,9 +21,9 @@ import androidx.compose.ui.res.dimensionResource
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.Rows
|
||||
import org.signal.core.ui.compose.SignalPreview
|
||||
import org.signal.core.util.mebiBytes
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.backup.RestoreState
|
||||
@@ -98,7 +98,7 @@ fun BackupStatusRow(
|
||||
|
||||
if (backupStatusData.restoreStatus == RestoreStatus.NOT_ENOUGH_DISK_SPACE) {
|
||||
BackupAlertText(
|
||||
text = stringResource(R.string.BackupStatusRow__not_enough_space, backupStatusData.remainingRestoreSize)
|
||||
text = stringResource(R.string.BackupStatusRow__not_enough_space, backupStatusData.remainingRestoreSize.toUnitString())
|
||||
)
|
||||
|
||||
Rows.TextRow(
|
||||
@@ -221,7 +221,7 @@ private fun progressColor(backupStatusData: ArchiveRestoreProgressState): Color
|
||||
}
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
fun BackupStatusRowNormalPreview() {
|
||||
Previews.Preview {
|
||||
@@ -232,7 +232,7 @@ fun BackupStatusRowNormalPreview() {
|
||||
}
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
fun BackupStatusRowWaitingForWifiPreview() {
|
||||
Previews.Preview {
|
||||
@@ -242,7 +242,7 @@ fun BackupStatusRowWaitingForWifiPreview() {
|
||||
}
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
fun BackupStatusRowWaitingForInternetPreview() {
|
||||
Previews.Preview {
|
||||
@@ -252,7 +252,7 @@ fun BackupStatusRowWaitingForInternetPreview() {
|
||||
}
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
fun BackupStatusRowLowBatteryPreview() {
|
||||
Previews.Preview {
|
||||
@@ -262,7 +262,7 @@ fun BackupStatusRowLowBatteryPreview() {
|
||||
}
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
fun BackupStatusRowFinishedPreview() {
|
||||
Previews.Preview {
|
||||
@@ -273,7 +273,7 @@ fun BackupStatusRowFinishedPreview() {
|
||||
}
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
fun BackupStatusRowNotEnoughFreeSpacePreview() {
|
||||
Previews.Preview {
|
||||
|
||||
@@ -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
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.backup.v2.ui.subscription
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.google.android.gms.common.ConnectionResult
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Dialogs
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.R
|
||||
|
||||
/**
|
||||
* Represents the availability status of Google Play Services on the device.
|
||||
*
|
||||
* Maps Google Play Services ConnectionResult codes to enum values for easier handling
|
||||
* in the application. Each enum value corresponds to a specific state that determines
|
||||
* what dialog or action should be presented to the user.
|
||||
*
|
||||
* @param code The corresponding ConnectionResult code from Google Play Services
|
||||
*/
|
||||
enum class GooglePlayServicesAvailability(val code: Int) {
|
||||
/** An unknown code. Possibly due to an update on Google's end */
|
||||
UNKNOWN(code = Int.MIN_VALUE),
|
||||
|
||||
/** Google Play Services is available and ready to use */
|
||||
SUCCESS(code = ConnectionResult.SUCCESS),
|
||||
|
||||
/** Google Play Services is not installed on the device */
|
||||
SERVICE_MISSING(code = ConnectionResult.SERVICE_MISSING),
|
||||
|
||||
/** Google Play Services is currently being updated */
|
||||
SERVICE_UPDATING(code = ConnectionResult.SERVICE_UPDATING),
|
||||
|
||||
/** Google Play Services requires an update to a newer version */
|
||||
SERVICE_VERSION_UPDATE_REQUIRED(code = ConnectionResult.SERVICE_VERSION_UPDATE_REQUIRED),
|
||||
|
||||
/** Google Play Services is installed but disabled by the user */
|
||||
SERVICE_DISABLED(code = ConnectionResult.SERVICE_DISABLED),
|
||||
|
||||
/** Google Play Services installation is invalid or corrupted */
|
||||
SERVICE_INVALID(code = ConnectionResult.SERVICE_INVALID);
|
||||
|
||||
companion object {
|
||||
|
||||
private val TAG = Log.tag(GooglePlayServicesAvailability::class)
|
||||
|
||||
/**
|
||||
* Converts a Google Play Services ConnectionResult code to the corresponding enum value.
|
||||
*
|
||||
* @param code The ConnectionResult code from Google Play Services
|
||||
* @return The matching GooglePlayServicesAvailability enum value
|
||||
*/
|
||||
fun fromCode(code: Int): GooglePlayServicesAvailability {
|
||||
val availability = entries.firstOrNull { it.code == code } ?: UNKNOWN
|
||||
if (availability == UNKNOWN) {
|
||||
Log.w(TAG, "Unknown availability code: $code")
|
||||
}
|
||||
|
||||
return availability
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays a dialog based on the Google Play Services availability status.
|
||||
*
|
||||
* Shows different dialogs with appropriate messages and actions depending on whether
|
||||
* Google Play Services is missing, updating, requires an update, is disabled, or invalid.
|
||||
* When availability is SUCCESS, automatically calls onDismissRequest to dismiss any dialog.
|
||||
*
|
||||
* @param onDismissRequest Callback invoked when the dialog is dismissed or when SUCCESS status is received
|
||||
* @param onLearnMoreClick Callback invoked when the "Learn More" action is selected
|
||||
* @param onMakeServicesAvailableClick Callback invoked when an action to make services
|
||||
* available is selected (e.g., install or update)
|
||||
* @param googlePlayServicesAvailability The current availability status of Google Play Services
|
||||
*/
|
||||
@Composable
|
||||
fun GooglePlayServicesAvailabilityDialog(
|
||||
onDismissRequest: () -> Unit,
|
||||
onLearnMoreClick: () -> Unit,
|
||||
onMakeServicesAvailableClick: () -> Unit,
|
||||
googlePlayServicesAvailability: GooglePlayServicesAvailability
|
||||
) {
|
||||
when (googlePlayServicesAvailability) {
|
||||
GooglePlayServicesAvailability.SUCCESS -> {
|
||||
LaunchedEffect(Unit) {
|
||||
onDismissRequest()
|
||||
}
|
||||
}
|
||||
GooglePlayServicesAvailability.SERVICE_MISSING, GooglePlayServicesAvailability.UNKNOWN -> {
|
||||
ServiceMissingDialog(
|
||||
onDismissRequest = onDismissRequest,
|
||||
onInstallPlayServicesClick = onMakeServicesAvailableClick
|
||||
)
|
||||
}
|
||||
GooglePlayServicesAvailability.SERVICE_UPDATING -> {
|
||||
ServiceUpdatingDialog(onDismissRequest = onDismissRequest)
|
||||
}
|
||||
GooglePlayServicesAvailability.SERVICE_VERSION_UPDATE_REQUIRED -> {
|
||||
ServiceVersionUpdateRequiredDialog(
|
||||
onDismissRequest = onDismissRequest,
|
||||
onUpdateClick = onMakeServicesAvailableClick
|
||||
)
|
||||
}
|
||||
GooglePlayServicesAvailability.SERVICE_DISABLED -> {
|
||||
ServiceDisabledDialog(
|
||||
onDismissRequest = onDismissRequest,
|
||||
onLearnMoreClick = onLearnMoreClick
|
||||
)
|
||||
}
|
||||
GooglePlayServicesAvailability.SERVICE_INVALID -> {
|
||||
ServiceInvalidDialog(
|
||||
onDismissRequest = onDismissRequest,
|
||||
onLearnMoreClick = onLearnMoreClick
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ServiceMissingDialog(onDismissRequest: () -> Unit, onInstallPlayServicesClick: () -> Unit) {
|
||||
Dialogs.SimpleAlertDialog(
|
||||
title = stringResource(R.string.GooglePlayServicesAvailability__service_missing_title),
|
||||
body = stringResource(R.string.GooglePlayServicesAvailability__service_missing_message),
|
||||
confirm = stringResource(R.string.GooglePlayServicesAvailability__install_play_services),
|
||||
dismiss = stringResource(android.R.string.cancel),
|
||||
onConfirm = {},
|
||||
onDeny = onInstallPlayServicesClick,
|
||||
onDismiss = onDismissRequest,
|
||||
onDismissRequest = onDismissRequest
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ServiceUpdatingDialog(onDismissRequest: () -> Unit) {
|
||||
Dialogs.SimpleAlertDialog(
|
||||
title = stringResource(R.string.GooglePlayServicesAvailability__service_updating_title),
|
||||
body = stringResource(R.string.GooglePlayServicesAvailability__service_updating_message),
|
||||
confirm = stringResource(android.R.string.ok),
|
||||
onConfirm = {},
|
||||
onDismiss = onDismissRequest,
|
||||
onDismissRequest = onDismissRequest
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ServiceVersionUpdateRequiredDialog(onDismissRequest: () -> Unit, onUpdateClick: () -> Unit) {
|
||||
Dialogs.SimpleAlertDialog(
|
||||
title = stringResource(R.string.GooglePlayServicesAvailability__service_update_required_title),
|
||||
body = stringResource(R.string.GooglePlayServicesAvailability__service_update_required_message),
|
||||
confirm = stringResource(R.string.GooglePlayServicesAvailability__update),
|
||||
dismiss = stringResource(android.R.string.cancel),
|
||||
onConfirm = onUpdateClick,
|
||||
onDismiss = onDismissRequest,
|
||||
onDismissRequest = onDismissRequest
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ServiceDisabledDialog(onDismissRequest: () -> Unit, onLearnMoreClick: () -> Unit) {
|
||||
Dialogs.SimpleAlertDialog(
|
||||
title = stringResource(R.string.GooglePlayServicesAvailability__service_disabled_title),
|
||||
body = stringResource(R.string.GooglePlayServicesAvailability__service_disabled_message),
|
||||
confirm = stringResource(android.R.string.ok),
|
||||
dismiss = stringResource(R.string.GooglePlayServicesAvailability__learn_more),
|
||||
onConfirm = onDismissRequest,
|
||||
onDeny = onLearnMoreClick,
|
||||
onDismiss = onDismissRequest,
|
||||
onDismissRequest = onDismissRequest
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ServiceInvalidDialog(onDismissRequest: () -> Unit, onLearnMoreClick: () -> Unit) {
|
||||
Dialogs.SimpleAlertDialog(
|
||||
title = stringResource(R.string.GooglePlayServicesAvailability__service_disabled_title),
|
||||
body = stringResource(R.string.GooglePlayServicesAvailability__service_invalid_message),
|
||||
confirm = stringResource(android.R.string.ok),
|
||||
dismiss = stringResource(R.string.GooglePlayServicesAvailability__learn_more),
|
||||
onConfirm = {},
|
||||
onDeny = onLearnMoreClick,
|
||||
onDismiss = onDismissRequest,
|
||||
onDismissRequest = onDismissRequest
|
||||
)
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun ServiceMissingDialogPreview() {
|
||||
Previews.Preview {
|
||||
ServiceMissingDialog({}, {})
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun ServiceUpdatingDialogPreview() {
|
||||
Previews.Preview {
|
||||
ServiceUpdatingDialog({})
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun ServiceVersionUpdateRequiredDialogPreview() {
|
||||
Previews.Preview {
|
||||
ServiceVersionUpdateRequiredDialog({}, {})
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun ServiceDisabledDialogPreview() {
|
||||
Previews.Preview {
|
||||
ServiceDisabledDialog({}, {})
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun ServiceInvalidDialogPreview() {
|
||||
Previews.Preview {
|
||||
ServiceInvalidDialog({}, {})
|
||||
}
|
||||
}
|
||||
@@ -28,9 +28,9 @@ import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.signal.core.ui.compose.Buttons
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.Scaffolds
|
||||
import org.thoughtcrime.securesms.R
|
||||
@@ -144,7 +144,7 @@ fun MessageBackupsEducationScreen(
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun MessageBackupsEducationSheetPreview() {
|
||||
Previews.Preview {
|
||||
@@ -156,7 +156,7 @@ private fun MessageBackupsEducationSheetPreview() {
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun NotableFeatureRowPreview() {
|
||||
Previews.Preview {
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -45,7 +45,6 @@ import org.thoughtcrime.securesms.jobs.InAppPaymentPurchaseTokenJob
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.storage.StorageSyncHelper
|
||||
import org.thoughtcrime.securesms.util.RemoteConfig
|
||||
import org.thoughtcrime.securesms.util.next
|
||||
import org.whispersystems.signalservice.api.storage.IAPSubscriptionId
|
||||
import org.whispersystems.signalservice.internal.push.SubscriptionsConfiguration
|
||||
@@ -53,6 +52,7 @@ import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
class MessageBackupsFlowViewModel(
|
||||
private val initialTierSelection: MessageBackupTier?,
|
||||
googlePlayApiAvailability: Int,
|
||||
startScreen: MessageBackupsStage = if (SignalStore.backup.backupTier == null) MessageBackupsStage.EDUCATION else MessageBackupsStage.TYPE_SELECTION
|
||||
) : ViewModel(), BackupKeyCredentialManagerHandler {
|
||||
|
||||
@@ -64,6 +64,7 @@ class MessageBackupsFlowViewModel(
|
||||
private val internalStateFlow = MutableStateFlow(
|
||||
MessageBackupsFlowState(
|
||||
allBackupTypes = emptyList(),
|
||||
googlePlayApiAvailability = GooglePlayServicesAvailability.fromCode(googlePlayApiAvailability),
|
||||
currentMessageBackupTier = SignalStore.backup.backupTier,
|
||||
selectedMessageBackupTier = resolveSelectedTier(initialTierSelection, SignalStore.backup.backupTier),
|
||||
startScreen = startScreen
|
||||
@@ -74,6 +75,14 @@ class MessageBackupsFlowViewModel(
|
||||
val deletionState: Flow<DeletionState> = SignalStore.backup.deletionStateFlow
|
||||
|
||||
init {
|
||||
viewModelScope.launch(SignalDispatchers.IO) {
|
||||
internalStateFlow.update {
|
||||
it.copy(
|
||||
googlePlayBillingAvailability = AppDependencies.billingApi.getApiAvailability()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
val result = withContext(SignalDispatchers.IO) {
|
||||
BackupRepository.triggerBackupIdReservation()
|
||||
@@ -94,7 +103,7 @@ class MessageBackupsFlowViewModel(
|
||||
val allBackupTypes: List<MessageBackupsType> = try {
|
||||
withContext(SignalDispatchers.IO) {
|
||||
BackupRepository.getBackupTypes(
|
||||
if (!RemoteConfig.messageBackups) emptyList() else listOf(MessageBackupTier.FREE, MessageBackupTier.PAID)
|
||||
listOf(MessageBackupTier.FREE, MessageBackupTier.PAID)
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
@@ -105,7 +114,6 @@ class MessageBackupsFlowViewModel(
|
||||
internalStateFlow.update { state ->
|
||||
state.copy(
|
||||
allBackupTypes = allBackupTypes,
|
||||
isBillingApiAvailable = AppDependencies.billingApi.isApiAvailable(),
|
||||
selectedMessageBackupTier = if (state.selectedMessageBackupTier in allBackupTypes.map { it.tier }) state.selectedMessageBackupTier else allBackupTypes.firstOrNull()?.tier
|
||||
)
|
||||
}
|
||||
@@ -158,6 +166,12 @@ class MessageBackupsFlowViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
fun setGooglePlayApiAvailability(googlePlayApiAvailability: Int) {
|
||||
internalStateFlow.update {
|
||||
it.copy(googlePlayApiAvailability = GooglePlayServicesAvailability.fromCode(googlePlayApiAvailability))
|
||||
}
|
||||
}
|
||||
|
||||
fun refreshCurrentTier() {
|
||||
val tier = SignalStore.backup.backupTier
|
||||
if (tier == MessageBackupTier.PAID) {
|
||||
@@ -167,7 +181,7 @@ class MessageBackupsFlowViewModel(
|
||||
RecurringInAppPaymentRepository.getActiveSubscriptionSync(InAppPaymentSubscriberRecord.Type.BACKUP)
|
||||
}
|
||||
|
||||
activeSubscription.onSuccess { subscription ->
|
||||
activeSubscription.runIfSuccessful { subscription ->
|
||||
if (subscription.willCancelAtPeriodEnd()) {
|
||||
Log.d(TAG, "Active subscription is cancelled. Clearing tier.")
|
||||
internalStateFlow.update {
|
||||
|
||||
@@ -8,10 +8,13 @@ package org.thoughtcrime.securesms.backup.v2.ui.subscription
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -25,9 +28,9 @@ import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.signal.core.ui.compose.Buttons
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.Scaffolds
|
||||
import org.signal.core.ui.compose.SignalPreview
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.signal.core.ui.R as CoreUiR
|
||||
|
||||
@@ -39,6 +42,8 @@ fun MessageBackupsKeyEducationScreen(
|
||||
onNavigationClick: () -> Unit = {},
|
||||
onNextClick: () -> Unit = {}
|
||||
) {
|
||||
val scrollState = rememberScrollState()
|
||||
|
||||
Scaffolds.Settings(
|
||||
title = "",
|
||||
navigationIcon = ImageVector.vectorResource(R.drawable.symbol_arrow_start_24),
|
||||
@@ -48,7 +53,8 @@ fun MessageBackupsKeyEducationScreen(
|
||||
modifier = Modifier
|
||||
.padding(it)
|
||||
.padding(horizontal = dimensionResource(CoreUiR.dimen.gutter))
|
||||
.fillMaxSize(),
|
||||
.fillMaxSize()
|
||||
.verticalScroll(scrollState),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Image(
|
||||
@@ -61,6 +67,7 @@ fun MessageBackupsKeyEducationScreen(
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.MessageBackupsKeyEducationScreen__your_backup_key),
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
modifier = Modifier.padding(top = 16.dp)
|
||||
)
|
||||
@@ -81,11 +88,16 @@ fun MessageBackupsKeyEducationScreen(
|
||||
modifier = Modifier.padding(top = 16.dp)
|
||||
)
|
||||
|
||||
Box(
|
||||
Spacer(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f)
|
||||
.padding(bottom = 24.dp)
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 16.dp, bottom = 24.dp)
|
||||
) {
|
||||
Buttons.LargeTonal(
|
||||
onClick = onNextClick,
|
||||
@@ -100,7 +112,7 @@ fun MessageBackupsKeyEducationScreen(
|
||||
}
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun MessageBackupsKeyEducationScreenPreview() {
|
||||
Previews.Preview {
|
||||
|
||||
@@ -47,10 +47,10 @@ import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import org.signal.core.ui.compose.Buttons
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Dialogs
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.Scaffolds
|
||||
import org.signal.core.ui.compose.SignalPreview
|
||||
import org.signal.core.ui.compose.Snackbars
|
||||
import org.signal.core.ui.compose.horizontalGutters
|
||||
import org.signal.core.ui.compose.theme.SignalTheme
|
||||
@@ -421,7 +421,7 @@ private suspend fun saveKeyToCredentialManager(
|
||||
)
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun MessageBackupsKeyRecordScreenPreview() {
|
||||
Previews.Preview {
|
||||
@@ -438,7 +438,7 @@ private fun MessageBackupsKeyRecordScreenPreview() {
|
||||
}
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun SaveKeyConfirmationDialogPreview() {
|
||||
Previews.Preview {
|
||||
@@ -452,7 +452,7 @@ private fun SaveKeyConfirmationDialogPreview() {
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@SignalPreview
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun CreateNewBackupKeySheetContentPreview() {
|
||||
Previews.BottomSheetPreview {
|
||||
@@ -462,7 +462,7 @@ private fun CreateNewBackupKeySheetContentPreview() {
|
||||
}
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun DownloadMediaDialogPreview() {
|
||||
Previews.Preview {
|
||||
|
||||
@@ -34,9 +34,9 @@ import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.launch
|
||||
import org.signal.core.ui.compose.BottomSheets
|
||||
import org.signal.core.ui.compose.Buttons
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.Scaffolds
|
||||
import org.signal.core.ui.compose.SignalPreview
|
||||
import org.signal.core.ui.compose.theme.SignalTheme
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
@@ -192,7 +192,7 @@ private fun BottomSheetContent(
|
||||
}
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun MessageBackupsKeyRecordScreenPreview() {
|
||||
Previews.Preview {
|
||||
@@ -202,7 +202,7 @@ private fun MessageBackupsKeyRecordScreenPreview() {
|
||||
}
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun BottomSheetContentPreview() {
|
||||
Previews.BottomSheetPreview {
|
||||
|
||||
@@ -19,8 +19,8 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.SignalPreview
|
||||
import org.thoughtcrime.securesms.R
|
||||
|
||||
/**
|
||||
@@ -58,7 +58,7 @@ fun MessageBackupsTypeFeatureRow(
|
||||
}
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun MessageBackupsTypeFeatureRowPreview() {
|
||||
Previews.Preview {
|
||||
|
||||
@@ -21,6 +21,7 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -47,11 +48,12 @@ import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import org.signal.core.ui.compose.Buttons
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Dialogs
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.Scaffolds
|
||||
import org.signal.core.ui.compose.SignalPreview
|
||||
import org.signal.core.ui.compose.theme.SignalTheme
|
||||
import org.signal.core.util.billing.BillingResponseCode
|
||||
import org.signal.core.util.bytes
|
||||
import org.signal.core.util.money.FiatMoney
|
||||
import org.thoughtcrime.securesms.R
|
||||
@@ -75,13 +77,16 @@ fun MessageBackupsTypeSelectionScreen(
|
||||
currentBackupTier: MessageBackupTier?,
|
||||
selectedBackupTier: MessageBackupTier?,
|
||||
allBackupTypes: List<MessageBackupsType>,
|
||||
isBillingApiAvailable: Boolean,
|
||||
googlePlayServicesAvailability: GooglePlayServicesAvailability,
|
||||
googlePlayBillingAvailability: BillingResponseCode,
|
||||
isNextEnabled: Boolean,
|
||||
onMessageBackupsTierSelected: (MessageBackupTier) -> Unit,
|
||||
onNavigationClick: () -> Unit,
|
||||
onReadMoreClicked: () -> Unit,
|
||||
onNextClicked: () -> Unit,
|
||||
onLearnMoreAboutWhyUserCanNotUpgrade: () -> Unit
|
||||
onLearnMoreAboutWhyUserCanNotUpgrade: () -> Unit,
|
||||
onMakeGooglePlayServicesAvailable: () -> Unit,
|
||||
onOpenPlayStore: () -> Unit
|
||||
) {
|
||||
Scaffolds.Settings(
|
||||
title = "",
|
||||
@@ -160,29 +165,26 @@ fun MessageBackupsTypeSelectionScreen(
|
||||
}
|
||||
}
|
||||
|
||||
val hasCurrentBackupTier = currentBackupTier != null
|
||||
var displayNotAvailableDialog by remember { mutableStateOf(false) }
|
||||
val onSubscribeButtonClick = remember(isBillingApiAvailable, selectedBackupTier) {
|
||||
val paidTierNotAvailableDialogState = remember { PaidTierNotAvailableDialogState() }
|
||||
val onSubscribeButtonClick = remember(googlePlayServicesAvailability, googlePlayBillingAvailability, selectedBackupTier) {
|
||||
{
|
||||
if (selectedBackupTier == MessageBackupTier.PAID && !isBillingApiAvailable) {
|
||||
displayNotAvailableDialog = true
|
||||
if (selectedBackupTier == MessageBackupTier.PAID && googlePlayServicesAvailability != GooglePlayServicesAvailability.SUCCESS) {
|
||||
paidTierNotAvailableDialogState.displayGooglePlayApiErrorDialog = true
|
||||
} else if (selectedBackupTier == MessageBackupTier.PAID && !googlePlayBillingAvailability.isSuccess) {
|
||||
paidTierNotAvailableDialogState.displayGooglePlayBillingErrorDialog = true
|
||||
} else {
|
||||
onNextClicked()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (displayNotAvailableDialog) {
|
||||
UpgradeNotAvailableDialog(
|
||||
onConfirm = {
|
||||
displayNotAvailableDialog = false
|
||||
},
|
||||
onDismiss = onLearnMoreAboutWhyUserCanNotUpgrade,
|
||||
onDismissRequest = {
|
||||
displayNotAvailableDialog = false
|
||||
}
|
||||
)
|
||||
}
|
||||
PaidTierNotAvailableDialogs(
|
||||
state = paidTierNotAvailableDialogState,
|
||||
onOpenPlayStore = onOpenPlayStore,
|
||||
onLearnMoreAboutWhyUserCanNotUpgrade = onLearnMoreAboutWhyUserCanNotUpgrade,
|
||||
onMakeGooglePlayServicesAvailable = onMakeGooglePlayServicesAvailable,
|
||||
googlePlayServicesAvailability = googlePlayServicesAvailability
|
||||
)
|
||||
|
||||
Buttons.LargeTonal(
|
||||
onClick = onSubscribeButtonClick,
|
||||
@@ -190,10 +192,12 @@ fun MessageBackupsTypeSelectionScreen(
|
||||
modifier = Modifier
|
||||
.testTag("subscribe-button")
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = if (hasCurrentBackupTier) 10.dp else 16.dp)
|
||||
.padding(vertical = 16.dp)
|
||||
) {
|
||||
val text: String = if (currentBackupTier == null) {
|
||||
if (selectedBackupTier == MessageBackupTier.PAID && allBackupTypes.map { it.tier }.contains(selectedBackupTier)) {
|
||||
if (selectedBackupTier == MessageBackupTier.PAID && (googlePlayServicesAvailability != GooglePlayServicesAvailability.SUCCESS || !googlePlayBillingAvailability.isSuccess)) {
|
||||
stringResource(R.string.MessageBackupsTypeSelectionScreen__more_about_this_plan)
|
||||
} else if (selectedBackupTier == MessageBackupTier.PAID && allBackupTypes.map { it.tier }.contains(selectedBackupTier)) {
|
||||
val paidTier = allBackupTypes.first { it.tier == MessageBackupTier.PAID } as MessageBackupsType.Paid
|
||||
val context = LocalContext.current
|
||||
|
||||
@@ -207,6 +211,8 @@ fun MessageBackupsTypeSelectionScreen(
|
||||
} else {
|
||||
stringResource(R.string.MessageBackupsTypeSelectionScreen__subscribe)
|
||||
}
|
||||
} else if (selectedBackupTier == MessageBackupTier.PAID && (googlePlayServicesAvailability != GooglePlayServicesAvailability.SUCCESS || !googlePlayBillingAvailability.isSuccess)) {
|
||||
stringResource(R.string.MessageBackupsTypeSelectionScreen__more_about_this_plan)
|
||||
} else {
|
||||
stringResource(R.string.MessageBackupsTypeSelectionScreen__change_backup_type)
|
||||
}
|
||||
@@ -224,24 +230,54 @@ fun MessageBackupsTypeSelectionScreen(
|
||||
}
|
||||
}
|
||||
|
||||
@Stable
|
||||
class PaidTierNotAvailableDialogState {
|
||||
var displayGooglePlayBillingErrorDialog: Boolean by mutableStateOf(false)
|
||||
var displayGooglePlayApiErrorDialog: Boolean by mutableStateOf(false)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UpgradeNotAvailableDialog(
|
||||
onConfirm: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
onDismissRequest: () -> Unit
|
||||
fun PaidTierNotAvailableDialogs(
|
||||
state: PaidTierNotAvailableDialogState,
|
||||
googlePlayServicesAvailability: GooglePlayServicesAvailability,
|
||||
onLearnMoreAboutWhyUserCanNotUpgrade: () -> Unit,
|
||||
onMakeGooglePlayServicesAvailable: () -> Unit,
|
||||
onOpenPlayStore: () -> Unit
|
||||
) {
|
||||
if (state.displayGooglePlayApiErrorDialog) {
|
||||
GooglePlayServicesAvailabilityDialog(
|
||||
onDismissRequest = { state.displayGooglePlayApiErrorDialog = false },
|
||||
googlePlayServicesAvailability = googlePlayServicesAvailability,
|
||||
onLearnMoreClick = onLearnMoreAboutWhyUserCanNotUpgrade,
|
||||
onMakeServicesAvailableClick = onMakeGooglePlayServicesAvailable
|
||||
)
|
||||
}
|
||||
|
||||
if (state.displayGooglePlayBillingErrorDialog) {
|
||||
UserNotSignedInDialog(
|
||||
onDismissRequest = { state.displayGooglePlayBillingErrorDialog = false },
|
||||
onOpenPlayStore = onOpenPlayStore
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UserNotSignedInDialog(
|
||||
onDismissRequest: () -> Unit,
|
||||
onOpenPlayStore: () -> Unit
|
||||
) {
|
||||
Dialogs.SimpleAlertDialog(
|
||||
title = stringResource(R.string.MessageBackupsTypeSelectionScreen__cant_upgrade_plan),
|
||||
body = stringResource(R.string.MessageBackupsTypeSelectionScreen__to_subscribe_to_signal_secure_backups),
|
||||
confirm = stringResource(android.R.string.ok),
|
||||
dismiss = stringResource(R.string.MessageBackupsTypeSelectionScreen__learn_more),
|
||||
onConfirm = onConfirm,
|
||||
onDismiss = onDismiss,
|
||||
onDismissRequest = onDismissRequest
|
||||
title = stringResource(R.string.GooglePlayServicesAvailability__service_disabled_title),
|
||||
body = "To subscribe to Signal Secure Backups, please sign into the Google Play store.",
|
||||
onConfirm = onOpenPlayStore,
|
||||
onDismiss = onDismissRequest,
|
||||
onDismissRequest = onDismissRequest,
|
||||
confirm = "Open Play Store",
|
||||
dismiss = stringResource(android.R.string.cancel)
|
||||
)
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun MessageBackupsTypeSelectionScreenPreview() {
|
||||
var selectedBackupsType by remember { mutableStateOf(MessageBackupTier.FREE) }
|
||||
@@ -256,14 +292,17 @@ private fun MessageBackupsTypeSelectionScreenPreview() {
|
||||
onReadMoreClicked = {},
|
||||
onNextClicked = {},
|
||||
onLearnMoreAboutWhyUserCanNotUpgrade = {},
|
||||
onMakeGooglePlayServicesAvailable = {},
|
||||
onOpenPlayStore = {},
|
||||
currentBackupTier = null,
|
||||
isBillingApiAvailable = true,
|
||||
googlePlayServicesAvailability = GooglePlayServicesAvailability.SUCCESS,
|
||||
googlePlayBillingAvailability = BillingResponseCode.OK,
|
||||
isNextEnabled = true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun MessageBackupsTypeSelectionScreenWithCurrentTierPreview() {
|
||||
var selectedBackupsType by remember { mutableStateOf(MessageBackupTier.FREE) }
|
||||
@@ -278,25 +317,16 @@ private fun MessageBackupsTypeSelectionScreenWithCurrentTierPreview() {
|
||||
onReadMoreClicked = {},
|
||||
onNextClicked = {},
|
||||
onLearnMoreAboutWhyUserCanNotUpgrade = {},
|
||||
onMakeGooglePlayServicesAvailable = {},
|
||||
onOpenPlayStore = {},
|
||||
currentBackupTier = MessageBackupTier.PAID,
|
||||
isBillingApiAvailable = true,
|
||||
googlePlayServicesAvailability = GooglePlayServicesAvailability.SUCCESS,
|
||||
googlePlayBillingAvailability = BillingResponseCode.OK,
|
||||
isNextEnabled = true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@Composable
|
||||
private fun UpgradeNotAvailableDialogPreview() {
|
||||
Previews.Preview {
|
||||
UpgradeNotAvailableDialog(
|
||||
onConfirm = {},
|
||||
onDismiss = {},
|
||||
onDismissRequest = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MessageBackupsTypeBlock(
|
||||
messageBackupsType: MessageBackupsType,
|
||||
@@ -382,8 +412,12 @@ private fun getFormattedPricePerMonth(messageBackupsType: MessageBackupsType): S
|
||||
return when (messageBackupsType) {
|
||||
is MessageBackupsType.Free -> stringResource(id = R.string.MessageBackupsTypeSelectionScreen__free)
|
||||
is MessageBackupsType.Paid -> {
|
||||
val formattedAmount = FiatMoneyUtil.format(LocalContext.current.resources, messageBackupsType.pricePerMonth, FiatMoneyUtil.formatOptions().trimZerosAfterDecimal())
|
||||
stringResource(id = R.string.MessageBackupsTypeSelectionScreen__s_month, formattedAmount)
|
||||
if (messageBackupsType.pricePerMonth.amount == BigDecimal.ZERO) {
|
||||
stringResource(R.string.MessageBackupsTypeSelectionScreen__paid)
|
||||
} else {
|
||||
val formattedAmount = FiatMoneyUtil.format(LocalContext.current.resources, messageBackupsType.pricePerMonth, FiatMoneyUtil.formatOptions().trimZerosAfterDecimal())
|
||||
stringResource(id = R.string.MessageBackupsTypeSelectionScreen__s_month, formattedAmount)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,8 +25,8 @@ import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.withLink
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.SignalPreview
|
||||
import org.signal.core.ui.compose.theme.SignalTheme
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.BiometricDeviceAuthentication
|
||||
@@ -188,7 +188,7 @@ fun VerifyBackupPinScreen(
|
||||
}
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun VerifyBackupKeyScreen() {
|
||||
Previews.Preview {
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -12,8 +12,8 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.SignalPreview
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.banner.Banner
|
||||
import org.thoughtcrime.securesms.banner.ui.compose.Action
|
||||
@@ -49,7 +49,7 @@ private fun Banner(contentPadding: PaddingValues, actionListener: (Boolean) -> U
|
||||
)
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun BannerPreview() {
|
||||
Previews.Preview {
|
||||
|
||||
@@ -11,8 +11,8 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.SignalPreview
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.banner.Banner
|
||||
import org.thoughtcrime.securesms.banner.ui.compose.Action
|
||||
@@ -65,7 +65,7 @@ private fun Banner(contentPadding: PaddingValues, onLearnMoreClicked: () -> Unit
|
||||
)
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun BannerPreview() {
|
||||
Previews.Preview {
|
||||
|
||||
@@ -11,8 +11,8 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.SignalPreview
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.banner.Banner
|
||||
import org.thoughtcrime.securesms.banner.ui.compose.Action
|
||||
@@ -56,7 +56,7 @@ private fun Banner(contentPadding: PaddingValues, onLearnMoreClicked: () -> Unit
|
||||
)
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun BannerPreview() {
|
||||
Previews.Preview {
|
||||
|
||||
@@ -12,8 +12,8 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.SignalPreview
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.banner.Banner
|
||||
import org.thoughtcrime.securesms.banner.ui.compose.Action
|
||||
@@ -60,7 +60,7 @@ private fun Banner(contentPadding: PaddingValues, onUpdateClicked: () -> Unit =
|
||||
)
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun BannerPreview() {
|
||||
Previews.Preview {
|
||||
|
||||
@@ -13,8 +13,8 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.SignalPreview
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.banner.Banner
|
||||
import org.thoughtcrime.securesms.banner.ui.compose.Action
|
||||
@@ -67,7 +67,7 @@ private fun Banner(contentPadding: PaddingValues, onDismissListener: () -> Unit
|
||||
)
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun BannerPreview() {
|
||||
Previews.Preview {
|
||||
|
||||
@@ -12,8 +12,8 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.SignalPreview
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.banner.Banner
|
||||
import org.thoughtcrime.securesms.banner.ui.compose.Action
|
||||
@@ -56,7 +56,7 @@ private fun Banner(contentPadding: PaddingValues, onUpdateNow: () -> Unit = {})
|
||||
)
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun BannerPreview() {
|
||||
Previews.Preview {
|
||||
|
||||
@@ -11,8 +11,8 @@ import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.SignalPreview
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.banner.Banner
|
||||
import org.thoughtcrime.securesms.banner.ui.compose.Action
|
||||
@@ -62,7 +62,7 @@ private fun Banner(contentPadding: PaddingValues, suggestionsSize: Int, onAddMem
|
||||
)
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun BannerPreviewSingular() {
|
||||
Previews.Preview {
|
||||
@@ -70,7 +70,7 @@ private fun BannerPreviewSingular() {
|
||||
}
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun BannerPreviewPlural() {
|
||||
Previews.Preview {
|
||||
|
||||
@@ -13,8 +13,8 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.SignalPreview
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.banner.Banner
|
||||
import org.thoughtcrime.securesms.banner.ui.compose.Action
|
||||
@@ -87,7 +87,7 @@ private fun Banner(contentPadding: PaddingValues, daysUntilExpiry: Int, onUpdate
|
||||
)
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun BannerPreviewExpireToday() {
|
||||
Previews.Preview {
|
||||
@@ -98,7 +98,7 @@ private fun BannerPreviewExpireToday() {
|
||||
}
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun BannerPreviewExpireTomorrow() {
|
||||
Previews.Preview {
|
||||
@@ -109,7 +109,7 @@ private fun BannerPreviewExpireTomorrow() {
|
||||
}
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun BannerPreviewExpireLater() {
|
||||
Previews.Preview {
|
||||
|
||||
@@ -15,8 +15,8 @@ import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.SignalPreview
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.banner.Banner
|
||||
import org.thoughtcrime.securesms.banner.ui.compose.Action
|
||||
@@ -66,7 +66,7 @@ private fun Banner(contentPadding: PaddingValues, suggestionsSize: Int, onViewCl
|
||||
)
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun BannerPreviewSingular() {
|
||||
Previews.Preview {
|
||||
@@ -74,7 +74,7 @@ private fun BannerPreviewSingular() {
|
||||
}
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun BannerPreviewPlural() {
|
||||
Previews.Preview {
|
||||
|
||||
@@ -12,8 +12,8 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.SignalPreview
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.banner.Banner
|
||||
import org.thoughtcrime.securesms.banner.ui.compose.DefaultBanner
|
||||
@@ -41,7 +41,7 @@ private fun Banner(contentPadding: PaddingValues) {
|
||||
)
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun BannerPreview() {
|
||||
Previews.Preview {
|
||||
|
||||
@@ -13,8 +13,8 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.SignalPreview
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.banner.Banner
|
||||
import org.thoughtcrime.securesms.banner.ui.compose.Action
|
||||
@@ -59,7 +59,7 @@ private fun Banner(contentPadding: PaddingValues) {
|
||||
)
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun BannerPreview() {
|
||||
Previews.Preview {
|
||||
|
||||
@@ -11,8 +11,8 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.SignalPreview
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.banner.Banner
|
||||
import org.thoughtcrime.securesms.banner.ui.compose.Action
|
||||
@@ -65,7 +65,7 @@ private fun Banner(contentPadding: PaddingValues, usernameSyncState: UsernameSyn
|
||||
)
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun BannerPreviewUsernameCorrupted() {
|
||||
Previews.Preview {
|
||||
@@ -73,7 +73,7 @@ private fun BannerPreviewUsernameCorrupted() {
|
||||
}
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun BannerPreviewLinkCorrupted() {
|
||||
Previews.Preview {
|
||||
|
||||
@@ -34,8 +34,8 @@ import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.SignalPreview
|
||||
import org.signal.core.util.isNotNullOrBlank
|
||||
import org.thoughtcrime.securesms.R
|
||||
|
||||
@@ -195,7 +195,7 @@ enum class Importance {
|
||||
}
|
||||
|
||||
@Composable
|
||||
@SignalPreview
|
||||
@DayNightPreviews
|
||||
private fun BubblesOptOutPreview() {
|
||||
Previews.Preview {
|
||||
DefaultBanner(
|
||||
@@ -212,7 +212,7 @@ private fun BubblesOptOutPreview() {
|
||||
}
|
||||
|
||||
@Composable
|
||||
@SignalPreview
|
||||
@DayNightPreviews
|
||||
private fun ForcedUpgradePreview() {
|
||||
Previews.Preview {
|
||||
DefaultBanner(
|
||||
@@ -228,7 +228,7 @@ private fun ForcedUpgradePreview() {
|
||||
}
|
||||
|
||||
@Composable
|
||||
@SignalPreview
|
||||
@DayNightPreviews
|
||||
private fun FullyLoadedErrorPreview() {
|
||||
val actions = listOf(
|
||||
Action(R.string.ExpiredBuildReminder_update_now) { },
|
||||
|
||||
@@ -26,8 +26,8 @@ import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.signal.core.ui.compose.BottomSheets
|
||||
import org.signal.core.ui.compose.Buttons
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.SignalPreview
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsType
|
||||
import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsTypeBlock
|
||||
@@ -145,7 +145,7 @@ private fun UpgradeToEnableOptimizedStorageSheetContent(
|
||||
}
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun UpgradeToEnableOptimizedStorageSheetContentPreview() {
|
||||
Previews.BottomSheetPreview {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -27,8 +27,8 @@ import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.signal.core.ui.compose.BottomSheets
|
||||
import org.signal.core.ui.compose.Buttons
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.SignalPreview
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsType
|
||||
import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsTypeBlock
|
||||
@@ -143,7 +143,7 @@ private fun UpgradeToStartMediaBackupSheetContent(
|
||||
}
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun UpgradeToStartMediaBackupSheetContentPreview() {
|
||||
Previews.Preview {
|
||||
|
||||
@@ -21,6 +21,7 @@ import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.components.ContactFilterView;
|
||||
import org.thoughtcrime.securesms.contacts.ContactSelectionDisplayMode;
|
||||
import org.thoughtcrime.securesms.contacts.paged.ChatType;
|
||||
import org.thoughtcrime.securesms.contacts.selection.ContactSelectionArguments;
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
@@ -172,10 +173,9 @@ public class BlockedUsersActivity extends PassphraseRequiredActivity implements
|
||||
ContactSelectionListFragment fragment = new ContactSelectionListFragment();
|
||||
Intent intent = getIntent();
|
||||
|
||||
intent.putExtra(ContactSelectionListFragment.REFRESHABLE, false);
|
||||
intent.putExtra(ContactSelectionListFragment.SELECTION_LIMITS, 1);
|
||||
intent.putExtra(ContactSelectionListFragment.HIDE_COUNT, true);
|
||||
intent.putExtra(ContactSelectionListFragment.DISPLAY_MODE,
|
||||
intent.putExtra(ContactSelectionArguments.REFRESHABLE, false);
|
||||
intent.putExtra(ContactSelectionArguments.SELECTION_LIMITS, 1);
|
||||
intent.putExtra(ContactSelectionArguments.DISPLAY_MODE,
|
||||
ContactSelectionDisplayMode.FLAG_PUSH |
|
||||
ContactSelectionDisplayMode.FLAG_SMS |
|
||||
ContactSelectionDisplayMode.FLAG_ACTIVE_GROUPS |
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,8 +36,8 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.signal.core.ui.compose.Buttons
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.SignalPreview
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.conversation.colors.AvatarColorPair
|
||||
import org.thoughtcrime.securesms.database.CallLinkTable
|
||||
@@ -49,7 +49,7 @@ import org.thoughtcrime.securesms.service.webrtc.links.SignalCallLinkState
|
||||
import java.time.Instant
|
||||
import org.signal.core.ui.R as CoreUiR
|
||||
|
||||
@SignalPreview
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun SignalCallRowPreview() {
|
||||
val callLink = remember {
|
||||
|
||||
@@ -39,10 +39,10 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import io.reactivex.rxjava3.kotlin.subscribeBy
|
||||
import org.signal.core.ui.compose.BottomSheets
|
||||
import org.signal.core.ui.compose.Buttons
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Dividers
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.Rows
|
||||
import org.signal.core.ui.compose.SignalPreview
|
||||
import org.signal.core.util.concurrent.LifecycleDisposable
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.ringrtc.CallLinkState
|
||||
@@ -325,7 +325,7 @@ private fun CreateCallLinkBottomSheetContent(
|
||||
}
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun CreateCallLinkBottomSheetContentPreview() {
|
||||
Previews.BottomSheetPreview {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
/**
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.calls.links.details
|
||||
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Intent
|
||||
import android.widget.Toast
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.app.ShareCompat
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import kotlinx.coroutines.launch
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Dialogs
|
||||
import org.signal.core.ui.compose.Dividers
|
||||
import org.signal.core.ui.compose.Rows
|
||||
import org.signal.core.ui.compose.Scaffolds
|
||||
import org.signal.core.ui.compose.Snackbars
|
||||
import org.signal.core.ui.compose.theme.SignalTheme
|
||||
import org.signal.core.util.concurrent.LifecycleDisposable
|
||||
import org.signal.ringrtc.CallLinkState.Restrictions
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.calls.YouAreAlreadyInACallSnackbar.YouAreAlreadyInACallSnackbar
|
||||
import org.thoughtcrime.securesms.calls.links.CallLinks
|
||||
import org.thoughtcrime.securesms.calls.links.SignalCallRow
|
||||
import org.thoughtcrime.securesms.database.CallLinkTable
|
||||
import org.thoughtcrime.securesms.main.MainNavigationDetailLocation
|
||||
import org.thoughtcrime.securesms.main.MainNavigationListLocation
|
||||
import org.thoughtcrime.securesms.main.MainNavigationRouter
|
||||
import org.thoughtcrime.securesms.main.MainNavigationViewModel
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkCredentials
|
||||
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId
|
||||
import org.thoughtcrime.securesms.service.webrtc.links.SignalCallLinkState
|
||||
import org.thoughtcrime.securesms.sharing.v2.ShareActivity
|
||||
import org.thoughtcrime.securesms.util.CommunicationActions
|
||||
import org.thoughtcrime.securesms.util.Util
|
||||
import org.thoughtcrime.securesms.window.WindowSizeClass
|
||||
import java.time.Instant
|
||||
|
||||
@Composable
|
||||
fun CallLinkDetailsScreen(
|
||||
roomId: CallLinkRoomId,
|
||||
viewModel: CallLinkDetailsViewModel = viewModel {
|
||||
CallLinkDetailsViewModel(roomId)
|
||||
},
|
||||
router: MainNavigationRouter = viewModel<MainNavigationViewModel>(viewModelStoreOwner = LocalContext.current as ComponentActivity) {
|
||||
error("Should already be created.")
|
||||
}
|
||||
) {
|
||||
val activity = LocalContext.current as FragmentActivity
|
||||
val callback = remember {
|
||||
DefaultCallLinkDetailsCallback(
|
||||
activity = activity,
|
||||
viewModel = viewModel,
|
||||
router = router
|
||||
)
|
||||
}
|
||||
|
||||
val state by viewModel.state.collectAsStateWithLifecycle(activity)
|
||||
val showAlreadyInACall by viewModel.showAlreadyInACall.collectAsStateWithLifecycle(initialValue = false, lifecycleOwner = activity)
|
||||
|
||||
CallLinkDetailsScreen(
|
||||
state = state,
|
||||
showAlreadyInACall = showAlreadyInACall,
|
||||
callback = callback,
|
||||
showNavigationIcon = !WindowSizeClass.rememberWindowSizeClass().isSplitPane()
|
||||
)
|
||||
}
|
||||
|
||||
class DefaultCallLinkDetailsCallback(
|
||||
private val activity: FragmentActivity,
|
||||
private val viewModel: CallLinkDetailsViewModel,
|
||||
private val router: MainNavigationRouter
|
||||
) : CallLinkDetailsCallback {
|
||||
|
||||
private val lifecycleDisposable = LifecycleDisposable()
|
||||
|
||||
init {
|
||||
lifecycleDisposable.bindTo(activity)
|
||||
}
|
||||
|
||||
override fun onNavigationClicked() {
|
||||
activity.onBackPressedDispatcher.onBackPressed()
|
||||
}
|
||||
|
||||
override fun onJoinClicked() {
|
||||
val recipientSnapshot = viewModel.recipientSnapshot
|
||||
if (recipientSnapshot != null) {
|
||||
CommunicationActions.startVideoCall(activity, recipientSnapshot) {
|
||||
viewModel.showAlreadyInACall(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onEditNameClicked() {
|
||||
router.goTo(MainNavigationDetailLocation.Calls.CallLinks.EditCallLinkName(callLinkRoomId = viewModel.recipientSnapshot!!.requireCallLinkRoomId()))
|
||||
}
|
||||
|
||||
override fun onShareClicked() {
|
||||
val mimeType = Intent.normalizeMimeType("text/plain")
|
||||
val shareIntent = ShareCompat.IntentBuilder(activity)
|
||||
.setText(CallLinks.url(viewModel.rootKeySnapshot, viewModel.epochSnapshot))
|
||||
.setType(mimeType)
|
||||
.createChooserIntent()
|
||||
|
||||
try {
|
||||
activity.startActivity(shareIntent)
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
Toast.makeText(activity, R.string.CreateCallLinkBottomSheetDialogFragment__failed_to_open_share_sheet, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCopyClicked() {
|
||||
Util.copyToClipboard(activity, CallLinks.url(viewModel.rootKeySnapshot, viewModel.epochSnapshot))
|
||||
Toast.makeText(activity, R.string.CreateCallLinkBottomSheetDialogFragment__copied_to_clipboard, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
|
||||
override fun onShareLinkViaSignalClicked() {
|
||||
activity.startActivity(
|
||||
ShareActivity.sendSimpleText(
|
||||
activity,
|
||||
activity.getString(R.string.CreateCallLink__use_this_link_to_join_a_signal_call, CallLinks.url(viewModel.rootKeySnapshot, viewModel.epochSnapshot))
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override fun onDeleteClicked() {
|
||||
viewModel.setDisplayRevocationDialog(true)
|
||||
}
|
||||
|
||||
override fun onDeleteConfirmed() {
|
||||
viewModel.setDisplayRevocationDialog(false)
|
||||
activity.lifecycleScope.launch {
|
||||
if (viewModel.delete()) {
|
||||
router.goTo(MainNavigationListLocation.CALLS)
|
||||
router.goTo(MainNavigationDetailLocation.Empty)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDeleteCanceled() {
|
||||
viewModel.setDisplayRevocationDialog(false)
|
||||
}
|
||||
|
||||
override fun onApproveAllMembersChanged(checked: Boolean) {
|
||||
activity.lifecycleScope.launch {
|
||||
viewModel.setApproveAllMembers(checked)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface CallLinkDetailsCallback {
|
||||
fun onNavigationClicked() = Unit
|
||||
fun onJoinClicked() = Unit
|
||||
fun onEditNameClicked() = Unit
|
||||
fun onShareClicked() = Unit
|
||||
fun onCopyClicked() = Unit
|
||||
fun onShareLinkViaSignalClicked() = Unit
|
||||
fun onDeleteClicked() = Unit
|
||||
fun onDeleteConfirmed() = Unit
|
||||
fun onDeleteCanceled() = Unit
|
||||
fun onApproveAllMembersChanged(checked: Boolean) = Unit
|
||||
|
||||
object Empty : CallLinkDetailsCallback
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CallLinkDetailsScreen(
|
||||
state: CallLinkDetailsState,
|
||||
showAlreadyInACall: Boolean,
|
||||
callback: CallLinkDetailsCallback,
|
||||
showNavigationIcon: Boolean = true
|
||||
) {
|
||||
Scaffolds.Settings(
|
||||
title = stringResource(id = R.string.CallLinkDetailsFragment__call_details),
|
||||
snackbarHost = {
|
||||
YouAreAlreadyInACallSnackbar(showAlreadyInACall)
|
||||
FailureSnackbar(failureSnackbar = state.failureSnackbar)
|
||||
},
|
||||
onNavigationClick = callback::onNavigationClicked,
|
||||
navigationIcon = if (showNavigationIcon) {
|
||||
ImageVector.vectorResource(id = R.drawable.symbol_arrow_start_24)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
) { paddingValues ->
|
||||
if (state.callLink == null) {
|
||||
return@Settings
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.padding(paddingValues)
|
||||
.fillMaxHeight()
|
||||
) {
|
||||
item {
|
||||
SignalCallRow(
|
||||
callLink = state.callLink,
|
||||
callLinkPeekInfo = state.peekInfo,
|
||||
onJoinClicked = callback::onJoinClicked,
|
||||
modifier = Modifier.padding(top = 16.dp, bottom = 12.dp)
|
||||
)
|
||||
}
|
||||
|
||||
if (state.callLink.credentials?.adminPassBytes != null) {
|
||||
item {
|
||||
Rows.TextRow(
|
||||
text = stringResource(
|
||||
id = if (state.callLink.state.name.isEmpty()) {
|
||||
R.string.CreateCallLinkBottomSheetDialogFragment__add_call_name
|
||||
} else {
|
||||
R.string.CreateCallLinkBottomSheetDialogFragment__edit_call_name
|
||||
}
|
||||
),
|
||||
onClick = callback::onEditNameClicked
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Rows.ToggleRow(
|
||||
checked = state.callLink.state.restrictions == Restrictions.ADMIN_APPROVAL,
|
||||
text = stringResource(id = R.string.CallLinkDetailsFragment__require_admin_approval),
|
||||
onCheckChanged = callback::onApproveAllMembersChanged,
|
||||
isLoading = state.isLoadingAdminApprovalChange
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Dividers.Default()
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Rows.TextRow(
|
||||
text = stringResource(id = R.string.CreateCallLinkBottomSheetDialogFragment__share_link_via_signal),
|
||||
icon = ImageVector.vectorResource(id = R.drawable.symbol_forward_24),
|
||||
onClick = callback::onShareLinkViaSignalClicked
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Rows.TextRow(
|
||||
text = stringResource(id = R.string.CreateCallLinkBottomSheetDialogFragment__copy_link),
|
||||
icon = ImageVector.vectorResource(id = R.drawable.symbol_copy_android_24),
|
||||
onClick = callback::onCopyClicked
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Rows.TextRow(
|
||||
text = stringResource(id = R.string.CallLinkDetailsFragment__share_link),
|
||||
icon = ImageVector.vectorResource(id = R.drawable.symbol_link_24),
|
||||
onClick = callback::onShareClicked
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Rows.TextRow(
|
||||
text = stringResource(id = R.string.CallLinkDetailsFragment__delete_call_link),
|
||||
icon = ImageVector.vectorResource(id = R.drawable.symbol_trash_24),
|
||||
foregroundTint = MaterialTheme.colorScheme.error,
|
||||
onClick = callback::onDeleteClicked
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (state.displayRevocationDialog) {
|
||||
Dialogs.SimpleAlertDialog(
|
||||
title = stringResource(R.string.CallLinkDetailsFragment__delete_link),
|
||||
body = stringResource(id = R.string.CallLinkDetailsFragment__this_link_will_no_longer_work),
|
||||
confirm = stringResource(id = R.string.delete),
|
||||
dismiss = stringResource(id = android.R.string.cancel),
|
||||
onConfirm = callback::onDeleteConfirmed,
|
||||
onDismiss = callback::onDeleteCanceled
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FailureSnackbar(
|
||||
failureSnackbar: CallLinkDetailsState.FailureSnackbar?,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val message: String? = when (failureSnackbar) {
|
||||
CallLinkDetailsState.FailureSnackbar.COULD_NOT_DELETE_CALL_LINK -> stringResource(R.string.CallLinkDetailsFragment__couldnt_delete_call_link)
|
||||
CallLinkDetailsState.FailureSnackbar.COULD_NOT_SAVE_CHANGES -> stringResource(R.string.CallLinkDetailsFragment__couldnt_save_changes)
|
||||
CallLinkDetailsState.FailureSnackbar.COULD_NOT_UPDATE_ADMIN_APPROVAL -> stringResource(R.string.CallLinkDetailsFragment__couldnt_update_admin_approval)
|
||||
null -> null
|
||||
}
|
||||
|
||||
val hostState = remember { SnackbarHostState() }
|
||||
Snackbars.Host(hostState, modifier = modifier)
|
||||
|
||||
LaunchedEffect(message) {
|
||||
if (message != null) {
|
||||
hostState.showSnackbar(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun CallLinkDetailsScreenPreview() {
|
||||
val callLink = remember {
|
||||
val credentials = CallLinkCredentials(
|
||||
byteArrayOf(1, 2, 3, 4),
|
||||
byteArrayOf(0, 1, 2, 3),
|
||||
byteArrayOf(3, 4, 5, 6)
|
||||
)
|
||||
CallLinkTable.CallLink(
|
||||
recipientId = RecipientId.UNKNOWN,
|
||||
roomId = CallLinkRoomId.fromBytes(byteArrayOf(1, 2, 3, 4)),
|
||||
credentials = credentials,
|
||||
state = SignalCallLinkState(
|
||||
name = "Call Name",
|
||||
revoked = false,
|
||||
restrictions = Restrictions.NONE,
|
||||
expiration = Instant.MAX
|
||||
),
|
||||
deletionTimestamp = 0L
|
||||
)
|
||||
}
|
||||
|
||||
SignalTheme {
|
||||
CallLinkDetailsScreen(
|
||||
CallLinkDetailsState(
|
||||
false,
|
||||
false,
|
||||
callLink
|
||||
),
|
||||
true,
|
||||
CallLinkDetailsCallback.Empty
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -92,6 +92,7 @@ class CallLogAdapter(
|
||||
fun submitCallRows(
|
||||
rows: List<CallLogRow?>,
|
||||
selectionState: CallLogSelectionState,
|
||||
activeCallLogRowId: CallLogRow.Id?,
|
||||
localCallRecipientId: RecipientId,
|
||||
onCommit: () -> Unit
|
||||
): Int {
|
||||
@@ -99,8 +100,19 @@ class CallLogAdapter(
|
||||
.filterNotNull()
|
||||
.map {
|
||||
when (it) {
|
||||
is CallLogRow.Call -> CallModel(it, selectionState, itemCount, it.peer.id == localCallRecipientId)
|
||||
is CallLogRow.CallLink -> CallLinkModel(it, selectionState, itemCount, it.recipient.id == localCallRecipientId)
|
||||
is CallLogRow.Call -> CallModel(
|
||||
call = it,
|
||||
selectionState = selectionState,
|
||||
itemCount = itemCount,
|
||||
isLocalDeviceInCall = it.peer.id == localCallRecipientId
|
||||
)
|
||||
is CallLogRow.CallLink -> CallLinkModel(
|
||||
callLink = it,
|
||||
selectionState = selectionState,
|
||||
activeCallLogRowId = activeCallLogRowId,
|
||||
itemCount = itemCount,
|
||||
isLocalDeviceInCall = it.recipient.id == localCallRecipientId
|
||||
)
|
||||
is CallLogRow.ClearFilter -> ClearFilterModel()
|
||||
is CallLogRow.ClearFilterEmpty -> ClearFilterEmptyModel()
|
||||
is CallLogRow.CreateCallLink -> CreateCallLinkModel()
|
||||
@@ -148,6 +160,7 @@ class CallLogAdapter(
|
||||
private class CallLinkModel(
|
||||
val callLink: CallLogRow.CallLink,
|
||||
val selectionState: CallLogSelectionState,
|
||||
val activeCallLogRowId: CallLogRow.Id?,
|
||||
val itemCount: Int,
|
||||
val isLocalDeviceInCall: Boolean
|
||||
) : MappingModel<CallLinkModel> {
|
||||
@@ -159,12 +172,13 @@ class CallLogAdapter(
|
||||
override fun areContentsTheSame(newItem: CallLinkModel): Boolean {
|
||||
return callLink == newItem.callLink &&
|
||||
isSelectionStateTheSame(newItem) &&
|
||||
isActiveIdStateTheSame(newItem) &&
|
||||
isItemCountTheSame(newItem) &&
|
||||
isLocalDeviceInCall == newItem.isLocalDeviceInCall
|
||||
}
|
||||
|
||||
override fun getChangePayload(newItem: CallLinkModel): Any? {
|
||||
return if (callLink == newItem.callLink && (!isSelectionStateTheSame(newItem) || !isItemCountTheSame(newItem))) {
|
||||
return if (callLink == newItem.callLink && (!isSelectionStateTheSame(newItem) || !isItemCountTheSame(newItem) || !isActiveIdStateTheSame(newItem))) {
|
||||
PAYLOAD_SELECTION_STATE
|
||||
} else {
|
||||
null
|
||||
@@ -176,6 +190,13 @@ class CallLogAdapter(
|
||||
selectionState.isNotEmpty(itemCount) == newItem.selectionState.isNotEmpty(newItem.itemCount)
|
||||
}
|
||||
|
||||
private fun isActiveIdStateTheSame(newItem: CallLinkModel): Boolean {
|
||||
val isOldItemActive = activeCallLogRowId == callLink.id
|
||||
val isNewItemActive = newItem.activeCallLogRowId == newItem.callLink.id
|
||||
|
||||
return (isOldItemActive && isNewItemActive) || (!isOldItemActive && !isNewItemActive)
|
||||
}
|
||||
|
||||
private fun isItemCountTheSame(newItem: CallLinkModel): Boolean {
|
||||
return itemCount == newItem.itemCount
|
||||
}
|
||||
@@ -220,6 +241,8 @@ class CallLogAdapter(
|
||||
binding.callSelected.isChecked = model.selectionState.contains(model.callLink.id)
|
||||
binding.callSelected.visible = model.selectionState.isNotEmpty(model.itemCount)
|
||||
|
||||
itemView.isActivated = model.activeCallLogRowId == model.callLink.id
|
||||
|
||||
if (payload.isNotEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -11,16 +11,22 @@ 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.core.BackpressureStrategy
|
||||
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
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.core.util.orNull
|
||||
import org.thoughtcrime.securesms.MainNavigator
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.calls.links.create.CreateCallLinkBottomSheetDialogFragment
|
||||
@@ -41,6 +47,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 +85,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
|
||||
|
||||
@@ -116,12 +124,13 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
|
||||
)
|
||||
|
||||
disposables += scrollToPositionDelegate
|
||||
disposables += Flowables.combineLatest(viewModel.data, viewModel.selected)
|
||||
disposables += Flowables.combineLatest(viewModel.data, viewModel.selected, mainNavigationViewModel.observableActiveCallId.toFlowable(BackpressureStrategy.LATEST))
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe { (data, selected) ->
|
||||
.subscribe { (data, selected, activeRowId) ->
|
||||
val filteredCount = callLogAdapter.submitCallRows(
|
||||
data,
|
||||
selected,
|
||||
activeCallLogRowId = activeRowId.orNull().takeIf { resources.getWindowSizeClass().isSplitPane() },
|
||||
viewModel.callLogPeekHelper.localDeviceCallRecipientId,
|
||||
scrollToPositionDelegate::notifyListCommitted
|
||||
)
|
||||
@@ -165,16 +174,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 +323,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 +489,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()
|
||||
|
||||
@@ -18,6 +18,7 @@ import org.thoughtcrime.securesms.calls.YouAreAlreadyInACallSnackbar
|
||||
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
|
||||
import org.thoughtcrime.securesms.contacts.ContactSelectionDisplayMode
|
||||
import org.thoughtcrime.securesms.contacts.paged.ChatType
|
||||
import org.thoughtcrime.securesms.contacts.selection.ContactSelectionArguments
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
@@ -100,7 +101,7 @@ class NewCallActivity : ContactSelectionActivity(), ContactSelectionListFragment
|
||||
fun createIntent(context: Context): Intent {
|
||||
return Intent(context, NewCallActivity::class.java)
|
||||
.putExtra(
|
||||
ContactSelectionListFragment.DISPLAY_MODE,
|
||||
ContactSelectionArguments.DISPLAY_MODE,
|
||||
ContactSelectionDisplayMode.none()
|
||||
.withPush()
|
||||
.withActiveGroups()
|
||||
|
||||
@@ -25,8 +25,13 @@ import org.thoughtcrime.securesms.util.ServiceUtil;
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
import org.thoughtcrime.securesms.util.ViewUtil;
|
||||
|
||||
/**
|
||||
* A search input field for finding recipients.
|
||||
* <p>
|
||||
* In compose, use RecipientSearchField instead.
|
||||
*/
|
||||
public final class ContactFilterView extends FrameLayout {
|
||||
private OnFilterChangedListener listener;
|
||||
private OnFilterChangedListener listener;
|
||||
|
||||
private final EditText searchText;
|
||||
private final AnimatingToggle toggle;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user