mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-11 20:43:34 +01:00
Compare commits
152 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 = 1598
|
||||
val canonicalVersionName = "7.60.1"
|
||||
val currentHotfixVersion = 0
|
||||
val maxHotfixVersions = 100
|
||||
|
||||
@@ -217,7 +218,7 @@ android {
|
||||
buildConfigField("String", "SVR2_MRENCLAVE_LEGACY_LEGACY", "\"9314436a9a144992bb3680770ea5fd7934a7ffd29257844a33763a238903d570\"")
|
||||
buildConfigField("String", "SVR2_MRENCLAVE_LEGACY", "\"093be9ea32405e85ae28dbb48eb668aebeb7dbe29517b9b86ad4bec4dfe0e6a6\"")
|
||||
buildConfigField("String", "SVR2_MRENCLAVE", "\"29cd63c87bea751e3bfd0fbd401279192e2e5c99948b4ee9437eafc4968355fb\"")
|
||||
buildConfigField("String", "UNIDENTIFIED_SENDER_TRUST_ROOT", "\"BXu6QIKVz5MA8gstzfOgRQGqyLqOwNKHL6INkv3IHWMF\"")
|
||||
buildConfigField("String[]", "UNIDENTIFIED_SENDER_TRUST_ROOTS", "new String[]{ \"BXu6QIKVz5MA8gstzfOgRQGqyLqOwNKHL6INkv3IHWMF\", \"BUkY0I+9+oPgDCn4+Ac6Iu813yvqkDr/ga8DzLxFxuk6\"}")
|
||||
buildConfigField("String", "ZKGROUP_SERVER_PUBLIC_PARAMS", "\"AMhf5ywVwITZMsff/eCyudZx9JDmkkkbV6PInzG4p8x3VqVJSFiMvnvlEKWuRob/1eaIetR31IYeAbm0NdOuHH8Qi+Rexi1wLlpzIo1gstHWBfZzy1+qHRV5A4TqPp15YzBPm0WSggW6PbSn+F4lf57VCnHF7p8SvzAA2ZZJPYJURt8X7bbg+H3i+PEjH9DXItNEqs2sNcug37xZQDLm7X36nOoGPs54XsEGzPdEV+itQNGUFEjY6X9Uv+Acuks7NpyGvCoKxGwgKgE5XyJ+nNKlyHHOLb6N1NuHyBrZrgtY/JYJHRooo5CEqYKBqdFnmbTVGEkCvJKxLnjwKWf+fEPoWeQFj5ObDjcKMZf2Jm2Ae69x+ikU5gBXsRmoF94GXTLfN0/vLt98KDPnxwAQL9j5V1jGOY8jQl6MLxEs56cwXN0dqCnImzVH3TZT1cJ8SW1BRX6qIVxEzjsSGx3yxF3suAilPMqGRp4ffyopjMD1JXiKR2RwLKzizUe5e8XyGOy9fplzhw3jVzTRyUZTRSZKkMLWcQ/gv0E4aONNqs4P+NameAZYOD12qRkxosQQP5uux6B2nRyZ7sAV54DgFyLiRcq1FvwKw2EPQdk4HDoePrO/RNUbyNddnM/mMgj4FW65xCoT1LmjrIjsv/Ggdlx46ueczhMgtBunx1/w8k8V+l8LVZ8gAT6wkU5J+DPQalQguMg12Jzug3q4TbdHiGCmD9EunCwOmsLuLJkz6EcSYXtrlDEnAM+hicw7iergYLLlMXpfTdGxJCWJmP4zqUFeTTmsmhsjGBt7NiEB/9pFFEB3pSbf4iiUukw63Eo8Aqnf4iwob6X1QviCWuc8t0LUlT9vALgh/f2DPVOOmR0RW6bgRvc7DSF20V/omg+YBw==\"")
|
||||
buildConfigField("String", "GENERIC_SERVER_PUBLIC_PARAMS", "\"AByD873dTilmOSG0TjKrvpeaKEsUmIO8Vx9BeMmftwUs9v7ikPwM8P3OHyT0+X3EUMZrSe9VUp26Wai51Q9I8mdk0hX/yo7CeFGJyzoOqn8e/i4Ygbn5HoAyXJx5eXfIbqpc0bIxzju4H/HOQeOpt6h742qii5u/cbwOhFZCsMIbElZTaeU+BWMBQiZHIGHT5IE0qCordQKZ5iPZom0HeFa8Yq0ShuEyAl0WINBiY6xE3H/9WnvzXBbMuuk//eRxXgzO8ieCeK8FwQNxbfXqZm6Ro1cMhCOF3u7xoX83QhpN\"")
|
||||
buildConfigField("String", "BACKUP_SERVER_PUBLIC_PARAMS", "\"AJwNSU55fsFCbgaxGRD11wO1juAs8Yr5GF8FPlGzzvdJJIKH5/4CC7ZJSOe3yL2vturVaRU2Cx0n751Vt8wkj1bozK3CBV1UokxV09GWf+hdVImLGjXGYLLhnI1J2TWEe7iWHyb553EEnRb5oxr9n3lUbNAJuRmFM7hrr0Al0F0wrDD4S8lo2mGaXe0MJCOM166F8oYRQqpFeEHfiLnxA1O8ZLh7vMdv4g9jI5phpRBTsJ5IjiJrWeP0zdIGHEssUeprDZ9OUJ14m0v61eYJMKsf59Bn+mAT2a7YfB+Don9O\"")
|
||||
@@ -237,7 +238,6 @@ android {
|
||||
buildConfigField("String", "STRIPE_BASE_URL", "\"https://api.stripe.com/v1\"")
|
||||
buildConfigField("String", "STRIPE_PUBLISHABLE_KEY", "\"pk_live_6cmGZopuTsV8novGgJJW9JpC00vLIgtQ1D\"")
|
||||
buildConfigField("boolean", "TRACING_ENABLED", "false")
|
||||
buildConfigField("boolean", "MESSAGE_BACKUP_RESTORE_ENABLED", "true")
|
||||
buildConfigField("boolean", "LINK_DEVICE_UX_ENABLED", "false")
|
||||
|
||||
ndk {
|
||||
@@ -378,7 +378,6 @@ android {
|
||||
buildConfigField("boolean", "MANAGES_APP_UPDATES", "true")
|
||||
buildConfigField("String", "APK_UPDATE_MANIFEST_URL", "\"${apkUpdateManifestUrl}\"")
|
||||
buildConfigField("String", "BUILD_DISTRIBUTION_TYPE", "\"nightly\"")
|
||||
buildConfigField("boolean", "MESSAGE_BACKUP_RESTORE_ENABLED", "true")
|
||||
}
|
||||
|
||||
create("prod") {
|
||||
@@ -405,7 +404,7 @@ android {
|
||||
buildConfigField("String", "SVR2_MRENCLAVE_LEGACY_LEGACY", "\"38e01eff4fe357dc0b0e8ef7a44b4abc5489fbccba3a78780f3872c277f62bf3\"")
|
||||
buildConfigField("String", "SVR2_MRENCLAVE_LEGACY", "\"2e8cefe6e3f389d8426adb24e9b7fb7adf10902c96f06f7bbcee36277711ed91\"")
|
||||
buildConfigField("String", "SVR2_MRENCLAVE", "\"a75542d82da9f6914a1e31f8a7407053b99cc99a0e7291d8fbd394253e19b036\"")
|
||||
buildConfigField("String", "UNIDENTIFIED_SENDER_TRUST_ROOT", "\"BbqY1DzohE4NUZoVF+L18oUPrK3kILllLEJh2UnPSsEx\"")
|
||||
buildConfigField("String[]", "UNIDENTIFIED_SENDER_TRUST_ROOTS", "new String[]{\"BbqY1DzohE4NUZoVF+L18oUPrK3kILllLEJh2UnPSsEx\", \"BYhU6tPjqP46KGZEzRs1OL4U39V5dlPJ/X09ha4rErkm\"}")
|
||||
buildConfigField("String", "ZKGROUP_SERVER_PUBLIC_PARAMS", "\"ABSY21VckQcbSXVNCGRYJcfWHiAMZmpTtTELcDmxgdFbtp/bWsSxZdMKzfCp8rvIs8ocCU3B37fT3r4Mi5qAemeGeR2X+/YmOGR5ofui7tD5mDQfstAI9i+4WpMtIe8KC3wU5w3Inq3uNWVmoGtpKndsNfwJrCg0Hd9zmObhypUnSkfYn2ooMOOnBpfdanRtrvetZUayDMSC5iSRcXKpdlukrpzzsCIvEwjwQlJYVPOQPj4V0F4UXXBdHSLK05uoPBCQG8G9rYIGedYsClJXnbrgGYG3eMTG5hnx4X4ntARBgELuMWWUEEfSK0mjXg+/2lPmWcTZWR9nkqgQQP0tbzuiPm74H2wMO4u1Wafe+UwyIlIT9L7KLS19Aw8r4sPrXZSSsOZ6s7M1+rTJN0bI5CKY2PX29y5Ok3jSWufIKcgKOnWoP67d5b2du2ZVJjpjfibNIHbT/cegy/sBLoFwtHogVYUewANUAXIaMPyCLRArsKhfJ5wBtTminG/PAvuBdJ70Z/bXVPf8TVsR292zQ65xwvWTejROW6AZX6aqucUjlENAErBme1YHmOSpU6tr6doJ66dPzVAWIanmO/5mgjNEDeK7DDqQdB1xd03HT2Qs2TxY3kCK8aAb/0iM0HQiXjxZ9HIgYhbtvGEnDKW5ILSUydqH/KBhW4Pb0jZWnqN/YgbWDKeJxnDbYcUob5ZY5Lt5ZCMKuaGUvCJRrCtuugSMaqjowCGRempsDdJEt+cMaalhZ6gczklJB/IbdwENW9KeVFPoFNFzhxWUIS5ML9riVYhAtE6JE5jX0xiHNVIIPthb458cfA8daR0nYfYAUKogQArm0iBezOO+mPk5vCNWI+wwkyFCqNDXz/qxl1gAntuCJtSfq9OC3NkdhQlgYQ==\"")
|
||||
buildConfigField("String", "GENERIC_SERVER_PUBLIC_PARAMS", "\"AHILOIrFPXX9laLbalbA9+L1CXpSbM/bTJXZGZiuyK1JaI6dK5FHHWL6tWxmHKYAZTSYmElmJ5z2A5YcirjO/yfoemE03FItyaf8W1fE4p14hzb5qnrmfXUSiAIVrhaXVwIwSzH6RL/+EO8jFIjJ/YfExfJ8aBl48CKHgu1+A6kWynhttonvWWx6h7924mIzW0Czj2ROuh4LwQyZypex4GuOPW8sgIT21KNZaafgg+KbV7XM1x1tF3XA17B4uGUaDbDw2O+nR1+U5p6qHPzmJ7ggFjSN6Utu+35dS1sS0P9N\"")
|
||||
buildConfigField("String", "BACKUP_SERVER_PUBLIC_PARAMS", "\"AHYrGb9IfugAAJiPKp+mdXUx+OL9zBolPYHYQz6GI1gWjpEu5me3zVNSvmYY4zWboZHif+HG1sDHSuvwFd0QszSwuSF4X4kRP3fJREdTZ5MCR0n55zUppTwfHRW2S4sdQ0JGz7YDQIJCufYSKh0pGNEHL6hv79Agrdnr4momr3oXdnkpVBIp3HWAQ6IbXQVSG18X36GaicI1vdT0UFmTwU2KTneluC2eyL9c5ff8PcmiS+YcLzh0OKYQXB5ZfQ06d6DiINvDQLy75zcfUOniLAj0lGJiHxGczin/RXisKSR8\"")
|
||||
@@ -417,7 +416,6 @@ android {
|
||||
|
||||
buildConfigField("String", "BUILD_ENVIRONMENT_TYPE", "\"Staging\"")
|
||||
buildConfigField("String", "STRIPE_PUBLISHABLE_KEY", "\"pk_test_sngOd8FnXNkpce9nPXawKrJD00kIDngZkD\"")
|
||||
buildConfigField("boolean", "MESSAGE_BACKUP_RESTORE_ENABLED", "true")
|
||||
}
|
||||
|
||||
create("backup") {
|
||||
@@ -429,7 +427,6 @@ android {
|
||||
|
||||
buildConfigField("boolean", "MANAGES_APP_UPDATES", "true")
|
||||
buildConfigField("String", "BUILD_ENVIRONMENT_TYPE", "\"Backup\"")
|
||||
buildConfigField("boolean", "MESSAGE_BACKUP_RESTORE_ENABLED", "true")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -511,7 +508,6 @@ dependencies {
|
||||
implementation(project(":core-ui"))
|
||||
|
||||
implementation(libs.androidx.fragment.ktx)
|
||||
implementation(libs.androidx.fragment.compose)
|
||||
implementation(libs.androidx.appcompat) {
|
||||
version {
|
||||
strictly("1.6.1")
|
||||
@@ -607,6 +603,7 @@ dependencies {
|
||||
implementation(libs.rxdogtag)
|
||||
implementation(libs.androidx.credentials)
|
||||
implementation(libs.androidx.credentials.compat)
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
|
||||
implementation(project(":billing"))
|
||||
|
||||
|
||||
@@ -21,7 +21,6 @@ import assertk.assertions.isEqualTo
|
||||
import assertk.assertions.isNull
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.every
|
||||
import io.mockk.mockkStatic
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
@@ -43,7 +42,6 @@ import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.testing.CoroutineDispatcherRule
|
||||
import org.thoughtcrime.securesms.testing.InAppPaymentsRule
|
||||
import org.thoughtcrime.securesms.testing.SignalActivityRule
|
||||
import org.thoughtcrime.securesms.util.RemoteConfig
|
||||
import java.math.BigDecimal
|
||||
import java.util.Currency
|
||||
|
||||
@@ -67,9 +65,6 @@ class MessageBackupsCheckoutActivityTest {
|
||||
every { AppDependencies.billingApi.getBillingPurchaseResults() } returns purchaseResults
|
||||
coEvery { AppDependencies.billingApi.queryProduct() } returns BillingProduct(price = FiatMoney(BigDecimal.ONE, Currency.getInstance("USD")))
|
||||
coEvery { AppDependencies.billingApi.launchBillingFlow(any()) } returns Unit
|
||||
|
||||
mockkStatic(RemoteConfig::class)
|
||||
every { RemoteConfig.messageBackups } returns true
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,131 @@
|
||||
package org.thoughtcrime.securesms.database
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.signal.core.util.deleteAll
|
||||
import org.thoughtcrime.securesms.database.model.MessageId
|
||||
import org.thoughtcrime.securesms.mms.IncomingMessage
|
||||
import org.thoughtcrime.securesms.polls.PollOption
|
||||
import org.thoughtcrime.securesms.polls.PollRecord
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.testing.SignalActivityRule
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class PollTablesTest {
|
||||
|
||||
@get:Rule
|
||||
val harness = SignalActivityRule()
|
||||
|
||||
private lateinit var poll1: PollRecord
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
poll1 = PollRecord(
|
||||
id = 1,
|
||||
question = "how do you feel about unit testing?",
|
||||
pollOptions = listOf(
|
||||
PollOption(1, "yay", listOf(1)),
|
||||
PollOption(2, "ok", emptyList()),
|
||||
PollOption(3, "nay", emptyList())
|
||||
),
|
||||
allowMultipleVotes = false,
|
||||
hasEnded = false,
|
||||
authorId = 1,
|
||||
messageId = 1
|
||||
)
|
||||
|
||||
SignalDatabase.polls.writableDatabase.deleteAll(PollTables.PollTable.TABLE_NAME)
|
||||
SignalDatabase.polls.writableDatabase.deleteAll(PollTables.PollOptionTable.TABLE_NAME)
|
||||
SignalDatabase.polls.writableDatabase.deleteAll(PollTables.PollVoteTable.TABLE_NAME)
|
||||
|
||||
val message = IncomingMessage(type = MessageType.NORMAL, from = harness.others[0], sentTimeMillis = 100, serverTimeMillis = 100, receivedTimeMillis = 100)
|
||||
SignalDatabase.messages.insertMessageInbox(message, SignalDatabase.threads.getOrCreateThreadIdFor(harness.others[0], isGroup = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenAPollWithVoting_whenIGetPoll_thenIExpectThatPoll() {
|
||||
SignalDatabase.polls.insertPoll("how do you feel about unit testing?", false, listOf("yay", "ok", "nay"), 1, 1)
|
||||
SignalDatabase.polls.insertVotes(pollId = 1, pollOptionIds = listOf(1), voterId = 1, voteCount = 1, messageId = MessageId(1))
|
||||
|
||||
assertEquals(poll1, SignalDatabase.polls.getPoll(1))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenAPoll_whenIGetItsOptionIds_thenIExpectAllOptionsIds() {
|
||||
SignalDatabase.polls.insertPoll("how do you feel about unit testing?", false, listOf("yay", "ok", "nay"), 1, 1)
|
||||
assertEquals(poll1.pollOptions.map { it.id }, SignalDatabase.polls.getPollOptionIds(1))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenAPollAndVoter_whenIGetItsVoteCount_thenIExpectTheCorrectVoterCount() {
|
||||
SignalDatabase.polls.insertPoll("how do you feel about unit testing?", false, listOf("yay", "ok", "nay"), 1, 1)
|
||||
SignalDatabase.polls.insertVotes(pollId = 1, pollOptionIds = listOf(1), voterId = 1, voteCount = 1, messageId = MessageId(1))
|
||||
SignalDatabase.polls.insertVotes(pollId = 1, pollOptionIds = listOf(2), voterId = 2, voteCount = 2, messageId = MessageId(1))
|
||||
SignalDatabase.polls.insertVotes(pollId = 1, pollOptionIds = listOf(3), voterId = 3, voteCount = 3, messageId = MessageId(1))
|
||||
|
||||
assertEquals(1, SignalDatabase.polls.getCurrentPollVoteCount(1, 1))
|
||||
assertEquals(2, SignalDatabase.polls.getCurrentPollVoteCount(1, 2))
|
||||
assertEquals(3, SignalDatabase.polls.getCurrentPollVoteCount(1, 3))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenMultipleRoundsOfVoting_whenIGetItsCount_thenIExpectTheMostRecentResults() {
|
||||
SignalDatabase.polls.insertPoll("how do you feel about unit testing?", false, listOf("yay", "ok", "nay"), 1, 1)
|
||||
SignalDatabase.polls.insertVotes(pollId = 1, pollOptionIds = listOf(2), voterId = 1, voteCount = 1, messageId = MessageId(1))
|
||||
SignalDatabase.polls.insertVotes(pollId = 1, pollOptionIds = listOf(3), voterId = 1, voteCount = 2, messageId = MessageId(1))
|
||||
SignalDatabase.polls.insertVotes(pollId = 1, pollOptionIds = listOf(1), voterId = 1, voteCount = 3, messageId = MessageId(1))
|
||||
|
||||
assertEquals(poll1, SignalDatabase.polls.getPoll(1))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenAPoll_whenITerminateIt_thenIExpectItToEnd() {
|
||||
SignalDatabase.polls.insertPoll("how do you feel about unit testing?", false, listOf("yay", "ok", "nay"), 1, 1)
|
||||
SignalDatabase.polls.endPoll(1, System.currentTimeMillis())
|
||||
|
||||
assertEquals(true, SignalDatabase.polls.getPoll(1)!!.hasEnded)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenAPoll_whenIIVote_thenIExpectThatVote() {
|
||||
SignalDatabase.polls.insertPoll("how do you feel about unit testing?", false, listOf("yay", "ok", "nay"), 1, 1)
|
||||
val poll = SignalDatabase.polls.getPoll(1)!!
|
||||
val pollOption = poll.pollOptions.first()
|
||||
|
||||
val voteCount = SignalDatabase.polls.insertVote(poll, pollOption)
|
||||
|
||||
assertEquals(1, voteCount)
|
||||
assertEquals(listOf(0), SignalDatabase.polls.getVotes(poll.id, false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenAPoll_whenIRemoveVote_thenVoteIsCleared() {
|
||||
SignalDatabase.polls.insertPoll("how do you feel about unit testing?", false, listOf("yay", "ok", "nay"), 1, 1)
|
||||
val poll = SignalDatabase.polls.getPoll(1)!!
|
||||
val pollOption = poll.pollOptions.first()
|
||||
|
||||
val voteCount = SignalDatabase.polls.removeVote(poll, pollOption)
|
||||
SignalDatabase.polls.markPendingAsRemoved(poll.id, Recipient.self().id.toLong(), voteCount, 1)
|
||||
|
||||
assertEquals(1, voteCount)
|
||||
val status = SignalDatabase.polls.getPollVoteStateForGivenVote(poll.id, voteCount)
|
||||
assertEquals(PollTables.VoteState.REMOVED, status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenAVote_whenISetPollOptionId_thenOptionIdIsUpdated() {
|
||||
SignalDatabase.polls.insertPoll("how do you feel about unit testing?", false, listOf("yay", "ok", "nay"), 1, 1)
|
||||
val poll = SignalDatabase.polls.getPoll(1)!!
|
||||
val option = poll.pollOptions.first()
|
||||
|
||||
SignalDatabase.polls.insertVotes(poll.id, listOf(option.id), Recipient.self().id.toLong(), 5, MessageId(1))
|
||||
SignalDatabase.polls.setPollVoteStateForGivenVote(poll.id, Recipient.self().id.toLong(), 5, 1, true)
|
||||
val status = SignalDatabase.polls.getPollVoteStateForGivenVote(poll.id, 5)
|
||||
|
||||
assertEquals(PollTables.VoteState.ADDED, status)
|
||||
}
|
||||
}
|
||||
@@ -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,329 @@
|
||||
package org.thoughtcrime.securesms.messages
|
||||
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.isEqualTo
|
||||
import io.mockk.every
|
||||
import io.mockk.mockkStatic
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.thoughtcrime.securesms.database.MessageTable
|
||||
import org.thoughtcrime.securesms.database.MessageType
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.model.MessageId
|
||||
import org.thoughtcrime.securesms.groups.GroupId
|
||||
import org.thoughtcrime.securesms.mms.IncomingMessage
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.testing.GroupTestingUtils
|
||||
import org.thoughtcrime.securesms.testing.GroupTestingUtils.asMember
|
||||
import org.thoughtcrime.securesms.testing.MessageContentFuzzer
|
||||
import org.thoughtcrime.securesms.testing.SignalActivityRule
|
||||
import org.thoughtcrime.securesms.util.RemoteConfig
|
||||
import org.whispersystems.signalservice.api.crypto.EnvelopeMetadata
|
||||
import org.whispersystems.signalservice.internal.push.DataMessage
|
||||
|
||||
@Suppress("ClassName")
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class DataMessageProcessorTest_polls {
|
||||
|
||||
@get:Rule
|
||||
val harness = SignalActivityRule(createGroup = true)
|
||||
|
||||
private lateinit var alice: Recipient
|
||||
private lateinit var bob: Recipient
|
||||
private lateinit var charlie: Recipient
|
||||
private lateinit var groupId: GroupId.V2
|
||||
private lateinit var groupRecipientId: RecipientId
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
mockkStatic(RemoteConfig::class)
|
||||
|
||||
every { RemoteConfig.receivePolls } returns true
|
||||
|
||||
alice = Recipient.resolved(harness.others[0])
|
||||
bob = Recipient.resolved(harness.others[1])
|
||||
charlie = Recipient.resolved(harness.others[2])
|
||||
|
||||
val groupInfo = GroupTestingUtils.insertGroup(revision = 0, harness.self.asMember(), alice.asMember(), bob.asMember())
|
||||
groupId = groupInfo.groupId
|
||||
groupRecipientId = groupInfo.recipientId
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handlePollCreate_whenIHaveAValidPollProto_createPoll() {
|
||||
val insertResult = handlePollCreate(
|
||||
pollCreate = DataMessage.PollCreate(question = "question?", options = listOf("a", "b", "c"), allowMultiple = false),
|
||||
senderRecipient = alice,
|
||||
threadRecipient = Recipient.resolved(groupRecipientId),
|
||||
groupId = groupId
|
||||
)
|
||||
|
||||
assert(insertResult != null)
|
||||
val poll = SignalDatabase.polls.getPoll(insertResult!!.messageId)
|
||||
assert(poll != null)
|
||||
assertThat(poll!!.question).isEqualTo("question?")
|
||||
assertThat(poll.pollOptions.size).isEqualTo(3)
|
||||
assertThat(poll.allowMultipleVotes).isEqualTo(false)
|
||||
assertThat(poll.hasEnded).isEqualTo(false)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handlePollCreate_whenSenderIsNotInGroup_dropMessage() {
|
||||
val insertResult = handlePollCreate(
|
||||
pollCreate = DataMessage.PollCreate(question = "question?", options = listOf("a", "b", "c"), allowMultiple = false),
|
||||
senderRecipient = charlie,
|
||||
threadRecipient = Recipient.resolved(groupRecipientId),
|
||||
groupId = groupId
|
||||
)
|
||||
|
||||
assert(insertResult == null)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handlePollCreate_whenTargetRecipientIsNotAGroup_dropMessage() {
|
||||
val insertResult = handlePollCreate(
|
||||
pollCreate = DataMessage.PollCreate(question = "question?", options = listOf("a", "b", "c"), allowMultiple = false),
|
||||
senderRecipient = alice,
|
||||
threadRecipient = bob,
|
||||
groupId = null
|
||||
)
|
||||
|
||||
assert(insertResult == null)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handlePollTerminate_whenIHaveValidProto_endPoll() {
|
||||
val pollMessageId = insertPoll()
|
||||
|
||||
val insertResult = DataMessageProcessor.handlePollTerminate(
|
||||
context = ApplicationProvider.getApplicationContext(),
|
||||
envelope = MessageContentFuzzer.envelope(200),
|
||||
message = DataMessage(pollTerminate = DataMessage.PollTerminate(targetSentTimestamp = 100)),
|
||||
senderRecipient = alice,
|
||||
metadata = EnvelopeMetadata(alice.requireServiceId(), null, 1, false, null, harness.self.requireServiceId()),
|
||||
threadRecipient = bob,
|
||||
groupId = groupId,
|
||||
receivedTime = 200
|
||||
)
|
||||
|
||||
assert(insertResult?.messageId != null)
|
||||
val poll = SignalDatabase.polls.getPoll(pollMessageId)
|
||||
assert(poll != null)
|
||||
assert(poll!!.hasEnded)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handlePollTerminate_whenIHaveDifferentTimestamp_dropMessage() {
|
||||
insertPoll()
|
||||
|
||||
val insertResult = DataMessageProcessor.handlePollTerminate(
|
||||
context = ApplicationProvider.getApplicationContext(),
|
||||
envelope = MessageContentFuzzer.envelope(200),
|
||||
message = DataMessage(pollTerminate = DataMessage.PollTerminate(200)),
|
||||
senderRecipient = alice,
|
||||
metadata = EnvelopeMetadata(alice.requireServiceId(), null, 1, false, null, harness.self.requireServiceId()),
|
||||
threadRecipient = bob,
|
||||
groupId = groupId,
|
||||
receivedTime = 200
|
||||
)
|
||||
|
||||
assert(insertResult == null)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handlePollTerminate_whenMessageIsNotFromCreatorOfPoll_dropMessage() {
|
||||
insertPoll()
|
||||
|
||||
val insertResult = DataMessageProcessor.handlePollTerminate(
|
||||
context = ApplicationProvider.getApplicationContext(),
|
||||
envelope = MessageContentFuzzer.envelope(200),
|
||||
message = DataMessage(pollTerminate = DataMessage.PollTerminate(100)),
|
||||
senderRecipient = bob,
|
||||
metadata = EnvelopeMetadata(alice.requireServiceId(), null, 1, false, null, harness.self.requireServiceId()),
|
||||
threadRecipient = bob,
|
||||
groupId = groupId,
|
||||
receivedTime = 200
|
||||
)
|
||||
|
||||
assert(insertResult == null)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handlePollTerminate_whenPollDoesNotExist_dropMessage() {
|
||||
val insertResult = DataMessageProcessor.handlePollTerminate(
|
||||
context = ApplicationProvider.getApplicationContext(),
|
||||
envelope = MessageContentFuzzer.envelope(200),
|
||||
message = DataMessage(pollTerminate = DataMessage.PollTerminate(100)),
|
||||
senderRecipient = alice,
|
||||
metadata = EnvelopeMetadata(alice.requireServiceId(), null, 1, false, null, harness.self.requireServiceId()),
|
||||
threadRecipient = bob,
|
||||
groupId = groupId,
|
||||
receivedTime = 200
|
||||
)
|
||||
|
||||
assert(insertResult == null)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handlePollVote_whenValidPollVote_processVote() {
|
||||
insertPoll()
|
||||
|
||||
val messageId = handlePollVote(
|
||||
DataMessage.PollVote(
|
||||
targetAuthorAciBinary = alice.asMember().aciBytes,
|
||||
targetSentTimestamp = 100,
|
||||
optionIndexes = listOf(0),
|
||||
voteCount = 1
|
||||
),
|
||||
bob
|
||||
)
|
||||
|
||||
assert(messageId != null)
|
||||
assertThat(messageId!!.id).isEqualTo(1)
|
||||
val poll = SignalDatabase.polls.getPoll(messageId.id)
|
||||
assert(poll != null)
|
||||
assertThat(poll!!.pollOptions[0].voterIds).isEqualTo(listOf(bob.id.toLong()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handlePollVote_whenMultipleVoteAllowed_processAllVote() {
|
||||
insertPoll()
|
||||
|
||||
val messageId = handlePollVote(
|
||||
DataMessage.PollVote(
|
||||
targetAuthorAciBinary = alice.asMember().aciBytes,
|
||||
targetSentTimestamp = 100,
|
||||
optionIndexes = listOf(0, 1, 2),
|
||||
voteCount = 1
|
||||
),
|
||||
bob
|
||||
)
|
||||
|
||||
assert(messageId != null)
|
||||
val poll = SignalDatabase.polls.getPoll(messageId!!.id)
|
||||
assert(poll != null)
|
||||
assertThat(poll!!.pollOptions[0].voterIds).isEqualTo(listOf(bob.id.toLong()))
|
||||
assertThat(poll.pollOptions[1].voterIds).isEqualTo(listOf(bob.id.toLong()))
|
||||
assertThat(poll.pollOptions[2].voterIds).isEqualTo(listOf(bob.id.toLong()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handlePollVote_whenMultipleVoteSentToSingleVotePolls_dropMessage() {
|
||||
insertPoll(false)
|
||||
|
||||
val messageId = handlePollVote(
|
||||
DataMessage.PollVote(
|
||||
targetAuthorAciBinary = alice.asMember().aciBytes,
|
||||
targetSentTimestamp = 100,
|
||||
optionIndexes = listOf(0, 1, 2),
|
||||
voteCount = 1
|
||||
),
|
||||
bob
|
||||
)
|
||||
|
||||
assert(messageId == null)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handlePollVote_whenVoteCountIsNotHigher_dropMessage() {
|
||||
insertPoll()
|
||||
|
||||
val messageId = handlePollVote(
|
||||
DataMessage.PollVote(
|
||||
targetAuthorAciBinary = alice.asMember().aciBytes,
|
||||
targetSentTimestamp = 100,
|
||||
optionIndexes = listOf(0, 1, 2),
|
||||
voteCount = -1
|
||||
),
|
||||
bob
|
||||
)
|
||||
|
||||
assert(messageId == null)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handlePollVote_whenVoteOptionDoesNotExist_dropMessage() {
|
||||
insertPoll()
|
||||
|
||||
val messageId = handlePollVote(
|
||||
DataMessage.PollVote(
|
||||
targetAuthorAciBinary = alice.asMember().aciBytes,
|
||||
targetSentTimestamp = 100,
|
||||
optionIndexes = listOf(5),
|
||||
voteCount = 1
|
||||
),
|
||||
bob
|
||||
)
|
||||
|
||||
assert(messageId == null)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handlePollVote_whenVoterNotInGroup_dropMessage() {
|
||||
insertPoll()
|
||||
|
||||
val messageId = handlePollVote(
|
||||
DataMessage.PollVote(
|
||||
targetAuthorAciBinary = alice.asMember().aciBytes,
|
||||
targetSentTimestamp = 100,
|
||||
optionIndexes = listOf(0, 1, 2),
|
||||
voteCount = 1
|
||||
|
||||
),
|
||||
charlie
|
||||
)
|
||||
|
||||
assert(messageId == null)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handlePollVote_whenPollDoesNotExist_dropMessage() {
|
||||
val messageId = handlePollVote(
|
||||
DataMessage.PollVote(
|
||||
targetAuthorAciBinary = alice.asMember().aciBytes,
|
||||
targetSentTimestamp = 100,
|
||||
optionIndexes = listOf(0, 1, 2),
|
||||
voteCount = 1
|
||||
),
|
||||
bob
|
||||
)
|
||||
|
||||
assert(messageId == null)
|
||||
}
|
||||
|
||||
private fun handlePollCreate(pollCreate: DataMessage.PollCreate, senderRecipient: Recipient, threadRecipient: Recipient, groupId: GroupId.V2?): MessageTable.InsertResult? {
|
||||
return DataMessageProcessor.handlePollCreate(
|
||||
envelope = MessageContentFuzzer.envelope(100),
|
||||
message = DataMessage(pollCreate = pollCreate),
|
||||
senderRecipient = senderRecipient,
|
||||
threadRecipient = threadRecipient,
|
||||
groupId = groupId,
|
||||
receivedTime = 0,
|
||||
context = ApplicationProvider.getApplicationContext(),
|
||||
metadata = EnvelopeMetadata(alice.requireServiceId(), null, 1, false, null, harness.self.requireServiceId())
|
||||
)
|
||||
}
|
||||
|
||||
private fun handlePollVote(pollVote: DataMessage.PollVote, senderRecipient: Recipient): MessageId? {
|
||||
return DataMessageProcessor.handlePollVote(
|
||||
context = ApplicationProvider.getApplicationContext(),
|
||||
envelope = MessageContentFuzzer.envelope(100),
|
||||
message = DataMessage(pollVote = pollVote),
|
||||
senderRecipient = senderRecipient,
|
||||
earlyMessageCacheEntry = null
|
||||
)
|
||||
}
|
||||
|
||||
private fun insertPoll(allowMultiple: Boolean = true): Long {
|
||||
val envelope = MessageContentFuzzer.envelope(100)
|
||||
val pollMessage = IncomingMessage(type = MessageType.NORMAL, from = alice.id, sentTimeMillis = envelope.timestamp!!, serverTimeMillis = envelope.serverTimestamp!!, receivedTimeMillis = 0, groupId = groupId)
|
||||
val messageId = SignalDatabase.messages.insertMessageInbox(pollMessage).get()
|
||||
SignalDatabase.polls.insertPoll("question?", allowMultiple, listOf("a", "b", "c"), alice.id.toLong(), messageId.messageId)
|
||||
return messageId.messageId
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -20,8 +20,10 @@ import org.thoughtcrime.securesms.crypto.MasterSecretUtil
|
||||
import org.thoughtcrime.securesms.crypto.ProfileKeyUtil
|
||||
import org.thoughtcrime.securesms.database.IdentityTable
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.RestoreDecisionState
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.dependencies.InstrumentationApplicationDependencyProvider
|
||||
import org.thoughtcrime.securesms.keyvalue.NewAccount
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.profiles.ProfileName
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
@@ -125,6 +127,7 @@ class SignalActivityRule(private val othersCount: Int = 4, private val createGro
|
||||
SignalDatabase.recipients.setProfileName(Recipient.self().id, ProfileName.fromParts("Tester", "McTesterson"))
|
||||
|
||||
SignalStore.settings.isMessageNotificationsEnabled = false
|
||||
SignalStore.registration.restoreDecisionState = RestoreDecisionState.NewAccount
|
||||
|
||||
return Recipient.self()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -697,6 +697,11 @@
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
|
||||
android:exported="false"/>
|
||||
|
||||
<activity
|
||||
android:name=".conversation.NewConversationActivityV2"
|
||||
android:exported="false"
|
||||
android:theme="@style/Signal.DayNight.NoActionBar" />
|
||||
|
||||
<activity android:name=".recipients.ui.findby.FindByActivity"
|
||||
android:theme="@style/Theme.Signal.DayNight.NoActionBar"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
@@ -843,19 +848,12 @@
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
|
||||
android:exported="false"/>
|
||||
|
||||
<activity android:name=".registrationv3.olddevice.TransferAccountActivity"
|
||||
<activity android:name=".registration.olddevice.TransferAccountActivity"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
|
||||
android:theme="@style/Theme.Signal.DayNight.NoActionBar"
|
||||
android:exported="false"/>
|
||||
|
||||
<activity android:name=".registration.ui.RegistrationActivity"
|
||||
android:launchMode="singleTask"
|
||||
android:theme="@style/Theme.Signal.DayNight.NoActionBar"
|
||||
android:windowSoftInputMode="stateHidden"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
|
||||
android:exported="false"/>
|
||||
|
||||
<activity android:name=".registrationv3.ui.RegistrationActivity"
|
||||
android:launchMode="singleTask"
|
||||
android:theme="@style/Theme.Signal.DayNight.NoActionBar"
|
||||
android:windowSoftInputMode="stateHidden|adjustResize"
|
||||
@@ -930,17 +928,7 @@
|
||||
android:theme="@style/TextSecure.DialogActivity"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
|
||||
|
||||
<activity android:name=".SmsSendtoActivity" android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SENDTO" />
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="sms" />
|
||||
<data android:scheme="smsto" />
|
||||
<data android:scheme="mms" />
|
||||
<data android:scheme="mmsto" />
|
||||
</intent-filter>
|
||||
<activity android:name=".SystemContactsEntrypointActivity" android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
@@ -991,7 +979,7 @@
|
||||
android:windowSoftInputMode="stateVisible|adjustResize"
|
||||
android:exported="false"/>
|
||||
|
||||
<activity android:name=".registrationv3.ui.restore.RemoteRestoreActivity"
|
||||
<activity android:name=".registration.ui.restore.RemoteRestoreActivity"
|
||||
android:theme="@style/Signal.DayNight.NoActionBar"
|
||||
android:exported="false"/>
|
||||
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,16 +138,16 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
private TextView headerActionView;
|
||||
private ContactSearchMediator contactSearchMediator;
|
||||
|
||||
@Nullable private NewConversationCallback newConversationCallback;
|
||||
@Nullable private FindByCallback findByCallback;
|
||||
@Nullable private NewCallCallback newCallCallback;
|
||||
@Nullable private ScrollCallback scrollCallback;
|
||||
@Nullable private OnItemLongClickListener onItemLongClickListener;
|
||||
private SelectionLimits selectionLimit = SelectionLimits.NO_LIMITS;
|
||||
private Set<RecipientId> currentSelection;
|
||||
private boolean isMulti;
|
||||
private boolean canSelectSelf;
|
||||
private boolean resetPositionOnCommit = false;
|
||||
@Nullable private NewConversationCallback newConversationCallback;
|
||||
@Nullable private FindByCallback findByCallback;
|
||||
@Nullable private NewCallCallback newCallCallback;
|
||||
@Nullable private ScrollCallback scrollCallback;
|
||||
@Nullable private OnItemLongClickListener onItemLongClickListener;
|
||||
private SelectionLimits selectionLimit = SelectionLimits.NO_LIMITS;
|
||||
private Set<RecipientId> currentSelection;
|
||||
private boolean isMulti;
|
||||
private boolean canSelectSelf;
|
||||
private boolean resetPositionOnCommit = false;
|
||||
|
||||
private ListClickListener listClickListener = new ListClickListener();
|
||||
@Nullable private SwipeRefreshLayout.OnRefreshListener onRefreshListener;
|
||||
@@ -161,7 +161,7 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
}
|
||||
|
||||
if (context instanceof FindByCallback) {
|
||||
findByCallback = (FindByCallback) context;
|
||||
showFindByUsernameAndPhoneOptions((FindByCallback) context);
|
||||
}
|
||||
|
||||
if (context instanceof NewCallCallback) {
|
||||
@@ -177,11 +177,11 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
}
|
||||
|
||||
if (getParentFragment() instanceof OnContactSelectedListener) {
|
||||
onContactSelectedListener = (OnContactSelectedListener) getParentFragment();
|
||||
setOnContactSelectedListener((OnContactSelectedListener) getParentFragment());
|
||||
}
|
||||
|
||||
if (context instanceof OnContactSelectedListener) {
|
||||
onContactSelectedListener = (OnContactSelectedListener) context;
|
||||
setOnContactSelectedListener((OnContactSelectedListener) context);
|
||||
}
|
||||
|
||||
if (context instanceof OnSelectionLimitReachedListener) {
|
||||
@@ -209,6 +209,14 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
}
|
||||
}
|
||||
|
||||
public void showFindByUsernameAndPhoneOptions(@Nullable FindByCallback callback) {
|
||||
this.findByCallback = callback;
|
||||
}
|
||||
|
||||
public void setOnContactSelectedListener(@Nullable OnContactSelectedListener listener) {
|
||||
this.onContactSelectedListener = listener;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onActivityCreated(Bundle icicle) {
|
||||
super.onActivityCreated(icicle);
|
||||
@@ -221,7 +229,7 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
super.onStart();
|
||||
|
||||
if (hasContactsPermissions(requireContext()) && !TextSecurePreferences.hasSuccessfullyRetrievedDirectory(getActivity())) {
|
||||
handleContactPermissionGranted();
|
||||
handleContactPermissionGranted();
|
||||
} else {
|
||||
requireActivity().getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_HIDDEN);
|
||||
contactSearchMediator.refresh();
|
||||
@@ -232,13 +240,13 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
|
||||
View view = inflater.inflate(R.layout.contact_selection_list_fragment, container, false);
|
||||
|
||||
emptyText = view.findViewById(android.R.id.empty);
|
||||
recyclerView = view.findViewById(R.id.recycler_view);
|
||||
swipeRefresh = view.findViewById(R.id.swipe_refresh);
|
||||
fastScroller = view.findViewById(R.id.fast_scroller);
|
||||
chipRecycler = view.findViewById(R.id.chipRecycler);
|
||||
constraintLayout = view.findViewById(R.id.container);
|
||||
headerActionView = view.findViewById(R.id.header_action);
|
||||
emptyText = view.findViewById(android.R.id.empty);
|
||||
recyclerView = view.findViewById(R.id.recycler_view);
|
||||
swipeRefresh = view.findViewById(R.id.swipe_refresh);
|
||||
fastScroller = view.findViewById(R.id.fast_scroller);
|
||||
chipRecycler = view.findViewById(R.id.chipRecycler);
|
||||
constraintLayout = view.findViewById(R.id.container);
|
||||
headerActionView = view.findViewById(R.id.header_action);
|
||||
|
||||
final LinearLayoutManager layoutManager = new LinearLayoutManager(requireContext());
|
||||
|
||||
@@ -441,7 +449,7 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
@Override
|
||||
public void onDestroyView() {
|
||||
super.onDestroyView();
|
||||
constraintLayout = null;
|
||||
constraintLayout = null;
|
||||
onRefreshListener = null;
|
||||
}
|
||||
|
||||
@@ -723,7 +731,7 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
|
||||
SimpleTask.run(getViewLifecycleOwner().getLifecycle(), () -> {
|
||||
return UsernameRepository.fetchAciForUsername(UsernameUtil.sanitizeUsernameFromSearch(username));
|
||||
}, result -> {
|
||||
}, result -> {
|
||||
loadingDialog.dismiss();
|
||||
|
||||
// TODO Could be more specific with errors
|
||||
@@ -756,10 +764,10 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
selectedContact.getNumber(),
|
||||
Optional.empty(),
|
||||
allowed -> {
|
||||
if (allowed) {
|
||||
markContactSelected(selectedContact);
|
||||
}
|
||||
});
|
||||
if (allowed) {
|
||||
markContactSelected(selectedContact);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
markContactSelected(selectedContact);
|
||||
}
|
||||
@@ -913,9 +921,10 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
builder.setQuery(contactSearchState.getQuery());
|
||||
|
||||
if ((newConversationCallback != null || findByCallback != null) &&
|
||||
!hasContactsPermissions(requireContext()) &&
|
||||
!hasContactsPermissions(requireContext()) &&
|
||||
!SignalStore.uiHints().getDismissedContactsPermissionBanner() &&
|
||||
!hasQuery) {
|
||||
!hasQuery)
|
||||
{
|
||||
builder.arbitrary(ContactSelectionListAdapter.ArbitraryRepository.ArbitraryRow.FIND_CONTACTS_BANNER.getCode());
|
||||
}
|
||||
|
||||
|
||||
@@ -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,8 @@ import org.thoughtcrime.securesms.devicetransfer.olddevice.OldDeviceExitActivity
|
||||
import org.thoughtcrime.securesms.groups.ui.creategroup.CreateGroupActivity
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.lock.v2.CreateSvrPinActivity
|
||||
import org.thoughtcrime.securesms.main.DetailsScreenNavHost
|
||||
import org.thoughtcrime.securesms.main.InsetsViewModelUpdater
|
||||
import org.thoughtcrime.securesms.main.MainBottomChrome
|
||||
import org.thoughtcrime.securesms.main.MainBottomChromeCallback
|
||||
import org.thoughtcrime.securesms.main.MainBottomChromeState
|
||||
@@ -118,6 +121,13 @@ import org.thoughtcrime.securesms.main.MainToolbarViewModel
|
||||
import org.thoughtcrime.securesms.main.Material3OnScrollHelperBinder
|
||||
import org.thoughtcrime.securesms.main.NavigationBarSpacerCompat
|
||||
import org.thoughtcrime.securesms.main.SnackbarState
|
||||
import org.thoughtcrime.securesms.main.callNavGraphBuilder
|
||||
import org.thoughtcrime.securesms.main.chatNavGraphBuilder
|
||||
import org.thoughtcrime.securesms.main.navigateToDetailLocation
|
||||
import org.thoughtcrime.securesms.main.rememberDetailNavHostController
|
||||
import org.thoughtcrime.securesms.main.rememberFocusRequester
|
||||
import org.thoughtcrime.securesms.main.rememberMainNavigationDetailLocation
|
||||
import org.thoughtcrime.securesms.main.storiesNavGraphBuilder
|
||||
import org.thoughtcrime.securesms.mediasend.camerax.CameraXUtil
|
||||
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionActivity
|
||||
import org.thoughtcrime.securesms.megaphone.Megaphone
|
||||
@@ -148,8 +158,9 @@ import org.thoughtcrime.securesms.util.Util
|
||||
import org.thoughtcrime.securesms.util.viewModel
|
||||
import org.thoughtcrime.securesms.window.AppPaneDragHandle
|
||||
import org.thoughtcrime.securesms.window.AppScaffold
|
||||
import org.thoughtcrime.securesms.window.AppScaffoldNavigator
|
||||
import org.thoughtcrime.securesms.window.WindowSizeClass
|
||||
import org.thoughtcrime.securesms.window.rememberAppScaffoldNavigator
|
||||
import org.thoughtcrime.securesms.window.rememberThreePaneScaffoldNavigatorDelegate
|
||||
import org.whispersystems.signalservice.api.websocket.WebSocketConnectionState
|
||||
|
||||
class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner, MainNavigator.NavigatorProvider, Material3OnScrollHelperBinder, ConversationListFragment.Callback, CallLogFragment.Callback {
|
||||
@@ -297,7 +308,6 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
|
||||
val mainToolbarState by toolbarViewModel.state.collectAsStateWithLifecycle()
|
||||
val megaphone by mainNavigationViewModel.megaphone.collectAsStateWithLifecycle()
|
||||
val mainNavigationState by mainNavigationViewModel.mainNavigationState.collectAsStateWithLifecycle()
|
||||
val mainNavigationDetailLocation by mainNavigationViewModel.detailLocation.collectAsStateWithLifecycle()
|
||||
|
||||
LaunchedEffect(mainNavigationState.currentListLocation) {
|
||||
when (mainNavigationState.currentListLocation) {
|
||||
@@ -308,8 +318,12 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
|
||||
}
|
||||
}
|
||||
|
||||
val isNavigationVisible = remember(mainToolbarState.mode) {
|
||||
mainToolbarState.mode == MainToolbarMode.FULL
|
||||
val isNavigationVisible = mainToolbarState.mode == MainToolbarMode.FULL
|
||||
val isBackHandlerEnabled = mainToolbarState.destination != MainNavigationListLocation.CHATS
|
||||
|
||||
BackHandler(enabled = isBackHandlerEnabled) {
|
||||
mainNavigationViewModel.setFocusedPane(ThreePaneScaffoldRole.Secondary)
|
||||
mainNavigationViewModel.goTo(MainNavigationListLocation.CHATS)
|
||||
}
|
||||
|
||||
val mainBottomChromeState = remember(mainToolbarState.destination, snackbar, mainToolbarState.mode, megaphone) {
|
||||
@@ -329,8 +343,79 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
|
||||
|
||||
MainContainer {
|
||||
val wrappedNavigator = rememberNavigator(windowSizeClass, contentLayoutData, maxWidth)
|
||||
val paneExpansionState = rememberPaneExpansionState()
|
||||
val listPaneWidth = contentLayoutData.rememberDefaultPanePreferredWidth(maxWidth)
|
||||
val halfPartitionWidth = contentLayoutData.partitionWidth / 2
|
||||
|
||||
val detailOffset = if (mainToolbarState.mode == MainToolbarMode.SEARCH || mainToolbarState.mode == MainToolbarMode.ACTION_MODE) 0.dp else 72.dp
|
||||
val detailOnlyAnchor = PaneExpansionAnchor.Offset.fromStart(detailOffset + contentLayoutData.listPaddingStart + halfPartitionWidth)
|
||||
val detailAndListAnchor = PaneExpansionAnchor.Offset.fromStart(listPaneWidth + halfPartitionWidth)
|
||||
val listOnlyAnchor = PaneExpansionAnchor.Offset.fromEnd(contentLayoutData.detailPaddingEnd - halfPartitionWidth)
|
||||
|
||||
val paneExpansionState = rememberPaneExpansionState(
|
||||
anchors = listOf(detailOnlyAnchor, detailAndListAnchor, listOnlyAnchor)
|
||||
)
|
||||
|
||||
val mutableInteractionSource = remember { MutableInteractionSource() }
|
||||
val mainNavigationDetailLocation by rememberMainNavigationDetailLocation(mainNavigationViewModel)
|
||||
|
||||
val chatsNavHostController = rememberDetailNavHostController(
|
||||
onRequestFocus = rememberFocusRequester(
|
||||
mainNavigationViewModel = mainNavigationViewModel,
|
||||
currentListLocation = mainNavigationState.currentListLocation,
|
||||
isTargetListLocation = { it in listOf(MainNavigationListLocation.CHATS, MainNavigationListLocation.ARCHIVE) }
|
||||
)
|
||||
) {
|
||||
chatNavGraphBuilder()
|
||||
}
|
||||
|
||||
val callsNavHostController = rememberDetailNavHostController(
|
||||
onRequestFocus = rememberFocusRequester(
|
||||
mainNavigationViewModel = mainNavigationViewModel,
|
||||
currentListLocation = mainNavigationState.currentListLocation
|
||||
) { it == MainNavigationListLocation.CALLS }
|
||||
) {
|
||||
callNavGraphBuilder(it)
|
||||
}
|
||||
|
||||
val storiesNavHostController = rememberDetailNavHostController(
|
||||
onRequestFocus = rememberFocusRequester(
|
||||
mainNavigationViewModel = mainNavigationViewModel,
|
||||
currentListLocation = mainNavigationState.currentListLocation
|
||||
) { it == MainNavigationListLocation.STORIES }
|
||||
) {
|
||||
storiesNavGraphBuilder()
|
||||
}
|
||||
|
||||
LaunchedEffect(mainNavigationDetailLocation) {
|
||||
mainNavigationViewModel.clearEarlyDetailLocation()
|
||||
when (mainNavigationDetailLocation) {
|
||||
is MainNavigationDetailLocation.Empty -> {
|
||||
when (mainNavigationState.currentListLocation) {
|
||||
MainNavigationListLocation.CHATS, MainNavigationListLocation.ARCHIVE -> chatsNavHostController
|
||||
MainNavigationListLocation.CALLS -> callsNavHostController
|
||||
MainNavigationListLocation.STORIES -> storiesNavHostController
|
||||
}.navigateToDetailLocation(mainNavigationDetailLocation)
|
||||
}
|
||||
|
||||
is MainNavigationDetailLocation.Chats -> chatsNavHostController.navigateToDetailLocation(mainNavigationDetailLocation)
|
||||
is MainNavigationDetailLocation.Calls -> callsNavHostController.navigateToDetailLocation(mainNavigationDetailLocation)
|
||||
is MainNavigationDetailLocation.Stories -> storiesNavHostController.navigateToDetailLocation(mainNavigationDetailLocation)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(mainNavigationDetailLocation) {
|
||||
if (paneExpansionState.currentAnchor == listOnlyAnchor && wrappedNavigator.currentDestination?.pane == ThreePaneScaffoldRole.Primary) {
|
||||
paneExpansionState.animateTo(detailOnlyAnchor)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(mainNavigationState.currentListLocation) {
|
||||
if (paneExpansionState.currentAnchor == detailOnlyAnchor && wrappedNavigator.currentDestination?.pane == ThreePaneScaffoldRole.Secondary) {
|
||||
paneExpansionState.animateTo(listOnlyAnchor)
|
||||
}
|
||||
}
|
||||
|
||||
InsetsViewModelUpdater()
|
||||
|
||||
AppScaffold(
|
||||
navigator = wrappedNavigator,
|
||||
@@ -362,7 +447,7 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
|
||||
)
|
||||
}
|
||||
},
|
||||
listContent = {
|
||||
secondaryContent = {
|
||||
val listContainerColor = if (windowSizeClass.isMedium()) {
|
||||
SignalTheme.colors.colorSurface1
|
||||
} else {
|
||||
@@ -431,36 +516,27 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
|
||||
}
|
||||
}
|
||||
},
|
||||
detailContent = {
|
||||
when (val destination = mainNavigationDetailLocation) {
|
||||
is MainNavigationDetailLocation.Conversation -> {
|
||||
val fragmentState = key(destination) { rememberFragmentState() }
|
||||
AndroidFragment(
|
||||
clazz = ConversationFragment::class.java,
|
||||
fragmentState = fragmentState,
|
||||
arguments = requireNotNull(destination.intent.extras) { "Handed null Conversation intent arguments." },
|
||||
modifier = Modifier
|
||||
.padding(end = contentLayoutData.detailPaddingEnd)
|
||||
.clip(contentLayoutData.shape)
|
||||
.background(color = MaterialTheme.colorScheme.surface)
|
||||
.fillMaxSize()
|
||||
primaryContent = {
|
||||
when (mainNavigationState.currentListLocation) {
|
||||
MainNavigationListLocation.CHATS, MainNavigationListLocation.ARCHIVE -> {
|
||||
DetailsScreenNavHost(
|
||||
navHostController = chatsNavHostController,
|
||||
contentLayoutData = contentLayoutData
|
||||
)
|
||||
}
|
||||
|
||||
MainNavigationDetailLocation.Empty -> {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(end = contentLayoutData.detailPaddingEnd)
|
||||
.clip(contentLayoutData.shape)
|
||||
.background(color = MaterialTheme.colorScheme.surface)
|
||||
.fillMaxSize()
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.ic_signal_logo_large),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.align(Alignment.Center)
|
||||
)
|
||||
}
|
||||
MainNavigationListLocation.CALLS -> {
|
||||
DetailsScreenNavHost(
|
||||
navHostController = callsNavHostController,
|
||||
contentLayoutData = contentLayoutData
|
||||
)
|
||||
}
|
||||
|
||||
MainNavigationListLocation.STORIES -> {
|
||||
DetailsScreenNavHost(
|
||||
navHostController = storiesNavHostController,
|
||||
contentLayoutData = contentLayoutData
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -509,8 +585,8 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
|
||||
windowSizeClass: WindowSizeClass,
|
||||
contentLayoutData: MainContentLayoutData,
|
||||
maxWidth: Dp
|
||||
): ThreePaneScaffoldNavigator<Any> {
|
||||
val scaffoldNavigator = rememberAppScaffoldNavigator(
|
||||
): AppScaffoldNavigator<Any> {
|
||||
val scaffoldNavigator = rememberThreePaneScaffoldNavigatorDelegate(
|
||||
isSplitPane = windowSizeClass.isSplitPane(),
|
||||
horizontalPartitionSpacerSize = contentLayoutData.partitionWidth,
|
||||
defaultPanePreferredWidth = contentLayoutData.rememberDefaultPanePreferredWidth(maxWidth)
|
||||
@@ -521,8 +597,20 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
|
||||
return remember(scaffoldNavigator, coroutine) {
|
||||
mainNavigationViewModel.wrapNavigator(coroutine, scaffoldNavigator) { detailLocation ->
|
||||
when (detailLocation) {
|
||||
is MainNavigationDetailLocation.Conversation -> {
|
||||
startActivity(detailLocation.intent)
|
||||
is MainNavigationDetailLocation.Chats.Conversation -> {
|
||||
startActivity(
|
||||
ConversationIntents.createBuilderSync(this, detailLocation.conversationArgs.recipientId, detailLocation.conversationArgs.threadId)
|
||||
.withArgs(detailLocation.conversationArgs)
|
||||
.build()
|
||||
)
|
||||
}
|
||||
|
||||
is MainNavigationDetailLocation.Calls.CallLinks.CallLinkDetails -> {
|
||||
startActivity(CallLinkDetailsActivity.createIntent(this, detailLocation.callLinkRoomId))
|
||||
}
|
||||
|
||||
is MainNavigationDetailLocation.Calls.CallLinks.EditCallLinkName -> {
|
||||
error("Unexpected subroute EditCallLinkName.")
|
||||
}
|
||||
|
||||
MainNavigationDetailLocation.Empty -> Unit
|
||||
@@ -744,7 +832,7 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
|
||||
private fun handleConversationIntent(intent: Intent) {
|
||||
if (ConversationIntents.isConversationIntent(intent)) {
|
||||
mainNavigationViewModel.goTo(MainNavigationListLocation.CHATS)
|
||||
mainNavigationViewModel.goTo(MainNavigationDetailLocation.Conversation(intent))
|
||||
mainNavigationViewModel.goTo(MainNavigationDetailLocation.Chats.Conversation(ConversationIntents.readArgsFromBundle(intent.extras!!)))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
@@ -1757,6 +1839,18 @@ object BackupRepository {
|
||||
return RestoreTimestampResult.NotFound
|
||||
}
|
||||
|
||||
timestampResult is NetworkResult.StatusCodeError && timestampResult.code == 401 -> {
|
||||
Log.i(TAG, "Backups not enabled")
|
||||
SignalStore.backup.lastBackupTime = 0L
|
||||
SignalStore.backup.isBackupTimestampRestored = true
|
||||
return RestoreTimestampResult.BackupsNotEnabled
|
||||
}
|
||||
|
||||
timestampResult is NetworkResult.ApplicationError && timestampResult.getCause() is VerificationFailedException -> {
|
||||
Log.w(TAG, "Entered AEP fails zk verification", timestampResult.getCause())
|
||||
return RestoreTimestampResult.VerificationFailure
|
||||
}
|
||||
|
||||
else -> {
|
||||
Log.w(TAG, "Could not check for backup file.", timestampResult.getCause())
|
||||
return RestoreTimestampResult.Failure
|
||||
@@ -1764,11 +1858,40 @@ object BackupRepository {
|
||||
}
|
||||
}
|
||||
|
||||
fun verifyBackupKeyAssociatedWithAccount(aci: ACI, aep: AccountEntropyPool): MessageBackupTier? {
|
||||
fun verifyBackupKeyAssociatedWithAccount(aci: ACI, aep: AccountEntropyPool): RestoreTimestampResult {
|
||||
Log.i(TAG, "Verifying enter aep is associated with account")
|
||||
var result: RestoreTimestampResult = getBackupTimestampToVerifyAepAssociatedWithAccountAndHasBackup(aci, aep)
|
||||
|
||||
if (result is RestoreTimestampResult.VerificationFailure) {
|
||||
Log.w(TAG, "Resetting backup id reservation due to zk verification failure")
|
||||
val triggerResult = SignalNetwork.archive.triggerBackupIdReservation(aep.deriveMessageBackupKey(), null, aci)
|
||||
result = when {
|
||||
triggerResult is NetworkResult.Success -> {
|
||||
Log.i(TAG, "Reset successful, retrying aep verification")
|
||||
SignalStore.backup.messageCredentials.clearAll()
|
||||
getBackupTimestampToVerifyAepAssociatedWithAccountAndHasBackup(aci, aep)
|
||||
}
|
||||
|
||||
triggerResult is NetworkResult.StatusCodeError && triggerResult.code == 429 -> {
|
||||
Log.w(TAG, "Rate limited when resetting backup id, failing operation $triggerResult")
|
||||
RestoreTimestampResult.RateLimited(triggerResult.retryAfter())
|
||||
}
|
||||
|
||||
else -> {
|
||||
Log.w(TAG, "Reset backup id failed, failing operation", triggerResult.getCause())
|
||||
result
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
private fun getBackupTimestampToVerifyAepAssociatedWithAccountAndHasBackup(aci: ACI, aep: AccountEntropyPool): RestoreTimestampResult {
|
||||
val currentTime = System.currentTimeMillis()
|
||||
val messageBackupKey = aep.deriveMessageBackupKey()
|
||||
|
||||
val result: NetworkResult<MessageBackupTier> = SignalNetwork.archive.getServiceCredentials(currentTime)
|
||||
val result: NetworkResult<ZonedDateTime> = SignalNetwork.archive.getServiceCredentials(currentTime)
|
||||
.then { result ->
|
||||
val credential: ArchiveServiceCredential? = ArchiveServiceCredentials(result.messageCredentials.associateBy { it.redemptionTime }).getForCurrentTime(currentTime.milliseconds)
|
||||
|
||||
@@ -1783,20 +1906,41 @@ object BackupRepository {
|
||||
)
|
||||
}
|
||||
}
|
||||
.map { messageAccess ->
|
||||
val zkCredential = SignalNetwork.archive.getZkCredential(aci, messageAccess)
|
||||
if (zkCredential.backupLevel == BackupLevel.PAID) {
|
||||
MessageBackupTier.PAID
|
||||
} else {
|
||||
MessageBackupTier.FREE
|
||||
}
|
||||
.then { messageAccess ->
|
||||
SignalNetwork.archive.getBackupInfo(SignalStore.account.requireAci(), messageAccess)
|
||||
.then { info -> SignalNetwork.archive.getCdnReadCredentials(info.cdn ?: RemoteConfig.backupFallbackArchiveCdn, aci, messageAccess).map { it.headers to info } }
|
||||
.then { pair ->
|
||||
val (cdnCredentials, info) = pair
|
||||
NetworkResult.fromFetch {
|
||||
AppDependencies.signalServiceMessageReceiver.getCdnLastModifiedTime(info.cdn!!, cdnCredentials, "backups/${info.backupDir}/${info.backupName}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return if (result is NetworkResult.Success) {
|
||||
result.result
|
||||
} else {
|
||||
Log.i(TAG, "Unable to verify backup key", result.getCause())
|
||||
null
|
||||
return when {
|
||||
result is NetworkResult.Success -> {
|
||||
RestoreTimestampResult.Success(result.result.toMillis())
|
||||
}
|
||||
|
||||
result is NetworkResult.StatusCodeError && result.code == 404 -> {
|
||||
Log.i(TAG, "No backup file exists")
|
||||
RestoreTimestampResult.NotFound
|
||||
}
|
||||
|
||||
result is NetworkResult.StatusCodeError && result.code == 401 -> {
|
||||
Log.i(TAG, "Backups not enabled")
|
||||
RestoreTimestampResult.BackupsNotEnabled
|
||||
}
|
||||
|
||||
result is NetworkResult.ApplicationError && result.getCause() is VerificationFailedException -> {
|
||||
Log.w(TAG, "Entered AEP fails zk verification", result.getCause())
|
||||
RestoreTimestampResult.VerificationFailure
|
||||
}
|
||||
|
||||
else -> {
|
||||
Log.w(TAG, "Could not check for backup file.", result.getCause())
|
||||
RestoreTimestampResult.Failure
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1870,23 +2014,14 @@ object BackupRepository {
|
||||
suspend fun getPaidType(): NetworkResult<MessageBackupsType.Paid> {
|
||||
val productPrice: FiatMoney? = if (SignalStore.backup.backupTierInternalOverride == MessageBackupTier.PAID) {
|
||||
Log.d(TAG, "Accessing price via mock subscription.")
|
||||
RecurringInAppPaymentRepository.getActiveSubscriptionSync(InAppPaymentSubscriberRecord.Type.BACKUP).getOrNull()?.activeSubscription?.let {
|
||||
RecurringInAppPaymentRepository.getActiveSubscriptionSync(InAppPaymentSubscriberRecord.Type.BACKUP).successOrNull()?.activeSubscription?.let {
|
||||
FiatMoney.fromSignalNetworkAmount(it.amount, Currency.getInstance(it.currency))
|
||||
}
|
||||
} else if (AppDependencies.billingApi.isApiAvailable()) {
|
||||
} else if (AppDependencies.billingApi.getApiAvailability().isSuccess) {
|
||||
Log.d(TAG, "Accessing price via billing api.")
|
||||
AppDependencies.billingApi.queryProduct()?.price
|
||||
} else {
|
||||
Log.d(TAG, "Billing API is not available on this device. Accessing price via subscription configuration.")
|
||||
val configurationResult = AppDependencies.donationsService.getDonationsConfiguration(Locale.getDefault()).toNetworkResult()
|
||||
val currency = Currency.getInstance(Locale.getDefault())
|
||||
|
||||
when (configurationResult) {
|
||||
is NetworkResult.Success -> configurationResult.result.currencies[currency.currencyCode.lowercase()]?.backupSubscription[SubscriptionsConfiguration.BACKUPS_LEVEL]?.let {
|
||||
FiatMoney(it, currency)
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
FiatMoney(BigDecimal.ZERO, SignalStore.inAppPayments.getRecurringDonationCurrency())
|
||||
}
|
||||
|
||||
if (productPrice == null) {
|
||||
@@ -1920,22 +2055,25 @@ object BackupRepository {
|
||||
* prevents early initialization with incorrect keys before we have restored them.
|
||||
*/
|
||||
private fun initBackupAndFetchAuth(): NetworkResult<ArchiveServiceAccessPair> {
|
||||
return if (!RemoteConfig.messageBackups) {
|
||||
NetworkResult.StatusCodeError(555, null, null, emptyMap(), NonSuccessfulResponseCodeException(555, "Backups disabled!"))
|
||||
} else if (SignalStore.backup.backupsInitialized || SignalStore.account.isLinkedDevice) {
|
||||
return if (SignalStore.backup.backupsInitialized || SignalStore.account.isLinkedDevice) {
|
||||
getArchiveServiceAccessPair()
|
||||
.runOnStatusCodeError(resetInitializedStateErrorAction)
|
||||
.runOnApplicationError(clearAuthCredentials)
|
||||
} else if (isPreRestoreDuringRegistration()) {
|
||||
Log.w(TAG, "Requesting/using auth credentials in pre-restore state", Throwable())
|
||||
getArchiveServiceAccessPair()
|
||||
.runOnApplicationError(clearAuthCredentials)
|
||||
} else {
|
||||
val messageBackupKey = SignalStore.backup.messageBackupKey
|
||||
val mediaRootBackupKey = SignalStore.backup.mediaRootBackupKey
|
||||
|
||||
return SignalNetwork.archive
|
||||
.triggerBackupIdReservation(messageBackupKey, mediaRootBackupKey, SignalStore.account.requireAci())
|
||||
.then { getArchiveServiceAccessPair() }
|
||||
.then {
|
||||
SignalStore.backup.messageCredentials.clearAll()
|
||||
SignalStore.backup.mediaCredentials.clearAll()
|
||||
getArchiveServiceAccessPair()
|
||||
}
|
||||
.then { credential -> SignalNetwork.archive.setPublicKey(SignalStore.account.requireAci(), credential.messageBackupAccess).map { credential } }
|
||||
.then { credential -> SignalNetwork.archive.setPublicKey(SignalStore.account.requireAci(), credential.mediaBackupAccess).map { credential } }
|
||||
.runIfSuccessful { SignalStore.backup.backupsInitialized = true }
|
||||
@@ -1980,8 +2118,7 @@ object BackupRepository {
|
||||
|
||||
private fun isPreRestoreDuringRegistration(): Boolean {
|
||||
return !SignalStore.registration.isRegistrationComplete &&
|
||||
SignalStore.registration.restoreDecisionState.isDecisionPending &&
|
||||
RemoteConfig.restoreAfterRegistration
|
||||
SignalStore.registration.restoreDecisionState.isDecisionPending
|
||||
}
|
||||
|
||||
private fun scheduleSyncForAccountChange() {
|
||||
@@ -2076,7 +2213,7 @@ object BackupRepository {
|
||||
val messageBackupKey = SignalStore.backup.messageBackupKey
|
||||
|
||||
Log.i(TAG, "[remoteRestore] Fetching SVRB data")
|
||||
val svrBAuth = when (val result = BackupRepository.getSvrBAuth()) {
|
||||
val svrBAuth = when (val result = getSvrBAuth()) {
|
||||
is NetworkResult.Success -> result.result
|
||||
is NetworkResult.NetworkError -> return RemoteRestoreResult.NetworkError.logW(TAG, "[remoteRestore] Network error when getting SVRB auth.", result.getCause())
|
||||
is NetworkResult.StatusCodeError -> return RemoteRestoreResult.NetworkError.logW(TAG, "[remoteRestore] Status code error when getting SVRB auth.", result.getCause())
|
||||
@@ -2088,20 +2225,24 @@ object BackupRepository {
|
||||
SignalStore.backup.nextBackupSecretData = result.data.nextBackupSecretData
|
||||
result.data.forwardSecrecyToken
|
||||
}
|
||||
|
||||
is SvrBApi.RestoreResult.NetworkError -> {
|
||||
Log.w(TAG, "[remoteRestore] Network error during SVRB.", result.exception)
|
||||
return RemoteRestoreResult.NetworkError
|
||||
}
|
||||
|
||||
is SvrBApi.RestoreResult.RestoreFailedError,
|
||||
SvrBApi.RestoreResult.InvalidDataError -> {
|
||||
Log.w(TAG, "[remoteRestore] Permanent SVRB error! $result")
|
||||
return RemoteRestoreResult.PermanentSvrBFailure
|
||||
}
|
||||
|
||||
SvrBApi.RestoreResult.DataMissingError,
|
||||
is SvrBApi.RestoreResult.SvrError -> {
|
||||
Log.w(TAG, "[remoteRestore] Failed to fetch SVRB data: $result")
|
||||
return RemoteRestoreResult.Failure
|
||||
}
|
||||
|
||||
is SvrBApi.RestoreResult.UnknownError -> {
|
||||
Log.e(TAG, "[remoteRestore] Unknown SVRB result! Crashing.", result.throwable)
|
||||
throw result.throwable
|
||||
@@ -2326,6 +2467,9 @@ sealed interface RemoteRestoreResult {
|
||||
sealed interface RestoreTimestampResult {
|
||||
data class Success(val timestamp: Long) : RestoreTimestampResult
|
||||
data object NotFound : RestoreTimestampResult
|
||||
data object BackupsNotEnabled : RestoreTimestampResult
|
||||
data object VerificationFailure : RestoreTimestampResult
|
||||
data class RateLimited(val retryAfter: Duration?) : RestoreTimestampResult
|
||||
data object Failure : RestoreTimestampResult
|
||||
}
|
||||
|
||||
|
||||
@@ -549,7 +549,7 @@ private fun BackupMessageRecord.toBasicChatItemBuilder(selfRecipientId: Recipien
|
||||
dateReceived = dateReceived
|
||||
)
|
||||
|
||||
if (expiresInMs != null && outgoing?.sendStatus?.all { it.pending == null && it.failed == null } == true) {
|
||||
if (expiresInMs != null && outgoing?.sendStatus?.all { it.pending == null && it.failed == null } == true && expireStartDate == null) {
|
||||
Log.w(TAG, ExportOddities.outgoingMessageWasSentButTimerNotStarted(record.dateSent))
|
||||
expireStartDate = record.dateReceived
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -11,12 +11,16 @@ import androidx.compose.material3.SnackbarDuration
|
||||
import androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.google.android.material.appbar.AppBarLayout
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.kotlin.Flowables
|
||||
import io.reactivex.rxjava3.kotlin.subscribeBy
|
||||
import kotlinx.coroutines.launch
|
||||
import org.signal.core.util.DimensionUnit
|
||||
import org.signal.core.util.concurrent.LifecycleDisposable
|
||||
import org.signal.core.util.concurrent.addTo
|
||||
@@ -41,6 +45,7 @@ import org.thoughtcrime.securesms.conversationlist.chatfilter.FilterLerp
|
||||
import org.thoughtcrime.securesms.conversationlist.chatfilter.FilterPullState
|
||||
import org.thoughtcrime.securesms.databinding.CallLogFragmentBinding
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.main.MainNavigationDetailLocation
|
||||
import org.thoughtcrime.securesms.main.MainNavigationListLocation
|
||||
import org.thoughtcrime.securesms.main.MainNavigationViewModel
|
||||
import org.thoughtcrime.securesms.main.MainToolbarMode
|
||||
@@ -78,6 +83,7 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
|
||||
private lateinit var callLogActionMode: CallLogActionMode
|
||||
private val conversationUpdateTick: ConversationUpdateTick = ConversationUpdateTick(this::onTimestampTick)
|
||||
private var callLogAdapter: CallLogAdapter? = null
|
||||
private val backPressedCallback = OnBackPressed()
|
||||
|
||||
private lateinit var signalBottomActionBarController: SignalBottomActionBarController
|
||||
|
||||
@@ -165,16 +171,14 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
|
||||
initializePullToFilter(scrollToPositionDelegate)
|
||||
initializeTapToScrollToTop(scrollToPositionDelegate)
|
||||
|
||||
requireActivity().onBackPressedDispatcher.addCallback(
|
||||
viewLifecycleOwner,
|
||||
object : OnBackPressedCallback(true) {
|
||||
override fun handleOnBackPressed() {
|
||||
if (!closeSearchIfOpen()) {
|
||||
mainNavigationViewModel.onChatsSelected()
|
||||
}
|
||||
requireActivity().onBackPressedDispatcher.addCallback(backPressedCallback)
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
repeatOnLifecycle(Lifecycle.State.RESUMED) {
|
||||
mainToolbarViewModel.state.collect {
|
||||
backPressedCallback.isEnabled = it.mode == MainToolbarMode.SEARCH
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (resources.getWindowSizeClass().isCompact()) {
|
||||
ViewUtil.setBottomMargin(binding.bottomActionBar, ViewUtil.getNavigationBarHeight(binding.bottomActionBar))
|
||||
@@ -316,7 +320,7 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
|
||||
if (viewModel.selectionStateSnapshot.isNotEmpty(binding.recycler.adapter!!.itemCount)) {
|
||||
viewModel.toggleSelected(callLogRow.id)
|
||||
} else {
|
||||
startActivity(CallLinkDetailsActivity.createIntent(requireContext(), callLogRow.record.roomId))
|
||||
mainNavigationViewModel.goTo(MainNavigationDetailLocation.Calls.CallLinks.CallLinkDetails(callLogRow.record.roomId))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -482,6 +486,12 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
|
||||
}
|
||||
}
|
||||
|
||||
private inner class OnBackPressed : OnBackPressedCallback(enabled = false) {
|
||||
override fun handleOnBackPressed() {
|
||||
closeSearchIfOpen()
|
||||
}
|
||||
}
|
||||
|
||||
interface Callback {
|
||||
fun onMultiSelectStarted()
|
||||
fun onMultiSelectFinished()
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -13,8 +13,10 @@ import androidx.core.view.WindowInsetsCompat
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.main.InsetsViewModel
|
||||
import org.thoughtcrime.securesms.util.ViewUtil
|
||||
import org.thoughtcrime.securesms.window.WindowSizeClass.Companion.getWindowSizeClass
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* A specialized [ConstraintLayout] that sets guidelines based on the window insets provided
|
||||
@@ -64,6 +66,7 @@ open class InsetAwareConstraintLayout @JvmOverloads constructor(
|
||||
|
||||
private var insets: WindowInsetsCompat? = null
|
||||
private var windowTypes: Int = InsetAwareConstraintLayout.windowTypes
|
||||
private var verticalInsetOverride: InsetsViewModel.Insets = InsetsViewModel.Insets.Zero
|
||||
|
||||
private val windowInsetsListener = androidx.core.view.OnApplyWindowInsetsListener { _, insets ->
|
||||
this.insets = insets
|
||||
@@ -127,6 +130,22 @@ open class InsetAwareConstraintLayout @JvmOverloads constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun applyInsets(insets: InsetsViewModel.Insets) {
|
||||
verticalInsetOverride = insets
|
||||
|
||||
if (this.insets != null) {
|
||||
applyInsets(this.insets!!.getInsets(windowTypes), this.insets!!.getInsets(keyboardType))
|
||||
}
|
||||
}
|
||||
|
||||
fun clearVerticalInsetOverride() {
|
||||
verticalInsetOverride = InsetsViewModel.Insets.Zero
|
||||
|
||||
if (this.insets != null) {
|
||||
applyInsets(this.insets!!.getInsets(windowTypes), this.insets!!.getInsets(keyboardType))
|
||||
}
|
||||
}
|
||||
|
||||
fun addKeyboardStateListener(listener: KeyboardStateListener) {
|
||||
keyboardStateListeners += listener
|
||||
}
|
||||
@@ -146,8 +165,8 @@ open class InsetAwareConstraintLayout @JvmOverloads constructor(
|
||||
private fun applyInsets(windowInsets: Insets, keyboardInsets: Insets) {
|
||||
val isLtr = ViewUtil.isLtr(this)
|
||||
|
||||
val statusBar = windowInsets.top
|
||||
val navigationBar = windowInsets.bottom
|
||||
val statusBar = if (verticalInsetOverride == InsetsViewModel.Insets.Zero) windowInsets.top else verticalInsetOverride.statusBar.roundToInt()
|
||||
val navigationBar = if (verticalInsetOverride == InsetsViewModel.Insets.Zero) windowInsets.bottom else verticalInsetOverride.navBar.roundToInt()
|
||||
val parentStart = if (isLtr) windowInsets.left else windowInsets.right
|
||||
val parentEnd = if (isLtr) windowInsets.right else windowInsets.left
|
||||
|
||||
@@ -156,7 +175,9 @@ open class InsetAwareConstraintLayout @JvmOverloads constructor(
|
||||
parentStartGuideline?.setGuidelineBegin(parentStart)
|
||||
parentEndGuideline?.setGuidelineEnd(parentEnd)
|
||||
|
||||
windowInsetsListeners.forEach { it.onApplyWindowInsets(statusBar, navigationBar, parentStart, parentEnd) }
|
||||
windowInsetsListeners.forEach {
|
||||
it.onApplyWindowInsets(statusBar, navigationBar, parentStart, parentEnd)
|
||||
}
|
||||
|
||||
if (keyboardInsets.bottom > 0) {
|
||||
setKeyboardHeight(keyboardInsets.bottom)
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import com.google.android.material.textfield.TextInputEditText
|
||||
|
||||
/**
|
||||
* An EditText that completely disables Android's auto-fill functionality.
|
||||
*/
|
||||
class NoAutofillEditText : TextInputEditText {
|
||||
constructor(context: Context) : super(context, null)
|
||||
constructor(context: Context, attrs: AttributeSet?) : super(context, attrs)
|
||||
constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr)
|
||||
|
||||
override fun getAutofillType(): Int = AUTOFILL_TYPE_NONE
|
||||
}
|
||||
@@ -21,8 +21,8 @@ import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.SignalPreview
|
||||
import org.signal.core.ui.compose.horizontalGutters
|
||||
import org.signal.core.ui.compose.theme.SignalTheme
|
||||
import org.thoughtcrime.securesms.R
|
||||
@@ -88,7 +88,7 @@ fun BetaHeader(modifier: Modifier = Modifier) {
|
||||
}
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
fun BetaLabelPreview() {
|
||||
Previews.Preview {
|
||||
@@ -113,7 +113,7 @@ fun LongTextBetaLabelPreview() {
|
||||
}
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
fun BetaHeaderPreview() {
|
||||
Previews.Preview {
|
||||
|
||||
@@ -26,8 +26,8 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import org.signal.core.ui.compose.BottomSheets
|
||||
import org.signal.core.ui.compose.Buttons
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.SignalPreview
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.compose.ComposeBottomSheetDialogFragment
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
@@ -102,7 +102,7 @@ private fun Sheet(onDismiss: () -> Unit = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun ConnectivityWarningSheetPreview() {
|
||||
Previews.BottomSheetPreview {
|
||||
|
||||
@@ -26,8 +26,8 @@ import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.core.Completable
|
||||
import io.reactivex.rxjava3.subjects.CompletableSubject
|
||||
import org.signal.core.ui.compose.Buttons
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.SignalPreview
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.compose.ComposeBottomSheetDialogFragment
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
@@ -126,7 +126,7 @@ private fun Sheet(
|
||||
}
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun SheetPreview() {
|
||||
Previews.Preview {
|
||||
|
||||
@@ -27,8 +27,8 @@ import androidx.core.os.bundleOf
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import org.signal.core.ui.compose.BottomSheets
|
||||
import org.signal.core.ui.compose.Buttons
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.SignalPreview
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.compose.ComposeBottomSheetDialogFragment
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
@@ -124,7 +124,7 @@ private fun DeviceSpecificSheet(onContinue: () -> Unit = {}, onDismiss: () -> Un
|
||||
}
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun DeviceSpecificSheetPreview() {
|
||||
Previews.BottomSheetPreview {
|
||||
|
||||
@@ -18,7 +18,6 @@ import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.Checkbox
|
||||
@@ -26,6 +25,7 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.stringResource
|
||||
@@ -34,8 +34,9 @@ import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.role
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.signal.core.ui.compose.SignalPreview
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.theme.SignalTheme
|
||||
import org.thoughtcrime.securesms.R
|
||||
|
||||
@@ -50,7 +51,11 @@ import org.thoughtcrime.securesms.R
|
||||
fun RoundCheckbox(
|
||||
checked: Boolean,
|
||||
onCheckedChange: (Boolean) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
modifier: Modifier = Modifier,
|
||||
size: Dp = 24.dp,
|
||||
enabled: Boolean = true,
|
||||
outlineColor: Color = MaterialTheme.colorScheme.outline,
|
||||
checkedColor: Color = MaterialTheme.colorScheme.primary
|
||||
) {
|
||||
val contentDescription = if (checked) {
|
||||
stringResource(R.string.SignalCheckbox_accessibility_checked_description)
|
||||
@@ -60,15 +65,14 @@ fun RoundCheckbox(
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.padding(12.dp)
|
||||
.size(24.dp)
|
||||
.size(size)
|
||||
.aspectRatio(1f)
|
||||
.border(
|
||||
width = 1.5.dp,
|
||||
color = if (checked) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
checkedColor
|
||||
} else {
|
||||
MaterialTheme.colorScheme.outline
|
||||
outlineColor
|
||||
},
|
||||
shape = CircleShape
|
||||
)
|
||||
@@ -76,7 +80,8 @@ fun RoundCheckbox(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null,
|
||||
onClick = { onCheckedChange(!checked) },
|
||||
onClickLabel = stringResource(R.string.SignalCheckbox_accessibility_on_click_label)
|
||||
onClickLabel = stringResource(R.string.SignalCheckbox_accessibility_on_click_label),
|
||||
enabled = enabled
|
||||
)
|
||||
.semantics(mergeDescendants = true) {
|
||||
this.role = Role.Checkbox
|
||||
@@ -90,7 +95,7 @@ fun RoundCheckbox(
|
||||
) {
|
||||
Image(
|
||||
imageVector = ImageVector.vectorResource(id = R.drawable.ic_check_circle_solid_24),
|
||||
colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.primary),
|
||||
colorFilter = ColorFilter.tint(checkedColor),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
@@ -98,13 +103,13 @@ fun RoundCheckbox(
|
||||
}
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun RoundCheckboxCheckedPreview() = SignalTheme {
|
||||
RoundCheckbox(checked = true, onCheckedChange = {})
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun RoundCheckboxUncheckedPreview() = SignalTheme {
|
||||
RoundCheckbox(checked = false, onCheckedChange = {})
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.compose
|
||||
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.thoughtcrime.securesms.window.WindowSizeClass
|
||||
|
||||
/**
|
||||
* Displays the screen title for split-pane UIs on tablets and foldable devices.
|
||||
*/
|
||||
@Composable
|
||||
fun ScreenTitlePane(
|
||||
title: String,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val windowSizeClass = WindowSizeClass.rememberWindowSizeClass()
|
||||
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.headlineLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = modifier
|
||||
.padding(
|
||||
start = if (windowSizeClass.isExtended()) 80.dp else 20.dp,
|
||||
end = 20.dp,
|
||||
top = 12.dp,
|
||||
bottom = 12.dp
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -10,9 +10,9 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Dialogs
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.SignalPreview
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.util.CommunicationActions
|
||||
import org.thoughtcrime.securesms.util.SupportEmailUtil
|
||||
@@ -86,7 +86,7 @@ fun <Reason> SendSupportEmailEffect(
|
||||
}
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun ContactSupportDialogPreview() {
|
||||
Previews.Preview {
|
||||
|
||||
@@ -10,5 +10,6 @@ public final class EmojiStrings {
|
||||
public static final String STICKER = "\u2B50";
|
||||
public static final String GIFT = "\uD83C\uDF81";
|
||||
public static final String CARD = "\uD83D\uDCB3";
|
||||
public static final String POLL = "\uD83D\uDCCA";
|
||||
public static final String FAILED_STORY = "\u2757";
|
||||
}
|
||||
|
||||
@@ -294,7 +294,7 @@ public class EmojiTextView extends AppCompatTextView {
|
||||
int desiredWidth = (int) measuredTextWidth + getPaddingLeft() + getPaddingRight();
|
||||
|
||||
if (widthSpecMode == MeasureSpec.AT_MOST && desiredWidth < widthSpecSize) {
|
||||
return MeasureSpec.makeMeasureSpec(desiredWidth + 1, MeasureSpec.EXACTLY);
|
||||
return MeasureSpec.makeMeasureSpec(desiredWidth + 3, MeasureSpec.EXACTLY);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,8 +18,8 @@ import androidx.compose.ui.text.PlaceholderVerticalAlign
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.google.accompanist.drawablepainter.rememberDrawablePainter
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.SignalPreview
|
||||
|
||||
/**
|
||||
* Applies Signal or System emoji to the given content based off user settings.
|
||||
@@ -71,7 +71,7 @@ fun Emojifier(
|
||||
}
|
||||
|
||||
@Composable
|
||||
@SignalPreview
|
||||
@DayNightPreviews
|
||||
private fun EmojifierPreview() {
|
||||
Previews.Preview {
|
||||
Emojifier(text = "This message has an emoji ❤\uFE0F")
|
||||
|
||||
@@ -7,17 +7,18 @@ import androidx.navigation.NavDirections
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import io.reactivex.rxjava3.subjects.PublishSubject
|
||||
import io.reactivex.rxjava3.subjects.Subject
|
||||
import org.signal.core.util.getParcelableExtraCompat
|
||||
import org.signal.donations.InAppPaymentType
|
||||
import org.thoughtcrime.securesms.MainActivity
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsActivity
|
||||
import org.thoughtcrime.securesms.components.settings.app.chats.folders.CreateFoldersFragmentArgs
|
||||
import org.thoughtcrime.securesms.components.settings.app.notifications.profiles.EditNotificationProfileScheduleFragmentArgs
|
||||
import org.thoughtcrime.securesms.components.settings.app.routes.AppSettingsRoute
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.GooglePayComponent
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.GooglePayRepository
|
||||
import org.thoughtcrime.securesms.help.HelpFragment
|
||||
import org.thoughtcrime.securesms.keyvalue.SettingsValues
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.profiles.manage.UsernameEditMode
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.service.KeyCachingService
|
||||
import org.thoughtcrime.securesms.util.CachedInflater
|
||||
@@ -25,8 +26,7 @@ import org.thoughtcrime.securesms.util.DynamicTheme
|
||||
import org.thoughtcrime.securesms.util.SignalE164Util
|
||||
import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
||||
|
||||
private const val START_LOCATION = "app.settings.start.location"
|
||||
private const val START_ARGUMENTS = "app.settings.start.arguments"
|
||||
private const val START_ROUTE = "app.settings.args.START_ROUTE"
|
||||
private const val NOTIFICATION_CATEGORY = "android.intent.category.NOTIFICATION_PREFERENCES"
|
||||
private const val STATE_WAS_CONFIGURATION_UPDATED = "app.settings.state.configuration.updated"
|
||||
private const val EXTRA_PERFORM_ACTION_ON_CREATE = "extra_perform_action_on_create"
|
||||
@@ -48,42 +48,41 @@ class AppSettingsActivity : DSLSettingsActivity(), GooglePayComponent {
|
||||
val startingAction: NavDirections? = if (intent?.categories?.contains(NOTIFICATION_CATEGORY) == true) {
|
||||
AppSettingsFragmentDirections.actionDirectToNotificationsSettingsFragment()
|
||||
} else {
|
||||
when (StartLocation.fromCode(intent?.getIntExtra(START_LOCATION, StartLocation.HOME.code))) {
|
||||
StartLocation.HOME -> null
|
||||
StartLocation.BACKUPS -> AppSettingsFragmentDirections.actionDirectToBackupsPreferenceFragment()
|
||||
StartLocation.HELP -> AppSettingsFragmentDirections.actionDirectToHelpFragment()
|
||||
.setStartCategoryIndex(intent.getIntExtra(HelpFragment.START_CATEGORY_INDEX, 0))
|
||||
|
||||
StartLocation.PROXY -> AppSettingsFragmentDirections.actionDirectToEditProxyFragment()
|
||||
StartLocation.NOTIFICATIONS -> AppSettingsFragmentDirections.actionDirectToNotificationsSettingsFragment()
|
||||
StartLocation.CHANGE_NUMBER -> AppSettingsFragmentDirections.actionDirectToChangeNumberFragment()
|
||||
StartLocation.SUBSCRIPTIONS -> AppSettingsFragmentDirections.actionDirectToManageDonations().setDirectToCheckoutType(InAppPaymentType.RECURRING_DONATION)
|
||||
StartLocation.BOOST -> AppSettingsFragmentDirections.actionDirectToManageDonations().setDirectToCheckoutType(InAppPaymentType.ONE_TIME_DONATION)
|
||||
StartLocation.MANAGE_SUBSCRIPTIONS -> AppSettingsFragmentDirections.actionDirectToManageDonations()
|
||||
StartLocation.NOTIFICATION_PROFILES -> AppSettingsFragmentDirections.actionDirectToNotificationProfiles()
|
||||
StartLocation.CREATE_NOTIFICATION_PROFILE -> AppSettingsFragmentDirections.actionDirectToCreateNotificationProfiles()
|
||||
StartLocation.NOTIFICATION_PROFILE_DETAILS -> AppSettingsFragmentDirections.actionDirectToNotificationProfileDetails(
|
||||
EditNotificationProfileScheduleFragmentArgs.fromBundle(intent.getBundleExtra(START_ARGUMENTS)!!).profileId
|
||||
val appSettingsRoute: AppSettingsRoute? = intent?.getParcelableExtraCompat(START_ROUTE, AppSettingsRoute::class.java)
|
||||
when (appSettingsRoute) {
|
||||
AppSettingsRoute.Empty -> null
|
||||
AppSettingsRoute.BackupsRoute.Local -> AppSettingsFragmentDirections.actionDirectToBackupsPreferenceFragment()
|
||||
is AppSettingsRoute.HelpRoute.Settings -> AppSettingsFragmentDirections.actionDirectToHelpFragment()
|
||||
.setStartCategoryIndex(appSettingsRoute.startCategoryIndex)
|
||||
AppSettingsRoute.DataAndStorageRoute.Proxy -> AppSettingsFragmentDirections.actionDirectToEditProxyFragment()
|
||||
AppSettingsRoute.NotificationsRoute.Notifications -> AppSettingsFragmentDirections.actionDirectToNotificationsSettingsFragment()
|
||||
AppSettingsRoute.ChangeNumberRoute.Start -> AppSettingsFragmentDirections.actionDirectToChangeNumberFragment()
|
||||
is AppSettingsRoute.DonationsRoute.Donations -> AppSettingsFragmentDirections.actionDirectToManageDonations().setDirectToCheckoutType(appSettingsRoute.directToCheckoutType)
|
||||
AppSettingsRoute.NotificationsRoute.NotificationProfiles -> AppSettingsFragmentDirections.actionDirectToNotificationProfiles()
|
||||
is AppSettingsRoute.NotificationsRoute.EditProfile -> AppSettingsFragmentDirections.actionDirectToCreateNotificationProfiles()
|
||||
is AppSettingsRoute.NotificationsRoute.ProfileDetails -> AppSettingsFragmentDirections.actionDirectToNotificationProfileDetails(
|
||||
appSettingsRoute.profileId
|
||||
)
|
||||
|
||||
StartLocation.PRIVACY -> AppSettingsFragmentDirections.actionDirectToPrivacy()
|
||||
StartLocation.LINKED_DEVICES -> AppSettingsFragmentDirections.actionDirectToDevices()
|
||||
StartLocation.USERNAME_LINK -> AppSettingsFragmentDirections.actionDirectToUsernameLinkSettings()
|
||||
StartLocation.RECOVER_USERNAME -> AppSettingsFragmentDirections.actionDirectToUsernameRecovery()
|
||||
StartLocation.REMOTE_BACKUPS -> AppSettingsFragmentDirections.actionDirectToRemoteBackupsSettingsFragment()
|
||||
StartLocation.CHAT_FOLDERS -> AppSettingsFragmentDirections.actionDirectToChatFoldersFragment()
|
||||
StartLocation.CREATE_CHAT_FOLDER -> AppSettingsFragmentDirections.actionDirectToCreateFoldersFragment(
|
||||
CreateFoldersFragmentArgs.fromBundle(intent.getBundleExtra(START_ARGUMENTS)!!).folderId,
|
||||
CreateFoldersFragmentArgs.fromBundle(intent.getBundleExtra(START_ARGUMENTS)!!).threadIds
|
||||
AppSettingsRoute.PrivacyRoute.Privacy -> AppSettingsFragmentDirections.actionDirectToPrivacy()
|
||||
AppSettingsRoute.LinkDeviceRoute.LinkDevice -> AppSettingsFragmentDirections.actionDirectToDevices()
|
||||
AppSettingsRoute.UsernameLinkRoute.UsernameLink -> AppSettingsFragmentDirections.actionDirectToUsernameLinkSettings()
|
||||
is AppSettingsRoute.AccountRoute.Username -> AppSettingsFragmentDirections.actionDirectToUsernameRecovery()
|
||||
is AppSettingsRoute.BackupsRoute.Remote -> AppSettingsFragmentDirections.actionDirectToRemoteBackupsSettingsFragment()
|
||||
AppSettingsRoute.ChatFoldersRoute.ChatFolders -> AppSettingsFragmentDirections.actionDirectToChatFoldersFragment()
|
||||
is AppSettingsRoute.ChatFoldersRoute.CreateChatFolders -> AppSettingsFragmentDirections.actionDirectToCreateFoldersFragment(
|
||||
appSettingsRoute.folderId,
|
||||
appSettingsRoute.threadIds
|
||||
)
|
||||
|
||||
StartLocation.BACKUPS_SETTINGS -> AppSettingsFragmentDirections.actionDirectToBackupsSettingsFragment()
|
||||
StartLocation.INVITE -> AppSettingsFragmentDirections.actionDirectToInviteFragment()
|
||||
StartLocation.MANAGE_STORAGE -> AppSettingsFragmentDirections.actionDirectToStoragePreferenceFragment()
|
||||
AppSettingsRoute.BackupsRoute.Backups -> AppSettingsFragmentDirections.actionDirectToBackupsSettingsFragment()
|
||||
AppSettingsRoute.Invite -> AppSettingsFragmentDirections.actionDirectToInviteFragment()
|
||||
AppSettingsRoute.DataAndStorageRoute.DataAndStorage -> AppSettingsFragmentDirections.actionDirectToStoragePreferenceFragment()
|
||||
else -> error("Unsupported start location: ${appSettingsRoute?.javaClass?.name}")
|
||||
}
|
||||
}
|
||||
|
||||
intent = intent.putExtra(START_LOCATION, StartLocation.HOME)
|
||||
intent = intent.putExtra(START_ROUTE, AppSettingsRoute.Empty)
|
||||
|
||||
if (startingAction == null && savedInstanceState != null) {
|
||||
wasConfigurationUpdated = savedInstanceState.getBoolean(STATE_WAS_CONFIGURATION_UPDATED)
|
||||
@@ -148,123 +147,89 @@ class AppSettingsActivity : DSLSettingsActivity(), GooglePayComponent {
|
||||
@JvmStatic
|
||||
@JvmOverloads
|
||||
fun home(context: Context, action: String? = null): Intent {
|
||||
return getIntentForStartLocation(context, StartLocation.HOME)
|
||||
return getIntentForStartLocation(context, AppSettingsRoute.Empty)
|
||||
.putExtra(EXTRA_PERFORM_ACTION_ON_CREATE, action)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun backups(context: Context): Intent = getIntentForStartLocation(context, StartLocation.BACKUPS)
|
||||
fun backups(context: Context): Intent = getIntentForStartLocation(context, AppSettingsRoute.BackupsRoute.Local)
|
||||
|
||||
@JvmStatic
|
||||
fun help(context: Context, startCategoryIndex: Int = 0): Intent {
|
||||
return getIntentForStartLocation(context, StartLocation.HELP)
|
||||
return getIntentForStartLocation(context, AppSettingsRoute.HelpRoute.Settings(startCategoryIndex = startCategoryIndex))
|
||||
.putExtra(HelpFragment.START_CATEGORY_INDEX, startCategoryIndex)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun proxy(context: Context): Intent = getIntentForStartLocation(context, StartLocation.PROXY)
|
||||
fun proxy(context: Context): Intent = getIntentForStartLocation(context, AppSettingsRoute.DataAndStorageRoute.Proxy)
|
||||
|
||||
@JvmStatic
|
||||
fun notifications(context: Context): Intent = getIntentForStartLocation(context, StartLocation.NOTIFICATIONS)
|
||||
fun notifications(context: Context): Intent = getIntentForStartLocation(context, AppSettingsRoute.NotificationsRoute.Notifications)
|
||||
|
||||
@JvmStatic
|
||||
fun changeNumber(context: Context): Intent = getIntentForStartLocation(context, StartLocation.CHANGE_NUMBER)
|
||||
fun changeNumber(context: Context): Intent = getIntentForStartLocation(context, AppSettingsRoute.ChangeNumberRoute.Start)
|
||||
|
||||
@JvmStatic
|
||||
fun subscriptions(context: Context): Intent = getIntentForStartLocation(context, StartLocation.SUBSCRIPTIONS)
|
||||
fun subscriptions(context: Context): Intent = getIntentForStartLocation(context, AppSettingsRoute.DonationsRoute.Donations(directToCheckoutType = InAppPaymentType.RECURRING_DONATION))
|
||||
|
||||
@JvmStatic
|
||||
fun boost(context: Context): Intent = getIntentForStartLocation(context, StartLocation.BOOST)
|
||||
fun boost(context: Context): Intent = getIntentForStartLocation(context, AppSettingsRoute.DonationsRoute.Donations(directToCheckoutType = InAppPaymentType.ONE_TIME_DONATION))
|
||||
|
||||
@JvmStatic
|
||||
fun manageSubscriptions(context: Context): Intent = getIntentForStartLocation(context, StartLocation.MANAGE_SUBSCRIPTIONS)
|
||||
fun manageSubscriptions(context: Context): Intent = getIntentForStartLocation(context, AppSettingsRoute.DonationsRoute.Donations())
|
||||
|
||||
fun manageStorage(context: Context): Intent = getIntentForStartLocation(context, StartLocation.MANAGE_STORAGE)
|
||||
fun manageStorage(context: Context): Intent = getIntentForStartLocation(context, AppSettingsRoute.DataAndStorageRoute.DataAndStorage)
|
||||
|
||||
@JvmStatic
|
||||
fun notificationProfiles(context: Context): Intent = getIntentForStartLocation(context, StartLocation.NOTIFICATION_PROFILES)
|
||||
fun notificationProfiles(context: Context): Intent = getIntentForStartLocation(context, AppSettingsRoute.NotificationsRoute.NotificationProfiles)
|
||||
|
||||
@JvmStatic
|
||||
fun createNotificationProfile(context: Context): Intent = getIntentForStartLocation(context, StartLocation.CREATE_NOTIFICATION_PROFILE)
|
||||
fun createNotificationProfile(context: Context): Intent = getIntentForStartLocation(context, AppSettingsRoute.NotificationsRoute.EditProfile())
|
||||
|
||||
@JvmStatic
|
||||
fun privacy(context: Context): Intent = getIntentForStartLocation(context, StartLocation.PRIVACY)
|
||||
fun privacy(context: Context): Intent = getIntentForStartLocation(context, AppSettingsRoute.PrivacyRoute.Privacy)
|
||||
|
||||
@JvmStatic
|
||||
fun notificationProfileDetails(context: Context, profileId: Long): Intent {
|
||||
val arguments = EditNotificationProfileScheduleFragmentArgs.Builder(profileId, false)
|
||||
.build()
|
||||
.toBundle()
|
||||
|
||||
return getIntentForStartLocation(context, StartLocation.NOTIFICATION_PROFILE_DETAILS)
|
||||
.putExtra(START_ARGUMENTS, arguments)
|
||||
return getIntentForStartLocation(context, AppSettingsRoute.NotificationsRoute.ProfileDetails(profileId = profileId))
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun linkedDevices(context: Context): Intent = getIntentForStartLocation(context, StartLocation.LINKED_DEVICES)
|
||||
fun linkedDevices(context: Context): Intent = getIntentForStartLocation(context, AppSettingsRoute.LinkDeviceRoute.LinkDevice)
|
||||
|
||||
@JvmStatic
|
||||
fun usernameLinkSettings(context: Context): Intent = getIntentForStartLocation(context, StartLocation.USERNAME_LINK)
|
||||
fun usernameLinkSettings(context: Context): Intent = getIntentForStartLocation(context, AppSettingsRoute.UsernameLinkRoute.UsernameLink)
|
||||
|
||||
@JvmStatic
|
||||
fun usernameRecovery(context: Context): Intent = getIntentForStartLocation(context, StartLocation.RECOVER_USERNAME)
|
||||
fun usernameRecovery(context: Context): Intent = getIntentForStartLocation(context, AppSettingsRoute.AccountRoute.Username(mode = UsernameEditMode.RECOVERY))
|
||||
|
||||
@JvmStatic
|
||||
fun remoteBackups(context: Context): Intent = getIntentForStartLocation(context, StartLocation.REMOTE_BACKUPS)
|
||||
fun remoteBackups(context: Context): Intent = getIntentForStartLocation(context, AppSettingsRoute.BackupsRoute.Remote())
|
||||
|
||||
@JvmStatic
|
||||
fun chatFolders(context: Context): Intent = getIntentForStartLocation(context, StartLocation.CHAT_FOLDERS)
|
||||
fun chatFolders(context: Context): Intent = getIntentForStartLocation(context, AppSettingsRoute.ChatFoldersRoute.ChatFolders)
|
||||
|
||||
@JvmStatic
|
||||
fun createChatFolder(context: Context, id: Long = -1, threadIds: LongArray?): Intent {
|
||||
val arguments = CreateFoldersFragmentArgs.Builder(id, threadIds ?: longArrayOf())
|
||||
.build()
|
||||
.toBundle()
|
||||
|
||||
return getIntentForStartLocation(context, StartLocation.CREATE_CHAT_FOLDER).putExtra(START_ARGUMENTS, arguments)
|
||||
return getIntentForStartLocation(
|
||||
context,
|
||||
AppSettingsRoute.ChatFoldersRoute.CreateChatFolders(
|
||||
folderId = id,
|
||||
threadIds = threadIds ?: longArrayOf()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun backupsSettings(context: Context): Intent = getIntentForStartLocation(context, StartLocation.BACKUPS_SETTINGS)
|
||||
fun backupsSettings(context: Context): Intent = getIntentForStartLocation(context, AppSettingsRoute.BackupsRoute.Backups)
|
||||
|
||||
@JvmStatic
|
||||
fun invite(context: Context): Intent = getIntentForStartLocation(context, StartLocation.INVITE)
|
||||
fun invite(context: Context): Intent = getIntentForStartLocation(context, AppSettingsRoute.Invite)
|
||||
|
||||
private fun getIntentForStartLocation(context: Context, startLocation: StartLocation): Intent {
|
||||
private fun getIntentForStartLocation(context: Context, startRoute: AppSettingsRoute): Intent {
|
||||
return Intent(context, AppSettingsActivity::class.java)
|
||||
.putExtra(ARG_NAV_GRAPH, R.navigation.app_settings_with_change_number)
|
||||
.putExtra(START_LOCATION, startLocation.code)
|
||||
}
|
||||
}
|
||||
|
||||
private enum class StartLocation(val code: Int) {
|
||||
HOME(0),
|
||||
BACKUPS(1),
|
||||
HELP(2),
|
||||
PROXY(3),
|
||||
NOTIFICATIONS(4),
|
||||
CHANGE_NUMBER(5),
|
||||
SUBSCRIPTIONS(6),
|
||||
BOOST(7),
|
||||
MANAGE_SUBSCRIPTIONS(8),
|
||||
NOTIFICATION_PROFILES(9),
|
||||
CREATE_NOTIFICATION_PROFILE(10),
|
||||
NOTIFICATION_PROFILE_DETAILS(11),
|
||||
PRIVACY(12),
|
||||
LINKED_DEVICES(13),
|
||||
USERNAME_LINK(14),
|
||||
RECOVER_USERNAME(15),
|
||||
REMOTE_BACKUPS(16),
|
||||
CHAT_FOLDERS(17),
|
||||
CREATE_CHAT_FOLDER(18),
|
||||
BACKUPS_SETTINGS(19),
|
||||
INVITE(20),
|
||||
MANAGE_STORAGE(21);
|
||||
|
||||
companion object {
|
||||
fun fromCode(code: Int?): StartLocation {
|
||||
return entries.find { code == it.code } ?: HOME
|
||||
}
|
||||
.putExtra(START_ROUTE, startRoute)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user