mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-13 13:33:20 +01:00
Compare commits
93 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 = 1592
|
||||
val canonicalVersionName = "7.58.0"
|
||||
val currentHotfixVersion = 0
|
||||
val maxHotfixVersions = 100
|
||||
|
||||
@@ -217,7 +218,7 @@ android {
|
||||
buildConfigField("String", "SVR2_MRENCLAVE_LEGACY_LEGACY", "\"9314436a9a144992bb3680770ea5fd7934a7ffd29257844a33763a238903d570\"")
|
||||
buildConfigField("String", "SVR2_MRENCLAVE_LEGACY", "\"093be9ea32405e85ae28dbb48eb668aebeb7dbe29517b9b86ad4bec4dfe0e6a6\"")
|
||||
buildConfigField("String", "SVR2_MRENCLAVE", "\"29cd63c87bea751e3bfd0fbd401279192e2e5c99948b4ee9437eafc4968355fb\"")
|
||||
buildConfigField("String", "UNIDENTIFIED_SENDER_TRUST_ROOT", "\"BXu6QIKVz5MA8gstzfOgRQGqyLqOwNKHL6INkv3IHWMF\"")
|
||||
buildConfigField("String[]", "UNIDENTIFIED_SENDER_TRUST_ROOTS", "new String[]{ \"BXu6QIKVz5MA8gstzfOgRQGqyLqOwNKHL6INkv3IHWMF\", \"BUkY0I+9+oPgDCn4+Ac6Iu813yvqkDr/ga8DzLxFxuk6\"}")
|
||||
buildConfigField("String", "ZKGROUP_SERVER_PUBLIC_PARAMS", "\"AMhf5ywVwITZMsff/eCyudZx9JDmkkkbV6PInzG4p8x3VqVJSFiMvnvlEKWuRob/1eaIetR31IYeAbm0NdOuHH8Qi+Rexi1wLlpzIo1gstHWBfZzy1+qHRV5A4TqPp15YzBPm0WSggW6PbSn+F4lf57VCnHF7p8SvzAA2ZZJPYJURt8X7bbg+H3i+PEjH9DXItNEqs2sNcug37xZQDLm7X36nOoGPs54XsEGzPdEV+itQNGUFEjY6X9Uv+Acuks7NpyGvCoKxGwgKgE5XyJ+nNKlyHHOLb6N1NuHyBrZrgtY/JYJHRooo5CEqYKBqdFnmbTVGEkCvJKxLnjwKWf+fEPoWeQFj5ObDjcKMZf2Jm2Ae69x+ikU5gBXsRmoF94GXTLfN0/vLt98KDPnxwAQL9j5V1jGOY8jQl6MLxEs56cwXN0dqCnImzVH3TZT1cJ8SW1BRX6qIVxEzjsSGx3yxF3suAilPMqGRp4ffyopjMD1JXiKR2RwLKzizUe5e8XyGOy9fplzhw3jVzTRyUZTRSZKkMLWcQ/gv0E4aONNqs4P+NameAZYOD12qRkxosQQP5uux6B2nRyZ7sAV54DgFyLiRcq1FvwKw2EPQdk4HDoePrO/RNUbyNddnM/mMgj4FW65xCoT1LmjrIjsv/Ggdlx46ueczhMgtBunx1/w8k8V+l8LVZ8gAT6wkU5J+DPQalQguMg12Jzug3q4TbdHiGCmD9EunCwOmsLuLJkz6EcSYXtrlDEnAM+hicw7iergYLLlMXpfTdGxJCWJmP4zqUFeTTmsmhsjGBt7NiEB/9pFFEB3pSbf4iiUukw63Eo8Aqnf4iwob6X1QviCWuc8t0LUlT9vALgh/f2DPVOOmR0RW6bgRvc7DSF20V/omg+YBw==\"")
|
||||
buildConfigField("String", "GENERIC_SERVER_PUBLIC_PARAMS", "\"AByD873dTilmOSG0TjKrvpeaKEsUmIO8Vx9BeMmftwUs9v7ikPwM8P3OHyT0+X3EUMZrSe9VUp26Wai51Q9I8mdk0hX/yo7CeFGJyzoOqn8e/i4Ygbn5HoAyXJx5eXfIbqpc0bIxzju4H/HOQeOpt6h742qii5u/cbwOhFZCsMIbElZTaeU+BWMBQiZHIGHT5IE0qCordQKZ5iPZom0HeFa8Yq0ShuEyAl0WINBiY6xE3H/9WnvzXBbMuuk//eRxXgzO8ieCeK8FwQNxbfXqZm6Ro1cMhCOF3u7xoX83QhpN\"")
|
||||
buildConfigField("String", "BACKUP_SERVER_PUBLIC_PARAMS", "\"AJwNSU55fsFCbgaxGRD11wO1juAs8Yr5GF8FPlGzzvdJJIKH5/4CC7ZJSOe3yL2vturVaRU2Cx0n751Vt8wkj1bozK3CBV1UokxV09GWf+hdVImLGjXGYLLhnI1J2TWEe7iWHyb553EEnRb5oxr9n3lUbNAJuRmFM7hrr0Al0F0wrDD4S8lo2mGaXe0MJCOM166F8oYRQqpFeEHfiLnxA1O8ZLh7vMdv4g9jI5phpRBTsJ5IjiJrWeP0zdIGHEssUeprDZ9OUJ14m0v61eYJMKsf59Bn+mAT2a7YfB+Don9O\"")
|
||||
@@ -237,7 +238,6 @@ android {
|
||||
buildConfigField("String", "STRIPE_BASE_URL", "\"https://api.stripe.com/v1\"")
|
||||
buildConfigField("String", "STRIPE_PUBLISHABLE_KEY", "\"pk_live_6cmGZopuTsV8novGgJJW9JpC00vLIgtQ1D\"")
|
||||
buildConfigField("boolean", "TRACING_ENABLED", "false")
|
||||
buildConfigField("boolean", "MESSAGE_BACKUP_RESTORE_ENABLED", "true")
|
||||
buildConfigField("boolean", "LINK_DEVICE_UX_ENABLED", "false")
|
||||
|
||||
ndk {
|
||||
@@ -378,7 +378,6 @@ android {
|
||||
buildConfigField("boolean", "MANAGES_APP_UPDATES", "true")
|
||||
buildConfigField("String", "APK_UPDATE_MANIFEST_URL", "\"${apkUpdateManifestUrl}\"")
|
||||
buildConfigField("String", "BUILD_DISTRIBUTION_TYPE", "\"nightly\"")
|
||||
buildConfigField("boolean", "MESSAGE_BACKUP_RESTORE_ENABLED", "true")
|
||||
}
|
||||
|
||||
create("prod") {
|
||||
@@ -405,7 +404,7 @@ android {
|
||||
buildConfigField("String", "SVR2_MRENCLAVE_LEGACY_LEGACY", "\"38e01eff4fe357dc0b0e8ef7a44b4abc5489fbccba3a78780f3872c277f62bf3\"")
|
||||
buildConfigField("String", "SVR2_MRENCLAVE_LEGACY", "\"2e8cefe6e3f389d8426adb24e9b7fb7adf10902c96f06f7bbcee36277711ed91\"")
|
||||
buildConfigField("String", "SVR2_MRENCLAVE", "\"a75542d82da9f6914a1e31f8a7407053b99cc99a0e7291d8fbd394253e19b036\"")
|
||||
buildConfigField("String", "UNIDENTIFIED_SENDER_TRUST_ROOT", "\"BbqY1DzohE4NUZoVF+L18oUPrK3kILllLEJh2UnPSsEx\"")
|
||||
buildConfigField("String[]", "UNIDENTIFIED_SENDER_TRUST_ROOTS", "new String[]{\"BbqY1DzohE4NUZoVF+L18oUPrK3kILllLEJh2UnPSsEx\", \"BYhU6tPjqP46KGZEzRs1OL4U39V5dlPJ/X09ha4rErkm\"}")
|
||||
buildConfigField("String", "ZKGROUP_SERVER_PUBLIC_PARAMS", "\"ABSY21VckQcbSXVNCGRYJcfWHiAMZmpTtTELcDmxgdFbtp/bWsSxZdMKzfCp8rvIs8ocCU3B37fT3r4Mi5qAemeGeR2X+/YmOGR5ofui7tD5mDQfstAI9i+4WpMtIe8KC3wU5w3Inq3uNWVmoGtpKndsNfwJrCg0Hd9zmObhypUnSkfYn2ooMOOnBpfdanRtrvetZUayDMSC5iSRcXKpdlukrpzzsCIvEwjwQlJYVPOQPj4V0F4UXXBdHSLK05uoPBCQG8G9rYIGedYsClJXnbrgGYG3eMTG5hnx4X4ntARBgELuMWWUEEfSK0mjXg+/2lPmWcTZWR9nkqgQQP0tbzuiPm74H2wMO4u1Wafe+UwyIlIT9L7KLS19Aw8r4sPrXZSSsOZ6s7M1+rTJN0bI5CKY2PX29y5Ok3jSWufIKcgKOnWoP67d5b2du2ZVJjpjfibNIHbT/cegy/sBLoFwtHogVYUewANUAXIaMPyCLRArsKhfJ5wBtTminG/PAvuBdJ70Z/bXVPf8TVsR292zQ65xwvWTejROW6AZX6aqucUjlENAErBme1YHmOSpU6tr6doJ66dPzVAWIanmO/5mgjNEDeK7DDqQdB1xd03HT2Qs2TxY3kCK8aAb/0iM0HQiXjxZ9HIgYhbtvGEnDKW5ILSUydqH/KBhW4Pb0jZWnqN/YgbWDKeJxnDbYcUob5ZY5Lt5ZCMKuaGUvCJRrCtuugSMaqjowCGRempsDdJEt+cMaalhZ6gczklJB/IbdwENW9KeVFPoFNFzhxWUIS5ML9riVYhAtE6JE5jX0xiHNVIIPthb458cfA8daR0nYfYAUKogQArm0iBezOO+mPk5vCNWI+wwkyFCqNDXz/qxl1gAntuCJtSfq9OC3NkdhQlgYQ==\"")
|
||||
buildConfigField("String", "GENERIC_SERVER_PUBLIC_PARAMS", "\"AHILOIrFPXX9laLbalbA9+L1CXpSbM/bTJXZGZiuyK1JaI6dK5FHHWL6tWxmHKYAZTSYmElmJ5z2A5YcirjO/yfoemE03FItyaf8W1fE4p14hzb5qnrmfXUSiAIVrhaXVwIwSzH6RL/+EO8jFIjJ/YfExfJ8aBl48CKHgu1+A6kWynhttonvWWx6h7924mIzW0Czj2ROuh4LwQyZypex4GuOPW8sgIT21KNZaafgg+KbV7XM1x1tF3XA17B4uGUaDbDw2O+nR1+U5p6qHPzmJ7ggFjSN6Utu+35dS1sS0P9N\"")
|
||||
buildConfigField("String", "BACKUP_SERVER_PUBLIC_PARAMS", "\"AHYrGb9IfugAAJiPKp+mdXUx+OL9zBolPYHYQz6GI1gWjpEu5me3zVNSvmYY4zWboZHif+HG1sDHSuvwFd0QszSwuSF4X4kRP3fJREdTZ5MCR0n55zUppTwfHRW2S4sdQ0JGz7YDQIJCufYSKh0pGNEHL6hv79Agrdnr4momr3oXdnkpVBIp3HWAQ6IbXQVSG18X36GaicI1vdT0UFmTwU2KTneluC2eyL9c5ff8PcmiS+YcLzh0OKYQXB5ZfQ06d6DiINvDQLy75zcfUOniLAj0lGJiHxGczin/RXisKSR8\"")
|
||||
@@ -417,7 +416,6 @@ android {
|
||||
|
||||
buildConfigField("String", "BUILD_ENVIRONMENT_TYPE", "\"Staging\"")
|
||||
buildConfigField("String", "STRIPE_PUBLISHABLE_KEY", "\"pk_test_sngOd8FnXNkpce9nPXawKrJD00kIDngZkD\"")
|
||||
buildConfigField("boolean", "MESSAGE_BACKUP_RESTORE_ENABLED", "true")
|
||||
}
|
||||
|
||||
create("backup") {
|
||||
@@ -429,7 +427,6 @@ android {
|
||||
|
||||
buildConfigField("boolean", "MANAGES_APP_UPDATES", "true")
|
||||
buildConfigField("String", "BUILD_ENVIRONMENT_TYPE", "\"Backup\"")
|
||||
buildConfigField("boolean", "MESSAGE_BACKUP_RESTORE_ENABLED", "true")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -607,6 +604,7 @@ dependencies {
|
||||
implementation(libs.rxdogtag)
|
||||
implementation(libs.androidx.credentials)
|
||||
implementation(libs.androidx.credentials.compat)
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
|
||||
implementation(project(":billing"))
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -24,6 +24,7 @@ import org.junit.runner.RunWith
|
||||
import org.signal.core.util.billing.BillingProduct
|
||||
import org.signal.core.util.billing.BillingPurchaseResult
|
||||
import org.signal.core.util.billing.BillingPurchaseState
|
||||
import org.signal.core.util.billing.BillingResponseCode
|
||||
import org.signal.core.util.money.FiatMoney
|
||||
import org.signal.donations.InAppPaymentType
|
||||
import org.thoughtcrime.securesms.backup.DeletionState
|
||||
@@ -64,10 +65,9 @@ class BackupSubscriptionCheckJobTest {
|
||||
@Before
|
||||
fun setUp() {
|
||||
mockkObject(RemoteConfig)
|
||||
every { RemoteConfig.messageBackups } returns true
|
||||
every { RemoteConfig.internalUser } returns true
|
||||
|
||||
coEvery { AppDependencies.billingApi.isApiAvailable() } returns true
|
||||
coEvery { AppDependencies.billingApi.getApiAvailability() } returns BillingResponseCode.OK
|
||||
coEvery { AppDependencies.billingApi.queryPurchases() } returns mockk()
|
||||
coEvery { AppDependencies.billingApi.queryProduct() } returns null
|
||||
|
||||
@@ -143,7 +143,7 @@ class BackupSubscriptionCheckJobTest {
|
||||
|
||||
@Test
|
||||
fun givenBillingApiNotAvailable_whenIRun_thenIExpectSuccessAndEarlyExit() {
|
||||
coEvery { AppDependencies.billingApi.isApiAvailable() } returns false
|
||||
coEvery { AppDependencies.billingApi.getApiAvailability() } returns BillingResponseCode.BILLING_UNAVAILABLE
|
||||
|
||||
val job = BackupSubscriptionCheckJob.create()
|
||||
val result = job.run()
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -843,19 +843,12 @@
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
|
||||
android:exported="false"/>
|
||||
|
||||
<activity android:name=".registrationv3.olddevice.TransferAccountActivity"
|
||||
<activity android:name=".registration.olddevice.TransferAccountActivity"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
|
||||
android:theme="@style/Theme.Signal.DayNight.NoActionBar"
|
||||
android:exported="false"/>
|
||||
|
||||
<activity android:name=".registration.ui.RegistrationActivity"
|
||||
android:launchMode="singleTask"
|
||||
android:theme="@style/Theme.Signal.DayNight.NoActionBar"
|
||||
android:windowSoftInputMode="stateHidden"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
|
||||
android:exported="false"/>
|
||||
|
||||
<activity android:name=".registrationv3.ui.RegistrationActivity"
|
||||
android:launchMode="singleTask"
|
||||
android:theme="@style/Theme.Signal.DayNight.NoActionBar"
|
||||
android:windowSoftInputMode="stateHidden|adjustResize"
|
||||
@@ -930,17 +923,7 @@
|
||||
android:theme="@style/TextSecure.DialogActivity"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
|
||||
|
||||
<activity android:name=".SmsSendtoActivity" android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SENDTO" />
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="sms" />
|
||||
<data android:scheme="smsto" />
|
||||
<data android:scheme="mms" />
|
||||
<data android:scheme="mmsto" />
|
||||
</intent-filter>
|
||||
<activity android:name=".SystemContactsEntrypointActivity" android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
@@ -991,7 +974,7 @@
|
||||
android:windowSoftInputMode="stateVisible|adjustResize"
|
||||
android:exported="false"/>
|
||||
|
||||
<activity android:name=".registrationv3.ui.restore.RemoteRestoreActivity"
|
||||
<activity android:name=".registration.ui.restore.RemoteRestoreActivity"
|
||||
android:theme="@style/Signal.DayNight.NoActionBar"
|
||||
android:exported="false"/>
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -18,12 +18,12 @@ import android.view.ViewTreeObserver
|
||||
import android.widget.Toast
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.activity.SystemBarStyle
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.activity.viewModels
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -37,6 +37,8 @@ import androidx.compose.foundation.layout.systemBarsPadding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.SnackbarDuration
|
||||
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
|
||||
import androidx.compose.material3.adaptive.layout.PaneExpansionAnchor
|
||||
import androidx.compose.material3.adaptive.layout.ThreePaneScaffoldRole
|
||||
import androidx.compose.material3.adaptive.layout.rememberPaneExpansionState
|
||||
import androidx.compose.material3.adaptive.navigation.ThreePaneScaffoldNavigator
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -48,8 +50,8 @@ import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.fragment.app.DialogFragment
|
||||
import androidx.fragment.compose.AndroidFragment
|
||||
import androidx.fragment.compose.rememberFragmentState
|
||||
@@ -74,6 +76,7 @@ import org.signal.donations.StripeApi
|
||||
import org.thoughtcrime.securesms.backup.v2.ArchiveRestoreProgress
|
||||
import org.thoughtcrime.securesms.backup.v2.ui.verify.VerifyBackupKeyActivity
|
||||
import org.thoughtcrime.securesms.calls.YouAreAlreadyInACallSnackbar.show
|
||||
import org.thoughtcrime.securesms.calls.links.details.CallLinkDetailsActivity
|
||||
import org.thoughtcrime.securesms.calls.log.CallLogFilter
|
||||
import org.thoughtcrime.securesms.calls.log.CallLogFragment
|
||||
import org.thoughtcrime.securesms.calls.new.NewCallActivity
|
||||
@@ -87,7 +90,6 @@ import org.thoughtcrime.securesms.components.settings.app.notifications.manual.N
|
||||
import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaController
|
||||
import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaControllerOwner
|
||||
import org.thoughtcrime.securesms.conversation.ConversationIntents
|
||||
import org.thoughtcrime.securesms.conversation.v2.ConversationFragment
|
||||
import org.thoughtcrime.securesms.conversation.v2.MotionEventRelay
|
||||
import org.thoughtcrime.securesms.conversation.v2.ShareDataTimestampViewModel
|
||||
import org.thoughtcrime.securesms.conversationlist.ConversationListArchiveFragment
|
||||
@@ -100,6 +102,8 @@ import org.thoughtcrime.securesms.devicetransfer.olddevice.OldDeviceExitActivity
|
||||
import org.thoughtcrime.securesms.groups.ui.creategroup.CreateGroupActivity
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.lock.v2.CreateSvrPinActivity
|
||||
import org.thoughtcrime.securesms.main.DetailsScreenNavHost
|
||||
import org.thoughtcrime.securesms.main.InsetsViewModelUpdater
|
||||
import org.thoughtcrime.securesms.main.MainBottomChrome
|
||||
import org.thoughtcrime.securesms.main.MainBottomChromeCallback
|
||||
import org.thoughtcrime.securesms.main.MainBottomChromeState
|
||||
@@ -118,6 +122,11 @@ import org.thoughtcrime.securesms.main.MainToolbarViewModel
|
||||
import org.thoughtcrime.securesms.main.Material3OnScrollHelperBinder
|
||||
import org.thoughtcrime.securesms.main.NavigationBarSpacerCompat
|
||||
import org.thoughtcrime.securesms.main.SnackbarState
|
||||
import org.thoughtcrime.securesms.main.callNavGraphBuilder
|
||||
import org.thoughtcrime.securesms.main.chatNavGraphBuilder
|
||||
import org.thoughtcrime.securesms.main.navigateToDetailLocation
|
||||
import org.thoughtcrime.securesms.main.rememberDetailNavHostController
|
||||
import org.thoughtcrime.securesms.main.storiesNavGraphBuilder
|
||||
import org.thoughtcrime.securesms.mediasend.camerax.CameraXUtil
|
||||
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionActivity
|
||||
import org.thoughtcrime.securesms.megaphone.Megaphone
|
||||
@@ -297,7 +306,6 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
|
||||
val mainToolbarState by toolbarViewModel.state.collectAsStateWithLifecycle()
|
||||
val megaphone by mainNavigationViewModel.megaphone.collectAsStateWithLifecycle()
|
||||
val mainNavigationState by mainNavigationViewModel.mainNavigationState.collectAsStateWithLifecycle()
|
||||
val mainNavigationDetailLocation by mainNavigationViewModel.detailLocation.collectAsStateWithLifecycle()
|
||||
|
||||
LaunchedEffect(mainNavigationState.currentListLocation) {
|
||||
when (mainNavigationState.currentListLocation) {
|
||||
@@ -308,8 +316,11 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
|
||||
}
|
||||
}
|
||||
|
||||
val isNavigationVisible = remember(mainToolbarState.mode) {
|
||||
mainToolbarState.mode == MainToolbarMode.FULL
|
||||
val isNavigationVisible = mainToolbarState.mode == MainToolbarMode.FULL
|
||||
val isBackHandlerEnabled = mainToolbarState.destination != MainNavigationListLocation.CHATS
|
||||
|
||||
BackHandler(enabled = isBackHandlerEnabled) {
|
||||
mainNavigationViewModel.goTo(MainNavigationListLocation.CHATS)
|
||||
}
|
||||
|
||||
val mainBottomChromeState = remember(mainToolbarState.destination, snackbar, mainToolbarState.mode, megaphone) {
|
||||
@@ -329,8 +340,62 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
|
||||
|
||||
MainContainer {
|
||||
val wrappedNavigator = rememberNavigator(windowSizeClass, contentLayoutData, maxWidth)
|
||||
val paneExpansionState = rememberPaneExpansionState()
|
||||
val listPaneWidth = contentLayoutData.rememberDefaultPanePreferredWidth(maxWidth)
|
||||
val halfPartitionWidth = contentLayoutData.partitionWidth / 2
|
||||
|
||||
val detailOffset = if (mainToolbarState.mode == MainToolbarMode.SEARCH || mainToolbarState.mode == MainToolbarMode.ACTION_MODE) 0.dp else 72.dp
|
||||
val detailOnlyAnchor = PaneExpansionAnchor.Offset.fromStart(detailOffset + contentLayoutData.listPaddingStart + halfPartitionWidth)
|
||||
val detailAndListAnchor = PaneExpansionAnchor.Offset.fromStart(listPaneWidth + halfPartitionWidth)
|
||||
val listOnlyAnchor = PaneExpansionAnchor.Offset.fromEnd(contentLayoutData.detailPaddingEnd - halfPartitionWidth)
|
||||
|
||||
val paneExpansionState = rememberPaneExpansionState(
|
||||
anchors = listOf(detailOnlyAnchor, detailAndListAnchor, listOnlyAnchor)
|
||||
)
|
||||
|
||||
val mutableInteractionSource = remember { MutableInteractionSource() }
|
||||
val mainNavigationDetailLocation by mainNavigationViewModel.detailLocation.collectAsStateWithLifecycle(mainNavigationViewModel.earlyNavigationDetailLocationRequested ?: MainNavigationDetailLocation.Empty)
|
||||
|
||||
val chatsNavHostController = rememberDetailNavHostController {
|
||||
chatNavGraphBuilder()
|
||||
}
|
||||
|
||||
val callsNavHostController = rememberDetailNavHostController {
|
||||
callNavGraphBuilder(it)
|
||||
}
|
||||
|
||||
val storiesNavHostController = rememberDetailNavHostController {
|
||||
storiesNavGraphBuilder()
|
||||
}
|
||||
|
||||
LaunchedEffect(mainNavigationDetailLocation) {
|
||||
mainNavigationViewModel.clearEarlyDetailLocation()
|
||||
when (mainNavigationDetailLocation) {
|
||||
is MainNavigationDetailLocation.Empty -> {
|
||||
when (mainNavigationState.currentListLocation) {
|
||||
MainNavigationListLocation.CHATS, MainNavigationListLocation.ARCHIVE -> chatsNavHostController
|
||||
MainNavigationListLocation.CALLS -> callsNavHostController
|
||||
MainNavigationListLocation.STORIES -> storiesNavHostController
|
||||
}.navigateToDetailLocation(mainNavigationDetailLocation)
|
||||
}
|
||||
is MainNavigationDetailLocation.Chats -> chatsNavHostController.navigateToDetailLocation(mainNavigationDetailLocation)
|
||||
is MainNavigationDetailLocation.Calls -> callsNavHostController.navigateToDetailLocation(mainNavigationDetailLocation)
|
||||
is MainNavigationDetailLocation.Stories -> storiesNavHostController.navigateToDetailLocation(mainNavigationDetailLocation)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(mainNavigationDetailLocation) {
|
||||
if (paneExpansionState.currentAnchor == listOnlyAnchor && wrappedNavigator.currentDestination?.pane == ThreePaneScaffoldRole.Primary) {
|
||||
paneExpansionState.animateTo(detailOnlyAnchor)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(mainNavigationState.currentListLocation) {
|
||||
if (paneExpansionState.currentAnchor == detailOnlyAnchor && wrappedNavigator.currentDestination?.pane == ThreePaneScaffoldRole.Secondary) {
|
||||
paneExpansionState.animateTo(listOnlyAnchor)
|
||||
}
|
||||
}
|
||||
|
||||
InsetsViewModelUpdater()
|
||||
|
||||
AppScaffold(
|
||||
navigator = wrappedNavigator,
|
||||
@@ -432,35 +497,26 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
|
||||
}
|
||||
},
|
||||
detailContent = {
|
||||
when (val destination = mainNavigationDetailLocation) {
|
||||
is MainNavigationDetailLocation.Conversation -> {
|
||||
val fragmentState = key(destination) { rememberFragmentState() }
|
||||
AndroidFragment(
|
||||
clazz = ConversationFragment::class.java,
|
||||
fragmentState = fragmentState,
|
||||
arguments = requireNotNull(destination.intent.extras) { "Handed null Conversation intent arguments." },
|
||||
modifier = Modifier
|
||||
.padding(end = contentLayoutData.detailPaddingEnd)
|
||||
.clip(contentLayoutData.shape)
|
||||
.background(color = MaterialTheme.colorScheme.surface)
|
||||
.fillMaxSize()
|
||||
when (mainNavigationState.currentListLocation) {
|
||||
MainNavigationListLocation.CHATS, MainNavigationListLocation.ARCHIVE -> {
|
||||
DetailsScreenNavHost(
|
||||
navHostController = chatsNavHostController,
|
||||
contentLayoutData = contentLayoutData
|
||||
)
|
||||
}
|
||||
|
||||
MainNavigationDetailLocation.Empty -> {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(end = contentLayoutData.detailPaddingEnd)
|
||||
.clip(contentLayoutData.shape)
|
||||
.background(color = MaterialTheme.colorScheme.surface)
|
||||
.fillMaxSize()
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.ic_signal_logo_large),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.align(Alignment.Center)
|
||||
)
|
||||
}
|
||||
MainNavigationListLocation.CALLS -> {
|
||||
DetailsScreenNavHost(
|
||||
navHostController = callsNavHostController,
|
||||
contentLayoutData = contentLayoutData
|
||||
)
|
||||
}
|
||||
|
||||
MainNavigationListLocation.STORIES -> {
|
||||
DetailsScreenNavHost(
|
||||
navHostController = storiesNavHostController,
|
||||
contentLayoutData = contentLayoutData
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -521,8 +577,20 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
|
||||
return remember(scaffoldNavigator, coroutine) {
|
||||
mainNavigationViewModel.wrapNavigator(coroutine, scaffoldNavigator) { detailLocation ->
|
||||
when (detailLocation) {
|
||||
is MainNavigationDetailLocation.Conversation -> {
|
||||
startActivity(detailLocation.intent)
|
||||
is MainNavigationDetailLocation.Chats.Conversation -> {
|
||||
startActivity(
|
||||
ConversationIntents.createBuilderSync(this, detailLocation.conversationArgs.recipientId, detailLocation.conversationArgs.threadId)
|
||||
.withArgs(detailLocation.conversationArgs)
|
||||
.build()
|
||||
)
|
||||
}
|
||||
|
||||
is MainNavigationDetailLocation.Calls.CallLinks.CallLinkDetails -> {
|
||||
startActivity(CallLinkDetailsActivity.createIntent(this, detailLocation.callLinkRoomId))
|
||||
}
|
||||
|
||||
is MainNavigationDetailLocation.Calls.CallLinks.EditCallLinkName -> {
|
||||
error("Unexpected subroute EditCallLinkName.")
|
||||
}
|
||||
|
||||
MainNavigationDetailLocation.Empty -> Unit
|
||||
@@ -744,7 +812,7 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
|
||||
private fun handleConversationIntent(intent: Intent) {
|
||||
if (ConversationIntents.isConversationIntent(intent)) {
|
||||
mainNavigationViewModel.goTo(MainNavigationListLocation.CHATS)
|
||||
mainNavigationViewModel.goTo(MainNavigationDetailLocation.Conversation(intent))
|
||||
mainNavigationViewModel.goTo(MainNavigationDetailLocation.Chats.Conversation(ConversationIntents.readArgsFromBundle(intent.extras!!)))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,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
|
||||
@@ -130,6 +132,7 @@ import org.thoughtcrime.securesms.service.BackupProgressService
|
||||
import org.thoughtcrime.securesms.storage.StorageSyncHelper
|
||||
import org.thoughtcrime.securesms.util.RemoteConfig
|
||||
import org.thoughtcrime.securesms.util.ServiceUtil
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences
|
||||
import org.thoughtcrime.securesms.util.toMillis
|
||||
import org.whispersystems.signalservice.api.AccountEntropyPool
|
||||
import org.whispersystems.signalservice.api.ApplicationErrorAction
|
||||
@@ -163,6 +166,7 @@ import java.io.File
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
import java.math.BigDecimal
|
||||
import java.time.ZonedDateTime
|
||||
import java.util.Currency
|
||||
import java.util.Locale
|
||||
@@ -542,7 +546,9 @@ object BackupRepository {
|
||||
return false
|
||||
}
|
||||
|
||||
return SignalStore.backup.hasBackupBeenUploaded && System.currentTimeMillis().milliseconds > SignalStore.backup.nextBackupFailureSheetSnoozeTime
|
||||
val isRegistered = SignalStore.account.isRegistered && !TextSecurePreferences.isUnauthorizedReceived(AppDependencies.application)
|
||||
|
||||
return SignalStore.backup.hasBackupBeenUploaded && System.currentTimeMillis().milliseconds > SignalStore.backup.nextBackupFailureSheetSnoozeTime && isRegistered
|
||||
}
|
||||
|
||||
fun snoozeDownloadYourBackupData() {
|
||||
@@ -615,7 +621,7 @@ object BackupRepository {
|
||||
}
|
||||
|
||||
private fun shouldNotDisplayBackupFailedMessaging(): Boolean {
|
||||
return !SignalStore.account.isRegistered || !RemoteConfig.messageBackups || !SignalStore.backup.areBackupsEnabled
|
||||
return !SignalStore.account.isRegistered || !SignalStore.backup.areBackupsEnabled
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -718,13 +724,18 @@ object BackupRepository {
|
||||
append = { main.write(it) }
|
||||
)
|
||||
|
||||
val maxBufferSize = 10_000
|
||||
var totalAttachmentCount = 0
|
||||
val attachmentInfos: MutableSet<ArchiveAttachmentInfo> = mutableSetOf()
|
||||
|
||||
export(
|
||||
currentTime = System.currentTimeMillis(),
|
||||
isLocal = true,
|
||||
writer = writer,
|
||||
progressEmitter = localBackupProgressEmitter,
|
||||
cancellationSignal = cancellationSignal,
|
||||
forTransfer = false
|
||||
forTransfer = false,
|
||||
extraFrameOperation = null
|
||||
) { dbSnapshot ->
|
||||
val localArchivableAttachments = dbSnapshot
|
||||
.attachmentTable
|
||||
@@ -760,7 +771,7 @@ object BackupRepository {
|
||||
currentTime: Long,
|
||||
progressEmitter: ExportProgressListener? = null,
|
||||
cancellationSignal: () -> Boolean = { false },
|
||||
extraExportOperations: ((SignalDatabase) -> Unit)?
|
||||
extraFrameOperation: ((Frame) -> Unit)?
|
||||
) {
|
||||
val writer = EncryptedBackupWriter.createForSignalBackup(
|
||||
key = messageBackupKey,
|
||||
@@ -778,7 +789,8 @@ object BackupRepository {
|
||||
forTransfer = false,
|
||||
progressEmitter = progressEmitter,
|
||||
cancellationSignal = cancellationSignal,
|
||||
extraExportOperations = extraExportOperations
|
||||
extraFrameOperation = extraFrameOperation,
|
||||
endingExportOperation = null
|
||||
)
|
||||
}
|
||||
|
||||
@@ -807,7 +819,8 @@ object BackupRepository {
|
||||
forTransfer = true,
|
||||
progressEmitter = progressEmitter,
|
||||
cancellationSignal = cancellationSignal,
|
||||
extraExportOperations = null
|
||||
extraFrameOperation = null,
|
||||
endingExportOperation = null
|
||||
)
|
||||
}
|
||||
|
||||
@@ -821,8 +834,7 @@ object BackupRepository {
|
||||
currentTime: Long = System.currentTimeMillis(),
|
||||
forTransfer: Boolean = false,
|
||||
progressEmitter: ExportProgressListener? = null,
|
||||
cancellationSignal: () -> Boolean = { false },
|
||||
extraExportOperations: ((SignalDatabase) -> Unit)? = null
|
||||
cancellationSignal: () -> Boolean = { false }
|
||||
) {
|
||||
val writer: BackupExportWriter = if (plaintext) {
|
||||
PlainTextBackupWriter(outputStream)
|
||||
@@ -842,7 +854,8 @@ object BackupRepository {
|
||||
forTransfer = forTransfer,
|
||||
progressEmitter = progressEmitter,
|
||||
cancellationSignal = cancellationSignal,
|
||||
extraExportOperations = extraExportOperations
|
||||
extraFrameOperation = null,
|
||||
endingExportOperation = null
|
||||
)
|
||||
}
|
||||
|
||||
@@ -864,7 +877,8 @@ object BackupRepository {
|
||||
forTransfer: Boolean,
|
||||
progressEmitter: ExportProgressListener?,
|
||||
cancellationSignal: () -> Boolean,
|
||||
extraExportOperations: ((SignalDatabase) -> Unit)?
|
||||
extraFrameOperation: ((Frame) -> Unit)?,
|
||||
endingExportOperation: ((SignalDatabase) -> Unit)?
|
||||
) {
|
||||
val eventTimer = EventTimer()
|
||||
val mainDbName = if (isLocal) LOCAL_MAIN_DB_SNAPSHOT_NAME else REMOTE_MAIN_DB_SNAPSHOT_NAME
|
||||
@@ -902,8 +916,9 @@ object BackupRepository {
|
||||
// We're using a snapshot, so the transaction is more for perf than correctness
|
||||
dbSnapshot.rawWritableDatabase.withinTransaction {
|
||||
progressEmitter?.onAccount()
|
||||
AccountDataArchiveProcessor.export(dbSnapshot, signalStoreSnapshot) {
|
||||
writer.write(it)
|
||||
AccountDataArchiveProcessor.export(dbSnapshot, signalStoreSnapshot) { frame ->
|
||||
writer.write(frame)
|
||||
extraFrameOperation?.invoke(frame)
|
||||
eventTimer.emit("account")
|
||||
frameCount++
|
||||
}
|
||||
@@ -915,6 +930,7 @@ object BackupRepository {
|
||||
progressEmitter?.onRecipient()
|
||||
RecipientArchiveProcessor.export(dbSnapshot, signalStoreSnapshot, exportState, selfRecipientId, selfAci) {
|
||||
writer.write(it)
|
||||
extraFrameOperation?.invoke(it)
|
||||
eventTimer.emit("recipient")
|
||||
frameCount++
|
||||
}
|
||||
@@ -926,6 +942,7 @@ object BackupRepository {
|
||||
progressEmitter?.onThread()
|
||||
ChatArchiveProcessor.export(dbSnapshot, exportState) { frame ->
|
||||
writer.write(frame)
|
||||
extraFrameOperation?.invoke(frame)
|
||||
eventTimer.emit("thread")
|
||||
frameCount++
|
||||
}
|
||||
@@ -936,6 +953,7 @@ object BackupRepository {
|
||||
progressEmitter?.onCall()
|
||||
AdHocCallArchiveProcessor.export(dbSnapshot, exportState) { frame ->
|
||||
writer.write(frame)
|
||||
extraFrameOperation?.invoke(frame)
|
||||
eventTimer.emit("call")
|
||||
frameCount++
|
||||
}
|
||||
@@ -947,6 +965,7 @@ object BackupRepository {
|
||||
progressEmitter?.onSticker()
|
||||
StickerArchiveProcessor.export(dbSnapshot) { frame ->
|
||||
writer.write(frame)
|
||||
extraFrameOperation?.invoke(frame)
|
||||
eventTimer.emit("sticker-pack")
|
||||
frameCount++
|
||||
}
|
||||
@@ -958,6 +977,7 @@ object BackupRepository {
|
||||
progressEmitter?.onNotificationProfile()
|
||||
NotificationProfileProcessor.export(dbSnapshot, exportState) { frame ->
|
||||
writer.write(frame)
|
||||
extraFrameOperation?.invoke(frame)
|
||||
eventTimer.emit("notification-profile")
|
||||
frameCount++
|
||||
}
|
||||
@@ -969,6 +989,7 @@ object BackupRepository {
|
||||
progressEmitter?.onChatFolder()
|
||||
ChatFolderProcessor.export(dbSnapshot, exportState) { frame ->
|
||||
writer.write(frame)
|
||||
extraFrameOperation?.invoke(frame)
|
||||
eventTimer.emit("chat-folder")
|
||||
frameCount++
|
||||
}
|
||||
@@ -982,6 +1003,7 @@ object BackupRepository {
|
||||
progressEmitter?.onMessage(0, approximateMessageCount)
|
||||
ChatItemArchiveProcessor.export(dbSnapshot, exportState, selfRecipientId, cancellationSignal) { frame ->
|
||||
writer.write(frame)
|
||||
extraFrameOperation?.invoke(frame)
|
||||
eventTimer.emit("message")
|
||||
frameCount++
|
||||
|
||||
@@ -997,7 +1019,7 @@ object BackupRepository {
|
||||
}
|
||||
}
|
||||
|
||||
extraExportOperations?.invoke(dbSnapshot)
|
||||
endingExportOperation?.invoke(dbSnapshot)
|
||||
|
||||
Log.d(TAG, "[export] totalFrames: $frameCount | ${eventTimer.stop().summary}")
|
||||
} finally {
|
||||
@@ -1750,7 +1772,7 @@ object BackupRepository {
|
||||
return RestoreTimestampResult.Success(SignalStore.backup.lastBackupTime)
|
||||
}
|
||||
|
||||
timestampResult is NetworkResult.StatusCodeError && timestampResult.code == 404 -> {
|
||||
timestampResult is NetworkResult.StatusCodeError && (timestampResult.code == 401 || timestampResult.code == 404) -> {
|
||||
Log.i(TAG, "No backup file exists")
|
||||
SignalStore.backup.lastBackupTime = 0L
|
||||
SignalStore.backup.isBackupTimestampRestored = true
|
||||
@@ -1873,20 +1895,11 @@ object BackupRepository {
|
||||
RecurringInAppPaymentRepository.getActiveSubscriptionSync(InAppPaymentSubscriberRecord.Type.BACKUP).getOrNull()?.activeSubscription?.let {
|
||||
FiatMoney.fromSignalNetworkAmount(it.amount, Currency.getInstance(it.currency))
|
||||
}
|
||||
} else if (AppDependencies.billingApi.isApiAvailable()) {
|
||||
} else if (AppDependencies.billingApi.getApiAvailability().isSuccess) {
|
||||
Log.d(TAG, "Accessing price via billing api.")
|
||||
AppDependencies.billingApi.queryProduct()?.price
|
||||
} else {
|
||||
Log.d(TAG, "Billing API is not available on this device. Accessing price via subscription configuration.")
|
||||
val configurationResult = AppDependencies.donationsService.getDonationsConfiguration(Locale.getDefault()).toNetworkResult()
|
||||
val currency = Currency.getInstance(Locale.getDefault())
|
||||
|
||||
when (configurationResult) {
|
||||
is NetworkResult.Success -> configurationResult.result.currencies[currency.currencyCode.lowercase()]?.backupSubscription[SubscriptionsConfiguration.BACKUPS_LEVEL]?.let {
|
||||
FiatMoney(it, currency)
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
FiatMoney(BigDecimal.ZERO, SignalStore.inAppPayments.getRecurringDonationCurrency())
|
||||
}
|
||||
|
||||
if (productPrice == null) {
|
||||
@@ -1920,15 +1933,14 @@ object BackupRepository {
|
||||
* prevents early initialization with incorrect keys before we have restored them.
|
||||
*/
|
||||
private fun initBackupAndFetchAuth(): NetworkResult<ArchiveServiceAccessPair> {
|
||||
return if (!RemoteConfig.messageBackups) {
|
||||
NetworkResult.StatusCodeError(555, null, null, emptyMap(), NonSuccessfulResponseCodeException(555, "Backups disabled!"))
|
||||
} else if (SignalStore.backup.backupsInitialized || SignalStore.account.isLinkedDevice) {
|
||||
return if (SignalStore.backup.backupsInitialized || SignalStore.account.isLinkedDevice) {
|
||||
getArchiveServiceAccessPair()
|
||||
.runOnStatusCodeError(resetInitializedStateErrorAction)
|
||||
.runOnApplicationError(clearAuthCredentials)
|
||||
} else if (isPreRestoreDuringRegistration()) {
|
||||
Log.w(TAG, "Requesting/using auth credentials in pre-restore state", Throwable())
|
||||
getArchiveServiceAccessPair()
|
||||
.runOnApplicationError(clearAuthCredentials)
|
||||
} else {
|
||||
val messageBackupKey = SignalStore.backup.messageBackupKey
|
||||
val mediaRootBackupKey = SignalStore.backup.mediaRootBackupKey
|
||||
@@ -1980,8 +1992,7 @@ object BackupRepository {
|
||||
|
||||
private fun isPreRestoreDuringRegistration(): Boolean {
|
||||
return !SignalStore.registration.isRegistrationComplete &&
|
||||
SignalStore.registration.restoreDecisionState.isDecisionPending &&
|
||||
RemoteConfig.restoreAfterRegistration
|
||||
SignalStore.registration.restoreDecisionState.isDecisionPending
|
||||
}
|
||||
|
||||
private fun scheduleSyncForAccountChange() {
|
||||
@@ -2076,7 +2087,7 @@ object BackupRepository {
|
||||
val messageBackupKey = SignalStore.backup.messageBackupKey
|
||||
|
||||
Log.i(TAG, "[remoteRestore] Fetching SVRB data")
|
||||
val svrBAuth = when (val result = BackupRepository.getSvrBAuth()) {
|
||||
val svrBAuth = when (val result = getSvrBAuth()) {
|
||||
is NetworkResult.Success -> result.result
|
||||
is NetworkResult.NetworkError -> return RemoteRestoreResult.NetworkError.logW(TAG, "[remoteRestore] Network error when getting SVRB auth.", result.getCause())
|
||||
is NetworkResult.StatusCodeError -> return RemoteRestoreResult.NetworkError.logW(TAG, "[remoteRestore] Status code error when getting SVRB auth.", result.getCause())
|
||||
|
||||
@@ -15,6 +15,7 @@ import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.dimensionResource
|
||||
@@ -29,6 +30,7 @@ import org.signal.core.ui.compose.Buttons
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.SignalPreview
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.app.backups.BackupStateObserver
|
||||
import org.thoughtcrime.securesms.compose.ComposeBottomSheetDialogFragment
|
||||
import org.thoughtcrime.securesms.jobs.BackupMessagesJob
|
||||
import org.signal.core.ui.R as CoreUiR
|
||||
@@ -51,15 +53,15 @@ class CreateBackupBottomSheet : ComposeBottomSheetDialogFragment() {
|
||||
|
||||
@Composable
|
||||
override fun SheetContent() {
|
||||
val isPaidTier: Boolean = remember { BackupStateObserver.getNonIOBackupState().isLikelyPaidTier() }
|
||||
|
||||
CreateBackupBottomSheetContent(
|
||||
isPaidTier = isPaidTier,
|
||||
onBackupNowClick = {
|
||||
BackupMessagesJob.enqueue()
|
||||
setFragmentResult(REQUEST_KEY, bundleOf(REQUEST_KEY to Result.BACKUP_STARTED))
|
||||
isResultSet = true
|
||||
dismissAllowingStateLoss()
|
||||
},
|
||||
onBackupLaterClick = {
|
||||
dismissAllowingStateLoss()
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -80,8 +82,8 @@ class CreateBackupBottomSheet : ComposeBottomSheetDialogFragment() {
|
||||
|
||||
@Composable
|
||||
private fun CreateBackupBottomSheetContent(
|
||||
onBackupNowClick: () -> Unit,
|
||||
onBackupLaterClick: () -> Unit
|
||||
isPaidTier: Boolean,
|
||||
onBackupNowClick: () -> Unit
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
@@ -106,8 +108,14 @@ private fun CreateBackupBottomSheetContent(
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
val body = if (isPaidTier) {
|
||||
stringResource(id = R.string.CreateBackupBottomSheet__depending_on_the_size)
|
||||
} else {
|
||||
stringResource(id = R.string.CreateBackupBottomSheet__free_tier)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = stringResource(id = R.string.CreateBackupBottomSheet__depending_on_the_size),
|
||||
text = body,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
@@ -128,11 +136,22 @@ private fun CreateBackupBottomSheetContent(
|
||||
|
||||
@SignalPreview
|
||||
@Composable
|
||||
private fun CreateBackupBottomSheetContentPreview() {
|
||||
private fun CreateBackupBottomSheetContentPaidPreview() {
|
||||
Previews.BottomSheetPreview {
|
||||
CreateBackupBottomSheetContent(
|
||||
onBackupNowClick = {},
|
||||
onBackupLaterClick = {}
|
||||
isPaidTier = true,
|
||||
onBackupNowClick = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@Composable
|
||||
private fun CreateBackupBottomSheetContentFreePreview() {
|
||||
Previews.BottomSheetPreview {
|
||||
CreateBackupBottomSheetContent(
|
||||
isPaidTier = false,
|
||||
onBackupNowClick = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.Dialogs
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.SignalPreview
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.R
|
||||
|
||||
/**
|
||||
* Represents the availability status of Google Play Services on the device.
|
||||
*
|
||||
* Maps Google Play Services ConnectionResult codes to enum values for easier handling
|
||||
* in the application. Each enum value corresponds to a specific state that determines
|
||||
* what dialog or action should be presented to the user.
|
||||
*
|
||||
* @param code The corresponding ConnectionResult code from Google Play Services
|
||||
*/
|
||||
enum class GooglePlayServicesAvailability(val code: Int) {
|
||||
/** An unknown code. Possibly due to an update on Google's end */
|
||||
UNKNOWN(code = Int.MIN_VALUE),
|
||||
|
||||
/** Google Play Services is available and ready to use */
|
||||
SUCCESS(code = ConnectionResult.SUCCESS),
|
||||
|
||||
/** Google Play Services is not installed on the device */
|
||||
SERVICE_MISSING(code = ConnectionResult.SERVICE_MISSING),
|
||||
|
||||
/** Google Play Services is currently being updated */
|
||||
SERVICE_UPDATING(code = ConnectionResult.SERVICE_UPDATING),
|
||||
|
||||
/** Google Play Services requires an update to a newer version */
|
||||
SERVICE_VERSION_UPDATE_REQUIRED(code = ConnectionResult.SERVICE_VERSION_UPDATE_REQUIRED),
|
||||
|
||||
/** Google Play Services is installed but disabled by the user */
|
||||
SERVICE_DISABLED(code = ConnectionResult.SERVICE_DISABLED),
|
||||
|
||||
/** Google Play Services installation is invalid or corrupted */
|
||||
SERVICE_INVALID(code = ConnectionResult.SERVICE_INVALID);
|
||||
|
||||
companion object {
|
||||
|
||||
private val TAG = Log.tag(GooglePlayServicesAvailability::class)
|
||||
|
||||
/**
|
||||
* Converts a Google Play Services ConnectionResult code to the corresponding enum value.
|
||||
*
|
||||
* @param code The ConnectionResult code from Google Play Services
|
||||
* @return The matching GooglePlayServicesAvailability enum value
|
||||
*/
|
||||
fun fromCode(code: Int): GooglePlayServicesAvailability {
|
||||
val availability = entries.firstOrNull { it.code == code } ?: UNKNOWN
|
||||
if (availability == UNKNOWN) {
|
||||
Log.w(TAG, "Unknown availability code: $code")
|
||||
}
|
||||
|
||||
return availability
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays a dialog based on the Google Play Services availability status.
|
||||
*
|
||||
* Shows different dialogs with appropriate messages and actions depending on whether
|
||||
* Google Play Services is missing, updating, requires an update, is disabled, or invalid.
|
||||
* When availability is SUCCESS, automatically calls onDismissRequest to dismiss any dialog.
|
||||
*
|
||||
* @param onDismissRequest Callback invoked when the dialog is dismissed or when SUCCESS status is received
|
||||
* @param onLearnMoreClick Callback invoked when the "Learn More" action is selected
|
||||
* @param onMakeServicesAvailableClick Callback invoked when an action to make services
|
||||
* available is selected (e.g., install or update)
|
||||
* @param googlePlayServicesAvailability The current availability status of Google Play Services
|
||||
*/
|
||||
@Composable
|
||||
fun GooglePlayServicesAvailabilityDialog(
|
||||
onDismissRequest: () -> Unit,
|
||||
onLearnMoreClick: () -> Unit,
|
||||
onMakeServicesAvailableClick: () -> Unit,
|
||||
googlePlayServicesAvailability: GooglePlayServicesAvailability
|
||||
) {
|
||||
when (googlePlayServicesAvailability) {
|
||||
GooglePlayServicesAvailability.SUCCESS -> {
|
||||
LaunchedEffect(Unit) {
|
||||
onDismissRequest()
|
||||
}
|
||||
}
|
||||
GooglePlayServicesAvailability.SERVICE_MISSING, GooglePlayServicesAvailability.UNKNOWN -> {
|
||||
ServiceMissingDialog(
|
||||
onDismissRequest = onDismissRequest,
|
||||
onInstallPlayServicesClick = onMakeServicesAvailableClick
|
||||
)
|
||||
}
|
||||
GooglePlayServicesAvailability.SERVICE_UPDATING -> {
|
||||
ServiceUpdatingDialog(onDismissRequest = onDismissRequest)
|
||||
}
|
||||
GooglePlayServicesAvailability.SERVICE_VERSION_UPDATE_REQUIRED -> {
|
||||
ServiceVersionUpdateRequiredDialog(
|
||||
onDismissRequest = onDismissRequest,
|
||||
onUpdateClick = onMakeServicesAvailableClick
|
||||
)
|
||||
}
|
||||
GooglePlayServicesAvailability.SERVICE_DISABLED -> {
|
||||
ServiceDisabledDialog(
|
||||
onDismissRequest = onDismissRequest,
|
||||
onLearnMoreClick = onLearnMoreClick
|
||||
)
|
||||
}
|
||||
GooglePlayServicesAvailability.SERVICE_INVALID -> {
|
||||
ServiceInvalidDialog(
|
||||
onDismissRequest = onDismissRequest,
|
||||
onLearnMoreClick = onLearnMoreClick
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ServiceMissingDialog(onDismissRequest: () -> Unit, onInstallPlayServicesClick: () -> Unit) {
|
||||
Dialogs.SimpleAlertDialog(
|
||||
title = stringResource(R.string.GooglePlayServicesAvailability__service_missing_title),
|
||||
body = stringResource(R.string.GooglePlayServicesAvailability__service_missing_message),
|
||||
confirm = stringResource(R.string.GooglePlayServicesAvailability__install_play_services),
|
||||
dismiss = stringResource(android.R.string.cancel),
|
||||
onConfirm = {},
|
||||
onDeny = onInstallPlayServicesClick,
|
||||
onDismiss = onDismissRequest,
|
||||
onDismissRequest = onDismissRequest
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ServiceUpdatingDialog(onDismissRequest: () -> Unit) {
|
||||
Dialogs.SimpleAlertDialog(
|
||||
title = stringResource(R.string.GooglePlayServicesAvailability__service_updating_title),
|
||||
body = stringResource(R.string.GooglePlayServicesAvailability__service_updating_message),
|
||||
confirm = stringResource(android.R.string.ok),
|
||||
onConfirm = {},
|
||||
onDismiss = onDismissRequest,
|
||||
onDismissRequest = onDismissRequest
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ServiceVersionUpdateRequiredDialog(onDismissRequest: () -> Unit, onUpdateClick: () -> Unit) {
|
||||
Dialogs.SimpleAlertDialog(
|
||||
title = stringResource(R.string.GooglePlayServicesAvailability__service_update_required_title),
|
||||
body = stringResource(R.string.GooglePlayServicesAvailability__service_update_required_message),
|
||||
confirm = stringResource(R.string.GooglePlayServicesAvailability__update),
|
||||
dismiss = stringResource(android.R.string.cancel),
|
||||
onConfirm = onUpdateClick,
|
||||
onDismiss = onDismissRequest,
|
||||
onDismissRequest = onDismissRequest
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ServiceDisabledDialog(onDismissRequest: () -> Unit, onLearnMoreClick: () -> Unit) {
|
||||
Dialogs.SimpleAlertDialog(
|
||||
title = stringResource(R.string.GooglePlayServicesAvailability__service_disabled_title),
|
||||
body = stringResource(R.string.GooglePlayServicesAvailability__service_disabled_message),
|
||||
confirm = stringResource(android.R.string.ok),
|
||||
dismiss = stringResource(R.string.GooglePlayServicesAvailability__learn_more),
|
||||
onConfirm = onDismissRequest,
|
||||
onDeny = onLearnMoreClick,
|
||||
onDismiss = onDismissRequest,
|
||||
onDismissRequest = onDismissRequest
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ServiceInvalidDialog(onDismissRequest: () -> Unit, onLearnMoreClick: () -> Unit) {
|
||||
Dialogs.SimpleAlertDialog(
|
||||
title = stringResource(R.string.GooglePlayServicesAvailability__service_disabled_title),
|
||||
body = stringResource(R.string.GooglePlayServicesAvailability__service_invalid_message),
|
||||
confirm = stringResource(android.R.string.ok),
|
||||
dismiss = stringResource(R.string.GooglePlayServicesAvailability__learn_more),
|
||||
onConfirm = {},
|
||||
onDeny = onLearnMoreClick,
|
||||
onDismiss = onDismissRequest,
|
||||
onDismissRequest = onDismissRequest
|
||||
)
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@Composable
|
||||
private fun ServiceMissingDialogPreview() {
|
||||
Previews.Preview {
|
||||
ServiceMissingDialog({}, {})
|
||||
}
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@Composable
|
||||
private fun ServiceUpdatingDialogPreview() {
|
||||
Previews.Preview {
|
||||
ServiceUpdatingDialog({})
|
||||
}
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@Composable
|
||||
private fun ServiceVersionUpdateRequiredDialogPreview() {
|
||||
Previews.Preview {
|
||||
ServiceVersionUpdateRequiredDialog({}, {})
|
||||
}
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@Composable
|
||||
private fun ServiceDisabledDialogPreview() {
|
||||
Previews.Preview {
|
||||
ServiceDisabledDialog({}, {})
|
||||
}
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@Composable
|
||||
private fun ServiceInvalidDialogPreview() {
|
||||
Previews.Preview {
|
||||
ServiceInvalidDialog({}, {})
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -8,10 +8,13 @@ package org.thoughtcrime.securesms.backup.v2.ui.subscription
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -39,6 +42,8 @@ fun MessageBackupsKeyEducationScreen(
|
||||
onNavigationClick: () -> Unit = {},
|
||||
onNextClick: () -> Unit = {}
|
||||
) {
|
||||
val scrollState = rememberScrollState()
|
||||
|
||||
Scaffolds.Settings(
|
||||
title = "",
|
||||
navigationIcon = ImageVector.vectorResource(R.drawable.symbol_arrow_start_24),
|
||||
@@ -48,7 +53,8 @@ fun MessageBackupsKeyEducationScreen(
|
||||
modifier = Modifier
|
||||
.padding(it)
|
||||
.padding(horizontal = dimensionResource(CoreUiR.dimen.gutter))
|
||||
.fillMaxSize(),
|
||||
.fillMaxSize()
|
||||
.verticalScroll(scrollState),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Image(
|
||||
@@ -61,6 +67,7 @@ fun MessageBackupsKeyEducationScreen(
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.MessageBackupsKeyEducationScreen__your_backup_key),
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
modifier = Modifier.padding(top = 16.dp)
|
||||
)
|
||||
@@ -81,11 +88,16 @@ fun MessageBackupsKeyEducationScreen(
|
||||
modifier = Modifier.padding(top = 16.dp)
|
||||
)
|
||||
|
||||
Box(
|
||||
Spacer(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f)
|
||||
.padding(bottom = 24.dp)
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 16.dp, bottom = 24.dp)
|
||||
) {
|
||||
Buttons.LargeTonal(
|
||||
onClick = onNextClick,
|
||||
|
||||
@@ -21,6 +21,7 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -52,6 +53,7 @@ import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.Scaffolds
|
||||
import org.signal.core.ui.compose.SignalPreview
|
||||
import org.signal.core.ui.compose.theme.SignalTheme
|
||||
import org.signal.core.util.billing.BillingResponseCode
|
||||
import org.signal.core.util.bytes
|
||||
import org.signal.core.util.money.FiatMoney
|
||||
import org.thoughtcrime.securesms.R
|
||||
@@ -75,13 +77,16 @@ fun MessageBackupsTypeSelectionScreen(
|
||||
currentBackupTier: MessageBackupTier?,
|
||||
selectedBackupTier: MessageBackupTier?,
|
||||
allBackupTypes: List<MessageBackupsType>,
|
||||
isBillingApiAvailable: Boolean,
|
||||
googlePlayServicesAvailability: GooglePlayServicesAvailability,
|
||||
googlePlayBillingAvailability: BillingResponseCode,
|
||||
isNextEnabled: Boolean,
|
||||
onMessageBackupsTierSelected: (MessageBackupTier) -> Unit,
|
||||
onNavigationClick: () -> Unit,
|
||||
onReadMoreClicked: () -> Unit,
|
||||
onNextClicked: () -> Unit,
|
||||
onLearnMoreAboutWhyUserCanNotUpgrade: () -> Unit
|
||||
onLearnMoreAboutWhyUserCanNotUpgrade: () -> Unit,
|
||||
onMakeGooglePlayServicesAvailable: () -> Unit,
|
||||
onOpenPlayStore: () -> Unit
|
||||
) {
|
||||
Scaffolds.Settings(
|
||||
title = "",
|
||||
@@ -160,29 +165,26 @@ fun MessageBackupsTypeSelectionScreen(
|
||||
}
|
||||
}
|
||||
|
||||
val hasCurrentBackupTier = currentBackupTier != null
|
||||
var displayNotAvailableDialog by remember { mutableStateOf(false) }
|
||||
val onSubscribeButtonClick = remember(isBillingApiAvailable, selectedBackupTier) {
|
||||
val paidTierNotAvailableDialogState = remember { PaidTierNotAvailableDialogState() }
|
||||
val onSubscribeButtonClick = remember(googlePlayServicesAvailability, googlePlayBillingAvailability, selectedBackupTier) {
|
||||
{
|
||||
if (selectedBackupTier == MessageBackupTier.PAID && !isBillingApiAvailable) {
|
||||
displayNotAvailableDialog = true
|
||||
if (selectedBackupTier == MessageBackupTier.PAID && googlePlayServicesAvailability != GooglePlayServicesAvailability.SUCCESS) {
|
||||
paidTierNotAvailableDialogState.displayGooglePlayApiErrorDialog = true
|
||||
} else if (selectedBackupTier == MessageBackupTier.PAID && !googlePlayBillingAvailability.isSuccess) {
|
||||
paidTierNotAvailableDialogState.displayGooglePlayBillingErrorDialog = true
|
||||
} else {
|
||||
onNextClicked()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (displayNotAvailableDialog) {
|
||||
UpgradeNotAvailableDialog(
|
||||
onConfirm = {
|
||||
displayNotAvailableDialog = false
|
||||
},
|
||||
onDismiss = onLearnMoreAboutWhyUserCanNotUpgrade,
|
||||
onDismissRequest = {
|
||||
displayNotAvailableDialog = false
|
||||
}
|
||||
)
|
||||
}
|
||||
PaidTierNotAvailableDialogs(
|
||||
state = paidTierNotAvailableDialogState,
|
||||
onOpenPlayStore = onOpenPlayStore,
|
||||
onLearnMoreAboutWhyUserCanNotUpgrade = onLearnMoreAboutWhyUserCanNotUpgrade,
|
||||
onMakeGooglePlayServicesAvailable = onMakeGooglePlayServicesAvailable,
|
||||
googlePlayServicesAvailability = googlePlayServicesAvailability
|
||||
)
|
||||
|
||||
Buttons.LargeTonal(
|
||||
onClick = onSubscribeButtonClick,
|
||||
@@ -190,10 +192,12 @@ fun MessageBackupsTypeSelectionScreen(
|
||||
modifier = Modifier
|
||||
.testTag("subscribe-button")
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = if (hasCurrentBackupTier) 10.dp else 16.dp)
|
||||
.padding(vertical = 16.dp)
|
||||
) {
|
||||
val text: String = if (currentBackupTier == null) {
|
||||
if (selectedBackupTier == MessageBackupTier.PAID && allBackupTypes.map { it.tier }.contains(selectedBackupTier)) {
|
||||
if (selectedBackupTier == MessageBackupTier.PAID && (googlePlayServicesAvailability != GooglePlayServicesAvailability.SUCCESS || !googlePlayBillingAvailability.isSuccess)) {
|
||||
stringResource(R.string.MessageBackupsTypeSelectionScreen__more_about_this_plan)
|
||||
} else if (selectedBackupTier == MessageBackupTier.PAID && allBackupTypes.map { it.tier }.contains(selectedBackupTier)) {
|
||||
val paidTier = allBackupTypes.first { it.tier == MessageBackupTier.PAID } as MessageBackupsType.Paid
|
||||
val context = LocalContext.current
|
||||
|
||||
@@ -207,6 +211,8 @@ fun MessageBackupsTypeSelectionScreen(
|
||||
} else {
|
||||
stringResource(R.string.MessageBackupsTypeSelectionScreen__subscribe)
|
||||
}
|
||||
} else if (selectedBackupTier == MessageBackupTier.PAID && (googlePlayServicesAvailability != GooglePlayServicesAvailability.SUCCESS || !googlePlayBillingAvailability.isSuccess)) {
|
||||
stringResource(R.string.MessageBackupsTypeSelectionScreen__more_about_this_plan)
|
||||
} else {
|
||||
stringResource(R.string.MessageBackupsTypeSelectionScreen__change_backup_type)
|
||||
}
|
||||
@@ -224,20 +230,50 @@ fun MessageBackupsTypeSelectionScreen(
|
||||
}
|
||||
}
|
||||
|
||||
@Stable
|
||||
class PaidTierNotAvailableDialogState {
|
||||
var displayGooglePlayBillingErrorDialog: Boolean by mutableStateOf(false)
|
||||
var displayGooglePlayApiErrorDialog: Boolean by mutableStateOf(false)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UpgradeNotAvailableDialog(
|
||||
onConfirm: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
onDismissRequest: () -> Unit
|
||||
fun PaidTierNotAvailableDialogs(
|
||||
state: PaidTierNotAvailableDialogState,
|
||||
googlePlayServicesAvailability: GooglePlayServicesAvailability,
|
||||
onLearnMoreAboutWhyUserCanNotUpgrade: () -> Unit,
|
||||
onMakeGooglePlayServicesAvailable: () -> Unit,
|
||||
onOpenPlayStore: () -> Unit
|
||||
) {
|
||||
if (state.displayGooglePlayApiErrorDialog) {
|
||||
GooglePlayServicesAvailabilityDialog(
|
||||
onDismissRequest = { state.displayGooglePlayApiErrorDialog = false },
|
||||
googlePlayServicesAvailability = googlePlayServicesAvailability,
|
||||
onLearnMoreClick = onLearnMoreAboutWhyUserCanNotUpgrade,
|
||||
onMakeServicesAvailableClick = onMakeGooglePlayServicesAvailable
|
||||
)
|
||||
}
|
||||
|
||||
if (state.displayGooglePlayBillingErrorDialog) {
|
||||
UserNotSignedInDialog(
|
||||
onDismissRequest = { state.displayGooglePlayBillingErrorDialog = false },
|
||||
onOpenPlayStore = onOpenPlayStore
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UserNotSignedInDialog(
|
||||
onDismissRequest: () -> Unit,
|
||||
onOpenPlayStore: () -> Unit
|
||||
) {
|
||||
Dialogs.SimpleAlertDialog(
|
||||
title = stringResource(R.string.MessageBackupsTypeSelectionScreen__cant_upgrade_plan),
|
||||
body = stringResource(R.string.MessageBackupsTypeSelectionScreen__to_subscribe_to_signal_secure_backups),
|
||||
confirm = stringResource(android.R.string.ok),
|
||||
dismiss = stringResource(R.string.MessageBackupsTypeSelectionScreen__learn_more),
|
||||
onConfirm = onConfirm,
|
||||
onDismiss = onDismiss,
|
||||
onDismissRequest = onDismissRequest
|
||||
title = stringResource(R.string.GooglePlayServicesAvailability__service_disabled_title),
|
||||
body = "To subscribe to Signal Secure Backups, please sign into the Google Play store.",
|
||||
onConfirm = onOpenPlayStore,
|
||||
onDismiss = onDismissRequest,
|
||||
onDismissRequest = onDismissRequest,
|
||||
confirm = "Open Play Store",
|
||||
dismiss = stringResource(android.R.string.cancel)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -256,8 +292,11 @@ private fun MessageBackupsTypeSelectionScreenPreview() {
|
||||
onReadMoreClicked = {},
|
||||
onNextClicked = {},
|
||||
onLearnMoreAboutWhyUserCanNotUpgrade = {},
|
||||
onMakeGooglePlayServicesAvailable = {},
|
||||
onOpenPlayStore = {},
|
||||
currentBackupTier = null,
|
||||
isBillingApiAvailable = true,
|
||||
googlePlayServicesAvailability = GooglePlayServicesAvailability.SUCCESS,
|
||||
googlePlayBillingAvailability = BillingResponseCode.OK,
|
||||
isNextEnabled = true
|
||||
)
|
||||
}
|
||||
@@ -278,25 +317,16 @@ private fun MessageBackupsTypeSelectionScreenWithCurrentTierPreview() {
|
||||
onReadMoreClicked = {},
|
||||
onNextClicked = {},
|
||||
onLearnMoreAboutWhyUserCanNotUpgrade = {},
|
||||
onMakeGooglePlayServicesAvailable = {},
|
||||
onOpenPlayStore = {},
|
||||
currentBackupTier = MessageBackupTier.PAID,
|
||||
isBillingApiAvailable = true,
|
||||
googlePlayServicesAvailability = GooglePlayServicesAvailability.SUCCESS,
|
||||
googlePlayBillingAvailability = BillingResponseCode.OK,
|
||||
isNextEnabled = true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@Composable
|
||||
private fun UpgradeNotAvailableDialogPreview() {
|
||||
Previews.Preview {
|
||||
UpgradeNotAvailableDialog(
|
||||
onConfirm = {},
|
||||
onDismiss = {},
|
||||
onDismissRequest = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MessageBackupsTypeBlock(
|
||||
messageBackupsType: MessageBackupsType,
|
||||
@@ -382,8 +412,12 @@ private fun getFormattedPricePerMonth(messageBackupsType: MessageBackupsType): S
|
||||
return when (messageBackupsType) {
|
||||
is MessageBackupsType.Free -> stringResource(id = R.string.MessageBackupsTypeSelectionScreen__free)
|
||||
is MessageBackupsType.Paid -> {
|
||||
val formattedAmount = FiatMoneyUtil.format(LocalContext.current.resources, messageBackupsType.pricePerMonth, FiatMoneyUtil.formatOptions().trimZerosAfterDecimal())
|
||||
stringResource(id = R.string.MessageBackupsTypeSelectionScreen__s_month, formattedAmount)
|
||||
if (messageBackupsType.pricePerMonth.amount == BigDecimal.ZERO) {
|
||||
stringResource(R.string.MessageBackupsTypeSelectionScreen__paid)
|
||||
} else {
|
||||
val formattedAmount = FiatMoneyUtil.format(LocalContext.current.resources, messageBackupsType.pricePerMonth, FiatMoneyUtil.formatOptions().trimZerosAfterDecimal())
|
||||
stringResource(id = R.string.MessageBackupsTypeSelectionScreen__s_month, formattedAmount)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.app.ShareCompat
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import kotlinx.coroutines.launch
|
||||
import org.signal.core.ui.compose.Dialogs
|
||||
import org.signal.core.ui.compose.Dividers
|
||||
import org.signal.core.ui.compose.Rows
|
||||
import org.signal.core.ui.compose.Scaffolds
|
||||
import org.signal.core.ui.compose.Snackbars
|
||||
import org.signal.core.ui.compose.theme.SignalTheme
|
||||
import org.signal.core.util.concurrent.LifecycleDisposable
|
||||
import org.signal.ringrtc.CallLinkState.Restrictions
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.calls.YouAreAlreadyInACallSnackbar.YouAreAlreadyInACallSnackbar
|
||||
import org.thoughtcrime.securesms.calls.links.CallLinks
|
||||
import org.thoughtcrime.securesms.calls.links.SignalCallRow
|
||||
import org.thoughtcrime.securesms.database.CallLinkTable
|
||||
import org.thoughtcrime.securesms.main.MainNavigationDetailLocation
|
||||
import org.thoughtcrime.securesms.main.MainNavigationListLocation
|
||||
import org.thoughtcrime.securesms.main.MainNavigationRouter
|
||||
import org.thoughtcrime.securesms.main.MainNavigationViewModel
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkCredentials
|
||||
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId
|
||||
import org.thoughtcrime.securesms.service.webrtc.links.SignalCallLinkState
|
||||
import org.thoughtcrime.securesms.sharing.v2.ShareActivity
|
||||
import org.thoughtcrime.securesms.util.CommunicationActions
|
||||
import org.thoughtcrime.securesms.util.Util
|
||||
import org.thoughtcrime.securesms.window.WindowSizeClass
|
||||
import java.time.Instant
|
||||
|
||||
@Composable
|
||||
fun CallLinkDetailsScreen(
|
||||
roomId: CallLinkRoomId,
|
||||
viewModel: CallLinkDetailsViewModel = viewModel {
|
||||
CallLinkDetailsViewModel(roomId)
|
||||
},
|
||||
router: MainNavigationRouter = viewModel<MainNavigationViewModel>(viewModelStoreOwner = LocalContext.current as ComponentActivity) {
|
||||
error("Should already be created.")
|
||||
}
|
||||
) {
|
||||
val activity = LocalContext.current as FragmentActivity
|
||||
val callback = remember {
|
||||
DefaultCallLinkDetailsCallback(
|
||||
activity = activity,
|
||||
viewModel = viewModel,
|
||||
router = router
|
||||
)
|
||||
}
|
||||
|
||||
val state by viewModel.state.collectAsStateWithLifecycle(activity)
|
||||
val showAlreadyInACall by viewModel.showAlreadyInACall.collectAsStateWithLifecycle(initialValue = false, lifecycleOwner = activity)
|
||||
|
||||
CallLinkDetailsScreen(
|
||||
state = state,
|
||||
showAlreadyInACall = showAlreadyInACall,
|
||||
callback = callback,
|
||||
showNavigationIcon = !WindowSizeClass.rememberWindowSizeClass().isSplitPane()
|
||||
)
|
||||
}
|
||||
|
||||
class DefaultCallLinkDetailsCallback(
|
||||
private val activity: FragmentActivity,
|
||||
private val viewModel: CallLinkDetailsViewModel,
|
||||
private val router: MainNavigationRouter
|
||||
) : CallLinkDetailsCallback {
|
||||
|
||||
private val lifecycleDisposable = LifecycleDisposable()
|
||||
|
||||
init {
|
||||
lifecycleDisposable.bindTo(activity)
|
||||
}
|
||||
|
||||
override fun onNavigationClicked() {
|
||||
activity.onBackPressedDispatcher.onBackPressed()
|
||||
}
|
||||
|
||||
override fun onJoinClicked() {
|
||||
val recipientSnapshot = viewModel.recipientSnapshot
|
||||
if (recipientSnapshot != null) {
|
||||
CommunicationActions.startVideoCall(activity, recipientSnapshot) {
|
||||
viewModel.showAlreadyInACall(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onEditNameClicked() {
|
||||
router.goTo(MainNavigationDetailLocation.Calls.CallLinks.EditCallLinkName(callLinkRoomId = viewModel.recipientSnapshot!!.requireCallLinkRoomId()))
|
||||
}
|
||||
|
||||
override fun onShareClicked() {
|
||||
val mimeType = Intent.normalizeMimeType("text/plain")
|
||||
val shareIntent = ShareCompat.IntentBuilder(activity)
|
||||
.setText(CallLinks.url(viewModel.rootKeySnapshot, viewModel.epochSnapshot))
|
||||
.setType(mimeType)
|
||||
.createChooserIntent()
|
||||
|
||||
try {
|
||||
activity.startActivity(shareIntent)
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
Toast.makeText(activity, R.string.CreateCallLinkBottomSheetDialogFragment__failed_to_open_share_sheet, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCopyClicked() {
|
||||
Util.copyToClipboard(activity, CallLinks.url(viewModel.rootKeySnapshot, viewModel.epochSnapshot))
|
||||
Toast.makeText(activity, R.string.CreateCallLinkBottomSheetDialogFragment__copied_to_clipboard, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
|
||||
override fun onShareLinkViaSignalClicked() {
|
||||
activity.startActivity(
|
||||
ShareActivity.sendSimpleText(
|
||||
activity,
|
||||
activity.getString(R.string.CreateCallLink__use_this_link_to_join_a_signal_call, CallLinks.url(viewModel.rootKeySnapshot, viewModel.epochSnapshot))
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override fun onDeleteClicked() {
|
||||
viewModel.setDisplayRevocationDialog(true)
|
||||
}
|
||||
|
||||
override fun onDeleteConfirmed() {
|
||||
viewModel.setDisplayRevocationDialog(false)
|
||||
activity.lifecycleScope.launch {
|
||||
if (viewModel.delete()) {
|
||||
router.goTo(MainNavigationListLocation.CALLS)
|
||||
router.goTo(MainNavigationDetailLocation.Empty)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDeleteCanceled() {
|
||||
viewModel.setDisplayRevocationDialog(false)
|
||||
}
|
||||
|
||||
override fun onApproveAllMembersChanged(checked: Boolean) {
|
||||
activity.lifecycleScope.launch {
|
||||
viewModel.setApproveAllMembers(checked)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface CallLinkDetailsCallback {
|
||||
fun onNavigationClicked() = Unit
|
||||
fun onJoinClicked() = Unit
|
||||
fun onEditNameClicked() = Unit
|
||||
fun onShareClicked() = Unit
|
||||
fun onCopyClicked() = Unit
|
||||
fun onShareLinkViaSignalClicked() = Unit
|
||||
fun onDeleteClicked() = Unit
|
||||
fun onDeleteConfirmed() = Unit
|
||||
fun onDeleteCanceled() = Unit
|
||||
fun onApproveAllMembersChanged(checked: Boolean) = Unit
|
||||
|
||||
object Empty : CallLinkDetailsCallback
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CallLinkDetailsScreen(
|
||||
state: CallLinkDetailsState,
|
||||
showAlreadyInACall: Boolean,
|
||||
callback: CallLinkDetailsCallback,
|
||||
showNavigationIcon: Boolean = true
|
||||
) {
|
||||
Scaffolds.Settings(
|
||||
title = stringResource(id = R.string.CallLinkDetailsFragment__call_details),
|
||||
snackbarHost = {
|
||||
YouAreAlreadyInACallSnackbar(showAlreadyInACall)
|
||||
FailureSnackbar(failureSnackbar = state.failureSnackbar)
|
||||
},
|
||||
onNavigationClick = callback::onNavigationClicked,
|
||||
navigationIcon = if (showNavigationIcon) {
|
||||
ImageVector.vectorResource(id = R.drawable.symbol_arrow_start_24)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
) { paddingValues ->
|
||||
if (state.callLink == null) {
|
||||
return@Settings
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.padding(paddingValues)
|
||||
.fillMaxHeight()
|
||||
) {
|
||||
item {
|
||||
SignalCallRow(
|
||||
callLink = state.callLink,
|
||||
callLinkPeekInfo = state.peekInfo,
|
||||
onJoinClicked = callback::onJoinClicked,
|
||||
modifier = Modifier.padding(top = 16.dp, bottom = 12.dp)
|
||||
)
|
||||
}
|
||||
|
||||
if (state.callLink.credentials?.adminPassBytes != null) {
|
||||
item {
|
||||
Rows.TextRow(
|
||||
text = stringResource(
|
||||
id = if (state.callLink.state.name.isEmpty()) {
|
||||
R.string.CreateCallLinkBottomSheetDialogFragment__add_call_name
|
||||
} else {
|
||||
R.string.CreateCallLinkBottomSheetDialogFragment__edit_call_name
|
||||
}
|
||||
),
|
||||
onClick = callback::onEditNameClicked
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Rows.ToggleRow(
|
||||
checked = state.callLink.state.restrictions == Restrictions.ADMIN_APPROVAL,
|
||||
text = stringResource(id = R.string.CallLinkDetailsFragment__require_admin_approval),
|
||||
onCheckChanged = callback::onApproveAllMembersChanged,
|
||||
isLoading = state.isLoadingAdminApprovalChange
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Dividers.Default()
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Rows.TextRow(
|
||||
text = stringResource(id = R.string.CreateCallLinkBottomSheetDialogFragment__share_link_via_signal),
|
||||
icon = ImageVector.vectorResource(id = R.drawable.symbol_forward_24),
|
||||
onClick = callback::onShareLinkViaSignalClicked
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Rows.TextRow(
|
||||
text = stringResource(id = R.string.CreateCallLinkBottomSheetDialogFragment__copy_link),
|
||||
icon = ImageVector.vectorResource(id = R.drawable.symbol_copy_android_24),
|
||||
onClick = callback::onCopyClicked
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Rows.TextRow(
|
||||
text = stringResource(id = R.string.CallLinkDetailsFragment__share_link),
|
||||
icon = ImageVector.vectorResource(id = R.drawable.symbol_link_24),
|
||||
onClick = callback::onShareClicked
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Rows.TextRow(
|
||||
text = stringResource(id = R.string.CallLinkDetailsFragment__delete_call_link),
|
||||
icon = ImageVector.vectorResource(id = R.drawable.symbol_trash_24),
|
||||
foregroundTint = MaterialTheme.colorScheme.error,
|
||||
onClick = callback::onDeleteClicked
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (state.displayRevocationDialog) {
|
||||
Dialogs.SimpleAlertDialog(
|
||||
title = stringResource(R.string.CallLinkDetailsFragment__delete_link),
|
||||
body = stringResource(id = R.string.CallLinkDetailsFragment__this_link_will_no_longer_work),
|
||||
confirm = stringResource(id = R.string.delete),
|
||||
dismiss = stringResource(id = android.R.string.cancel),
|
||||
onConfirm = callback::onDeleteConfirmed,
|
||||
onDismiss = callback::onDeleteCanceled
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FailureSnackbar(
|
||||
failureSnackbar: CallLinkDetailsState.FailureSnackbar?,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val message: String? = when (failureSnackbar) {
|
||||
CallLinkDetailsState.FailureSnackbar.COULD_NOT_DELETE_CALL_LINK -> stringResource(R.string.CallLinkDetailsFragment__couldnt_delete_call_link)
|
||||
CallLinkDetailsState.FailureSnackbar.COULD_NOT_SAVE_CHANGES -> stringResource(R.string.CallLinkDetailsFragment__couldnt_save_changes)
|
||||
CallLinkDetailsState.FailureSnackbar.COULD_NOT_UPDATE_ADMIN_APPROVAL -> stringResource(R.string.CallLinkDetailsFragment__couldnt_update_admin_approval)
|
||||
null -> null
|
||||
}
|
||||
|
||||
val hostState = remember { SnackbarHostState() }
|
||||
Snackbars.Host(hostState, modifier = modifier)
|
||||
|
||||
LaunchedEffect(message) {
|
||||
if (message != null) {
|
||||
hostState.showSnackbar(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun CallLinkDetailsScreenPreview() {
|
||||
val callLink = remember {
|
||||
val credentials = CallLinkCredentials(
|
||||
byteArrayOf(1, 2, 3, 4),
|
||||
byteArrayOf(0, 1, 2, 3),
|
||||
byteArrayOf(3, 4, 5, 6)
|
||||
)
|
||||
CallLinkTable.CallLink(
|
||||
recipientId = RecipientId.UNKNOWN,
|
||||
roomId = CallLinkRoomId.fromBytes(byteArrayOf(1, 2, 3, 4)),
|
||||
credentials = credentials,
|
||||
state = SignalCallLinkState(
|
||||
name = "Call Name",
|
||||
revoked = false,
|
||||
restrictions = Restrictions.NONE,
|
||||
expiration = Instant.MAX
|
||||
),
|
||||
deletionTimestamp = 0L
|
||||
)
|
||||
}
|
||||
|
||||
SignalTheme(false) {
|
||||
CallLinkDetailsScreen(
|
||||
CallLinkDetailsState(
|
||||
false,
|
||||
false,
|
||||
callLink
|
||||
),
|
||||
true,
|
||||
CallLinkDetailsCallback.Empty
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ package org.thoughtcrime.securesms.components.settings.app
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.widget.Toast
|
||||
import androidx.annotation.IdRes
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
@@ -39,8 +38,9 @@ import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.navigation.NavDirections
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
@@ -67,6 +67,8 @@ import org.thoughtcrime.securesms.banner.ui.compose.DefaultBanner
|
||||
import org.thoughtcrime.securesms.banner.ui.compose.Importance
|
||||
import org.thoughtcrime.securesms.components.compose.TextWithBetaLabel
|
||||
import org.thoughtcrime.securesms.components.emoji.Emojifier
|
||||
import org.thoughtcrime.securesms.components.settings.app.routes.AppSettingsRoute
|
||||
import org.thoughtcrime.securesms.components.settings.app.routes.AppSettingsRouter
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.BadgeImageMedium
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.completed.InAppPaymentsBottomSheetDelegate
|
||||
@@ -83,9 +85,38 @@ import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
||||
class AppSettingsFragment : ComposeFragment(), Callbacks {
|
||||
|
||||
private val viewModel: AppSettingsViewModel by viewModels()
|
||||
private val appSettingsRouter by viewModels<AppSettingsRouter>()
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
viewLifecycleOwner.lifecycle.addObserver(InAppPaymentsBottomSheetDelegate(childFragmentManager, viewLifecycleOwner))
|
||||
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
repeatOnLifecycle(Lifecycle.State.RESUMED) {
|
||||
appSettingsRouter.currentRoute.collect { route ->
|
||||
when (route) {
|
||||
is AppSettingsRoute.BackupsRoute.Remote -> findNavController().safeNavigate(R.id.action_appSettingsFragment_to_remoteBackupsSettingsFragment)
|
||||
is AppSettingsRoute.AccountRoute.Account -> findNavController().safeNavigate(R.id.action_appSettingsFragment_to_accountSettingsFragment)
|
||||
is AppSettingsRoute.LinkDeviceRoute.LinkDevice -> findNavController().safeNavigate(R.id.action_appSettingsFragment_to_linkDeviceFragment)
|
||||
is AppSettingsRoute.DonationsRoute.Donations -> findNavController().safeNavigate(R.id.action_appSettingsFragment_to_manageDonationsFragment)
|
||||
is AppSettingsRoute.AppearanceRoute.Appearance -> findNavController().safeNavigate(R.id.action_appSettingsFragment_to_appearanceSettingsFragment)
|
||||
is AppSettingsRoute.ChatsRoute.Chats -> findNavController().safeNavigate(R.id.action_appSettingsFragment_to_chatsSettingsFragment)
|
||||
is AppSettingsRoute.StoriesRoute.Privacy -> findNavController().safeNavigate(AppSettingsFragmentDirections.actionAppSettingsFragmentToStoryPrivacySettings(route.titleId))
|
||||
is AppSettingsRoute.NotificationsRoute.Notifications -> findNavController().safeNavigate(R.id.action_appSettingsFragment_to_notificationsSettingsFragment)
|
||||
is AppSettingsRoute.PrivacyRoute.Privacy -> findNavController().safeNavigate(R.id.action_appSettingsFragment_to_privacySettingsFragment)
|
||||
is AppSettingsRoute.BackupsRoute.Backups -> findNavController().safeNavigate(R.id.action_appSettingsFragment_to_backupsSettingsFragment)
|
||||
is AppSettingsRoute.DataAndStorageRoute.DataAndStorage -> findNavController().safeNavigate(R.id.action_appSettingsFragment_to_dataAndStorageSettingsFragment)
|
||||
is AppSettingsRoute.AppUpdates -> findNavController().safeNavigate(R.id.action_appSettingsFragment_to_appUpdatesSettingsFragment)
|
||||
is AppSettingsRoute.Payments -> findNavController().safeNavigate(R.id.action_appSettingsFragment_to_paymentsActivity)
|
||||
is AppSettingsRoute.HelpRoute.Settings -> findNavController().safeNavigate(R.id.action_appSettingsFragment_to_helpSettingsFragment)
|
||||
is AppSettingsRoute.Invite -> findNavController().safeNavigate(R.id.action_appSettingsFragment_to_inviteFragment)
|
||||
is AppSettingsRoute.InternalRoute.Internal -> findNavController().safeNavigate(R.id.action_appSettingsFragment_to_internalSettingsFragment)
|
||||
is AppSettingsRoute.AccountRoute.ManageProfile -> findNavController().safeNavigate(R.id.action_appSettingsFragment_to_manageProfileActivity)
|
||||
is AppSettingsRoute.UsernameLinkRoute.UsernameLink -> findNavController().safeNavigate(R.id.action_appSettingsFragment_to_usernameLinkSettingsFragment)
|
||||
else -> error("Unsupported route: ${route.javaClass.name}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -118,12 +149,8 @@ class AppSettingsFragment : ComposeFragment(), Callbacks {
|
||||
requireActivity().finishAfterTransition()
|
||||
}
|
||||
|
||||
override fun navigate(actionId: Int) {
|
||||
findNavController().safeNavigate(actionId)
|
||||
}
|
||||
|
||||
override fun navigate(directions: NavDirections) {
|
||||
findNavController().safeNavigate(directions)
|
||||
override fun navigate(route: AppSettingsRoute) {
|
||||
appSettingsRouter.navigateTo(route)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
@@ -203,7 +230,7 @@ private fun AppSettingsContent(
|
||||
BackupsWarningRow(
|
||||
text = stringResource(R.string.AppSettingsFragment__renew_your_signal_backups_subscription),
|
||||
onClick = {
|
||||
callbacks.navigate(R.id.action_appSettingsFragment_to_remoteBackupsSettingsFragment)
|
||||
callbacks.navigate(AppSettingsRoute.BackupsRoute.Remote())
|
||||
}
|
||||
)
|
||||
|
||||
@@ -219,7 +246,7 @@ private fun AppSettingsContent(
|
||||
text = stringResource(R.string.AppSettingsFragment__couldnt_complete_backup),
|
||||
onClick = {
|
||||
BackupRepository.markBackupFailedIndicatorClicked()
|
||||
callbacks.navigate(R.id.action_appSettingsFragment_to_remoteBackupsSettingsFragment)
|
||||
callbacks.navigate(AppSettingsRoute.BackupsRoute.Remote())
|
||||
}
|
||||
)
|
||||
|
||||
@@ -235,7 +262,7 @@ private fun AppSettingsContent(
|
||||
text = stringResource(R.string.AppSettingsFragment__couldnt_redeem_your_backups_subscription),
|
||||
onClick = {
|
||||
BackupRepository.markBackupAlreadyRedeemedIndicatorClicked()
|
||||
callbacks.navigate(R.id.action_appSettingsFragment_to_remoteBackupsSettingsFragment)
|
||||
callbacks.navigate(AppSettingsRoute.BackupsRoute.Remote())
|
||||
}
|
||||
)
|
||||
|
||||
@@ -252,7 +279,7 @@ private fun AppSettingsContent(
|
||||
icon = ImageVector.vectorResource(R.drawable.symbol_error_circle_fill_24),
|
||||
iconTint = MaterialTheme.colorScheme.error,
|
||||
onClick = {
|
||||
callbacks.navigate(R.id.action_appSettingsFragment_to_remoteBackupsSettingsFragment)
|
||||
callbacks.navigate(AppSettingsRoute.BackupsRoute.Remote())
|
||||
}
|
||||
)
|
||||
|
||||
@@ -268,7 +295,7 @@ private fun AppSettingsContent(
|
||||
text = stringResource(R.string.AccountSettingsFragment__account),
|
||||
icon = painterResource(R.drawable.symbol_person_circle_24),
|
||||
onClick = {
|
||||
callbacks.navigate(R.id.action_appSettingsFragment_to_accountSettingsFragment)
|
||||
callbacks.navigate(AppSettingsRoute.AccountRoute.Account)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -278,7 +305,7 @@ private fun AppSettingsContent(
|
||||
text = stringResource(R.string.preferences__linked_devices),
|
||||
icon = painterResource(R.drawable.symbol_devices_24),
|
||||
onClick = {
|
||||
callbacks.navigate(R.id.action_appSettingsFragment_to_linkDeviceFragment)
|
||||
callbacks.navigate(AppSettingsRoute.LinkDeviceRoute.LinkDevice)
|
||||
},
|
||||
enabled = isRegisteredAndUpToDate
|
||||
)
|
||||
@@ -312,7 +339,7 @@ private fun AppSettingsContent(
|
||||
},
|
||||
onClick = {
|
||||
if (state.allowUserToGoToDonationManagementScreen) {
|
||||
callbacks.navigate(R.id.action_appSettingsFragment_to_manageDonationsFragment)
|
||||
callbacks.navigate(AppSettingsRoute.DonationsRoute.Donations())
|
||||
} else {
|
||||
CommunicationActions.openBrowserLink(context, donateUrl)
|
||||
}
|
||||
@@ -332,7 +359,7 @@ private fun AppSettingsContent(
|
||||
text = stringResource(R.string.preferences__appearance),
|
||||
icon = painterResource(R.drawable.symbol_appearance_24),
|
||||
onClick = {
|
||||
callbacks.navigate(R.id.action_appSettingsFragment_to_appearanceSettingsFragment)
|
||||
callbacks.navigate(AppSettingsRoute.AppearanceRoute.Appearance)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -342,9 +369,9 @@ private fun AppSettingsContent(
|
||||
text = stringResource(R.string.preferences_chats__chats),
|
||||
icon = painterResource(R.drawable.symbol_chat_24),
|
||||
onClick = {
|
||||
callbacks.navigate(R.id.action_appSettingsFragment_to_chatsSettingsFragment)
|
||||
callbacks.navigate(AppSettingsRoute.ChatsRoute.Chats)
|
||||
},
|
||||
enabled = state.legacyLocalBackupsEnabled || isRegisteredAndUpToDate
|
||||
enabled = isRegisteredAndUpToDate
|
||||
)
|
||||
}
|
||||
|
||||
@@ -353,7 +380,7 @@ private fun AppSettingsContent(
|
||||
text = stringResource(R.string.preferences__stories),
|
||||
icon = painterResource(R.drawable.symbol_stories_24),
|
||||
onClick = {
|
||||
callbacks.navigate(AppSettingsFragmentDirections.actionAppSettingsFragmentToStoryPrivacySettings(R.string.preferences__stories))
|
||||
callbacks.navigate(AppSettingsRoute.StoriesRoute.Privacy(titleId = R.string.preferences__stories))
|
||||
},
|
||||
enabled = isRegisteredAndUpToDate
|
||||
)
|
||||
@@ -364,7 +391,7 @@ private fun AppSettingsContent(
|
||||
text = stringResource(R.string.preferences__notifications),
|
||||
icon = painterResource(R.drawable.symbol_bell_24),
|
||||
onClick = {
|
||||
callbacks.navigate(R.id.action_appSettingsFragment_to_notificationsSettingsFragment)
|
||||
callbacks.navigate(AppSettingsRoute.NotificationsRoute.Notifications)
|
||||
},
|
||||
enabled = isRegisteredAndUpToDate
|
||||
)
|
||||
@@ -375,37 +402,35 @@ private fun AppSettingsContent(
|
||||
text = stringResource(R.string.preferences__privacy),
|
||||
icon = painterResource(R.drawable.symbol_lock_24),
|
||||
onClick = {
|
||||
callbacks.navigate(R.id.action_appSettingsFragment_to_privacySettingsFragment)
|
||||
callbacks.navigate(AppSettingsRoute.PrivacyRoute.Privacy)
|
||||
},
|
||||
enabled = isRegisteredAndUpToDate
|
||||
)
|
||||
}
|
||||
|
||||
if (state.showBackups) {
|
||||
item {
|
||||
Rows.TextRow(
|
||||
text = {
|
||||
TextWithBetaLabel(
|
||||
text = stringResource(R.string.preferences_chats__backups),
|
||||
textStyle = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
},
|
||||
icon = {
|
||||
Icon(
|
||||
imageVector = ImageVector.vectorResource(R.drawable.symbol_backup_24),
|
||||
contentDescription = stringResource(R.string.preferences_chats__backups),
|
||||
tint = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
callbacks.navigate(R.id.action_appSettingsFragment_to_backupsSettingsFragment)
|
||||
},
|
||||
onLongClick = {
|
||||
callbacks.copyRemoteBackupsSubscriberIdToClipboard()
|
||||
},
|
||||
enabled = isRegisteredAndUpToDate
|
||||
)
|
||||
}
|
||||
item {
|
||||
Rows.TextRow(
|
||||
text = {
|
||||
TextWithBetaLabel(
|
||||
text = stringResource(R.string.preferences_chats__backups),
|
||||
textStyle = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
},
|
||||
icon = {
|
||||
Icon(
|
||||
imageVector = ImageVector.vectorResource(R.drawable.symbol_backup_24),
|
||||
contentDescription = stringResource(R.string.preferences_chats__backups),
|
||||
tint = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
callbacks.navigate(AppSettingsRoute.BackupsRoute.Backups)
|
||||
},
|
||||
onLongClick = {
|
||||
callbacks.copyRemoteBackupsSubscriberIdToClipboard()
|
||||
},
|
||||
enabled = isRegisteredAndUpToDate
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
@@ -413,7 +438,7 @@ private fun AppSettingsContent(
|
||||
text = stringResource(R.string.preferences__data_and_storage),
|
||||
icon = painterResource(R.drawable.symbol_data_24),
|
||||
onClick = {
|
||||
callbacks.navigate(R.id.action_appSettingsFragment_to_dataAndStorageSettingsFragment)
|
||||
callbacks.navigate(AppSettingsRoute.DataAndStorageRoute.DataAndStorage)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -424,7 +449,7 @@ private fun AppSettingsContent(
|
||||
text = "App updates",
|
||||
icon = painterResource(R.drawable.symbol_calendar_24),
|
||||
onClick = {
|
||||
callbacks.navigate(R.id.action_appSettingsFragment_to_appUpdatesSettingsFragment)
|
||||
callbacks.navigate(AppSettingsRoute.AppUpdates)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -467,7 +492,7 @@ private fun AppSettingsContent(
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
callbacks.navigate(R.id.action_appSettingsFragment_to_paymentsActivity)
|
||||
callbacks.navigate(AppSettingsRoute.Payments)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -482,7 +507,7 @@ private fun AppSettingsContent(
|
||||
text = stringResource(R.string.preferences__help),
|
||||
icon = painterResource(R.drawable.symbol_help_24),
|
||||
onClick = {
|
||||
callbacks.navigate(R.id.action_appSettingsFragment_to_helpSettingsFragment)
|
||||
callbacks.navigate(AppSettingsRoute.HelpRoute.Settings())
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -492,7 +517,7 @@ private fun AppSettingsContent(
|
||||
text = stringResource(R.string.AppSettingsFragment__invite_your_friends),
|
||||
icon = painterResource(R.drawable.symbol_invite_24),
|
||||
onClick = {
|
||||
callbacks.navigate(R.id.action_appSettingsFragment_to_inviteFragment)
|
||||
callbacks.navigate(AppSettingsRoute.Invite)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -506,7 +531,7 @@ private fun AppSettingsContent(
|
||||
Rows.TextRow(
|
||||
text = stringResource(R.string.preferences__internal_preferences),
|
||||
onClick = {
|
||||
callbacks.navigate(R.id.action_appSettingsFragment_to_internalSettingsFragment)
|
||||
callbacks.navigate(AppSettingsRoute.InternalRoute.Internal)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -558,7 +583,7 @@ private fun BioRow(
|
||||
modifier = Modifier
|
||||
.clickable(
|
||||
onClick = {
|
||||
callbacks.navigate(R.id.action_appSettingsFragment_to_manageProfileActivity)
|
||||
callbacks.navigate(AppSettingsRoute.AccountRoute.ManageProfile)
|
||||
}
|
||||
)
|
||||
.horizontalGutters()
|
||||
@@ -632,7 +657,7 @@ private fun BioRow(
|
||||
if (hasUsername) {
|
||||
IconButtons.IconButton(
|
||||
onClick = {
|
||||
callbacks.navigate(R.id.action_appSettingsFragment_to_usernameLinkSettingsFragment)
|
||||
callbacks.navigate(AppSettingsRoute.UsernameLinkRoute.UsernameLink)
|
||||
},
|
||||
size = 36.dp,
|
||||
colors = IconButtons.iconButtonColors(
|
||||
@@ -675,9 +700,7 @@ private fun AppSettingsContentPreview() {
|
||||
showInternalPreferences = true,
|
||||
showPayments = true,
|
||||
showAppUpdates = true,
|
||||
showBackups = true,
|
||||
backupFailureState = BackupFailureState.OUT_OF_STORAGE_SPACE,
|
||||
legacyLocalBackupsEnabled = false
|
||||
backupFailureState = BackupFailureState.OUT_OF_STORAGE_SPACE
|
||||
),
|
||||
bannerManager = BannerManager(
|
||||
banners = listOf(TestBanner())
|
||||
@@ -711,8 +734,7 @@ private fun BioRowPreview() {
|
||||
|
||||
private interface Callbacks {
|
||||
fun onNavigationClick(): Unit = error("Not implemented.")
|
||||
fun navigate(@IdRes actionId: Int): Unit = error("Not implemented")
|
||||
fun navigate(directions: NavDirections): Unit = error("Not implemented")
|
||||
fun navigate(route: AppSettingsRoute): Unit = error("Not implemented")
|
||||
fun copyDonorBadgeSubscriberIdToClipboard(): Unit = error("Not implemented")
|
||||
fun copyRemoteBackupsSubscriberIdToClipboard(): Unit = error("Not implemented")
|
||||
}
|
||||
|
||||
@@ -15,9 +15,7 @@ data class AppSettingsState(
|
||||
val showInternalPreferences: Boolean = RemoteConfig.internalUser,
|
||||
val showPayments: Boolean = SignalStore.payments.paymentsAvailability.showPaymentsMenu(),
|
||||
val showAppUpdates: Boolean = Environment.IS_NIGHTLY,
|
||||
val showBackups: Boolean = RemoteConfig.messageBackups,
|
||||
val backupFailureState: BackupFailureState = BackupFailureState.NONE,
|
||||
val legacyLocalBackupsEnabled: Boolean
|
||||
val backupFailureState: BackupFailureState = BackupFailureState.NONE
|
||||
) {
|
||||
fun isRegisteredAndUpToDate(): Boolean {
|
||||
return !userUnregistered && !clientDeprecated
|
||||
|
||||
@@ -14,8 +14,6 @@ import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.util.BackupUtil
|
||||
import org.thoughtcrime.securesms.util.RemoteConfig
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences
|
||||
import org.thoughtcrime.securesms.util.livedata.Store
|
||||
|
||||
@@ -27,8 +25,7 @@ class AppSettingsViewModel : ViewModel() {
|
||||
hasExpiredGiftBadge = SignalStore.inAppPayments.getExpiredGiftBadge() != null,
|
||||
allowUserToGoToDonationManagementScreen = SignalStore.inAppPayments.isLikelyASustainer() || InAppDonations.hasAtLeastOnePaymentMethodAvailable(),
|
||||
userUnregistered = TextSecurePreferences.isUnauthorizedReceived(AppDependencies.application) || !SignalStore.account.isRegistered,
|
||||
clientDeprecated = SignalStore.misc.isClientDeprecated,
|
||||
legacyLocalBackupsEnabled = !RemoteConfig.messageBackups && SignalStore.settings.isBackupEnabled && BackupUtil.canUserAccessBackupDirectory(AppDependencies.application)
|
||||
clientDeprecated = SignalStore.misc.isClientDeprecated
|
||||
)
|
||||
)
|
||||
|
||||
@@ -74,9 +71,7 @@ class AppSettingsViewModel : ViewModel() {
|
||||
}
|
||||
|
||||
private fun getBackupFailureState(): BackupFailureState {
|
||||
return if (!RemoteConfig.messageBackups) {
|
||||
BackupFailureState.NONE
|
||||
} else if (BackupRepository.shouldDisplayOutOfRemoteStorageSpaceUx()) {
|
||||
return if (BackupRepository.shouldDisplayOutOfRemoteStorageSpaceUx()) {
|
||||
BackupFailureState.OUT_OF_STORAGE_SPACE
|
||||
} else if (BackupRepository.shouldDisplayBackupFailedSettingsRow()) {
|
||||
BackupFailureState.BACKUP_FAILED
|
||||
|
||||
@@ -31,10 +31,12 @@ import androidx.compose.ui.res.vectorResource
|
||||
import androidx.core.app.DialogCompat
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import com.google.android.material.button.MaterialButton
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import kotlinx.coroutines.launch
|
||||
import org.signal.core.ui.compose.Dialogs
|
||||
import org.signal.core.ui.compose.Dividers
|
||||
import org.signal.core.ui.compose.Previews
|
||||
@@ -112,24 +114,21 @@ class AccountSettingsFragment : ComposeFragment() {
|
||||
val turnOffButton = DialogCompat.requireViewById(dialog, R.id.reminder_disable_turn_off)
|
||||
val changeKeyboard = DialogCompat.requireViewById(dialog, R.id.reminder_change_keyboard) as MaterialButton
|
||||
|
||||
changeKeyboard.setOnClickListener {
|
||||
val newType = PinKeyboardType.fromEditText(pinEditText).other
|
||||
newType.applyTo(
|
||||
pinEditText = pinEditText,
|
||||
toggleTypeButton = changeKeyboard
|
||||
)
|
||||
pinEditText.typeface = Typeface.DEFAULT
|
||||
dialog.lifecycleScope.launch {
|
||||
viewModel.state.collect { state ->
|
||||
state.pinKeyboardType.applyTo(
|
||||
pinEditText = pinEditText,
|
||||
toggleTypeButton = changeKeyboard
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
changeKeyboard.setOnClickListener { viewModel.togglePinKeyboardType() }
|
||||
|
||||
pinEditText.post {
|
||||
ViewUtil.focusAndShowKeyboard(pinEditText)
|
||||
}
|
||||
|
||||
SignalStore.pin.keyboardType.applyTo(
|
||||
pinEditText = pinEditText,
|
||||
toggleTypeButton = changeKeyboard
|
||||
)
|
||||
|
||||
pinEditText.addTextChangedListener(object : SimpleTextWatcher() {
|
||||
override fun onTextChanged(text: String) {
|
||||
turnOffButton.isEnabled = text.length >= SvrConstants.MINIMUM_PIN_LENGTH
|
||||
@@ -459,6 +458,7 @@ private fun AccountSettingsScreenPreview() {
|
||||
AccountSettingsScreen(
|
||||
state = AccountSettingsState(
|
||||
hasPin = true,
|
||||
pinKeyboardType = PinKeyboardType.NUMERIC,
|
||||
hasRestoredAep = true,
|
||||
pinRemindersEnabled = true,
|
||||
registrationLockEnabled = true,
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.account
|
||||
|
||||
import org.thoughtcrime.securesms.lock.v2.PinKeyboardType
|
||||
|
||||
data class AccountSettingsState(
|
||||
val hasPin: Boolean,
|
||||
val pinKeyboardType: PinKeyboardType,
|
||||
val hasRestoredAep: Boolean,
|
||||
val pinRemindersEnabled: Boolean,
|
||||
val registrationLockEnabled: Boolean,
|
||||
|
||||
@@ -6,7 +6,6 @@ import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.util.RemoteConfig
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences
|
||||
|
||||
class AccountSettingsViewModel : ViewModel() {
|
||||
@@ -18,15 +17,22 @@ class AccountSettingsViewModel : ViewModel() {
|
||||
store.update { getCurrentState() }
|
||||
}
|
||||
|
||||
fun togglePinKeyboardType() {
|
||||
store.update { previousState ->
|
||||
previousState.copy(pinKeyboardType = previousState.pinKeyboardType.other)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getCurrentState(): AccountSettingsState {
|
||||
return AccountSettingsState(
|
||||
hasPin = SignalStore.svr.hasPin() && !SignalStore.svr.hasOptedOut(),
|
||||
pinKeyboardType = SignalStore.pin.keyboardType,
|
||||
hasRestoredAep = SignalStore.account.restoredAccountEntropyPool,
|
||||
pinRemindersEnabled = SignalStore.pin.arePinRemindersEnabled() && SignalStore.svr.hasPin(),
|
||||
registrationLockEnabled = SignalStore.svr.isRegistrationLockEnabled,
|
||||
userUnregistered = TextSecurePreferences.isUnauthorizedReceived(AppDependencies.application),
|
||||
clientDeprecated = SignalStore.misc.isClientDeprecated,
|
||||
canTransferWhileUnregistered = RemoteConfig.restoreAfterRegistration
|
||||
canTransferWhileUnregistered = true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,11 +15,6 @@ import kotlin.time.Duration.Companion.seconds
|
||||
* Describes the state of the user's selected backup tier.
|
||||
*/
|
||||
sealed interface BackupState {
|
||||
/**
|
||||
* Backups are not available on this device
|
||||
*/
|
||||
data object NotAvailable : BackupState
|
||||
|
||||
/**
|
||||
* User has no active backup tier, no tier history
|
||||
*/
|
||||
|
||||
@@ -33,7 +33,6 @@ import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.util.InternetConnectionObserver
|
||||
import org.thoughtcrime.securesms.util.RemoteConfig
|
||||
import org.whispersystems.signalservice.api.NetworkResult
|
||||
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription
|
||||
import java.math.BigDecimal
|
||||
@@ -80,16 +79,12 @@ class BackupStateObserver(
|
||||
* setting initial ViewModel state values.
|
||||
*/
|
||||
fun getNonIOBackupState(): BackupState {
|
||||
return if (RemoteConfig.messageBackups) {
|
||||
val tier = SignalStore.backup.backupTier
|
||||
val tier = SignalStore.backup.backupTier
|
||||
|
||||
if (tier != null) {
|
||||
BackupState.LocalStore(tier)
|
||||
} else {
|
||||
BackupState.None
|
||||
}
|
||||
return if (tier != null) {
|
||||
BackupState.LocalStore(tier)
|
||||
} else {
|
||||
BackupState.NotAvailable
|
||||
BackupState.None
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -217,11 +212,6 @@ class BackupStateObserver(
|
||||
}
|
||||
|
||||
private suspend fun performDatabaseBackupStateRefresh() {
|
||||
if (!RemoteConfig.messageBackups) {
|
||||
Log.d(TAG, "[performDatabaseBackupStateRefresh] Dropping refresh for disabled feature.")
|
||||
return
|
||||
}
|
||||
|
||||
if (!SignalStore.account.isRegistered) {
|
||||
Log.d(TAG, "[performDatabaseBackupStateRefresh] Dropping refresh for unregistered user.")
|
||||
return
|
||||
@@ -236,11 +226,6 @@ class BackupStateObserver(
|
||||
}
|
||||
|
||||
private suspend fun performFullBackupStateRefresh() {
|
||||
if (!RemoteConfig.messageBackups) {
|
||||
Log.d(TAG, "[performFullBackupStateRefresh] Dropping refresh for disabled feature.")
|
||||
return
|
||||
}
|
||||
|
||||
if (!SignalStore.account.isRegistered) {
|
||||
Log.d(TAG, "[performFullBackupStateRefresh] Dropping refresh for unregistered user.")
|
||||
return
|
||||
@@ -315,6 +300,11 @@ class BackupStateObserver(
|
||||
SignalStore.backup.subscriptionStateMismatchDetected = false
|
||||
}
|
||||
|
||||
SignalStore.backup.backupTier == MessageBackupTier.FREE -> {
|
||||
Log.i(TAG, "[getNetworkBackupState][subscriptionMismatchDetected] User is on the free tier, has no signal subscription, and has a google play subscription. Clearing mismatch.")
|
||||
SignalStore.backup.subscriptionStateMismatchDetected = false
|
||||
}
|
||||
|
||||
else -> {
|
||||
Log.w(TAG, "[getNetworkBackupState][subscriptionMismatchDetected] Hit unexpected subscription mismatch state: signal:false, google:true")
|
||||
return BackupState.NotFound
|
||||
|
||||
@@ -11,12 +11,10 @@ import android.view.View
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.IntrinsicSize
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.wrapContentHeight
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
@@ -94,7 +92,7 @@ class BackupsSettingsFragment : ComposeFragment() {
|
||||
onNavigationClick = { requireActivity().onNavigateUp() },
|
||||
onBackupsRowClick = {
|
||||
when (state.backupState) {
|
||||
is BackupState.Error, BackupState.NotAvailable -> Unit
|
||||
is BackupState.Error -> Unit
|
||||
|
||||
BackupState.None -> {
|
||||
checkoutLauncher.launch(null)
|
||||
@@ -199,8 +197,6 @@ private fun BackupsSettingsContent(
|
||||
OtherWaysToBackUpHeading()
|
||||
}
|
||||
|
||||
BackupState.NotAvailable -> Unit
|
||||
|
||||
BackupState.NotFound -> {
|
||||
NotFoundBackupRow(
|
||||
onBackupsRowClick = onBackupsRowClick
|
||||
@@ -253,12 +249,12 @@ private fun NeverEnabledBackupsRow(
|
||||
onBackupsRowClick: () -> Unit = {}
|
||||
) {
|
||||
Rows.TextRow(
|
||||
modifier = Modifier.height(IntrinsicSize.Min),
|
||||
modifier = Modifier.wrapContentHeight(),
|
||||
icon = {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxHeight()
|
||||
.padding(top = 12.dp)
|
||||
.align(Alignment.Top)
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.symbol_backup_24),
|
||||
@@ -331,7 +327,10 @@ private fun InactiveBackupsRow(
|
||||
Icon(
|
||||
imageVector = ImageVector.vectorResource(R.drawable.symbol_backup_24),
|
||||
contentDescription = stringResource(R.string.preferences_chats__backups),
|
||||
tint = MaterialTheme.colorScheme.onSurface
|
||||
tint = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier
|
||||
.padding(top = 12.dp)
|
||||
.align(Alignment.Top)
|
||||
)
|
||||
}
|
||||
)
|
||||
@@ -342,13 +341,12 @@ private fun NotFoundBackupRow(
|
||||
onBackupsRowClick: () -> Unit = {}
|
||||
) {
|
||||
Rows.TextRow(
|
||||
modifier = Modifier.height(IntrinsicSize.Min),
|
||||
modifier = Modifier.wrapContentHeight(),
|
||||
icon = {
|
||||
Box(
|
||||
contentAlignment = Alignment.TopCenter,
|
||||
modifier = Modifier
|
||||
.fillMaxHeight()
|
||||
.padding(top = 12.dp)
|
||||
.align(Alignment.Top)
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.symbol_backup_24),
|
||||
@@ -379,13 +377,12 @@ private fun PendingBackupRow(
|
||||
onBackupsRowClick: () -> Unit = {}
|
||||
) {
|
||||
Rows.TextRow(
|
||||
modifier = Modifier.height(IntrinsicSize.Min),
|
||||
modifier = Modifier.wrapContentHeight(),
|
||||
icon = {
|
||||
Box(
|
||||
contentAlignment = Alignment.TopCenter,
|
||||
modifier = Modifier
|
||||
.fillMaxHeight()
|
||||
.padding(top = 12.dp)
|
||||
.align(Alignment.Top)
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(24.dp)
|
||||
@@ -430,13 +427,12 @@ private fun LocalStoreBackupRow(
|
||||
onBackupsRowClick: () -> Unit
|
||||
) {
|
||||
Rows.TextRow(
|
||||
modifier = Modifier.height(IntrinsicSize.Min),
|
||||
modifier = Modifier.wrapContentHeight(),
|
||||
icon = {
|
||||
Box(
|
||||
contentAlignment = Alignment.TopCenter,
|
||||
modifier = Modifier
|
||||
.fillMaxHeight()
|
||||
.padding(top = 12.dp)
|
||||
.align(Alignment.Top)
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.symbol_backup_24),
|
||||
@@ -476,13 +472,12 @@ private fun ActiveBackupsRow(
|
||||
onBackupsRowClick: () -> Unit = {}
|
||||
) {
|
||||
Rows.TextRow(
|
||||
modifier = Modifier.height(IntrinsicSize.Min),
|
||||
modifier = Modifier.wrapContentHeight(),
|
||||
icon = {
|
||||
Box(
|
||||
contentAlignment = Alignment.TopCenter,
|
||||
modifier = Modifier
|
||||
.fillMaxHeight()
|
||||
.padding(top = 12.dp)
|
||||
.align(Alignment.Top)
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.symbol_backup_24),
|
||||
@@ -501,6 +496,11 @@ private fun ActiveBackupsRow(
|
||||
is MessageBackupsType.Paid -> {
|
||||
val body = if (backupState is BackupState.Canceled) {
|
||||
stringResource(R.string.BackupsSettingsFragment__subscription_canceled)
|
||||
} else if (type.pricePerMonth.amount == BigDecimal.ZERO) {
|
||||
stringResource(
|
||||
R.string.BackupsSettingsFragment_renews_s,
|
||||
DateUtils.formatDateWithYear(Locale.getDefault(), backupState.renewalTime.inWholeMilliseconds)
|
||||
)
|
||||
} else {
|
||||
stringResource(
|
||||
R.string.BackupsSettingsFragment_s_month_renews_s,
|
||||
@@ -545,9 +545,9 @@ private fun ActiveBackupsRow(
|
||||
private fun LastBackedUpText(lastBackupAt: Duration) {
|
||||
val context = LocalContext.current
|
||||
|
||||
var lastBackupString by remember { mutableStateOf(calculateLastBackupTimeString(context, lastBackupAt)) }
|
||||
var lastBackupString by remember(lastBackupAt) { mutableStateOf(calculateLastBackupTimeString(context, lastBackupAt)) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
LaunchedEffect(lastBackupAt) {
|
||||
while (true) {
|
||||
delay(1.minutes)
|
||||
lastBackupString = calculateLastBackupTimeString(context, lastBackupAt)
|
||||
@@ -566,11 +566,20 @@ private fun LastBackedUpText(lastBackupAt: Duration) {
|
||||
|
||||
private fun calculateLastBackupTimeString(context: Context, lastBackupAt: Duration): String {
|
||||
return if (lastBackupAt.inWholeMilliseconds > 0) {
|
||||
DateUtils.getDatelessRelativeTimeSpanFormattedDate(
|
||||
val relativeTime = DateUtils.getDatelessRelativeTimeSpanFormattedDate(
|
||||
context,
|
||||
Locale.getDefault(),
|
||||
lastBackupAt.inWholeMilliseconds
|
||||
).value
|
||||
)
|
||||
|
||||
if (relativeTime.isRelative) {
|
||||
relativeTime.value
|
||||
} else {
|
||||
val day = DateUtils.getDayPrecisionTimeString(context, Locale.getDefault(), lastBackupAt.inWholeMilliseconds)
|
||||
val time = relativeTime.value
|
||||
|
||||
context.getString(R.string.RemoteBackupsSettingsFragment__s_at_s, day, time)
|
||||
}
|
||||
} else {
|
||||
context.getString(R.string.RemoteBackupsSettingsFragment__never)
|
||||
}
|
||||
@@ -624,19 +633,6 @@ private fun BackupsSettingsContentPreview() {
|
||||
}
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@Composable
|
||||
private fun BackupsSettingsContentNotAvailablePreview() {
|
||||
Previews.Preview {
|
||||
BackupsSettingsContent(
|
||||
backupsSettingsState = BackupsSettingsState(
|
||||
backupState = BackupState.NotAvailable,
|
||||
lastBackupAt = 0.seconds
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@Composable
|
||||
private fun BackupsSettingsContentBackupTierInternalOverridePreview() {
|
||||
@@ -703,6 +699,25 @@ private fun ActivePaidBackupsRowPreview() {
|
||||
}
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@Composable
|
||||
private fun ActivePaidBackupsRowNoPricePreview() {
|
||||
Previews.Preview {
|
||||
ActiveBackupsRow(
|
||||
backupState = BackupState.ActivePaid(
|
||||
messageBackupsType = MessageBackupsType.Paid(
|
||||
pricePerMonth = FiatMoney(BigDecimal.ZERO, Currency.getInstance("CAD")),
|
||||
storageAllowanceBytes = 1_000_000,
|
||||
mediaTtl = 30.days
|
||||
),
|
||||
renewalTime = 0.seconds,
|
||||
price = FiatMoney(BigDecimal.valueOf(4), Currency.getInstance("CAD"))
|
||||
),
|
||||
lastBackupAt = 0.seconds
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@Composable
|
||||
private fun ActiveFreeBackupsRowPreview() {
|
||||
|
||||
@@ -16,6 +16,7 @@ import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement.spacedBy
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -94,7 +95,6 @@ import org.thoughtcrime.securesms.backup.DeletionState
|
||||
import org.thoughtcrime.securesms.backup.RestoreState
|
||||
import org.thoughtcrime.securesms.backup.v2.ArchiveRestoreProgressState
|
||||
import org.thoughtcrime.securesms.backup.v2.ArchiveRestoreProgressState.RestoreStatus
|
||||
import org.thoughtcrime.securesms.backup.v2.BackupFrequency
|
||||
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
|
||||
import org.thoughtcrime.securesms.backup.v2.ui.BackupAlert
|
||||
import org.thoughtcrime.securesms.backup.v2.ui.BackupAlertBottomSheet
|
||||
@@ -113,6 +113,7 @@ import org.thoughtcrime.securesms.help.HelpFragment
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.keyvalue.protos.ArchiveUploadProgressState
|
||||
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
|
||||
import org.thoughtcrime.securesms.util.CommunicationActions
|
||||
import org.thoughtcrime.securesms.util.DateUtils
|
||||
import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
||||
import org.thoughtcrime.securesms.util.viewModel
|
||||
@@ -285,6 +286,14 @@ class RemoteBackupsSettingsFragment : ComposeFragment() {
|
||||
override fun onIncludeDebuglogClick(newState: Boolean) {
|
||||
viewModel.setIncludeDebuglog(newState)
|
||||
}
|
||||
|
||||
override fun onMediaBackupSizeClick() {
|
||||
viewModel.requestDialog(RemoteBackupsSettingsState.Dialog.FREE_TIER_MEDIA_EXPLAINER)
|
||||
}
|
||||
|
||||
override fun onFreeTierBackupSizeLearnMore() {
|
||||
CommunicationActions.openBrowserLink(requireContext(), "https://support.signal.org/hc/articles/9708267671322")
|
||||
}
|
||||
}
|
||||
|
||||
private fun displayBackupKey() {
|
||||
@@ -387,6 +396,8 @@ private interface ContentCallbacks {
|
||||
fun onDisplayDownloadingBackupDialog() = Unit
|
||||
fun onManageStorageClick() = Unit
|
||||
fun onIncludeDebuglogClick(newState: Boolean) = Unit
|
||||
fun onMediaBackupSizeClick() = Unit
|
||||
fun onFreeTierBackupSizeLearnMore() = Unit
|
||||
|
||||
object Empty : ContentCallbacks
|
||||
}
|
||||
@@ -508,8 +519,6 @@ private fun RemoteBackupsSettingsContent(
|
||||
isRenewEnabled = backupDeleteState.isIdle()
|
||||
)
|
||||
}
|
||||
|
||||
BackupState.NotAvailable -> error("This shouldn't happen on this screen.")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -634,6 +643,18 @@ private fun RemoteBackupsSettingsContent(
|
||||
onResumeOverCellularClick = contentCallbacks::onRestoreUsingCellularClick
|
||||
)
|
||||
}
|
||||
|
||||
RemoteBackupsSettingsState.Dialog.FREE_TIER_MEDIA_EXPLAINER -> {
|
||||
Dialogs.SimpleAlertDialog(
|
||||
title = stringResource(R.string.RemoteBackupsSettingsFragment__free_tier_storage_title),
|
||||
body = pluralStringResource(R.plurals.RemoteBackupsSettingsFragment__backup_frequency_dialog_body, state.freeTierMediaRetentionDays, state.freeTierMediaRetentionDays),
|
||||
confirm = stringResource(android.R.string.ok),
|
||||
dismiss = stringResource(R.string.RemoteBackupsSettingsFragment__learn_more),
|
||||
onConfirm = {},
|
||||
onDismiss = contentCallbacks::onDialogDismissed,
|
||||
onDeny = contentCallbacks::onFreeTierBackupSizeLearnMore
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val snackbarMessageId = remember(state.snackbar) {
|
||||
@@ -894,6 +915,7 @@ private fun LazyListScope.appendBackupDetailsItems(
|
||||
LastBackupRow(
|
||||
lastBackupTimestamp = state.lastBackupTimestamp,
|
||||
enabled = !state.isOutOfStorageSpace,
|
||||
onRowClick = contentCallbacks::onBackupFrequencyClick,
|
||||
onBackupNowClick = contentCallbacks::onBackupNowClick
|
||||
)
|
||||
}
|
||||
@@ -901,6 +923,7 @@ private fun LazyListScope.appendBackupDetailsItems(
|
||||
item {
|
||||
InProgressBackupRow(
|
||||
archiveUploadProgressState = backupProgress,
|
||||
isPaidTier = state.tier == MessageBackupTier.PAID,
|
||||
canBackupMessagesRun = state.canBackupMessagesJobRun,
|
||||
canBackupUsingCellular = state.canBackUpUsingCellular,
|
||||
cancelArchiveUpload = contentCallbacks::onCancelUploadClick
|
||||
@@ -908,15 +931,15 @@ private fun LazyListScope.appendBackupDetailsItems(
|
||||
}
|
||||
}
|
||||
|
||||
if (state.backupState.isLikelyPaidTier()) {
|
||||
item {
|
||||
val sizeText = if (state.backupMediaSize < 0L) {
|
||||
stringResource(R.string.RemoteBackupsSettingsFragment__calculating)
|
||||
} else {
|
||||
state.backupMediaSize.bytes.toUnitString()
|
||||
}
|
||||
item {
|
||||
val sizeText = if (state.backupMediaSize < 0L) {
|
||||
stringResource(R.string.RemoteBackupsSettingsFragment__calculating)
|
||||
} else {
|
||||
state.backupMediaSize.bytes.toUnitString()
|
||||
}
|
||||
|
||||
Rows.TextRow(text = {
|
||||
Rows.TextRow(
|
||||
text = {
|
||||
Column {
|
||||
Text(
|
||||
text = stringResource(id = R.string.RemoteBackupsSettingsFragment__backup_size),
|
||||
@@ -929,27 +952,12 @@ private fun LazyListScope.appendBackupDetailsItems(
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Rows.TextRow(
|
||||
text = {
|
||||
Column {
|
||||
Text(
|
||||
text = stringResource(id = R.string.RemoteBackupsSettingsFragment__backup_frequency),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Text(
|
||||
text = stringResource(id = R.string.RemoteBackupsSettingsFragment__daily),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
},
|
||||
onClick = contentCallbacks::onBackupFrequencyClick
|
||||
onClick = if (state.backupMediaSize >= 0L && state.tier == MessageBackupTier.FREE) {
|
||||
{ contentCallbacks.onMediaBackupSizeClick() }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1372,6 +1380,7 @@ private fun SubscriptionMismatchMissingGooglePlayCard(
|
||||
@Composable
|
||||
private fun InProgressBackupRow(
|
||||
archiveUploadProgressState: ArchiveUploadProgressState,
|
||||
isPaidTier: Boolean,
|
||||
canBackupMessagesRun: Boolean = true,
|
||||
canBackupUsingCellular: Boolean = true,
|
||||
cancelArchiveUpload: () -> Unit = {}
|
||||
@@ -1409,7 +1418,7 @@ private fun InProgressBackupRow(
|
||||
}
|
||||
|
||||
Text(
|
||||
text = getProgressStateMessage(archiveUploadProgressState, canBackupMessagesRun, canBackupUsingCellular),
|
||||
text = getProgressStateMessage(archiveUploadProgressState, isPaidTier, canBackupMessagesRun, canBackupUsingCellular),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
@@ -1447,11 +1456,11 @@ private fun ArchiveProgressIndicator(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun getProgressStateMessage(archiveUploadProgressState: ArchiveUploadProgressState, canBackupMessagesRun: Boolean, canBackupUsingCellular: Boolean): String {
|
||||
private fun getProgressStateMessage(archiveUploadProgressState: ArchiveUploadProgressState, isPaidTier: Boolean, canBackupMessagesRun: Boolean, canBackupUsingCellular: Boolean): String {
|
||||
return when (archiveUploadProgressState.state) {
|
||||
ArchiveUploadProgressState.State.None, ArchiveUploadProgressState.State.UserCanceled -> stringResource(R.string.RemoteBackupsSettingsFragment__processing_backup)
|
||||
ArchiveUploadProgressState.State.Export -> getBackupExportPhaseProgressString(archiveUploadProgressState, canBackupMessagesRun, canBackupUsingCellular)
|
||||
ArchiveUploadProgressState.State.UploadBackupFile, ArchiveUploadProgressState.State.UploadMedia -> getBackupUploadPhaseProgressString(archiveUploadProgressState)
|
||||
ArchiveUploadProgressState.State.UploadBackupFile, ArchiveUploadProgressState.State.UploadMedia -> getBackupUploadPhaseProgressString(archiveUploadProgressState, isPaidTier)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1483,12 +1492,16 @@ private fun getBackupExportPhaseProgressString(state: ArchiveUploadProgressState
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun getBackupUploadPhaseProgressString(state: ArchiveUploadProgressState): String {
|
||||
private fun getBackupUploadPhaseProgressString(state: ArchiveUploadProgressState, isPaidTier: Boolean): String {
|
||||
val formattedTotalBytes = state.uploadBytesTotal.bytes.toUnitString()
|
||||
val formattedUploadedBytes = state.uploadBytesUploaded.bytes.toUnitString()
|
||||
val percent = (state.uploadProgress() * 100).toInt()
|
||||
|
||||
return stringResource(R.string.RemoteBackupsSettingsFragment__uploading_s_of_s_d, formattedUploadedBytes, formattedTotalBytes, percent)
|
||||
return if (isPaidTier) {
|
||||
stringResource(R.string.RemoteBackupsSettingsFragment__uploading_s_of_s_d, formattedUploadedBytes, formattedTotalBytes, percent)
|
||||
} else {
|
||||
stringResource(R.string.RemoteBackupsSettingsFragment__uploading_d, percent)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -1508,10 +1521,12 @@ private fun IncludeDebuglogRow(
|
||||
private fun LastBackupRow(
|
||||
lastBackupTimestamp: Long,
|
||||
enabled: Boolean,
|
||||
onRowClick: () -> Unit,
|
||||
onBackupNowClick: () -> Unit
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.clickable(onClick = onRowClick)
|
||||
.padding(horizontal = dimensionResource(id = CoreUiR.dimen.gutter))
|
||||
.padding(top = 16.dp, bottom = 14.dp)
|
||||
) {
|
||||
@@ -1705,16 +1720,6 @@ private fun BackupReadyToDownloadRow(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun getTextForFrequency(backupsFrequency: BackupFrequency): String {
|
||||
return when (backupsFrequency) {
|
||||
BackupFrequency.DAILY -> stringResource(id = R.string.RemoteBackupsSettingsFragment__daily)
|
||||
BackupFrequency.WEEKLY -> stringResource(id = R.string.RemoteBackupsSettingsFragment__weekly)
|
||||
BackupFrequency.MONTHLY -> stringResource(id = R.string.RemoteBackupsSettingsFragment__monthly)
|
||||
BackupFrequency.MANUAL -> stringResource(id = R.string.RemoteBackupsSettingsFragment__manually_back_up)
|
||||
}
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@Composable
|
||||
private fun RemoteBackupsSettingsContentPreview() {
|
||||
@@ -1965,6 +1970,7 @@ private fun LastBackupRowPreview() {
|
||||
LastBackupRow(
|
||||
lastBackupTimestamp = -1,
|
||||
enabled = true,
|
||||
onRowClick = {},
|
||||
onBackupNowClick = {}
|
||||
)
|
||||
}
|
||||
@@ -1975,18 +1981,20 @@ private fun LastBackupRowPreview() {
|
||||
private fun InProgressRowPreview() {
|
||||
Previews.Preview {
|
||||
Column {
|
||||
InProgressBackupRow(archiveUploadProgressState = ArchiveUploadProgressState())
|
||||
InProgressBackupRow(archiveUploadProgressState = ArchiveUploadProgressState(), isPaidTier = true)
|
||||
InProgressBackupRow(
|
||||
archiveUploadProgressState = ArchiveUploadProgressState(
|
||||
state = ArchiveUploadProgressState.State.Export,
|
||||
backupPhase = ArchiveUploadProgressState.BackupPhase.BackupPhaseNone
|
||||
)
|
||||
),
|
||||
isPaidTier = true
|
||||
)
|
||||
InProgressBackupRow(
|
||||
archiveUploadProgressState = ArchiveUploadProgressState(
|
||||
state = ArchiveUploadProgressState.State.Export,
|
||||
backupPhase = ArchiveUploadProgressState.BackupPhase.Account
|
||||
)
|
||||
),
|
||||
isPaidTier = true
|
||||
)
|
||||
InProgressBackupRow(
|
||||
archiveUploadProgressState = ArchiveUploadProgressState(
|
||||
@@ -1994,7 +2002,8 @@ private fun InProgressRowPreview() {
|
||||
backupPhase = ArchiveUploadProgressState.BackupPhase.Message,
|
||||
frameExportCount = 1,
|
||||
frameTotalCount = 1
|
||||
)
|
||||
),
|
||||
isPaidTier = true
|
||||
)
|
||||
InProgressBackupRow(
|
||||
archiveUploadProgressState = ArchiveUploadProgressState(
|
||||
@@ -2002,7 +2011,8 @@ private fun InProgressRowPreview() {
|
||||
backupPhase = ArchiveUploadProgressState.BackupPhase.Message,
|
||||
frameExportCount = 1000,
|
||||
frameTotalCount = 100_000
|
||||
)
|
||||
),
|
||||
isPaidTier = true
|
||||
)
|
||||
InProgressBackupRow(
|
||||
archiveUploadProgressState = ArchiveUploadProgressState(
|
||||
@@ -2010,7 +2020,8 @@ private fun InProgressRowPreview() {
|
||||
backupPhase = ArchiveUploadProgressState.BackupPhase.Message,
|
||||
frameExportCount = 1_000_000,
|
||||
frameTotalCount = 100_000
|
||||
)
|
||||
),
|
||||
isPaidTier = true
|
||||
)
|
||||
InProgressBackupRow(
|
||||
archiveUploadProgressState = ArchiveUploadProgressState(
|
||||
@@ -2020,7 +2031,19 @@ private fun InProgressRowPreview() {
|
||||
backupFileTotalBytes = 50.mebiBytes.inWholeBytes,
|
||||
mediaUploadedBytes = 0,
|
||||
mediaTotalBytes = 0
|
||||
)
|
||||
),
|
||||
isPaidTier = true
|
||||
)
|
||||
InProgressBackupRow(
|
||||
archiveUploadProgressState = ArchiveUploadProgressState(
|
||||
state = ArchiveUploadProgressState.State.UploadBackupFile,
|
||||
backupPhase = ArchiveUploadProgressState.BackupPhase.BackupPhaseNone,
|
||||
backupFileUploadedBytes = 10.mebiBytes.inWholeBytes,
|
||||
backupFileTotalBytes = 50.mebiBytes.inWholeBytes,
|
||||
mediaUploadedBytes = 0,
|
||||
mediaTotalBytes = 0
|
||||
),
|
||||
isPaidTier = false
|
||||
)
|
||||
InProgressBackupRow(
|
||||
archiveUploadProgressState = ArchiveUploadProgressState(
|
||||
@@ -2030,7 +2053,8 @@ private fun InProgressRowPreview() {
|
||||
backupFileTotalBytes = 50.mebiBytes.inWholeBytes,
|
||||
mediaUploadedBytes = 100.mebiBytes.inWholeBytes,
|
||||
mediaTotalBytes = 1.gibiBytes.inWholeBytes
|
||||
)
|
||||
),
|
||||
isPaidTier = true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,8 @@ data class RemoteBackupsSettingsState(
|
||||
val canBackupMessagesJobRun: Boolean = false,
|
||||
val backupMediaDetails: BackupMediaDetails? = null,
|
||||
val showBackupCreateFailedError: Boolean = false,
|
||||
val showBackupCreateCouldNotCompleteError: Boolean = false
|
||||
val showBackupCreateCouldNotCompleteError: Boolean = false,
|
||||
val freeTierMediaRetentionDays: Int = -1
|
||||
) {
|
||||
|
||||
data class BackupMediaDetails(
|
||||
@@ -50,7 +51,8 @@ data class RemoteBackupsSettingsState(
|
||||
SUBSCRIPTION_NOT_FOUND,
|
||||
SKIP_MEDIA_RESTORE_PROTECTION,
|
||||
CANCEL_MEDIA_RESTORE_PROTECTION,
|
||||
RESTORE_OVER_CELLULAR_PROTECTION
|
||||
RESTORE_OVER_CELLULAR_PROTECTION,
|
||||
FREE_TIER_MEDIA_EXPLAINER
|
||||
}
|
||||
|
||||
enum class Snackbar {
|
||||
|
||||
@@ -33,6 +33,9 @@ import org.thoughtcrime.securesms.backup.DeletionState
|
||||
import org.thoughtcrime.securesms.backup.v2.ArchiveRestoreProgress
|
||||
import org.thoughtcrime.securesms.backup.v2.ArchiveRestoreProgressState.RestoreStatus
|
||||
import org.thoughtcrime.securesms.backup.v2.BackupRepository
|
||||
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
|
||||
import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsType
|
||||
import org.thoughtcrime.securesms.components.settings.app.backups.BackupState
|
||||
import org.thoughtcrime.securesms.components.settings.app.backups.BackupStateObserver
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository
|
||||
import org.thoughtcrime.securesms.database.InAppPaymentTable
|
||||
@@ -47,6 +50,8 @@ import org.thoughtcrime.securesms.util.Environment
|
||||
import org.thoughtcrime.securesms.util.RemoteConfig
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences
|
||||
import org.whispersystems.signalservice.api.NetworkResult
|
||||
import kotlin.time.Duration.Companion.days
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
/**
|
||||
@@ -83,7 +88,7 @@ class RemoteBackupsSettingsViewModel : ViewModel() {
|
||||
|
||||
init {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val isBillingApiAvailable = AppDependencies.billingApi.isApiAvailable()
|
||||
val isBillingApiAvailable = AppDependencies.billingApi.getApiAvailability().isSuccess
|
||||
if (isBillingApiAvailable) {
|
||||
_state.update {
|
||||
it.copy(isPaidTierPricingAvailable = true)
|
||||
@@ -163,11 +168,12 @@ class RemoteBackupsSettingsViewModel : ViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
BackupStateObserver(viewModelScope).backupState.collect { state ->
|
||||
_state.update {
|
||||
it.copy(backupState = state)
|
||||
}
|
||||
refreshState(null)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -258,8 +264,10 @@ class RemoteBackupsSettingsViewModel : ViewModel() {
|
||||
|
||||
private fun refreshBackupMediaSizeState() {
|
||||
_state.update {
|
||||
val (mediaSize, mediaRetentionDays) = getBackupMediaSize(it.tier, (it.backupState as? BackupState.WithTypeAndRenewalTime)?.messageBackupsType)
|
||||
it.copy(
|
||||
backupMediaSize = getBackupMediaSize(),
|
||||
backupMediaSize = mediaSize,
|
||||
freeTierMediaRetentionDays = mediaRetentionDays,
|
||||
backupMediaDetails = if (RemoteConfig.internalUser || Environment.IS_STAGING) {
|
||||
RemoteBackupsSettingsState.BackupMediaDetails(
|
||||
awaitingRestore = SignalDatabase.attachments.getRemainingRestorableAttachmentSize().bytes,
|
||||
@@ -287,7 +295,7 @@ class RemoteBackupsSettingsViewModel : ViewModel() {
|
||||
|
||||
if (paidType is NetworkResult.Success) {
|
||||
val remoteStorageAllowance = paidType.result.storageAllowanceBytes.bytes
|
||||
val estimatedSize = SignalDatabase.attachments.getEstimatedArchiveMediaSize().bytes
|
||||
val estimatedSize = getBackupMediaSize(paidType.result.tier, paidType.result).first.bytes
|
||||
|
||||
if (estimatedSize + 300.mebiBytes <= remoteStorageAllowance) {
|
||||
BackupRepository.clearOutOfRemoteStorageSpaceError()
|
||||
@@ -303,13 +311,16 @@ class RemoteBackupsSettingsViewModel : ViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
val (mediaSize, mediaRetentionDays) = getBackupMediaSize(_state.value.tier, (_state.value.backupState as? BackupState.WithTypeAndRenewalTime)?.messageBackupsType)
|
||||
|
||||
_state.update {
|
||||
it.copy(
|
||||
tier = SignalStore.backup.backupTier,
|
||||
backupsEnabled = SignalStore.backup.areBackupsEnabled,
|
||||
lastBackupTimestamp = SignalStore.backup.lastBackupTime,
|
||||
canBackupMessagesJobRun = BackupMessagesConstraint.isMet(AppDependencies.application),
|
||||
backupMediaSize = getBackupMediaSize(),
|
||||
backupMediaSize = mediaSize,
|
||||
freeTierMediaRetentionDays = mediaRetentionDays,
|
||||
canBackUpUsingCellular = SignalStore.backup.backupWithCellular,
|
||||
canRestoreUsingCellular = SignalStore.backup.restoreWithCellular,
|
||||
isOutOfStorageSpace = BackupRepository.shouldDisplayOutOfRemoteStorageSpaceUx(),
|
||||
@@ -320,11 +331,39 @@ class RemoteBackupsSettingsViewModel : ViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun getBackupMediaSize(): Long {
|
||||
return if (SignalStore.backup.hasBackupBeenUploaded || SignalStore.backup.lastBackupTime > 0L) {
|
||||
SignalDatabase.attachments.getEstimatedArchiveMediaSize()
|
||||
private fun getBackupMediaSize(tier: MessageBackupTier?, messageBackupsType: MessageBackupsType?): Pair<Long, Int> {
|
||||
if (tier == null) {
|
||||
return -1L to 0
|
||||
}
|
||||
|
||||
val mediaRetentionDays = if (messageBackupsType is MessageBackupsType.Free) {
|
||||
messageBackupsType.mediaRetentionDays
|
||||
} else {
|
||||
0L
|
||||
when (tier) {
|
||||
MessageBackupTier.FREE -> {
|
||||
when (val result = BackupRepository.getFreeType()) {
|
||||
is NetworkResult.Success -> result.result.mediaRetentionDays
|
||||
else -> RemoteConfig.messageQueueTime.milliseconds.inWholeDays.toInt()
|
||||
}
|
||||
}
|
||||
|
||||
MessageBackupTier.PAID -> 0
|
||||
}
|
||||
}
|
||||
|
||||
return if (SignalStore.backup.hasBackupBeenUploaded || SignalStore.backup.lastBackupTime > 0L) {
|
||||
when (tier) {
|
||||
MessageBackupTier.PAID -> SignalDatabase.attachments.getPaidEstimatedArchiveMediaSize() to -1
|
||||
MessageBackupTier.FREE -> {
|
||||
if (mediaRetentionDays > 0) {
|
||||
SignalDatabase.attachments.getFreeEstimatedArchiveMediaSize(System.currentTimeMillis() - mediaRetentionDays.days.inWholeMilliseconds) to mediaRetentionDays
|
||||
} else {
|
||||
-1L to -1
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
0L to mediaRetentionDays
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,10 +5,7 @@
|
||||
|
||||
package org.thoughtcrime.securesms.components.settings.app.changenumber
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import org.thoughtcrime.securesms.registration.data.network.Challenge
|
||||
import org.thoughtcrime.securesms.registration.ui.captcha.CaptchaFragment
|
||||
|
||||
/**
|
||||
@@ -16,16 +13,8 @@ import org.thoughtcrime.securesms.registration.ui.captcha.CaptchaFragment
|
||||
*/
|
||||
class ChangeNumberCaptchaFragment : CaptchaFragment() {
|
||||
private val viewModel by activityViewModels<ChangeNumberViewModel>()
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
viewModel.addPresentedChallenge(Challenge.CAPTCHA)
|
||||
}
|
||||
|
||||
override fun handleCaptchaToken(token: String) {
|
||||
viewModel.setCaptchaResponse(token)
|
||||
}
|
||||
|
||||
override fun handleUserExit() {
|
||||
viewModel.removePresentedChallenge(Challenge.CAPTCHA)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,12 +23,9 @@ import org.thoughtcrime.securesms.components.ViewBinderDelegate
|
||||
import org.thoughtcrime.securesms.components.settings.app.changenumber.ChangeNumberUtil.changeNumberSuccess
|
||||
import org.thoughtcrime.securesms.databinding.FragmentRegistrationLockBinding
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.lock.v2.PinKeyboardType
|
||||
import org.thoughtcrime.securesms.lock.v2.SvrConstants
|
||||
import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequestResult
|
||||
import org.thoughtcrime.securesms.registration.fragments.RegistrationViewDelegate
|
||||
import org.thoughtcrime.securesms.registration.ui.registrationlock.RegistrationLockFragment
|
||||
import org.thoughtcrime.securesms.registration.ui.registrationlock.RegistrationLockFragmentArgs
|
||||
import org.thoughtcrime.securesms.util.CommunicationActions
|
||||
import org.thoughtcrime.securesms.util.SupportEmailUtil
|
||||
import org.thoughtcrime.securesms.util.ViewUtil
|
||||
@@ -42,18 +39,17 @@ import java.util.concurrent.TimeUnit
|
||||
class ChangeNumberRegistrationLockFragment : LoggingFragment(R.layout.fragment_change_number_registration_lock) {
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(RegistrationLockFragment::class.java)
|
||||
private val TAG = Log.tag(ChangeNumberRegistrationLockFragment::class.java)
|
||||
}
|
||||
|
||||
private val binding: FragmentRegistrationLockBinding by ViewBinderDelegate(FragmentRegistrationLockBinding::bind)
|
||||
private val binding: FragmentRegistrationLockBinding by ViewBinderDelegate(bindingFactory = { rootView ->
|
||||
FragmentRegistrationLockBinding.bind(rootView.findViewById(R.id.registration_lock_content))
|
||||
})
|
||||
|
||||
private val viewModel by activityViewModels<ChangeNumberViewModel>()
|
||||
|
||||
private var timeRemaining: Long = 0
|
||||
|
||||
private val pinEntryKeyboardType: PinKeyboardType
|
||||
get() = PinKeyboardType.fromEditText(editText = binding.kbsLockPinInput)
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
RegistrationViewDelegate.setDebugLogSubmitMultiTapView(view.findViewById(R.id.kbs_lock_pin_title))
|
||||
@@ -69,7 +65,7 @@ class ChangeNumberRegistrationLockFragment : LoggingFragment(R.layout.fragment_c
|
||||
}
|
||||
)
|
||||
|
||||
val args: RegistrationLockFragmentArgs = RegistrationLockFragmentArgs.fromBundle(requireArguments())
|
||||
val args: ChangeNumberRegistrationLockFragmentArgs = ChangeNumberRegistrationLockFragmentArgs.fromBundle(requireArguments())
|
||||
|
||||
timeRemaining = args.getTimeRemaining()
|
||||
|
||||
@@ -93,10 +89,7 @@ class ChangeNumberRegistrationLockFragment : LoggingFragment(R.layout.fragment_c
|
||||
handlePinEntry()
|
||||
}
|
||||
|
||||
binding.kbsLockKeyboardToggle.setOnClickListener {
|
||||
updateKeyboard(pinEntryKeyboardType.other)
|
||||
}
|
||||
updateKeyboard(pinEntryKeyboardType)
|
||||
binding.kbsLockKeyboardToggle.setOnClickListener { viewModel.togglePinKeyboardType() }
|
||||
|
||||
viewModel.liveLockedTimeRemaining.observe(viewLifecycleOwner) { t: Long -> timeRemaining = t }
|
||||
|
||||
@@ -123,6 +116,11 @@ class ChangeNumberRegistrationLockFragment : LoggingFragment(R.layout.fragment_c
|
||||
if (state.changeNumberOutcome == ChangeNumberOutcome.VerificationCodeWorked) {
|
||||
handleSuccessfulPinEntry(state.enteredPin)
|
||||
}
|
||||
|
||||
state.pinKeyboardType.applyTo(
|
||||
pinEditText = binding.kbsLockPinInput,
|
||||
toggleTypeButton = binding.kbsLockKeyboardToggle
|
||||
)
|
||||
}
|
||||
|
||||
private fun handlePinEntry() {
|
||||
@@ -205,7 +203,7 @@ class ChangeNumberRegistrationLockFragment : LoggingFragment(R.layout.fragment_c
|
||||
|
||||
private fun onIncorrectKbsRegistrationLockPin(svrTriesRemaining: Int) {
|
||||
binding.kbsLockPinConfirm.cancelSpinning()
|
||||
binding.kbsLockPinInput.getText().clear()
|
||||
binding.kbsLockPinInput.getText()?.clear()
|
||||
enableAndFocusPinEntry()
|
||||
|
||||
if (svrTriesRemaining == 0) {
|
||||
@@ -275,13 +273,6 @@ class ChangeNumberRegistrationLockFragment : LoggingFragment(R.layout.fragment_c
|
||||
ViewUtil.focusAndShowKeyboard(binding.kbsLockPinInput)
|
||||
}
|
||||
|
||||
private fun updateKeyboard(newType: PinKeyboardType) {
|
||||
newType.applyTo(
|
||||
pinEditText = binding.kbsLockPinInput,
|
||||
toggleTypeButton = binding.kbsLockKeyboardToggle
|
||||
)
|
||||
}
|
||||
|
||||
private fun navigateToAccountLocked() {
|
||||
findNavController().safeNavigate(ChangeNumberRegistrationLockFragmentDirections.actionChangeNumberRegistrationLockToChangeNumberAccountLocked())
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
|
||||
package org.thoughtcrime.securesms.components.settings.app.changenumber
|
||||
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.lock.v2.PinKeyboardType
|
||||
import org.thoughtcrime.securesms.registration.data.network.Challenge
|
||||
import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequestResult
|
||||
import org.thoughtcrime.securesms.registration.ui.countrycode.Country
|
||||
@@ -19,6 +21,7 @@ data class ChangeNumberState(
|
||||
val number: NumberViewState = NumberViewState.INITIAL,
|
||||
val enteredCode: String? = null,
|
||||
val enteredPin: String = "",
|
||||
val pinKeyboardType: PinKeyboardType = SignalStore.pin.keyboardType,
|
||||
val oldPhoneNumber: NumberViewState = NumberViewState.INITIAL,
|
||||
val sessionId: String? = null,
|
||||
val changeNumberOutcome: ChangeNumberOutcome? = null,
|
||||
@@ -35,10 +38,9 @@ data class ChangeNumberState(
|
||||
val challengesPresented: Set<Challenge> = emptySet(),
|
||||
val allowedToRequestCode: Boolean = false,
|
||||
val oldCountry: Country? = null,
|
||||
val newCountry: Country? = null
|
||||
) {
|
||||
val challengesRemaining: List<Challenge> = challengesRequested.filterNot { it in challengesPresented }
|
||||
}
|
||||
val newCountry: Country? = null,
|
||||
val challengeInProgress: Boolean = false
|
||||
)
|
||||
|
||||
sealed interface ChangeNumberOutcome {
|
||||
data object RecoveryPasswordWorked : ChangeNumberOutcome
|
||||
|
||||
@@ -54,8 +54,10 @@ class ChangeNumberVerifyFragment : LoggingFragment(R.layout.fragment_change_phon
|
||||
private fun onStateUpdate(state: ChangeNumberState) {
|
||||
if (state.challengesRequested.contains(Challenge.CAPTCHA) && state.captchaToken.isNotNullOrBlank()) {
|
||||
viewModel.submitCaptchaToken(requireContext())
|
||||
} else if (state.challengesRemaining.isNotEmpty()) {
|
||||
handleChallenges(state.challengesRemaining)
|
||||
} else if (state.challengesRequested.isNotEmpty()) {
|
||||
if (!state.challengeInProgress) {
|
||||
handleChallenges(state.challengesRequested)
|
||||
}
|
||||
} else if (state.changeNumberOutcome != null) {
|
||||
handleRequestCodeResult(state.changeNumberOutcome)
|
||||
} else if (!state.inProgress) {
|
||||
|
||||
@@ -150,26 +150,18 @@ class ChangeNumberViewModel : ViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
fun togglePinKeyboardType() {
|
||||
store.update { previousState ->
|
||||
previousState.copy(pinKeyboardType = previousState.pinKeyboardType.other)
|
||||
}
|
||||
}
|
||||
|
||||
fun incrementIncorrectCodeAttempts() {
|
||||
store.update {
|
||||
it.copy(incorrectCodeAttempts = it.incorrectCodeAttempts + 1)
|
||||
}
|
||||
}
|
||||
|
||||
fun addPresentedChallenge(challenge: Challenge) {
|
||||
Log.v(TAG, "addPresentedChallenge()")
|
||||
store.update {
|
||||
it.copy(challengesPresented = it.challengesPresented.plus(challenge))
|
||||
}
|
||||
}
|
||||
|
||||
fun removePresentedChallenge(challenge: Challenge) {
|
||||
Log.v(TAG, "addPresentedChallenge()")
|
||||
store.update {
|
||||
it.copy(challengesPresented = it.challengesPresented.minus(challenge))
|
||||
}
|
||||
}
|
||||
|
||||
fun resetLocalSessionState() {
|
||||
Log.v(TAG, "resetLocalSessionState()")
|
||||
store.update {
|
||||
@@ -292,7 +284,8 @@ class ChangeNumberViewModel : ViewModel() {
|
||||
it.copy(
|
||||
captchaToken = null,
|
||||
inProgress = true,
|
||||
changeNumberOutcome = null
|
||||
changeNumberOutcome = null,
|
||||
challengeInProgress = true
|
||||
)
|
||||
}
|
||||
|
||||
@@ -304,7 +297,8 @@ class ChangeNumberViewModel : ViewModel() {
|
||||
store.update {
|
||||
it.copy(
|
||||
inProgress = false,
|
||||
changeNumberOutcome = null
|
||||
changeNumberOutcome = null,
|
||||
challengeInProgress = false
|
||||
)
|
||||
}
|
||||
return@launch
|
||||
@@ -313,7 +307,7 @@ class ChangeNumberViewModel : ViewModel() {
|
||||
val captchaSubmissionResult = RegistrationRepository.submitCaptchaToken(context, e164, password, sessionData.sessionId, captchaToken)
|
||||
Log.d(TAG, "Captcha token submitted.")
|
||||
store.update {
|
||||
it.copy(inProgress = false, changeNumberOutcome = ChangeNumberOutcome.ChangeNumberRequestOutcome(captchaSubmissionResult))
|
||||
it.copy(inProgress = false, changeNumberOutcome = ChangeNumberOutcome.ChangeNumberRequestOutcome(captchaSubmissionResult), challengeInProgress = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -321,8 +315,6 @@ class ChangeNumberViewModel : ViewModel() {
|
||||
fun requestAndSubmitPushToken(context: Context) {
|
||||
Log.v(TAG, "validatePushToken()")
|
||||
|
||||
addPresentedChallenge(Challenge.PUSH)
|
||||
|
||||
val e164 = number.e164Number
|
||||
|
||||
viewModelScope.launch {
|
||||
|
||||
@@ -22,7 +22,6 @@ import org.signal.core.ui.compose.Texts
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.compose.ComposeFragment
|
||||
import org.thoughtcrime.securesms.compose.rememberStatusBarColorNestedScrollModifier
|
||||
import org.thoughtcrime.securesms.util.RemoteConfig
|
||||
import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
||||
|
||||
/**
|
||||
@@ -45,8 +44,7 @@ class ChatsSettingsFragment : ComposeFragment() {
|
||||
|
||||
ChatsSettingsScreen(
|
||||
state = state,
|
||||
callbacks = callbacks,
|
||||
isRemoteBackupsAvailable = RemoteConfig.messageBackups
|
||||
callbacks = callbacks
|
||||
)
|
||||
}
|
||||
|
||||
@@ -105,7 +103,6 @@ private interface ChatsSettingsCallbacks {
|
||||
|
||||
@Composable
|
||||
private fun ChatsSettingsScreen(
|
||||
isRemoteBackupsAvailable: Boolean,
|
||||
state: ChatsSettingsState,
|
||||
callbacks: ChatsSettingsCallbacks
|
||||
) {
|
||||
@@ -201,25 +198,6 @@ private fun ChatsSettingsScreen(
|
||||
onCheckChanged = callbacks::onEnterKeySendsChanged
|
||||
)
|
||||
}
|
||||
|
||||
if (!isRemoteBackupsAvailable) {
|
||||
item {
|
||||
Dividers.Default()
|
||||
}
|
||||
|
||||
item {
|
||||
Texts.SectionHeader(stringResource(R.string.preferences_chats__backups))
|
||||
}
|
||||
|
||||
item {
|
||||
Rows.TextRow(
|
||||
text = stringResource(R.string.preferences_chats__chat_backups),
|
||||
label = stringResource(if (state.localBackupsEnabled) R.string.arrays__enabled else R.string.arrays__disabled),
|
||||
enabled = state.localBackupsEnabled || state.isRegisteredAndUpToDate(),
|
||||
onClick = callbacks::onChatBackupsClick
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -240,8 +218,7 @@ private fun ChatsSettingsScreenPreview() {
|
||||
userUnregistered = false,
|
||||
clientDeprecated = false
|
||||
),
|
||||
callbacks = ChatsSettingsCallbacks.Empty,
|
||||
isRemoteBackupsAvailable = false
|
||||
callbacks = ChatsSettingsCallbacks.Empty
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -218,6 +218,14 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
|
||||
}
|
||||
)
|
||||
|
||||
clickPref(
|
||||
title = DSLSettingsText.from("Data Seeding Playground"),
|
||||
summary = DSLSettingsText.from("Seed conversations with media files from a folder."),
|
||||
onClick = {
|
||||
findNavController().safeNavigate(InternalSettingsFragmentDirections.actionInternalSettingsFragmentToDataSeedingPlaygroundFragment())
|
||||
}
|
||||
)
|
||||
|
||||
dividerPref()
|
||||
|
||||
sectionHeaderPref(DSLSettingsText.from("Miscellaneous"))
|
||||
|
||||
@@ -43,11 +43,6 @@ fun InternalBackupStatsTab(stats: InternalBackupPlaygroundViewModel.StatsState,
|
||||
label = "${stats.attachmentStats.totalUniqueMediaNames}"
|
||||
)
|
||||
|
||||
Rows.TextRow(
|
||||
text = "Total eligible for upload rows",
|
||||
label = "${stats.attachmentStats.totalEligibleForUploadRows}"
|
||||
)
|
||||
|
||||
Rows.TextRow(
|
||||
text = "Total unique media names eligible for upload ⭐",
|
||||
label = "${stats.attachmentStats.totalUniqueMediaNamesEligibleForUpload}"
|
||||
@@ -73,6 +68,16 @@ fun InternalBackupStatsTab(stats: InternalBackupPlaygroundViewModel.StatsState,
|
||||
label = "${stats.attachmentStats.pendingAttachmentUploadBytes} (~${stats.attachmentStats.pendingAttachmentUploadBytes.bytes.toUnitString()})"
|
||||
)
|
||||
|
||||
Rows.TextRow(
|
||||
text = "Last snapshot full-size count ⭐",
|
||||
label = "${stats.attachmentStats.lastSnapshotFullSizeCount}"
|
||||
)
|
||||
|
||||
Rows.TextRow(
|
||||
text = "Last snapshot thumbnail count ⭐",
|
||||
label = "${stats.attachmentStats.lastSnapshotThumbnailCount}"
|
||||
)
|
||||
|
||||
Rows.TextRow(
|
||||
text = "Uploaded attachment bytes ⭐",
|
||||
label = "${stats.attachmentStats.uploadedAttachmentBytes} (~${stats.attachmentStats.uploadedAttachmentBytes.bytes.toUnitString()})"
|
||||
|
||||
@@ -0,0 +1,352 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.settings.app.internal.dataseeding
|
||||
|
||||
import android.app.Activity.RESULT_OK
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.widget.Toast
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.Rows
|
||||
import org.signal.core.ui.compose.SignalPreview
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.compose.ComposeFragment
|
||||
import org.thoughtcrime.securesms.database.model.ThreadRecord
|
||||
|
||||
class DataSeedingPlaygroundFragment : ComposeFragment() {
|
||||
|
||||
private val viewModel: DataSeedingPlaygroundViewModel by viewModels()
|
||||
private lateinit var selectFolderLauncher: ActivityResultLauncher<Intent>
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
selectFolderLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
if (result.resultCode == RESULT_OK) {
|
||||
result.data?.data?.let { uri ->
|
||||
viewModel.selectFolder(uri)
|
||||
} ?: Toast.makeText(requireContext(), "No folder selected", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun FragmentContent() {
|
||||
val context = LocalContext.current
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.loadThreads()
|
||||
}
|
||||
|
||||
Screen(
|
||||
state = state,
|
||||
onBack = { findNavController().popBackStack() },
|
||||
onSelectFolderClicked = {
|
||||
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
|
||||
selectFolderLauncher.launch(intent)
|
||||
},
|
||||
onThreadSelectionChanged = { threadId, isSelected ->
|
||||
viewModel.toggleThreadSelection(threadId, isSelected)
|
||||
},
|
||||
onSeedDataClicked = {
|
||||
viewModel.seedData(
|
||||
onComplete = {
|
||||
Toast.makeText(context, "Data seeding completed!", Toast.LENGTH_SHORT).show()
|
||||
},
|
||||
onError = { error ->
|
||||
Toast.makeText(context, "Error: $error", Toast.LENGTH_LONG).show()
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun Screen(
|
||||
state: DataSeedingPlaygroundState,
|
||||
onBack: () -> Unit = {},
|
||||
onSelectFolderClicked: () -> Unit = {},
|
||||
onThreadSelectionChanged: (Long, Boolean) -> Unit = { _, _ -> },
|
||||
onSeedDataClicked: () -> Unit = {}
|
||||
) {
|
||||
var showConfirmDialog by remember { mutableStateOf(false) }
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text("Data Seeding Playground")
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.symbol_arrow_start_24),
|
||||
tint = MaterialTheme.colorScheme.onSurface,
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { paddingValues ->
|
||||
Surface(modifier = Modifier.padding(paddingValues)) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
// Folder selection section
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Media Folder",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
if (state.selectedFolderPath.isNotEmpty()) {
|
||||
Text(
|
||||
text = "Selected: ${state.selectedFolderPath}",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
text = "Media files found: ${state.mediaFiles.size}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = "No folder selected",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Rows.TextRow(
|
||||
text = "Select Media Folder",
|
||||
label = "Choose a folder containing photos and videos to seed into conversations.",
|
||||
onClick = onSelectFolderClicked
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Thread selection section
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Conversation Threads (${state.selectedThreads.size} selected)",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.height(300.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
items(state.threads) { thread ->
|
||||
ThreadSelectionRow(
|
||||
thread = thread,
|
||||
isSelected = state.selectedThreads.contains(thread.threadId),
|
||||
onSelectionChanged = { isSelected ->
|
||||
onThreadSelectionChanged(thread.threadId, isSelected)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Action section
|
||||
if (state.mediaFiles.isNotEmpty() && state.selectedThreads.isNotEmpty()) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Ready to Seed Data",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Text(
|
||||
text = "This will send ${state.mediaFiles.size} media files to ${state.selectedThreads.size} conversations in a round-robin fashion.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Rows.TextRow(
|
||||
text = "Seed Data",
|
||||
label = "Send the selected media files to the selected conversations.",
|
||||
onClick = {
|
||||
showConfirmDialog = true
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Confirmation dialog
|
||||
if (showConfirmDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showConfirmDialog = false },
|
||||
title = { Text("Confirm Data Seeding") },
|
||||
text = {
|
||||
Text("Are you sure you want to send ${state.mediaFiles.size} media files to ${state.selectedThreads.size} conversations? This action cannot be undone.")
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
showConfirmDialog = false
|
||||
onSeedDataClicked()
|
||||
}
|
||||
) {
|
||||
Text("Seed Data")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(
|
||||
onClick = { showConfirmDialog = false }
|
||||
) {
|
||||
Text("Cancel")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ThreadSelectionRow(
|
||||
thread: ThreadRecord,
|
||||
isSelected: Boolean,
|
||||
onSelectionChanged: (Boolean) -> Unit
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Checkbox(
|
||||
checked = isSelected,
|
||||
onCheckedChange = onSelectionChanged
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(start = 8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = thread.recipient.getDisplayName(LocalContext.current),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
if (thread.body.isNotEmpty()) {
|
||||
Text(
|
||||
text = thread.body,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@Composable
|
||||
fun PreviewScreen() {
|
||||
Previews.Preview {
|
||||
Screen(
|
||||
state = DataSeedingPlaygroundState(
|
||||
threads = emptyList(),
|
||||
selectedThreads = emptySet(),
|
||||
mediaFiles = emptyList(),
|
||||
selectedFolderPath = "/storage/emulated/0/Pictures"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@Composable
|
||||
fun PreviewScreenWithData() {
|
||||
Previews.Preview {
|
||||
Screen(
|
||||
state = DataSeedingPlaygroundState(
|
||||
threads = emptyList(),
|
||||
selectedThreads = setOf(1L, 2L),
|
||||
mediaFiles = listOf("photo1.jpg", "video1.mp4", "photo2.jpg"),
|
||||
selectedFolderPath = "/storage/emulated/0/Pictures"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.settings.app.internal.dataseeding
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import android.database.Cursor
|
||||
import android.net.Uri
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.attachments.Attachment
|
||||
import org.thoughtcrime.securesms.attachments.UriAttachment
|
||||
import org.thoughtcrime.securesms.database.AttachmentTable
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.model.ThreadRecord
|
||||
import org.thoughtcrime.securesms.mms.OutgoingMessage
|
||||
import org.thoughtcrime.securesms.sms.MessageSender
|
||||
import org.thoughtcrime.securesms.util.MediaUtil
|
||||
|
||||
class DataSeedingPlaygroundViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(DataSeedingPlaygroundViewModel::class.java)
|
||||
private const val MAX_RECENT_THREADS = 10
|
||||
}
|
||||
|
||||
private val _state = MutableStateFlow(DataSeedingPlaygroundState())
|
||||
val state: StateFlow<DataSeedingPlaygroundState> = _state.asStateFlow()
|
||||
|
||||
fun loadThreads() {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val threads = mutableListOf<ThreadRecord>()
|
||||
val cursor: Cursor = SignalDatabase.threads.getRecentConversationList(
|
||||
limit = MAX_RECENT_THREADS,
|
||||
includeInactiveGroups = false,
|
||||
hideV1Groups = true
|
||||
)
|
||||
|
||||
cursor.use {
|
||||
val reader = SignalDatabase.threads.readerFor(it)
|
||||
var threadRecord = reader.getNext()
|
||||
while (threadRecord != null) {
|
||||
threads.add(threadRecord)
|
||||
threadRecord = reader.getNext()
|
||||
}
|
||||
}
|
||||
|
||||
_state.value = _state.value.copy(threads = threads)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to load threads", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun selectFolder(uri: Uri) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val context = getApplication<Application>()
|
||||
val documentFile = DocumentFile.fromTreeUri(context, uri)
|
||||
|
||||
if (documentFile != null && documentFile.isDirectory) {
|
||||
val mediaFiles = findMediaFiles(documentFile)
|
||||
|
||||
_state.value = _state.value.copy(
|
||||
selectedFolderPath = documentFile.uri.toString(),
|
||||
mediaFiles = mediaFiles
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to select folder", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleThreadSelection(threadId: Long, isSelected: Boolean) {
|
||||
val selectedThreads = _state.value.selectedThreads.toMutableSet()
|
||||
if (isSelected) {
|
||||
selectedThreads.add(threadId)
|
||||
} else {
|
||||
selectedThreads.remove(threadId)
|
||||
}
|
||||
|
||||
_state.value = _state.value.copy(selectedThreads = selectedThreads)
|
||||
}
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
fun seedData(onComplete: () -> Unit, onError: (String) -> Unit) {
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val context = getApplication<Application>()
|
||||
val currentState = _state.value
|
||||
|
||||
if (currentState.mediaFiles.isEmpty()) {
|
||||
withContext(Dispatchers.Main) {
|
||||
onError("No media files selected")
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
|
||||
if (currentState.selectedThreads.isEmpty()) {
|
||||
withContext(Dispatchers.Main) {
|
||||
onError("No threads selected")
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
|
||||
val mediaFiles = currentState.mediaFiles
|
||||
val threadIds = currentState.selectedThreads.toList()
|
||||
var currentThreadIndex = 0
|
||||
|
||||
for (mediaFile in mediaFiles) {
|
||||
val threadId = threadIds[currentThreadIndex % threadIds.size]
|
||||
sendMediaToThread(context, mediaFile, threadId)
|
||||
currentThreadIndex++
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
onComplete()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to seed data", e)
|
||||
withContext(Dispatchers.Main) {
|
||||
onError(e.message ?: "Unknown error")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun findMediaFiles(directory: DocumentFile): List<String> {
|
||||
val mediaFiles = mutableListOf<String>()
|
||||
|
||||
directory.listFiles().forEach { file ->
|
||||
if (file.isFile && file.type != null) {
|
||||
val mimeType = file.type!!
|
||||
if (MediaUtil.isImageType(mimeType) || MediaUtil.isVideoType(mimeType)) {
|
||||
mediaFiles.add(file.name ?: "unknown")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return mediaFiles
|
||||
}
|
||||
|
||||
private suspend fun sendMediaToThread(context: Context, mediaFileName: String, threadId: Long) {
|
||||
try {
|
||||
// Find the actual file URI
|
||||
val currentState = _state.value
|
||||
val documentFile = DocumentFile.fromTreeUri(context, Uri.parse(currentState.selectedFolderPath))
|
||||
|
||||
if (documentFile != null) {
|
||||
val mediaFile = documentFile.listFiles().find { it.name == mediaFileName }
|
||||
|
||||
if (mediaFile != null && mediaFile.uri != null) {
|
||||
val recipient = SignalDatabase.threads.getRecipientForThreadId(threadId)
|
||||
|
||||
if (recipient != null) {
|
||||
val mimeType = mediaFile.type ?: MediaUtil.getCorrectedMimeType(mediaFileName)
|
||||
val attachment: Attachment = UriAttachment(
|
||||
uri = mediaFile.uri,
|
||||
contentType = mimeType,
|
||||
transferState = AttachmentTable.TRANSFER_PROGRESS_STARTED,
|
||||
size = mediaFile.length(),
|
||||
fileName = mediaFileName,
|
||||
voiceNote = false,
|
||||
borderless = false,
|
||||
videoGif = false,
|
||||
quote = false,
|
||||
quoteTargetContentType = null,
|
||||
caption = null,
|
||||
stickerLocator = null,
|
||||
blurHash = null,
|
||||
audioHash = null,
|
||||
transformProperties = null
|
||||
)
|
||||
|
||||
val message = OutgoingMessage(
|
||||
threadRecipient = recipient,
|
||||
body = "",
|
||||
attachments = listOf(attachment),
|
||||
sentTimeMillis = System.currentTimeMillis(),
|
||||
isSecure = true
|
||||
)
|
||||
|
||||
MessageSender.send(
|
||||
context,
|
||||
message,
|
||||
threadId,
|
||||
MessageSender.SendType.SIGNAL,
|
||||
null,
|
||||
null
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to send media to thread $threadId", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class DataSeedingPlaygroundState(
|
||||
val threads: List<ThreadRecord> = emptyList(),
|
||||
val selectedThreads: Set<Long> = emptySet(),
|
||||
val mediaFiles: List<String> = emptyList(),
|
||||
val selectedFolderPath: String = ""
|
||||
)
|
||||
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.settings.app.notifications
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.provider.Settings
|
||||
import androidx.activity.result.contract.ActivityResultContract
|
||||
import org.thoughtcrime.securesms.notifications.NotificationChannels
|
||||
|
||||
/**
|
||||
* Activity result contract for launching the system notification channel settings screen
|
||||
* for the messages notification channel.
|
||||
*
|
||||
* This contract allows users to configure notification priority, sound, vibration, and other
|
||||
* channel-specific settings through the system's native notification settings UI.
|
||||
*/
|
||||
class NotificationPrioritySelectionContract : ActivityResultContract<Unit, Unit>() {
|
||||
override fun createIntent(context: Context, input: Unit): Intent {
|
||||
return Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS)
|
||||
.putExtra(
|
||||
Settings.EXTRA_CHANNEL_ID,
|
||||
NotificationChannels.getInstance().messagesChannel
|
||||
)
|
||||
.putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName)
|
||||
}
|
||||
|
||||
override fun parseResult(resultCode: Int, intent: Intent?) = Unit
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.settings.app.notifications
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.media.RingtoneManager
|
||||
import android.net.Uri
|
||||
import android.provider.Settings
|
||||
import androidx.activity.result.contract.ActivityResultContract
|
||||
import org.signal.core.util.getParcelableExtraCompat
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
|
||||
/**
|
||||
* Activity result contract for launching the system ringtone picker to select notification sounds.
|
||||
*
|
||||
* Supports selecting sounds for both message notifications and call ringtones through the
|
||||
* Android system's ringtone picker interface.
|
||||
*
|
||||
* @param target Specifies whether to configure sounds for messages or calls
|
||||
*/
|
||||
class NotificationSoundSelectionContract(
|
||||
private val target: Target
|
||||
) : ActivityResultContract<Unit, Uri?>() {
|
||||
|
||||
/**
|
||||
* Defines the type of notification sound to configure.
|
||||
*/
|
||||
enum class Target {
|
||||
/** Message notification sounds */
|
||||
MESSAGE,
|
||||
|
||||
/** Call ringtones */
|
||||
CALL
|
||||
}
|
||||
|
||||
override fun createIntent(context: Context, input: Unit): Intent {
|
||||
return when (target) {
|
||||
Target.MESSAGE -> createIntentForMessageSoundSelection()
|
||||
Target.CALL -> createIntentForCallSoundSelection()
|
||||
}
|
||||
}
|
||||
|
||||
private fun createIntentForMessageSoundSelection(): Intent {
|
||||
val current = SignalStore.settings.messageNotificationSound
|
||||
|
||||
return Intent(RingtoneManager.ACTION_RINGTONE_PICKER)
|
||||
.putExtra(RingtoneManager.EXTRA_RINGTONE_SHOW_DEFAULT, true)
|
||||
.putExtra(RingtoneManager.EXTRA_RINGTONE_SHOW_SILENT, true)
|
||||
.putExtra(RingtoneManager.EXTRA_RINGTONE_TYPE, RingtoneManager.TYPE_NOTIFICATION)
|
||||
.putExtra(
|
||||
RingtoneManager.EXTRA_RINGTONE_DEFAULT_URI,
|
||||
Settings.System.DEFAULT_NOTIFICATION_URI
|
||||
)
|
||||
.putExtra(RingtoneManager.EXTRA_RINGTONE_EXISTING_URI, current)
|
||||
}
|
||||
|
||||
private fun createIntentForCallSoundSelection(): Intent {
|
||||
val current = SignalStore.settings.callRingtone
|
||||
|
||||
return Intent(RingtoneManager.ACTION_RINGTONE_PICKER)
|
||||
.putExtra(RingtoneManager.EXTRA_RINGTONE_SHOW_DEFAULT, true)
|
||||
.putExtra(RingtoneManager.EXTRA_RINGTONE_SHOW_SILENT, true)
|
||||
.putExtra(RingtoneManager.EXTRA_RINGTONE_TYPE, RingtoneManager.TYPE_RINGTONE)
|
||||
.putExtra(
|
||||
RingtoneManager.EXTRA_RINGTONE_DEFAULT_URI,
|
||||
Settings.System.DEFAULT_RINGTONE_URI
|
||||
)
|
||||
.putExtra(RingtoneManager.EXTRA_RINGTONE_EXISTING_URI, current)
|
||||
}
|
||||
|
||||
override fun parseResult(resultCode: Int, intent: Intent?): Uri? {
|
||||
return intent?.getParcelableExtraCompat(RingtoneManager.EXTRA_RINGTONE_PICKED_URI, Uri::class.java)
|
||||
}
|
||||
}
|
||||
@@ -1,420 +1,615 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.notifications
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Intent
|
||||
import android.graphics.ColorFilter
|
||||
import android.graphics.PorterDuff
|
||||
import android.graphics.PorterDuffColorFilter
|
||||
import android.media.Ringtone
|
||||
import android.media.RingtoneManager
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import android.text.TextUtils
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.widget.Toast
|
||||
import androidx.activity.result.ActivityResultCallback
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.activity.result.contract.ActivityResultContract
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.colorResource
|
||||
import androidx.compose.ui.res.stringArrayResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.preference.PreferenceManager
|
||||
import org.signal.core.util.getParcelableExtraCompat
|
||||
import kotlinx.coroutines.launch
|
||||
import org.signal.core.ui.compose.Dividers
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.Rows
|
||||
import org.signal.core.ui.compose.Scaffolds
|
||||
import org.signal.core.ui.compose.SignalPreview
|
||||
import org.signal.core.ui.compose.Texts
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.PromptBatterySaverDialogFragment
|
||||
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
|
||||
import org.thoughtcrime.securesms.components.settings.PreferenceModel
|
||||
import org.thoughtcrime.securesms.components.settings.PreferenceViewHolder
|
||||
import org.thoughtcrime.securesms.components.settings.RadioListPreference
|
||||
import org.thoughtcrime.securesms.components.settings.RadioListPreferenceViewHolder
|
||||
import org.thoughtcrime.securesms.components.settings.configure
|
||||
import org.thoughtcrime.securesms.components.settings.app.routes.AppSettingsRoute
|
||||
import org.thoughtcrime.securesms.components.settings.app.routes.AppSettingsRouter
|
||||
import org.thoughtcrime.securesms.components.settings.models.Banner
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.compose.ComposeFragment
|
||||
import org.thoughtcrime.securesms.notifications.NotificationChannels
|
||||
import org.thoughtcrime.securesms.notifications.TurnOnNotificationsBottomSheet
|
||||
import org.thoughtcrime.securesms.util.BottomSheetUtil
|
||||
import org.thoughtcrime.securesms.util.RingtoneUtil
|
||||
import org.thoughtcrime.securesms.util.ViewUtil
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
|
||||
import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
||||
import org.thoughtcrime.securesms.util.viewModel
|
||||
|
||||
private const val MESSAGE_SOUND_SELECT: Int = 1
|
||||
private const val CALL_RINGTONE_SELECT: Int = 2
|
||||
private val TAG = Log.tag(NotificationsSettingsFragment::class.java)
|
||||
class NotificationsSettingsFragment : ComposeFragment() {
|
||||
|
||||
class NotificationsSettingsFragment : DSLSettingsFragment(R.string.preferences__notifications) {
|
||||
private val viewModel: NotificationsSettingsViewModel by viewModel {
|
||||
val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(requireContext())
|
||||
|
||||
private val repeatAlertsValues by lazy { resources.getStringArray(R.array.pref_repeat_alerts_values) }
|
||||
private val repeatAlertsLabels by lazy { resources.getStringArray(R.array.pref_repeat_alerts_entries) }
|
||||
NotificationsSettingsViewModel.Factory(sharedPreferences).create(NotificationsSettingsViewModel::class.java)
|
||||
}
|
||||
|
||||
private val notificationPrivacyValues by lazy { resources.getStringArray(R.array.pref_notification_privacy_values) }
|
||||
private val notificationPrivacyLabels by lazy { resources.getStringArray(R.array.pref_notification_privacy_entries) }
|
||||
private val appSettingsRouter: AppSettingsRouter by viewModel {
|
||||
AppSettingsRouter()
|
||||
}
|
||||
|
||||
private val notificationPriorityValues by lazy { resources.getStringArray(R.array.pref_notification_priority_values) }
|
||||
private val notificationPriorityLabels by lazy { resources.getStringArray(R.array.pref_notification_priority_entries) }
|
||||
private lateinit var callbacks: DefaultNotificationsSettingsCallbacks
|
||||
|
||||
private val ledColorValues by lazy { resources.getStringArray(R.array.pref_led_color_values) }
|
||||
private val ledColorLabels by lazy { resources.getStringArray(R.array.pref_led_color_entries) }
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
private val ledBlinkValues by lazy { resources.getStringArray(R.array.pref_led_blink_pattern_values) }
|
||||
private val ledBlinkLabels by lazy { resources.getStringArray(R.array.pref_led_blink_pattern_entries) }
|
||||
callbacks = DefaultNotificationsSettingsCallbacks(requireActivity(), viewModel, appSettingsRouter, DefaultNotificationsSettingsCallbacks.ActivityResultRegisterer.ForFragment(this))
|
||||
|
||||
private lateinit var viewModel: NotificationsSettingsViewModel
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
repeatOnLifecycle(Lifecycle.State.RESUMED) {
|
||||
appSettingsRouter.currentRoute.collect {
|
||||
when (it) {
|
||||
AppSettingsRoute.NotificationsRoute.NotificationProfiles -> {
|
||||
findNavController().safeNavigate(R.id.action_notificationsSettingsFragment_to_notificationProfilesFragment)
|
||||
}
|
||||
|
||||
else -> error("Unexpected route: ${it.javaClass.name}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
viewModel.refresh()
|
||||
}
|
||||
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||
if (requestCode == MESSAGE_SOUND_SELECT && resultCode == Activity.RESULT_OK && data != null) {
|
||||
val uri: Uri? = data.getParcelableExtraCompat(RingtoneManager.EXTRA_RINGTONE_PICKED_URI, Uri::class.java)
|
||||
viewModel.setMessageNotificationsSound(uri)
|
||||
} else if (requestCode == CALL_RINGTONE_SELECT && resultCode == Activity.RESULT_OK && data != null) {
|
||||
val uri: Uri? = data.getParcelableExtraCompat(RingtoneManager.EXTRA_RINGTONE_PICKED_URI, Uri::class.java)
|
||||
viewModel.setCallRingtone(uri)
|
||||
@Composable
|
||||
override fun FragmentContent() {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
NotificationsSettingsScreen(state = state, callbacks = callbacks)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Default callbacks that package up launcher handling and other logic that was in the original fragment.
|
||||
* This must be called during the creation cycle of the component it is attached to.
|
||||
*/
|
||||
open class DefaultNotificationsSettingsCallbacks(
|
||||
val activity: FragmentActivity,
|
||||
val viewModel: NotificationsSettingsViewModel,
|
||||
val appSettingsRouter: AppSettingsRouter,
|
||||
activityResultRegisterer: ActivityResultRegisterer = ActivityResultRegisterer.ForActivity(activity)
|
||||
) : NotificationsSettingsCallbacks {
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(DefaultNotificationsSettingsCallbacks::class)
|
||||
}
|
||||
|
||||
interface ActivityResultRegisterer {
|
||||
fun <I, O> registerForActivityResult(
|
||||
contract: ActivityResultContract<I, O>,
|
||||
callback: ActivityResultCallback<O>
|
||||
): ActivityResultLauncher<I>
|
||||
|
||||
class ForActivity(val activity: FragmentActivity) : ActivityResultRegisterer {
|
||||
override fun <I, O> registerForActivityResult(
|
||||
contract: ActivityResultContract<I, O>,
|
||||
callback: ActivityResultCallback<O>
|
||||
): ActivityResultLauncher<I> {
|
||||
return activity.registerForActivityResult(contract, callback)
|
||||
}
|
||||
}
|
||||
|
||||
class ForFragment(val fragment: Fragment) : ActivityResultRegisterer {
|
||||
override fun <I, O> registerForActivityResult(
|
||||
contract: ActivityResultContract<I, O>,
|
||||
callback: ActivityResultCallback<O>
|
||||
): ActivityResultLauncher<I> {
|
||||
return fragment.registerForActivityResult(contract, callback)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun bindAdapter(adapter: MappingAdapter) {
|
||||
adapter.registerFactory(
|
||||
LedColorPreference::class.java,
|
||||
LayoutFactory(::LedColorPreferenceViewHolder, R.layout.dsl_preference_item)
|
||||
)
|
||||
private val messageSoundSelectionLauncher: ActivityResultLauncher<Unit> = activityResultRegisterer.registerForActivityResult(
|
||||
NotificationSoundSelectionContract(NotificationSoundSelectionContract.Target.MESSAGE),
|
||||
viewModel::setMessageNotificationsSound
|
||||
)
|
||||
|
||||
Banner.register(adapter)
|
||||
private val callsSoundSelectionLauncher: ActivityResultLauncher<Unit> = activityResultRegisterer.registerForActivityResult(
|
||||
NotificationSoundSelectionContract(NotificationSoundSelectionContract.Target.CALL),
|
||||
viewModel::setCallRingtone
|
||||
)
|
||||
|
||||
val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(requireContext())
|
||||
val factory = NotificationsSettingsViewModel.Factory(sharedPreferences)
|
||||
private val notificationPrioritySelectionLauncher: ActivityResultLauncher<Unit> = activityResultRegisterer.registerForActivityResult(
|
||||
contract = NotificationPrioritySelectionContract(),
|
||||
callback = {}
|
||||
)
|
||||
|
||||
viewModel = ViewModelProvider(this, factory)[NotificationsSettingsViewModel::class.java]
|
||||
override fun onTurnOnNotificationsActionClick() {
|
||||
TurnOnNotificationsBottomSheet.turnOnSystemNotificationsFragment(activity).show(activity.supportFragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
|
||||
}
|
||||
|
||||
viewModel.state.observe(viewLifecycleOwner) {
|
||||
adapter.submitList(getConfiguration(it).toMappingModelList())
|
||||
override fun onNavigationClick() {
|
||||
activity.onBackPressedDispatcher.onBackPressed()
|
||||
}
|
||||
|
||||
override fun setMessageNotificationsEnabled(enabled: Boolean) {
|
||||
viewModel.setMessageNotificationsEnabled(enabled)
|
||||
}
|
||||
|
||||
override fun onCustomizeClick() {
|
||||
activity.let {
|
||||
NotificationChannels.getInstance().openChannelSettings(it, NotificationChannels.getInstance().messagesChannel, null)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getConfiguration(state: NotificationsSettingsState): DSLConfiguration {
|
||||
return configure {
|
||||
if (!state.messageNotificationsState.canEnableNotifications) {
|
||||
customPref(
|
||||
Banner.Model(
|
||||
textId = R.string.NotificationSettingsFragment__to_enable_notifications,
|
||||
actionId = R.string.NotificationSettingsFragment__turn_on,
|
||||
onClick = {
|
||||
TurnOnNotificationsBottomSheet.turnOnSystemNotificationsFragment(requireContext()).show(childFragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
sectionHeaderPref(R.string.NotificationsSettingsFragment__messages)
|
||||
|
||||
switchPref(
|
||||
title = DSLSettingsText.from(R.string.preferences__notifications),
|
||||
isEnabled = state.messageNotificationsState.canEnableNotifications,
|
||||
isChecked = state.messageNotificationsState.notificationsEnabled,
|
||||
onClick = {
|
||||
viewModel.setMessageNotificationsEnabled(!state.messageNotificationsState.notificationsEnabled)
|
||||
}
|
||||
)
|
||||
|
||||
if (Build.VERSION.SDK_INT >= 30) {
|
||||
clickPref(
|
||||
title = DSLSettingsText.from(R.string.preferences__customize),
|
||||
summary = DSLSettingsText.from(R.string.preferences__change_sound_and_vibration),
|
||||
isEnabled = state.messageNotificationsState.notificationsEnabled,
|
||||
onClick = {
|
||||
NotificationChannels.getInstance().openChannelSettings(requireActivity(), NotificationChannels.getInstance().messagesChannel, null)
|
||||
}
|
||||
)
|
||||
} else {
|
||||
clickPref(
|
||||
title = DSLSettingsText.from(R.string.preferences__sound),
|
||||
summary = DSLSettingsText.from(getRingtoneSummary(state.messageNotificationsState.sound)),
|
||||
isEnabled = state.messageNotificationsState.notificationsEnabled,
|
||||
onClick = {
|
||||
launchMessageSoundSelectionIntent()
|
||||
}
|
||||
)
|
||||
|
||||
switchPref(
|
||||
title = DSLSettingsText.from(R.string.preferences__vibrate),
|
||||
isChecked = state.messageNotificationsState.vibrateEnabled,
|
||||
isEnabled = state.messageNotificationsState.notificationsEnabled,
|
||||
onClick = {
|
||||
viewModel.setMessageNotificationVibration(!state.messageNotificationsState.vibrateEnabled)
|
||||
}
|
||||
)
|
||||
|
||||
customPref(
|
||||
LedColorPreference(
|
||||
colorValues = ledColorValues,
|
||||
radioListPreference = RadioListPreference(
|
||||
title = DSLSettingsText.from(R.string.preferences__led_color),
|
||||
listItems = ledColorLabels,
|
||||
selected = ledColorValues.indexOf(state.messageNotificationsState.ledColor),
|
||||
isEnabled = state.messageNotificationsState.notificationsEnabled,
|
||||
onSelected = {
|
||||
viewModel.setMessageNotificationLedColor(ledColorValues[it])
|
||||
}
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
if (!NotificationChannels.supported()) {
|
||||
radioListPref(
|
||||
title = DSLSettingsText.from(R.string.preferences__pref_led_blink_title),
|
||||
listItems = ledBlinkLabels,
|
||||
selected = ledBlinkValues.indexOf(state.messageNotificationsState.ledBlink),
|
||||
isEnabled = state.messageNotificationsState.notificationsEnabled,
|
||||
onSelected = {
|
||||
viewModel.setMessageNotificationLedBlink(ledBlinkValues[it])
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
switchPref(
|
||||
title = DSLSettingsText.from(R.string.preferences_notifications__in_chat_sounds),
|
||||
isChecked = state.messageNotificationsState.inChatSoundsEnabled,
|
||||
isEnabled = state.messageNotificationsState.notificationsEnabled,
|
||||
onClick = {
|
||||
viewModel.setMessageNotificationInChatSoundsEnabled(!state.messageNotificationsState.inChatSoundsEnabled)
|
||||
}
|
||||
)
|
||||
|
||||
radioListPref(
|
||||
title = DSLSettingsText.from(R.string.preferences__repeat_alerts),
|
||||
listItems = repeatAlertsLabels,
|
||||
selected = repeatAlertsValues.indexOf(state.messageNotificationsState.repeatAlerts.toString()),
|
||||
isEnabled = state.messageNotificationsState.notificationsEnabled,
|
||||
onSelected = {
|
||||
viewModel.setMessageRepeatAlerts(repeatAlertsValues[it].toInt())
|
||||
}
|
||||
)
|
||||
|
||||
radioListPref(
|
||||
title = DSLSettingsText.from(R.string.preferences_notifications__show),
|
||||
listItems = notificationPrivacyLabels,
|
||||
selected = notificationPrivacyValues.indexOf(state.messageNotificationsState.messagePrivacy),
|
||||
isEnabled = state.messageNotificationsState.notificationsEnabled,
|
||||
onSelected = {
|
||||
viewModel.setMessageNotificationPrivacy(notificationPrivacyValues[it])
|
||||
}
|
||||
)
|
||||
|
||||
if (Build.VERSION.SDK_INT >= 23 && state.messageNotificationsState.troubleshootNotifications) {
|
||||
clickPref(
|
||||
title = DSLSettingsText.from(R.string.preferences_notifications__troubleshoot),
|
||||
isEnabled = true,
|
||||
onClick = {
|
||||
PromptBatterySaverDialogFragment.show(childFragmentManager)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT < 30) {
|
||||
if (NotificationChannels.supported()) {
|
||||
clickPref(
|
||||
title = DSLSettingsText.from(R.string.preferences_notifications__priority),
|
||||
isEnabled = state.messageNotificationsState.notificationsEnabled,
|
||||
onClick = {
|
||||
launchNotificationPriorityIntent()
|
||||
}
|
||||
)
|
||||
} else {
|
||||
radioListPref(
|
||||
title = DSLSettingsText.from(R.string.preferences_notifications__priority),
|
||||
listItems = notificationPriorityLabels,
|
||||
selected = notificationPriorityValues.indexOf(state.messageNotificationsState.priority.toString()),
|
||||
isEnabled = state.messageNotificationsState.notificationsEnabled,
|
||||
onSelected = {
|
||||
viewModel.setMessageNotificationPriority(notificationPriorityValues[it].toInt())
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
dividerPref()
|
||||
|
||||
sectionHeaderPref(R.string.NotificationsSettingsFragment__calls)
|
||||
|
||||
switchPref(
|
||||
title = DSLSettingsText.from(R.string.preferences__notifications),
|
||||
isEnabled = state.callNotificationsState.canEnableNotifications,
|
||||
isChecked = state.callNotificationsState.notificationsEnabled,
|
||||
onClick = {
|
||||
viewModel.setCallNotificationsEnabled(!state.callNotificationsState.notificationsEnabled)
|
||||
}
|
||||
)
|
||||
|
||||
clickPref(
|
||||
title = DSLSettingsText.from(R.string.preferences_notifications__ringtone),
|
||||
summary = DSLSettingsText.from(getRingtoneSummary(state.callNotificationsState.ringtone)),
|
||||
isEnabled = state.callNotificationsState.notificationsEnabled,
|
||||
onClick = {
|
||||
launchCallRingtoneSelectionIntent()
|
||||
}
|
||||
)
|
||||
|
||||
switchPref(
|
||||
title = DSLSettingsText.from(R.string.preferences__vibrate),
|
||||
isChecked = state.callNotificationsState.vibrateEnabled,
|
||||
isEnabled = state.callNotificationsState.notificationsEnabled,
|
||||
onClick = {
|
||||
viewModel.setCallVibrateEnabled(!state.callNotificationsState.vibrateEnabled)
|
||||
}
|
||||
)
|
||||
|
||||
dividerPref()
|
||||
|
||||
sectionHeaderPref(R.string.NotificationsSettingsFragment__notification_profiles)
|
||||
|
||||
clickPref(
|
||||
title = DSLSettingsText.from(R.string.NotificationsSettingsFragment__profiles),
|
||||
summary = DSLSettingsText.from(R.string.NotificationsSettingsFragment__create_a_profile_to_receive_notifications_only_from_people_and_groups_you_choose),
|
||||
onClick = {
|
||||
findNavController().safeNavigate(R.id.action_notificationsSettingsFragment_to_notificationProfilesFragment)
|
||||
}
|
||||
)
|
||||
|
||||
dividerPref()
|
||||
|
||||
sectionHeaderPref(R.string.NotificationsSettingsFragment__notify_when)
|
||||
|
||||
switchPref(
|
||||
title = DSLSettingsText.from(R.string.NotificationsSettingsFragment__contact_joins_signal),
|
||||
isChecked = state.notifyWhenContactJoinsSignal,
|
||||
onClick = {
|
||||
viewModel.setNotifyWhenContactJoinsSignal(!state.notifyWhenContactJoinsSignal)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getRingtoneSummary(uri: Uri): String {
|
||||
return if (TextUtils.isEmpty(uri.toString())) {
|
||||
getString(R.string.preferences__silent)
|
||||
override fun getRingtoneSummary(uri: Uri): String {
|
||||
return if (uri.toString().isBlank()) {
|
||||
activity.getString(R.string.preferences__silent)
|
||||
} else {
|
||||
val tone: Ringtone? = RingtoneUtil.getRingtone(requireContext(), uri)
|
||||
val tone: Ringtone? = RingtoneUtil.getRingtone(activity, uri)
|
||||
if (tone != null) {
|
||||
try {
|
||||
tone.getTitle(requireContext()) ?: getString(R.string.NotificationsSettingsFragment__unknown_ringtone)
|
||||
tone.getTitle(activity) ?: activity.getString(R.string.NotificationsSettingsFragment__unknown_ringtone)
|
||||
} catch (e: SecurityException) {
|
||||
Log.w(TAG, "Unable to get title for ringtone", e)
|
||||
return getString(R.string.NotificationsSettingsFragment__unknown_ringtone)
|
||||
return activity.getString(R.string.NotificationsSettingsFragment__unknown_ringtone)
|
||||
}
|
||||
} else {
|
||||
getString(R.string.preferences__default)
|
||||
activity.getString(R.string.preferences__default)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun launchMessageSoundSelectionIntent() {
|
||||
val current = SignalStore.settings.messageNotificationSound
|
||||
|
||||
val intent = Intent(RingtoneManager.ACTION_RINGTONE_PICKER)
|
||||
intent.putExtra(RingtoneManager.EXTRA_RINGTONE_SHOW_DEFAULT, true)
|
||||
intent.putExtra(RingtoneManager.EXTRA_RINGTONE_SHOW_SILENT, true)
|
||||
intent.putExtra(RingtoneManager.EXTRA_RINGTONE_TYPE, RingtoneManager.TYPE_NOTIFICATION)
|
||||
intent.putExtra(
|
||||
RingtoneManager.EXTRA_RINGTONE_DEFAULT_URI,
|
||||
Settings.System.DEFAULT_NOTIFICATION_URI
|
||||
)
|
||||
intent.putExtra(RingtoneManager.EXTRA_RINGTONE_EXISTING_URI, current)
|
||||
|
||||
openRingtonePicker(intent, MESSAGE_SOUND_SELECT)
|
||||
}
|
||||
|
||||
@RequiresApi(26)
|
||||
private fun launchNotificationPriorityIntent() {
|
||||
val intent = Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS)
|
||||
intent.putExtra(
|
||||
Settings.EXTRA_CHANNEL_ID,
|
||||
NotificationChannels.getInstance().messagesChannel
|
||||
)
|
||||
intent.putExtra(Settings.EXTRA_APP_PACKAGE, requireContext().packageName)
|
||||
startActivity(intent)
|
||||
}
|
||||
|
||||
private fun launchCallRingtoneSelectionIntent() {
|
||||
val current = SignalStore.settings.callRingtone
|
||||
|
||||
val intent = Intent(RingtoneManager.ACTION_RINGTONE_PICKER)
|
||||
intent.putExtra(RingtoneManager.EXTRA_RINGTONE_SHOW_DEFAULT, true)
|
||||
intent.putExtra(RingtoneManager.EXTRA_RINGTONE_SHOW_SILENT, true)
|
||||
intent.putExtra(RingtoneManager.EXTRA_RINGTONE_TYPE, RingtoneManager.TYPE_RINGTONE)
|
||||
intent.putExtra(
|
||||
RingtoneManager.EXTRA_RINGTONE_DEFAULT_URI,
|
||||
Settings.System.DEFAULT_RINGTONE_URI
|
||||
)
|
||||
intent.putExtra(RingtoneManager.EXTRA_RINGTONE_EXISTING_URI, current)
|
||||
|
||||
openRingtonePicker(intent, CALL_RINGTONE_SELECT)
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
private fun openRingtonePicker(intent: Intent, requestCode: Int) {
|
||||
override fun launchMessageSoundSelectionIntent() {
|
||||
try {
|
||||
startActivityForResult(intent, requestCode)
|
||||
messageSoundSelectionLauncher.launch(Unit)
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
Toast.makeText(requireContext(), R.string.NotificationSettingsFragment__failed_to_open_picker, Toast.LENGTH_LONG).show()
|
||||
Toast.makeText(activity, R.string.NotificationSettingsFragment__failed_to_open_picker, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
|
||||
private class LedColorPreference(
|
||||
val colorValues: Array<String>,
|
||||
val radioListPreference: RadioListPreference
|
||||
) : PreferenceModel<LedColorPreference>(
|
||||
title = radioListPreference.title,
|
||||
icon = radioListPreference.icon,
|
||||
summary = radioListPreference.summary
|
||||
override fun launchCallsSoundSelectionIntent() {
|
||||
try {
|
||||
callsSoundSelectionLauncher.launch(Unit)
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
Toast.makeText(activity, R.string.NotificationSettingsFragment__failed_to_open_picker, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
|
||||
override fun setMessageNotificationVibration(enabled: Boolean) {
|
||||
viewModel.setMessageNotificationsEnabled(enabled)
|
||||
}
|
||||
|
||||
override fun setMessasgeNotificationLedColor(selection: String) {
|
||||
viewModel.setMessageNotificationLedColor(selection)
|
||||
}
|
||||
|
||||
override fun setMessasgeNotificationLedBlink(selection: String) {
|
||||
viewModel.setMessageNotificationLedBlink(selection)
|
||||
}
|
||||
|
||||
override fun setMessageNotificationInChatSoundsEnabled(enabled: Boolean) {
|
||||
viewModel.setMessageNotificationInChatSoundsEnabled(enabled)
|
||||
}
|
||||
|
||||
override fun setMessageRepeatAlerts(selection: String) {
|
||||
viewModel.setMessageRepeatAlerts(selection.toInt())
|
||||
}
|
||||
|
||||
override fun setMessageNotificationPrivacy(selection: String) {
|
||||
viewModel.setMessageNotificationPrivacy(selection)
|
||||
}
|
||||
|
||||
@RequiresApi(23)
|
||||
override fun onTroubleshootNotificationsClick() {
|
||||
PromptBatterySaverDialogFragment.show(activity.supportFragmentManager)
|
||||
}
|
||||
|
||||
override fun launchNotificationPriorityIntent() {
|
||||
notificationPrioritySelectionLauncher.launch(Unit)
|
||||
}
|
||||
|
||||
override fun setMessageNotificationPriority(selection: String) {
|
||||
viewModel.setMessageNotificationPriority(selection.toInt())
|
||||
}
|
||||
|
||||
override fun setCallNotificationsEnabled(enabled: Boolean) {
|
||||
viewModel.setCallNotificationsEnabled(enabled)
|
||||
}
|
||||
|
||||
override fun setCallVibrateEnabled(enabled: Boolean) {
|
||||
viewModel.setCallVibrateEnabled(enabled)
|
||||
}
|
||||
|
||||
override fun onNavigationProfilesClick() {
|
||||
appSettingsRouter.navigateTo(AppSettingsRoute.NotificationsRoute.NotificationProfiles)
|
||||
}
|
||||
|
||||
override fun setNotifyWhenContactJoinsSignal(enabled: Boolean) {
|
||||
viewModel.setNotifyWhenContactJoinsSignal(enabled)
|
||||
}
|
||||
}
|
||||
|
||||
interface NotificationsSettingsCallbacks {
|
||||
fun onTurnOnNotificationsActionClick() = Unit
|
||||
fun onNavigationClick() = Unit
|
||||
fun setMessageNotificationsEnabled(enabled: Boolean) = Unit
|
||||
fun onCustomizeClick() = Unit
|
||||
fun getRingtoneSummary(uri: Uri): String = "Test Sound"
|
||||
fun launchMessageSoundSelectionIntent(): Unit = Unit
|
||||
fun launchCallsSoundSelectionIntent(): Unit = Unit
|
||||
fun setMessageNotificationVibration(enabled: Boolean) = Unit
|
||||
fun setMessasgeNotificationLedColor(selection: String) = Unit
|
||||
fun setMessasgeNotificationLedBlink(selection: String) = Unit
|
||||
fun setMessageNotificationInChatSoundsEnabled(enabled: Boolean) = Unit
|
||||
fun setMessageRepeatAlerts(selection: String) = Unit
|
||||
fun setMessageNotificationPrivacy(selection: String) = Unit
|
||||
fun onTroubleshootNotificationsClick() = Unit
|
||||
fun launchNotificationPriorityIntent() = Unit
|
||||
fun setMessageNotificationPriority(selection: String) = Unit
|
||||
fun setCallNotificationsEnabled(enabled: Boolean) = Unit
|
||||
fun setCallVibrateEnabled(enabled: Boolean) = Unit
|
||||
fun onNavigationProfilesClick() = Unit
|
||||
fun setNotifyWhenContactJoinsSignal(enabled: Boolean) = Unit
|
||||
|
||||
object Empty : NotificationsSettingsCallbacks
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun NotificationsSettingsScreen(
|
||||
state: NotificationsSettingsState,
|
||||
callbacks: NotificationsSettingsCallbacks,
|
||||
deviceState: DeviceState = remember { DeviceState() }
|
||||
) {
|
||||
Scaffolds.Settings(
|
||||
title = stringResource(R.string.preferences__notifications),
|
||||
onNavigationClick = callbacks::onNavigationClick,
|
||||
navigationIcon = ImageVector.vectorResource(R.drawable.symbol_arrow_start_24)
|
||||
) {
|
||||
override fun areContentsTheSame(newItem: LedColorPreference): Boolean {
|
||||
return super.areContentsTheSame(newItem) && radioListPreference.areContentsTheSame(newItem.radioListPreference)
|
||||
}
|
||||
}
|
||||
LazyColumn(
|
||||
modifier = Modifier.padding(it)
|
||||
) {
|
||||
if (!state.messageNotificationsState.canEnableNotifications) {
|
||||
item {
|
||||
Banner(
|
||||
text = stringResource(R.string.NotificationSettingsFragment__to_enable_notifications),
|
||||
action = stringResource(R.string.NotificationSettingsFragment__turn_on),
|
||||
onActionClick = callbacks::onTurnOnNotificationsActionClick
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private class LedColorPreferenceViewHolder(itemView: View) :
|
||||
PreferenceViewHolder<LedColorPreference>(itemView) {
|
||||
item {
|
||||
Texts.SectionHeader(stringResource(R.string.NotificationsSettingsFragment__messages))
|
||||
}
|
||||
|
||||
val radioListPreferenceViewHolder = RadioListPreferenceViewHolder(itemView)
|
||||
item {
|
||||
Rows.ToggleRow(
|
||||
text = stringResource(R.string.preferences__notifications),
|
||||
enabled = state.messageNotificationsState.canEnableNotifications,
|
||||
checked = state.messageNotificationsState.notificationsEnabled,
|
||||
onCheckChanged = callbacks::setMessageNotificationsEnabled
|
||||
)
|
||||
}
|
||||
|
||||
override fun bind(model: LedColorPreference) {
|
||||
super.bind(model)
|
||||
radioListPreferenceViewHolder.bind(model.radioListPreference)
|
||||
|
||||
summaryView.visibility = View.GONE
|
||||
|
||||
val circleDrawable = requireNotNull(ContextCompat.getDrawable(context, R.drawable.circle_tintable))
|
||||
circleDrawable.setBounds(0, 0, ViewUtil.dpToPx(20), ViewUtil.dpToPx(20))
|
||||
circleDrawable.colorFilter = model.colorValues[model.radioListPreference.selected].toColorFilter()
|
||||
|
||||
if (ViewUtil.isLtr(itemView)) {
|
||||
titleView.setCompoundDrawables(null, null, circleDrawable, null)
|
||||
if (deviceState.apiLevel >= 30) {
|
||||
item {
|
||||
Rows.TextRow(
|
||||
text = stringResource(R.string.preferences__customize),
|
||||
label = stringResource(R.string.preferences__change_sound_and_vibration),
|
||||
enabled = state.messageNotificationsState.notificationsEnabled,
|
||||
onClick = callbacks::onCustomizeClick
|
||||
)
|
||||
}
|
||||
} else {
|
||||
titleView.setCompoundDrawables(circleDrawable, null, null, null)
|
||||
}
|
||||
}
|
||||
item {
|
||||
Rows.TextRow(
|
||||
text = stringResource(R.string.preferences__sound),
|
||||
label = remember(state.messageNotificationsState.sound) {
|
||||
callbacks.getRingtoneSummary(state.messageNotificationsState.sound)
|
||||
},
|
||||
enabled = state.messageNotificationsState.notificationsEnabled,
|
||||
onClick = callbacks::launchMessageSoundSelectionIntent
|
||||
)
|
||||
}
|
||||
|
||||
private fun String.toColorFilter(): ColorFilter {
|
||||
val color = when (this) {
|
||||
"green" -> ContextCompat.getColor(context, R.color.green_500)
|
||||
"red" -> ContextCompat.getColor(context, R.color.red_500)
|
||||
"blue" -> ContextCompat.getColor(context, R.color.blue_500)
|
||||
"yellow" -> ContextCompat.getColor(context, R.color.yellow_500)
|
||||
"cyan" -> ContextCompat.getColor(context, R.color.cyan_500)
|
||||
"magenta" -> ContextCompat.getColor(context, R.color.pink_500)
|
||||
"white" -> ContextCompat.getColor(context, R.color.white)
|
||||
else -> ContextCompat.getColor(context, R.color.transparent)
|
||||
item {
|
||||
Rows.ToggleRow(
|
||||
text = stringResource(R.string.preferences__vibrate),
|
||||
checked = state.messageNotificationsState.vibrateEnabled,
|
||||
enabled = state.messageNotificationsState.notificationsEnabled,
|
||||
onCheckChanged = callbacks::setMessageNotificationsEnabled
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Rows.RadioListRow(
|
||||
text = {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(CircleShape)
|
||||
.size(24.dp)
|
||||
.background(color = getLedColor(state.messageNotificationsState.ledColor))
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.size(10.dp))
|
||||
|
||||
Text(text = stringResource(R.string.preferences__led_color))
|
||||
},
|
||||
dialogTitle = stringResource(R.string.preferences__led_color),
|
||||
labels = stringArrayResource(R.array.pref_led_color_entries),
|
||||
values = stringArrayResource(R.array.pref_led_color_values),
|
||||
selectedValue = state.messageNotificationsState.ledColor,
|
||||
enabled = state.messageNotificationsState.notificationsEnabled,
|
||||
onSelected = callbacks::setMessasgeNotificationLedColor
|
||||
)
|
||||
}
|
||||
|
||||
if (!deviceState.supportsNotificationChannels) {
|
||||
item {
|
||||
Rows.RadioListRow(
|
||||
text = stringResource(R.string.preferences__pref_led_blink_title),
|
||||
labels = stringArrayResource(R.array.pref_led_blink_pattern_entries),
|
||||
values = stringArrayResource(R.array.pref_led_blink_pattern_values),
|
||||
selectedValue = state.messageNotificationsState.ledBlink,
|
||||
enabled = state.messageNotificationsState.notificationsEnabled,
|
||||
onSelected = callbacks::setMessasgeNotificationLedBlink
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return PorterDuffColorFilter(color, PorterDuff.Mode.SRC_IN)
|
||||
item {
|
||||
Rows.ToggleRow(
|
||||
text = stringResource(R.string.preferences_notifications__in_chat_sounds),
|
||||
checked = state.messageNotificationsState.inChatSoundsEnabled,
|
||||
enabled = state.messageNotificationsState.notificationsEnabled,
|
||||
onCheckChanged = callbacks::setMessageNotificationInChatSoundsEnabled
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Rows.RadioListRow(
|
||||
text = stringResource(R.string.preferences__repeat_alerts),
|
||||
labels = stringArrayResource(R.array.pref_repeat_alerts_entries),
|
||||
values = stringArrayResource(R.array.pref_repeat_alerts_values),
|
||||
selectedValue = state.messageNotificationsState.repeatAlerts.toString(),
|
||||
enabled = state.messageNotificationsState.notificationsEnabled,
|
||||
onSelected = callbacks::setMessageRepeatAlerts
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Rows.RadioListRow(
|
||||
text = stringResource(R.string.preferences_notifications__show),
|
||||
labels = stringArrayResource(R.array.pref_notification_privacy_entries),
|
||||
values = stringArrayResource(R.array.pref_notification_privacy_values),
|
||||
selectedValue = state.messageNotificationsState.messagePrivacy,
|
||||
enabled = state.messageNotificationsState.notificationsEnabled,
|
||||
onSelected = callbacks::setMessageNotificationPrivacy
|
||||
)
|
||||
}
|
||||
|
||||
if (deviceState.apiLevel >= 23 && state.messageNotificationsState.troubleshootNotifications) {
|
||||
item {
|
||||
Rows.TextRow(
|
||||
text = stringResource(R.string.preferences_notifications__troubleshoot),
|
||||
onClick = callbacks::onTroubleshootNotificationsClick
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (deviceState.apiLevel < 30) {
|
||||
if (deviceState.supportsNotificationChannels) {
|
||||
item {
|
||||
Rows.TextRow(
|
||||
text = stringResource(R.string.preferences_notifications__priority),
|
||||
enabled = state.messageNotificationsState.notificationsEnabled,
|
||||
onClick = callbacks::launchNotificationPriorityIntent
|
||||
)
|
||||
}
|
||||
} else {
|
||||
item {
|
||||
Rows.RadioListRow(
|
||||
text = stringResource(R.string.preferences_notifications__priority),
|
||||
labels = stringArrayResource(R.array.pref_notification_priority_entries),
|
||||
values = stringArrayResource(R.array.pref_notification_priority_values),
|
||||
selectedValue = state.messageNotificationsState.priority.toString(),
|
||||
enabled = state.messageNotificationsState.notificationsEnabled,
|
||||
onSelected = callbacks::setMessageNotificationPriority
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Dividers.Default()
|
||||
}
|
||||
|
||||
item {
|
||||
Texts.SectionHeader(stringResource(R.string.NotificationsSettingsFragment__calls))
|
||||
}
|
||||
|
||||
item {
|
||||
Rows.ToggleRow(
|
||||
text = stringResource(R.string.preferences__notifications),
|
||||
enabled = state.callNotificationsState.canEnableNotifications,
|
||||
checked = state.callNotificationsState.notificationsEnabled,
|
||||
onCheckChanged = callbacks::setCallNotificationsEnabled
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
val ringtoneSummary = remember(state.callNotificationsState.ringtone) {
|
||||
callbacks.getRingtoneSummary(state.callNotificationsState.ringtone)
|
||||
}
|
||||
|
||||
Rows.TextRow(
|
||||
text = stringResource(R.string.preferences_notifications__ringtone),
|
||||
label = ringtoneSummary,
|
||||
enabled = state.callNotificationsState.notificationsEnabled,
|
||||
onClick = callbacks::launchCallsSoundSelectionIntent
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Rows.ToggleRow(
|
||||
text = stringResource(R.string.preferences__vibrate),
|
||||
checked = state.callNotificationsState.vibrateEnabled,
|
||||
enabled = state.callNotificationsState.notificationsEnabled,
|
||||
onCheckChanged = callbacks::setCallVibrateEnabled
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Dividers.Default()
|
||||
}
|
||||
|
||||
item {
|
||||
Texts.SectionHeader(stringResource(R.string.NotificationsSettingsFragment__notification_profiles))
|
||||
}
|
||||
|
||||
item {
|
||||
Rows.TextRow(
|
||||
text = stringResource(R.string.NotificationsSettingsFragment__profiles),
|
||||
label = stringResource(R.string.NotificationsSettingsFragment__create_a_profile_to_receive_notifications_only_from_people_and_groups_you_choose),
|
||||
onClick = callbacks::onNavigationProfilesClick
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Dividers.Default()
|
||||
}
|
||||
|
||||
item {
|
||||
Texts.SectionHeader(stringResource(R.string.NotificationsSettingsFragment__notify_when))
|
||||
}
|
||||
|
||||
item {
|
||||
Rows.ToggleRow(
|
||||
text = stringResource(R.string.NotificationsSettingsFragment__contact_joins_signal),
|
||||
checked = state.notifyWhenContactJoinsSignal,
|
||||
onCheckChanged = callbacks::setNotifyWhenContactJoinsSignal
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun getLedColor(ledColorString: String): Color {
|
||||
return when (ledColorString) {
|
||||
"green" -> colorResource(R.color.green_500)
|
||||
"red" -> colorResource(R.color.red_500)
|
||||
"blue" -> colorResource(R.color.blue_500)
|
||||
"yellow" -> colorResource(R.color.yellow_500)
|
||||
"cyan" -> colorResource(R.color.cyan_500)
|
||||
"magenta" -> colorResource(R.color.pink_500)
|
||||
"white" -> colorResource(R.color.white)
|
||||
else -> colorResource(R.color.transparent)
|
||||
}
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@Composable
|
||||
private fun NotificationsSettingsScreenPreview() {
|
||||
Previews.Preview {
|
||||
NotificationsSettingsScreen(
|
||||
deviceState = rememberTestDeviceState(),
|
||||
state = rememberTestState(),
|
||||
callbacks = NotificationsSettingsCallbacks.Empty
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@Composable
|
||||
private fun NotificationsSettingsScreenAPI21Preview() {
|
||||
Previews.Preview {
|
||||
NotificationsSettingsScreen(
|
||||
deviceState = rememberTestDeviceState(apiLevel = 21, supportsNotificationChannels = false),
|
||||
state = rememberTestState(),
|
||||
callbacks = NotificationsSettingsCallbacks.Empty
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun rememberTestDeviceState(
|
||||
apiLevel: Int = 35,
|
||||
supportsNotificationChannels: Boolean = true
|
||||
): DeviceState = remember {
|
||||
DeviceState(
|
||||
apiLevel = apiLevel,
|
||||
supportsNotificationChannels = supportsNotificationChannels
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun rememberTestState(): NotificationsSettingsState = remember {
|
||||
NotificationsSettingsState(
|
||||
messageNotificationsState = MessageNotificationsState(
|
||||
notificationsEnabled = true,
|
||||
canEnableNotifications = true,
|
||||
sound = Uri.EMPTY,
|
||||
vibrateEnabled = true,
|
||||
ledColor = "blue",
|
||||
ledBlink = "",
|
||||
inChatSoundsEnabled = true,
|
||||
repeatAlerts = 1,
|
||||
messagePrivacy = "",
|
||||
priority = 1,
|
||||
troubleshootNotifications = true
|
||||
),
|
||||
callNotificationsState = CallNotificationsState(
|
||||
notificationsEnabled = true,
|
||||
canEnableNotifications = true,
|
||||
ringtone = Uri.EMPTY,
|
||||
vibrateEnabled = true
|
||||
),
|
||||
notifyWhenContactJoinsSignal = true
|
||||
)
|
||||
}
|
||||
|
||||
data class DeviceState(
|
||||
val apiLevel: Int = Build.VERSION.SDK_INT,
|
||||
val supportsNotificationChannels: Boolean = NotificationChannels.supported()
|
||||
)
|
||||
|
||||
@@ -3,9 +3,11 @@ package org.thoughtcrime.securesms.components.settings.app.notifications
|
||||
import android.content.SharedPreferences
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.notifications.DeviceSpecificNotificationConfig
|
||||
@@ -13,13 +15,12 @@ import org.thoughtcrime.securesms.notifications.NotificationChannels
|
||||
import org.thoughtcrime.securesms.notifications.SlowNotificationHeuristics
|
||||
import org.thoughtcrime.securesms.preferences.widgets.NotificationPrivacyPreference
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences
|
||||
import org.thoughtcrime.securesms.util.livedata.Store
|
||||
|
||||
class NotificationsSettingsViewModel(private val sharedPreferences: SharedPreferences) : ViewModel() {
|
||||
|
||||
private val store = Store(getState())
|
||||
private val store = MutableStateFlow(getState())
|
||||
|
||||
val state: LiveData<NotificationsSettingsState> = store.stateLiveData
|
||||
val state: StateFlow<NotificationsSettingsState> = store
|
||||
|
||||
init {
|
||||
if (NotificationChannels.supported()) {
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.settings.app.routes
|
||||
|
||||
import android.os.Parcelable
|
||||
import androidx.annotation.StringRes
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import org.signal.donations.InAppPaymentType
|
||||
import org.thoughtcrime.securesms.database.model.DistributionListId
|
||||
import org.thoughtcrime.securesms.groups.GroupId
|
||||
import org.thoughtcrime.securesms.profiles.manage.UsernameEditMode
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
|
||||
/**
|
||||
* Describes a route that the AppSettings screen can open. Every route listed here is displayed in
|
||||
* the PRIMARY (detail) pane of the AppSettingsScreen.
|
||||
*/
|
||||
@Parcelize
|
||||
sealed interface AppSettingsRoute : Parcelable {
|
||||
/**
|
||||
* Empty state, displayed when there is no current route. In this case, the "top" of our
|
||||
* scaffold navigator should be the SECONDARY (list) pane.
|
||||
*/
|
||||
data object Empty : AppSettingsRoute
|
||||
|
||||
@Parcelize
|
||||
sealed interface AccountRoute : AppSettingsRoute {
|
||||
data object Account : AccountRoute
|
||||
data object ManageProfile : AccountRoute
|
||||
data object AdvancedPinSettings : AccountRoute
|
||||
data object DeleteAccount : AccountRoute
|
||||
data object ExportAccountData : AccountRoute
|
||||
data object OldDeviceTransfer : AccountRoute
|
||||
data class Username(val mode: UsernameEditMode = UsernameEditMode.NORMAL) : AccountRoute
|
||||
}
|
||||
|
||||
data object Payments : AppSettingsRoute
|
||||
data object Invite : AppSettingsRoute
|
||||
data object AppUpdates : AppSettingsRoute
|
||||
|
||||
@Parcelize
|
||||
sealed interface StoriesRoute : AppSettingsRoute {
|
||||
data class Privacy(@StringRes val titleId: Int) : StoriesRoute
|
||||
data object MyStory : StoriesRoute
|
||||
data class PrivateStory(val distributionListId: DistributionListId) : StoriesRoute
|
||||
data class GroupStory(val groupId: GroupId) : StoriesRoute
|
||||
data object OnlyShareWith : StoriesRoute
|
||||
data object AllExcept : StoriesRoute
|
||||
data object SignalConnections : StoriesRoute
|
||||
data class EditName(val distributionListId: DistributionListId, val name: String) : StoriesRoute
|
||||
data class AddViewers(val distributionListId: DistributionListId) : StoriesRoute
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
sealed interface UsernameLinkRoute : AppSettingsRoute {
|
||||
data object UsernameLink : UsernameLinkRoute
|
||||
data object QRColorPicker : UsernameLinkRoute
|
||||
data object Share : UsernameLinkRoute
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
sealed interface BackupsRoute : AppSettingsRoute {
|
||||
data object Backups : BackupsRoute
|
||||
data object Local : BackupsRoute
|
||||
data class Remote(val backupLaterSelected: Boolean = false) : BackupsRoute
|
||||
data object DisplayKey : BackupsRoute
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
sealed interface NotificationsRoute : AppSettingsRoute {
|
||||
data object Notifications : NotificationsRoute
|
||||
data object NotificationProfiles : NotificationsRoute
|
||||
data class EditProfile(val profileId: Long = -1L) : NotificationsRoute
|
||||
data class ProfileDetails(val profileId: Long) : NotificationsRoute
|
||||
data class AddAllowedMembers(val profileId: Long) : NotificationsRoute
|
||||
|
||||
data class SelectRecipients(val profileId: Long, val currentSelection: Array<RecipientId>? = null) : NotificationsRoute {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
|
||||
other as SelectRecipients
|
||||
|
||||
if (profileId != other.profileId) return false
|
||||
if (!currentSelection.contentEquals(other.currentSelection)) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = profileId.hashCode()
|
||||
result = 31 * result + (currentSelection?.contentHashCode() ?: 0)
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
data class EditSchedule(val profileId: Long, val createMode: Boolean) : NotificationsRoute
|
||||
data class Created(val profileId: Long) : NotificationsRoute
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
sealed interface DonationsRoute : AppSettingsRoute {
|
||||
data class Donations(
|
||||
val directToCheckoutType: InAppPaymentType = InAppPaymentType.UNKNOWN
|
||||
) : DonationsRoute
|
||||
|
||||
data object Badges : DonationsRoute
|
||||
data object Receipts : DonationsRoute
|
||||
data class Receipt(val id: Long) : DonationsRoute
|
||||
data object LearnMore : DonationsRoute
|
||||
data object Featured : DonationsRoute
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
sealed interface InternalRoute : AppSettingsRoute {
|
||||
data object Internal : InternalRoute
|
||||
data object DonorErrorConfiguration : InternalRoute
|
||||
data object StoryDialogs : InternalRoute
|
||||
data object Search : InternalRoute
|
||||
data object SvrPlayground : InternalRoute
|
||||
data object ChatSpringboard : InternalRoute
|
||||
data object OneTimeDonationConfiguration : InternalRoute
|
||||
data object TerminalDonationConfiguration : InternalRoute
|
||||
data object BackupPlayground : InternalRoute
|
||||
data object StorageServicePlayground : InternalRoute
|
||||
data object SqlitePlayground : InternalRoute
|
||||
data object ConversationTestFragment : InternalRoute
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
sealed interface PrivacyRoute : AppSettingsRoute {
|
||||
data object Privacy : PrivacyRoute
|
||||
data object BlockedUsers : PrivacyRoute
|
||||
data object Advanced : PrivacyRoute
|
||||
data object ExpiringMessages : PrivacyRoute
|
||||
data object PhoneNumberPrivacy : PrivacyRoute
|
||||
data object ScreenLock : PrivacyRoute
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
sealed interface DataAndStorageRoute : AppSettingsRoute {
|
||||
data object DataAndStorage : DataAndStorageRoute
|
||||
data object Storage : DataAndStorageRoute
|
||||
data object Proxy : DataAndStorageRoute
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
sealed interface HelpRoute : AppSettingsRoute {
|
||||
data class Settings(
|
||||
val startCategoryIndex: Int = 0
|
||||
) : HelpRoute
|
||||
|
||||
data object Help : HelpRoute
|
||||
data object DebugLog : HelpRoute
|
||||
data object Licenses : HelpRoute
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
sealed interface AppearanceRoute : AppSettingsRoute {
|
||||
data object Appearance : AppearanceRoute
|
||||
data object Wallpaper : AppearanceRoute
|
||||
data object AppIconSelection : AppearanceRoute
|
||||
data object AppIconTutorial : AppearanceRoute
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
sealed interface ChatsRoute : AppSettingsRoute {
|
||||
data object Chats : ChatsRoute
|
||||
data object Reactions : ChatsRoute
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
sealed interface ChatFoldersRoute : AppSettingsRoute {
|
||||
data object ChatFolders : ChatFoldersRoute
|
||||
|
||||
data class CreateChatFolders(
|
||||
val folderId: Long,
|
||||
val threadIds: LongArray
|
||||
) : ChatFoldersRoute {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
|
||||
other as CreateChatFolders
|
||||
|
||||
if (folderId != other.folderId) return false
|
||||
if (!threadIds.contentEquals(other.threadIds)) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = folderId.hashCode()
|
||||
result = 31 * result + threadIds.contentHashCode()
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
data object Education : ChatFoldersRoute
|
||||
data object ChooseChats : ChatFoldersRoute
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
sealed interface LinkDeviceRoute : AppSettingsRoute {
|
||||
data object LinkDevice : LinkDeviceRoute
|
||||
data object Finished : LinkDeviceRoute
|
||||
data object LearnMore : LinkDeviceRoute
|
||||
data object Education : LinkDeviceRoute
|
||||
data object EditName : LinkDeviceRoute
|
||||
data object Add : LinkDeviceRoute
|
||||
data object Intro : LinkDeviceRoute
|
||||
data object Sync : LinkDeviceRoute
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
sealed interface ChangeNumberRoute : AppSettingsRoute {
|
||||
data object Start : ChangeNumberRoute
|
||||
data object EnterPhoneNumber : ChangeNumberRoute
|
||||
data object Confirm : ChangeNumberRoute
|
||||
data object CountryPicker : ChangeNumberRoute
|
||||
data object Verify : ChangeNumberRoute
|
||||
data object Captcha : ChangeNumberRoute
|
||||
data object EnterCode : ChangeNumberRoute
|
||||
data object RegistrationLock : ChangeNumberRoute
|
||||
data object AccountLocked : ChangeNumberRoute
|
||||
data object PinDiffers : ChangeNumberRoute
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.settings.app.routes
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Router which manages what screen we are displaying in app settings. Underneath, this is a ViewModel
|
||||
* that is tied to the top-level parent, so that all screens throughout the app settings can access it.
|
||||
*
|
||||
* This gives a single point to navigate to a new page, but assumes that the actual backstack of routes
|
||||
* will be handled elsewhere. This just emits routing requests.
|
||||
*/
|
||||
class AppSettingsRouter() : ViewModel() {
|
||||
|
||||
val currentRoute = MutableSharedFlow<AppSettingsRoute>()
|
||||
|
||||
fun navigateTo(route: AppSettingsRoute) {
|
||||
viewModelScope.launch {
|
||||
currentRoute.emit(route)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -42,18 +42,16 @@ class ManageStorageSettingsViewModel : ViewModel() {
|
||||
val state = store.asStateFlow()
|
||||
|
||||
init {
|
||||
if (RemoteConfig.messageBackups) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
InAppPaymentsRepository.observeLatestBackupPayment()
|
||||
.collectLatest { payment ->
|
||||
store.update { it.copy(isPaidTierPending = payment.state == InAppPaymentTable.State.PENDING) }
|
||||
}
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
store.update {
|
||||
it.copy(onDeviceStorageOptimizationState = getOnDeviceStorageOptimizationState())
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
InAppPaymentsRepository.observeLatestBackupPayment()
|
||||
.collectLatest { payment ->
|
||||
store.update { it.copy(isPaidTierPending = payment.state == InAppPaymentTable.State.PENDING) }
|
||||
}
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
store.update {
|
||||
it.copy(onDeviceStorageOptimizationState = getOnDeviceStorageOptimizationState())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -135,7 +133,7 @@ class ManageStorageSettingsViewModel : ViewModel() {
|
||||
|
||||
private suspend fun getOnDeviceStorageOptimizationState(): OnDeviceStorageOptimizationState {
|
||||
return when {
|
||||
!RemoteConfig.messageBackups || !SignalStore.backup.areBackupsEnabled || !AppDependencies.billingApi.isApiAvailable() || (!RemoteConfig.internalUser && !Environment.IS_STAGING) -> OnDeviceStorageOptimizationState.FEATURE_NOT_AVAILABLE
|
||||
!SignalStore.backup.areBackupsEnabled || !AppDependencies.billingApi.getApiAvailability().isSuccess || (!RemoteConfig.internalUser && !Environment.IS_STAGING) -> OnDeviceStorageOptimizationState.FEATURE_NOT_AVAILABLE
|
||||
SignalStore.backup.backupTier != MessageBackupTier.PAID -> OnDeviceStorageOptimizationState.REQUIRES_PAID_TIER
|
||||
SignalStore.backup.optimizeStorage -> OnDeviceStorageOptimizationState.ENABLED
|
||||
else -> OnDeviceStorageOptimizationState.DISABLED
|
||||
|
||||
@@ -26,7 +26,6 @@ import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.DonationErrorValue
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.util.RemoteConfig
|
||||
|
||||
/**
|
||||
* Handles displaying bottom sheets for in-app payments. The current policy is to "fire and forget".
|
||||
@@ -58,10 +57,7 @@ class InAppPaymentsBottomSheetDelegate(
|
||||
handleLegacyTerminalDonationSheets()
|
||||
handleLegacyVerifiedMonthlyDonationSheets()
|
||||
handleInAppPaymentDonationSheets()
|
||||
|
||||
if (RemoteConfig.messageBackups) {
|
||||
handleInAppPaymentBackupsSheets()
|
||||
}
|
||||
handleInAppPaymentBackupsSheets()
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.errors
|
||||
|
||||
import android.Manifest
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import org.signal.core.util.PendingIntentFlags
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
|
||||
import org.thoughtcrime.securesms.help.HelpFragment
|
||||
@@ -18,7 +22,15 @@ import org.thoughtcrime.securesms.notifications.NotificationIds
|
||||
* Donation-related push notifications.
|
||||
*/
|
||||
object DonationErrorNotifications {
|
||||
|
||||
private val TAG = Log.tag(DonationErrorNotifications::class)
|
||||
|
||||
fun displayErrorNotification(context: Context, donationError: DonationError) {
|
||||
if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
|
||||
Log.w(TAG, "Permission to post notifications is not granted.")
|
||||
return
|
||||
}
|
||||
|
||||
val parameters = DonationErrorParams.create(context, donationError, NotificationCallback)
|
||||
val notification = NotificationCompat.Builder(context, NotificationChannels.getInstance().FAILURES)
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
|
||||
@@ -12,7 +12,6 @@ import com.google.android.material.transition.platform.MaterialContainerTransfor
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsActivity
|
||||
import org.thoughtcrime.securesms.groups.GroupId
|
||||
import org.thoughtcrime.securesms.groups.ParcelableGroupId
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.util.DynamicConversationSettingsTheme
|
||||
@@ -67,7 +66,7 @@ open class ConversationSettingsActivity : DSLSettingsActivity(), ConversationSet
|
||||
|
||||
@JvmStatic
|
||||
fun forGroup(context: Context, groupId: GroupId): Intent {
|
||||
val startBundle = ConversationSettingsFragmentArgs.Builder(null, ParcelableGroupId.from(groupId), null)
|
||||
val startBundle = ConversationSettingsFragmentArgs.Builder(null, groupId, null)
|
||||
.build()
|
||||
.toBundle()
|
||||
|
||||
@@ -88,7 +87,7 @@ open class ConversationSettingsActivity : DSLSettingsActivity(), ConversationSet
|
||||
@JvmStatic
|
||||
fun forCall(context: Context, callPeer: Recipient, callMessageIds: LongArray): Intent {
|
||||
val startBundleBuilder = if (callPeer.isGroup) {
|
||||
ConversationSettingsFragmentArgs.Builder(null, ParcelableGroupId.from(callPeer.requireGroupId()), callMessageIds)
|
||||
ConversationSettingsFragmentArgs.Builder(null, callPeer.requireGroupId(), callMessageIds)
|
||||
} else {
|
||||
ConversationSettingsFragmentArgs.Builder(callPeer.id, null, callMessageIds)
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ import org.thoughtcrime.securesms.components.settings.conversation.preferences.U
|
||||
import org.thoughtcrime.securesms.contacts.ContactSelectionDisplayMode
|
||||
import org.thoughtcrime.securesms.conversation.ConversationIntents
|
||||
import org.thoughtcrime.securesms.database.AttachmentTable
|
||||
import org.thoughtcrime.securesms.groups.ParcelableGroupId
|
||||
import org.thoughtcrime.securesms.groups.GroupId
|
||||
import org.thoughtcrime.securesms.groups.ui.GroupErrors
|
||||
import org.thoughtcrime.securesms.groups.ui.GroupLimitDialog
|
||||
import org.thoughtcrime.securesms.groups.ui.LeaveGroupDialog
|
||||
@@ -136,11 +136,11 @@ class ConversationSettingsFragment : DSLSettingsFragment(
|
||||
|
||||
private val viewModel by viewModels<ConversationSettingsViewModel>(
|
||||
factoryProducer = {
|
||||
val groupId = args.groupId as? ParcelableGroupId
|
||||
val groupId = args.groupId as? GroupId
|
||||
|
||||
ConversationSettingsViewModel.Factory(
|
||||
recipientId = args.recipientId,
|
||||
groupId = ParcelableGroupId.get(groupId),
|
||||
groupId = groupId,
|
||||
callMessageIds = args.callMessageIds ?: longArrayOf(),
|
||||
repository = ConversationSettingsRepository(requireContext()),
|
||||
messageRequestRepository = MessageRequestRepository(requireContext())
|
||||
@@ -210,9 +210,9 @@ class ConversationSettingsFragment : DSLSettingsFragment(
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||
return if (item.itemId == R.id.action_edit) {
|
||||
val args = ConversationSettingsFragmentArgs.fromBundle(requireArguments())
|
||||
val groupId = args.groupId as ParcelableGroupId
|
||||
val groupId = args.groupId as GroupId
|
||||
|
||||
startActivity(CreateProfileActivity.getIntentForGroupProfile(requireActivity(), requireNotNull(ParcelableGroupId.get(groupId))))
|
||||
startActivity(CreateProfileActivity.getIntentForGroupProfile(requireActivity(), requireNotNull(groupId)))
|
||||
true
|
||||
} else {
|
||||
super.onOptionsItemSelected(item)
|
||||
@@ -820,7 +820,7 @@ class ConversationSettingsFragment : DSLSettingsFragment(
|
||||
icon = DSLSettingsIcon.from(R.drawable.ic_lock_24),
|
||||
isEnabled = !state.isDeprecatedOrUnregistered,
|
||||
onClick = {
|
||||
val action = ConversationSettingsFragmentDirections.actionConversationSettingsFragmentToPermissionsSettingsFragment(ParcelableGroupId.from(groupState.groupId))
|
||||
val action = ConversationSettingsFragmentDirections.actionConversationSettingsFragmentToPermissionsSettingsFragment(groupState.groupId)
|
||||
navController.safeNavigate(action)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -8,7 +8,7 @@ import org.thoughtcrime.securesms.components.settings.DSLConfiguration
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
|
||||
import org.thoughtcrime.securesms.components.settings.configure
|
||||
import org.thoughtcrime.securesms.groups.ParcelableGroupId
|
||||
import org.thoughtcrime.securesms.groups.GroupId
|
||||
import org.thoughtcrime.securesms.groups.ui.GroupErrors
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
|
||||
|
||||
@@ -23,7 +23,7 @@ class PermissionsSettingsFragment : DSLSettingsFragment(
|
||||
private val viewModel: PermissionsSettingsViewModel by viewModels(
|
||||
factoryProducer = {
|
||||
val args = PermissionsSettingsFragmentArgs.fromBundle(requireArguments())
|
||||
val groupId = requireNotNull(ParcelableGroupId.get(args.groupId as ParcelableGroupId))
|
||||
val groupId = requireNotNull(args.groupId as GroupId)
|
||||
val repository = PermissionsSettingsRepository(requireContext())
|
||||
|
||||
PermissionsSettingsViewModel.Factory(groupId, repository)
|
||||
|
||||
@@ -6,6 +6,24 @@
|
||||
package org.thoughtcrime.securesms.components.settings.models
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedCard
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.colorResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.SignalPreview
|
||||
import org.signal.core.ui.compose.horizontalGutters
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.databinding.DslBannerBinding
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.BindingFactory
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.BindingViewHolder
|
||||
@@ -42,3 +60,53 @@ object Banner {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replicates the Banner DSL preference for use in compose components.
|
||||
*/
|
||||
@Composable
|
||||
fun Banner(
|
||||
text: String,
|
||||
action: String,
|
||||
onActionClick: () -> Unit
|
||||
) {
|
||||
OutlinedCard(
|
||||
shape = RoundedCornerShape(18.dp),
|
||||
border = BorderStroke(width = 1.dp, color = colorResource(R.color.signal_colorOutline_38)),
|
||||
modifier = Modifier
|
||||
.horizontalGutters()
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
Column {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 16.57.dp)
|
||||
.padding(top = 16.dp, bottom = 10.dp)
|
||||
)
|
||||
|
||||
TextButton(
|
||||
onClick = onActionClick,
|
||||
modifier = Modifier
|
||||
.align(Alignment.End)
|
||||
.padding(horizontal = 8.dp)
|
||||
) {
|
||||
Text(text = action)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@Composable
|
||||
private fun BannerPreview() {
|
||||
Previews.Preview {
|
||||
Banner(
|
||||
text = "Banner text will go here and probably be about something important",
|
||||
action = "Action",
|
||||
onActionClick = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
package org.thoughtcrime.securesms.components.webrtc;
|
||||
|
||||
import android.Manifest;
|
||||
import android.app.Notification;
|
||||
import android.app.PendingIntent;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.pm.PackageManager;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.core.app.NotificationCompat;
|
||||
import androidx.core.app.NotificationManagerCompat;
|
||||
import androidx.core.content.ContextCompat;
|
||||
|
||||
import org.signal.core.util.PendingIntentFlags;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.components.webrtc.v2.CallIntent;
|
||||
import org.thoughtcrime.securesms.notifications.NotificationChannels;
|
||||
@@ -20,12 +24,18 @@ import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
*/
|
||||
public final class GroupCallSafetyNumberChangeNotificationUtil {
|
||||
|
||||
public static final String TAG = Log.tag(GroupCallSafetyNumberChangeNotificationUtil.class);
|
||||
public static final String GROUP_CALLING_NOTIFICATION_TAG = "group_calling";
|
||||
|
||||
private GroupCallSafetyNumberChangeNotificationUtil() {
|
||||
}
|
||||
|
||||
public static void showNotification(@NonNull Context context, @NonNull Recipient recipient) {
|
||||
if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
|
||||
Log.w(TAG, "showNotification: Notification permission is not granted.");
|
||||
return;
|
||||
}
|
||||
|
||||
Intent contentIntent = new Intent(context, CallIntent.getActivityClass());
|
||||
contentIntent.setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP);
|
||||
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
package org.thoughtcrime.securesms.conversation
|
||||
|
||||
import android.net.Uri
|
||||
import android.os.Parcelable
|
||||
import kotlinx.parcelize.IgnoredOnParcel
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import kotlinx.serialization.Serializable
|
||||
import org.thoughtcrime.securesms.badges.models.Badge
|
||||
import org.thoughtcrime.securesms.conversation.ConversationIntents.ConversationScreenType
|
||||
import org.thoughtcrime.securesms.conversation.colors.ChatColors
|
||||
import org.thoughtcrime.securesms.mediasend.Media
|
||||
import org.thoughtcrime.securesms.mms.SlideFactory
|
||||
import org.thoughtcrime.securesms.recipients.Recipient.Companion.resolved
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.serialization.UriSerializer
|
||||
import org.thoughtcrime.securesms.stickers.StickerLocator
|
||||
import org.thoughtcrime.securesms.wallpaper.ChatWallpaper
|
||||
|
||||
@Serializable
|
||||
@Parcelize
|
||||
data class ConversationArgs(
|
||||
val recipientId: RecipientId,
|
||||
@JvmField val threadId: Long,
|
||||
val draftText: String?,
|
||||
@Serializable(with = UriSerializer::class) val draftMedia: Uri?,
|
||||
val draftContentType: String?,
|
||||
val media: List<Media?>?,
|
||||
val stickerLocator: StickerLocator?,
|
||||
val isBorderless: Boolean,
|
||||
val distributionType: Int,
|
||||
val startingPosition: Int,
|
||||
val isFirstTimeInSelfCreatedGroup: Boolean,
|
||||
val isWithSearchOpen: Boolean,
|
||||
val giftBadge: Badge?,
|
||||
val shareDataTimestamp: Long,
|
||||
val conversationScreenType: ConversationScreenType
|
||||
) : Parcelable {
|
||||
@IgnoredOnParcel
|
||||
val draftMediaType: SlideFactory.MediaType? = SlideFactory.MediaType.from(draftContentType)
|
||||
|
||||
@IgnoredOnParcel
|
||||
val wallpaper: ChatWallpaper?
|
||||
get() = resolved(recipientId).wallpaper
|
||||
|
||||
@IgnoredOnParcel
|
||||
val chatColors: ChatColors
|
||||
get() = resolved(recipientId).chatColors
|
||||
|
||||
fun canInitializeFromDatabase(): Boolean {
|
||||
return draftText == null && (draftMedia == null || ConversationIntents.isBubbleIntentUri(draftMedia) || ConversationIntents.isNotificationIntentUri(draftMedia)) && draftMediaType == null
|
||||
}
|
||||
}
|
||||
@@ -11,16 +11,13 @@ import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.thoughtcrime.securesms.badges.models.Badge;
|
||||
import org.thoughtcrime.securesms.conversation.colors.ChatColors;
|
||||
import org.thoughtcrime.securesms.conversation.v2.ConversationActivity;
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase;
|
||||
import org.thoughtcrime.securesms.database.ThreadTable;
|
||||
import org.thoughtcrime.securesms.mediasend.Media;
|
||||
import org.thoughtcrime.securesms.mms.SlideFactory;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.stickers.StickerLocator;
|
||||
import org.thoughtcrime.securesms.wallpaper.ChatWallpaper;
|
||||
import org.whispersystems.signalservice.api.util.Preconditions;
|
||||
|
||||
import java.util.ArrayList;
|
||||
@@ -99,6 +96,12 @@ public class ConversationIntents {
|
||||
return new Builder(context, ConversationActivity.class, recipientId, threadId, ConversationScreenType.NORMAL);
|
||||
}
|
||||
|
||||
public static @NonNull Builder createBuilderSync(@NonNull Context context, @NonNull ConversationArgs conversationArgs) {
|
||||
Preconditions.checkArgument(conversationArgs.threadId > 0, "threadId is invalid");
|
||||
return new Builder(context, ConversationActivity.class, conversationArgs.getRecipientId(), conversationArgs.threadId, ConversationScreenType.NORMAL)
|
||||
.withArgs(conversationArgs);
|
||||
}
|
||||
|
||||
static @Nullable Uri getIntentData(@NonNull Bundle bundle) {
|
||||
return bundle.getParcelable(INTENT_DATA);
|
||||
}
|
||||
@@ -132,170 +135,41 @@ public class ConversationIntents {
|
||||
return ACTION.equals(intent.getAction());
|
||||
}
|
||||
|
||||
public final static class Args {
|
||||
private final RecipientId recipientId;
|
||||
private final long threadId;
|
||||
private final String draftText;
|
||||
private final Uri draftMedia;
|
||||
private final String draftContentType;
|
||||
private final SlideFactory.MediaType draftMediaType;
|
||||
private final ArrayList<Media> media;
|
||||
private final StickerLocator stickerLocator;
|
||||
private final boolean isBorderless;
|
||||
private final int distributionType;
|
||||
private final int startingPosition;
|
||||
private final boolean firstTimeInSelfCreatedGroup;
|
||||
private final boolean withSearchOpen;
|
||||
private final Badge giftBadge;
|
||||
private final long shareDataTimestamp;
|
||||
private final ConversationScreenType conversationScreenType;
|
||||
|
||||
public static Args from(@NonNull Bundle arguments) {
|
||||
Uri intentDataUri = getIntentData(arguments);
|
||||
if (isBubbleIntentUri(intentDataUri)) {
|
||||
return new Args(RecipientId.from(intentDataUri.getQueryParameter(EXTRA_RECIPIENT)),
|
||||
Long.parseLong(intentDataUri.getQueryParameter(EXTRA_THREAD_ID)),
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
false,
|
||||
ThreadTable.DistributionTypes.DEFAULT,
|
||||
-1,
|
||||
false,
|
||||
false,
|
||||
null,
|
||||
-1L,
|
||||
ConversationScreenType.BUBBLE);
|
||||
}
|
||||
|
||||
return new Args(RecipientId.from(Objects.requireNonNull(arguments.getString(EXTRA_RECIPIENT))),
|
||||
arguments.getLong(EXTRA_THREAD_ID, -1),
|
||||
arguments.getString(EXTRA_TEXT),
|
||||
ConversationIntents.getIntentData(arguments),
|
||||
ConversationIntents.getIntentType(arguments),
|
||||
arguments.getParcelableArrayList(EXTRA_MEDIA),
|
||||
arguments.getParcelable(EXTRA_STICKER),
|
||||
arguments.getBoolean(EXTRA_BORDERLESS, false),
|
||||
arguments.getInt(EXTRA_DISTRIBUTION_TYPE, ThreadTable.DistributionTypes.DEFAULT),
|
||||
arguments.getInt(EXTRA_STARTING_POSITION, -1),
|
||||
arguments.getBoolean(EXTRA_FIRST_TIME_IN_SELF_CREATED_GROUP, false),
|
||||
arguments.getBoolean(EXTRA_WITH_SEARCH_OPEN, false),
|
||||
arguments.getParcelable(EXTRA_GIFT_BADGE),
|
||||
arguments.getLong(EXTRA_SHARE_DATA_TIMESTAMP, -1L),
|
||||
ConversationScreenType.from(arguments.getInt(EXTRA_CONVERSATION_TYPE, 0)));
|
||||
public static ConversationArgs readArgsFromBundle(@NonNull Bundle arguments) {
|
||||
Uri intentDataUri = getIntentData(arguments);
|
||||
if (isBubbleIntentUri(intentDataUri)) {
|
||||
return new ConversationArgs(RecipientId.from(intentDataUri.getQueryParameter(EXTRA_RECIPIENT)),
|
||||
Long.parseLong(intentDataUri.getQueryParameter(EXTRA_THREAD_ID)),
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
false,
|
||||
ThreadTable.DistributionTypes.DEFAULT,
|
||||
-1,
|
||||
false,
|
||||
false,
|
||||
null,
|
||||
-1L,
|
||||
ConversationScreenType.BUBBLE);
|
||||
}
|
||||
|
||||
private Args(@NonNull RecipientId recipientId,
|
||||
long threadId,
|
||||
@Nullable String draftText,
|
||||
@Nullable Uri draftMedia,
|
||||
@Nullable String draftContentType,
|
||||
@Nullable ArrayList<Media> media,
|
||||
@Nullable StickerLocator stickerLocator,
|
||||
boolean isBorderless,
|
||||
int distributionType,
|
||||
int startingPosition,
|
||||
boolean firstTimeInSelfCreatedGroup,
|
||||
boolean withSearchOpen,
|
||||
@Nullable Badge giftBadge,
|
||||
long shareDataTimestamp,
|
||||
@NonNull ConversationScreenType conversationScreenType)
|
||||
{
|
||||
this.recipientId = recipientId;
|
||||
this.threadId = threadId;
|
||||
this.draftText = draftText;
|
||||
this.draftMedia = draftMedia;
|
||||
this.draftContentType = draftContentType;
|
||||
this.media = media;
|
||||
this.stickerLocator = stickerLocator;
|
||||
this.isBorderless = isBorderless;
|
||||
this.distributionType = distributionType;
|
||||
this.startingPosition = startingPosition;
|
||||
this.firstTimeInSelfCreatedGroup = firstTimeInSelfCreatedGroup;
|
||||
this.withSearchOpen = withSearchOpen;
|
||||
this.giftBadge = giftBadge;
|
||||
this.shareDataTimestamp = shareDataTimestamp;
|
||||
this.conversationScreenType = conversationScreenType;
|
||||
this.draftMediaType = SlideFactory.MediaType.from(draftContentType);
|
||||
}
|
||||
|
||||
public @NonNull RecipientId getRecipientId() {
|
||||
return recipientId;
|
||||
}
|
||||
|
||||
public long getThreadId() {
|
||||
return threadId;
|
||||
}
|
||||
|
||||
public @Nullable String getDraftText() {
|
||||
return draftText;
|
||||
}
|
||||
|
||||
public @Nullable Uri getDraftMedia() {
|
||||
return draftMedia;
|
||||
}
|
||||
|
||||
public @Nullable String getDraftContentType() {
|
||||
return draftContentType;
|
||||
}
|
||||
|
||||
public @Nullable SlideFactory.MediaType getDraftMediaType() {
|
||||
return draftMediaType;
|
||||
}
|
||||
|
||||
public @Nullable ArrayList<Media> getMedia() {
|
||||
return media;
|
||||
}
|
||||
|
||||
public @Nullable StickerLocator getStickerLocator() {
|
||||
return stickerLocator;
|
||||
}
|
||||
|
||||
public int getDistributionType() {
|
||||
return distributionType;
|
||||
}
|
||||
|
||||
public int getStartingPosition() {
|
||||
return startingPosition;
|
||||
}
|
||||
|
||||
public boolean isBorderless() {
|
||||
return isBorderless;
|
||||
}
|
||||
|
||||
public boolean isFirstTimeInSelfCreatedGroup() {
|
||||
return firstTimeInSelfCreatedGroup;
|
||||
}
|
||||
|
||||
public @Nullable ChatWallpaper getWallpaper() {
|
||||
return Recipient.resolved(recipientId).getWallpaper();
|
||||
}
|
||||
|
||||
public @NonNull ChatColors getChatColors() {
|
||||
return Recipient.resolved(recipientId).getChatColors();
|
||||
}
|
||||
|
||||
public boolean isWithSearchOpen() {
|
||||
return withSearchOpen;
|
||||
}
|
||||
|
||||
public @Nullable Badge getGiftBadge() {
|
||||
return giftBadge;
|
||||
}
|
||||
|
||||
public long getShareDataTimestamp() {
|
||||
return shareDataTimestamp;
|
||||
}
|
||||
|
||||
public @NonNull ConversationScreenType getConversationScreenType() {
|
||||
return conversationScreenType;
|
||||
}
|
||||
|
||||
public boolean canInitializeFromDatabase() {
|
||||
return draftText == null && (draftMedia == null || ConversationIntents.isBubbleIntentUri(draftMedia) || ConversationIntents.isNotificationIntentUri(draftMedia)) && draftMediaType == null;
|
||||
}
|
||||
return new ConversationArgs(RecipientId.from(Objects.requireNonNull(arguments.getString(EXTRA_RECIPIENT))),
|
||||
arguments.getLong(EXTRA_THREAD_ID, -1),
|
||||
arguments.getString(EXTRA_TEXT),
|
||||
ConversationIntents.getIntentData(arguments),
|
||||
ConversationIntents.getIntentType(arguments),
|
||||
arguments.getParcelableArrayList(EXTRA_MEDIA),
|
||||
arguments.getParcelable(EXTRA_STICKER),
|
||||
arguments.getBoolean(EXTRA_BORDERLESS, false),
|
||||
arguments.getInt(EXTRA_DISTRIBUTION_TYPE, ThreadTable.DistributionTypes.DEFAULT),
|
||||
arguments.getInt(EXTRA_STARTING_POSITION, -1),
|
||||
arguments.getBoolean(EXTRA_FIRST_TIME_IN_SELF_CREATED_GROUP, false),
|
||||
arguments.getBoolean(EXTRA_WITH_SEARCH_OPEN, false),
|
||||
arguments.getParcelable(EXTRA_GIFT_BADGE),
|
||||
arguments.getLong(EXTRA_SHARE_DATA_TIMESTAMP, -1L),
|
||||
ConversationScreenType.from(arguments.getInt(EXTRA_CONVERSATION_TYPE, 0)));
|
||||
}
|
||||
|
||||
public final static class Builder {
|
||||
@@ -331,6 +205,23 @@ public class ConversationIntents {
|
||||
this.conversationScreenType = conversationScreenType;
|
||||
}
|
||||
|
||||
public @NonNull Builder withArgs(@NonNull ConversationArgs args) {
|
||||
draftText = args.getDraftText();
|
||||
media = args.getMedia();
|
||||
stickerLocator = args.getStickerLocator();
|
||||
isBorderless = args.isBorderless();
|
||||
distributionType = args.getDistributionType();
|
||||
startingPosition = args.getStartingPosition();
|
||||
dataType = args.getDraftContentType();
|
||||
dataUri = args.getDraftMedia();
|
||||
firstTimeInSelfCreatedGroup = args.isFirstTimeInSelfCreatedGroup();
|
||||
withSearchOpen = args.isWithSearchOpen();
|
||||
giftBadge = args.getGiftBadge();
|
||||
shareDataTimestamp = args.getShareDataTimestamp();
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
public @NonNull Builder withDraftText(@Nullable String draftText) {
|
||||
this.draftText = draftText;
|
||||
return this;
|
||||
@@ -391,6 +282,26 @@ public class ConversationIntents {
|
||||
return this;
|
||||
}
|
||||
|
||||
public @NonNull ConversationArgs toConversationArgs() {
|
||||
return new ConversationArgs(
|
||||
recipientId,
|
||||
threadId,
|
||||
draftText,
|
||||
dataUri,
|
||||
dataType,
|
||||
media,
|
||||
stickerLocator,
|
||||
isBorderless,
|
||||
distributionType,
|
||||
startingPosition,
|
||||
firstTimeInSelfCreatedGroup,
|
||||
withSearchOpen,
|
||||
giftBadge,
|
||||
shareDataTimestamp,
|
||||
conversationScreenType
|
||||
);
|
||||
}
|
||||
|
||||
public @NonNull Intent build() {
|
||||
if (stickerLocator != null && media != null) {
|
||||
throw new IllegalStateException("Cannot have both sticker and media array");
|
||||
|
||||
@@ -18,7 +18,6 @@ import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase;
|
||||
import org.thoughtcrime.securesms.database.model.GroupRecord;
|
||||
import org.thoughtcrime.securesms.groups.GroupId;
|
||||
import org.thoughtcrime.securesms.groups.ParcelableGroupId;
|
||||
import org.thoughtcrime.securesms.groups.ui.GroupMemberListView;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.util.BottomSheetUtil;
|
||||
@@ -46,7 +45,7 @@ public final class ShowAdminsBottomSheetDialog extends BottomSheetDialogFragment
|
||||
ShowAdminsBottomSheetDialog fragment = new ShowAdminsBottomSheetDialog();
|
||||
|
||||
Bundle args = new Bundle();
|
||||
args.putParcelable(KEY_GROUP_ID, ParcelableGroupId.from(groupId));
|
||||
args.putParcelable(KEY_GROUP_ID, groupId);
|
||||
fragment.setArguments(args);
|
||||
|
||||
fragment.show(manager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG);
|
||||
@@ -94,7 +93,7 @@ public final class ShowAdminsBottomSheetDialog extends BottomSheetDialogFragment
|
||||
}
|
||||
|
||||
private GroupId getGroupId() {
|
||||
return ParcelableGroupId.get(requireArguments().getParcelable(KEY_GROUP_ID));
|
||||
return requireArguments().getParcelable(KEY_GROUP_ID);
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
|
||||
@@ -13,7 +13,7 @@ import org.signal.core.util.concurrent.SignalExecutors
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.components.location.SignalPlace
|
||||
import org.thoughtcrime.securesms.components.mention.MentionAnnotation
|
||||
import org.thoughtcrime.securesms.conversation.ConversationIntents
|
||||
import org.thoughtcrime.securesms.conversation.ConversationArgs
|
||||
import org.thoughtcrime.securesms.conversation.ConversationMessage
|
||||
import org.thoughtcrime.securesms.conversation.ConversationMessage.ConversationMessageFactory
|
||||
import org.thoughtcrime.securesms.conversation.MessageStyler
|
||||
@@ -53,7 +53,7 @@ class DraftRepository(
|
||||
private val threadTable: ThreadTable = SignalDatabase.threads,
|
||||
private val draftTable: DraftTable = SignalDatabase.drafts,
|
||||
private val saveDraftsExecutor: Executor = SerialMonoLifoExecutor(SignalExecutors.BOUNDED),
|
||||
private val conversationArguments: ConversationIntents.Args? = null
|
||||
private val conversationArguments: ConversationArgs? = null
|
||||
) {
|
||||
|
||||
companion object {
|
||||
@@ -115,7 +115,7 @@ class DraftRepository(
|
||||
}
|
||||
|
||||
if (shareMediaList.isNotEmpty()) {
|
||||
return ShareOrDraftData.StartSendMedia(shareMediaList, shareText) to null
|
||||
return ShareOrDraftData.StartSendMedia(shareMediaList.filterNotNull(), shareText) to null
|
||||
}
|
||||
|
||||
if (shareMedia != null && shareMediaType != null) {
|
||||
|
||||
@@ -23,7 +23,6 @@ import org.thoughtcrime.securesms.mms.PartAuthority
|
||||
import org.thoughtcrime.securesms.sharing.MultiShareArgs
|
||||
import org.thoughtcrime.securesms.stories.Stories
|
||||
import org.thoughtcrime.securesms.util.hasSharedContact
|
||||
import java.util.Optional
|
||||
import java.util.function.Consumer
|
||||
|
||||
/**
|
||||
@@ -188,19 +187,19 @@ data class MultiselectForwardFragmentArgs @JvmOverloads constructor(
|
||||
val uri = this.uri ?: return null
|
||||
|
||||
return Media(
|
||||
uri,
|
||||
contentType,
|
||||
System.currentTimeMillis(),
|
||||
width,
|
||||
height,
|
||||
size,
|
||||
0,
|
||||
borderless,
|
||||
videoGif,
|
||||
Optional.empty(),
|
||||
Optional.ofNullable(caption),
|
||||
Optional.ofNullable(transformProperties),
|
||||
Optional.ofNullable(fileName)
|
||||
uri = uri,
|
||||
contentType = contentType,
|
||||
date = System.currentTimeMillis(),
|
||||
width = width,
|
||||
height = height,
|
||||
size = size,
|
||||
duration = 0,
|
||||
isBorderless = borderless,
|
||||
isVideoGif = videoGif,
|
||||
bucketId = null,
|
||||
caption = caption,
|
||||
transformProperties = transformProperties,
|
||||
fileName = fileName
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,10 +20,10 @@ import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaController
|
||||
import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaControllerOwner
|
||||
import org.thoughtcrime.securesms.conversation.ConversationIntents
|
||||
import org.thoughtcrime.securesms.jobs.ConversationShortcutUpdateJob
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.util.ConfigurationUtil
|
||||
import org.thoughtcrime.securesms.util.Debouncer
|
||||
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme
|
||||
import org.thoughtcrime.securesms.window.WindowSizeClass
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
@@ -53,7 +53,7 @@ open class ConversationActivity : PassphraseRequiredActivity(), VoiceNoteMediaCo
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) {
|
||||
if (SignalStore.internal.largeScreenUi) {
|
||||
if (WindowSizeClass.isLargeScreenSupportEnabled()) {
|
||||
startActivity(
|
||||
MainActivity.clearTop(this).apply {
|
||||
action = ConversationIntents.ACTION
|
||||
@@ -66,6 +66,7 @@ open class ConversationActivity : PassphraseRequiredActivity(), VoiceNoteMediaCo
|
||||
}
|
||||
|
||||
finish()
|
||||
return
|
||||
}
|
||||
|
||||
enableSavedStateHandles()
|
||||
|
||||
@@ -154,6 +154,7 @@ import org.thoughtcrime.securesms.contactshare.SharedContactDetailsActivity
|
||||
import org.thoughtcrime.securesms.conversation.AttachmentKeyboardButton
|
||||
import org.thoughtcrime.securesms.conversation.BadDecryptLearnMoreDialog
|
||||
import org.thoughtcrime.securesms.conversation.ConversationAdapter
|
||||
import org.thoughtcrime.securesms.conversation.ConversationArgs
|
||||
import org.thoughtcrime.securesms.conversation.ConversationBottomSheetCallback
|
||||
import org.thoughtcrime.securesms.conversation.ConversationData
|
||||
import org.thoughtcrime.securesms.conversation.ConversationHeaderView
|
||||
@@ -256,6 +257,7 @@ import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.linkpreview.LinkPreview
|
||||
import org.thoughtcrime.securesms.linkpreview.LinkPreviewViewModelV2
|
||||
import org.thoughtcrime.securesms.longmessage.LongMessageFragment
|
||||
import org.thoughtcrime.securesms.main.InsetsViewModel
|
||||
import org.thoughtcrime.securesms.main.MainNavigationListLocation
|
||||
import org.thoughtcrime.securesms.mediaoverview.MediaOverviewActivity
|
||||
import org.thoughtcrime.securesms.mediapreview.MediaIntentFactory
|
||||
@@ -348,6 +350,7 @@ import org.thoughtcrime.securesms.util.visible
|
||||
import org.thoughtcrime.securesms.verify.VerifyIdentityActivity
|
||||
import org.thoughtcrime.securesms.wallpaper.ChatWallpaper
|
||||
import org.thoughtcrime.securesms.wallpaper.ChatWallpaperDimLevelUtil
|
||||
import org.thoughtcrime.securesms.window.WindowSizeClass
|
||||
import org.thoughtcrime.securesms.window.WindowSizeClass.Companion.getWindowSizeClass
|
||||
import java.time.Instant
|
||||
import java.time.LocalDateTime
|
||||
@@ -390,8 +393,8 @@ class ConversationFragment :
|
||||
private const val IS_SCROLLED_TO_BOTTOM_THRESHOLD: Int = 2
|
||||
}
|
||||
|
||||
private val args: ConversationIntents.Args by lazy {
|
||||
ConversationIntents.Args.from(requireArguments())
|
||||
private val args: ConversationArgs by lazy {
|
||||
ConversationIntents.readArgsFromBundle(requireArguments())
|
||||
}
|
||||
|
||||
private val conversationRecipientRepository: ConversationRecipientRepository by lazy {
|
||||
@@ -482,6 +485,8 @@ class ConversationFragment :
|
||||
|
||||
private val shareDataTimestampViewModel: ShareDataTimestampViewModel by activityViewModels()
|
||||
|
||||
private val insetsViewModel: InsetsViewModel by activityViewModels()
|
||||
|
||||
private val inlineQueryController: InlineQueryResultsControllerV2 by lazy {
|
||||
InlineQueryResultsControllerV2(
|
||||
this,
|
||||
@@ -594,8 +599,21 @@ class ConversationFragment :
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
binding.toolbar.isBackInvokedCallbackEnabled = false
|
||||
|
||||
binding.root.setApplyRootInsets(!resources.getWindowSizeClass().isSplitPane())
|
||||
binding.root.setUseWindowTypes(!resources.getWindowSizeClass().isSplitPane())
|
||||
if (WindowSizeClass.isLargeScreenSupportEnabled()) {
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
repeatOnLifecycle(Lifecycle.State.RESUMED) {
|
||||
binding.root.clearVerticalInsetOverride()
|
||||
if (!resources.getWindowSizeClass().isSplitPane()) {
|
||||
insetsViewModel.insets.collect {
|
||||
binding.root.applyInsets(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
binding.root.setApplyRootInsets(!WindowSizeClass.isLargeScreenSupportEnabled())
|
||||
binding.root.setUseWindowTypes(!WindowSizeClass.isLargeScreenSupportEnabled())
|
||||
|
||||
disposables.bindTo(viewLifecycleOwner)
|
||||
|
||||
@@ -1329,19 +1347,19 @@ class ConversationFragment :
|
||||
} else {
|
||||
val mimeType = MediaUtil.getMimeType(requireContext(), uri) ?: mediaType.toFallbackMimeType()
|
||||
val media = Media(
|
||||
uri,
|
||||
mimeType,
|
||||
0,
|
||||
width,
|
||||
height,
|
||||
0,
|
||||
0,
|
||||
borderless,
|
||||
videoGif,
|
||||
Optional.empty(),
|
||||
Optional.empty(),
|
||||
Optional.of(AttachmentTable.TransformProperties.forSentMediaQuality(SignalStore.settings.sentMediaQuality.code)),
|
||||
Optional.empty()
|
||||
uri = uri,
|
||||
contentType = mimeType,
|
||||
date = 0,
|
||||
width = width,
|
||||
height = height,
|
||||
size = 0,
|
||||
duration = 0,
|
||||
isBorderless = borderless,
|
||||
isVideoGif = videoGif,
|
||||
bucketId = null,
|
||||
caption = null,
|
||||
transformProperties = AttachmentTable.TransformProperties.forSentMediaQuality(SignalStore.settings.sentMediaQuality.code),
|
||||
fileName = null
|
||||
)
|
||||
conversationActivityResultContracts.launchMediaEditor(listOf(media), recipientId, composeText.textTrimmed)
|
||||
}
|
||||
@@ -1600,6 +1618,7 @@ class ConversationFragment :
|
||||
composeText.setDraftText(data.text)
|
||||
inputPanel.clickOnComposeInput()
|
||||
}
|
||||
|
||||
is ShareOrDraftData.SetLocation -> attachmentManager.setLocation(data.location, MediaConstraints.getPushMediaConstraints())
|
||||
is ShareOrDraftData.SetEditMessage -> {
|
||||
composeText.setDraftText(data.draftText)
|
||||
@@ -3218,9 +3237,13 @@ class ConversationFragment :
|
||||
|
||||
override fun onItemLongClick(itemView: View, item: MultiselectPart) {
|
||||
Log.d(TAG, "onItemLongClick")
|
||||
if (actionMode != null) { return }
|
||||
if (actionMode != null) {
|
||||
return
|
||||
}
|
||||
|
||||
if (item.getMessageRecord().isInMemoryMessageRecord) { return }
|
||||
if (item.getMessageRecord().isInMemoryMessageRecord) {
|
||||
return
|
||||
}
|
||||
|
||||
val messageRecord = item.getMessageRecord()
|
||||
val recipient = viewModel.recipientSnapshot ?: return
|
||||
@@ -3788,11 +3811,11 @@ class ConversationFragment :
|
||||
|
||||
val slides: List<Slide> = result.nonUploadedMedia.mapNotNull {
|
||||
when {
|
||||
MediaUtil.isVideoType(it.contentType) -> VideoSlide(requireContext(), it.uri, it.size, it.isVideoGif, it.width, it.height, it.caption.orNull(), it.transformProperties.orNull())
|
||||
MediaUtil.isGif(it.contentType) -> GifSlide(requireContext(), it.uri, it.size, it.width, it.height, it.isBorderless, it.caption.orNull())
|
||||
MediaUtil.isImageType(it.contentType) -> ImageSlide(requireContext(), it.uri, it.contentType, it.size, it.width, it.height, it.isBorderless, it.caption.orNull(), null, it.transformProperties.orNull())
|
||||
MediaUtil.isVideoType(it.contentType) -> VideoSlide(requireContext(), it.uri, it.size, it.isVideoGif, it.width, it.height, it.caption, it.transformProperties)
|
||||
MediaUtil.isGif(it.contentType) -> GifSlide(requireContext(), it.uri, it.size, it.width, it.height, it.isBorderless, it.caption)
|
||||
MediaUtil.isImageType(it.contentType) -> ImageSlide(requireContext(), it.uri, it.contentType, it.size, it.width, it.height, it.isBorderless, it.caption, null, it.transformProperties)
|
||||
MediaUtil.isDocumentType(it.contentType) -> {
|
||||
DocumentSlide(requireContext(), it.uri, it.contentType, it.size, it.fileName.orNull())
|
||||
DocumentSlide(requireContext(), it.uri, it.contentType!!, it.size, it.fileName)
|
||||
}
|
||||
|
||||
else -> {
|
||||
|
||||
@@ -9,7 +9,7 @@ import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import org.thoughtcrime.securesms.conversation.ConversationIntents
|
||||
import org.thoughtcrime.securesms.conversation.ConversationArgs
|
||||
import org.thoughtcrime.securesms.util.delegate
|
||||
|
||||
/**
|
||||
@@ -33,7 +33,7 @@ class ShareDataTimestampViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
fun setTimestampFromConversationArgs(args: ConversationIntents.Args) {
|
||||
fun setTimestampFromConversationArgs(args: ConversationArgs) {
|
||||
timestamp = args.shareDataTimestamp
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,6 @@ import org.thoughtcrime.securesms.database.model.MmsMessageRecord
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.messagerequests.MessageRequestRepository
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.util.RemoteConfig
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel
|
||||
|
||||
private typealias ConversationElement = MappingModel<*>
|
||||
@@ -125,7 +124,7 @@ class ConversationDataSource(
|
||||
records = MessageDataFetcher.updateModelsWithData(records, extraData).toMutableList()
|
||||
stopwatch.split("models")
|
||||
|
||||
if (RemoteConfig.messageBackups && ArchiveRestoreProgress.state.activelyRestoring()) {
|
||||
if (ArchiveRestoreProgress.state.activelyRestoring()) {
|
||||
BackupRestoreManager.prioritizeAttachmentsIfNeeded(records)
|
||||
stopwatch.split("restore")
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ package org.thoughtcrime.securesms.conversationlist;
|
||||
|
||||
import android.annotation.SuppressLint;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.res.ColorStateList;
|
||||
import android.content.res.Configuration;
|
||||
import android.content.res.Resources;
|
||||
@@ -115,7 +114,7 @@ import org.thoughtcrime.securesms.contacts.paged.ContactSearchData;
|
||||
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey;
|
||||
import org.thoughtcrime.securesms.contacts.paged.ContactSearchMediator;
|
||||
import org.thoughtcrime.securesms.contacts.paged.ContactSearchState;
|
||||
import org.thoughtcrime.securesms.conversation.ConversationIntents;
|
||||
import org.thoughtcrime.securesms.conversation.ConversationArgs;
|
||||
import org.thoughtcrime.securesms.conversation.ConversationUpdateTick;
|
||||
import org.thoughtcrime.securesms.conversationlist.chatfilter.ConversationFilterRequest;
|
||||
import org.thoughtcrime.securesms.conversationlist.chatfilter.ConversationFilterSource;
|
||||
@@ -417,10 +416,9 @@ public class ConversationListFragment extends MainFragment implements Conversati
|
||||
lifecycleDisposable.add(mainNavigationViewModel.getDetailLocationObservable()
|
||||
.subscribeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(location -> {
|
||||
if (location instanceof MainNavigationDetailLocation.Conversation) {
|
||||
Intent intent = ((MainNavigationDetailLocation.Conversation) location).getIntent();
|
||||
ConversationIntents.Args args = ConversationIntents.Args.from(Objects.requireNonNull(intent.getExtras()));
|
||||
long threadId = args.getThreadId();
|
||||
if (location instanceof MainNavigationDetailLocation.Chats.Conversation) {
|
||||
ConversationArgs args = ((MainNavigationDetailLocation.Chats.Conversation) location).getConversationArgs();
|
||||
long threadId = args.threadId;
|
||||
|
||||
defaultAdapter.setActiveThreadId(threadId);
|
||||
}
|
||||
|
||||
@@ -37,8 +37,9 @@ fun Fragment.listenToEventBusWhileResumed(
|
||||
.collectLatest {
|
||||
if (resources.getWindowSizeClass().isCompact()) {
|
||||
when (it) {
|
||||
is MainNavigationDetailLocation.Conversation -> unsubscribe()
|
||||
is MainNavigationDetailLocation.Chats.Conversation -> unsubscribe()
|
||||
MainNavigationDetailLocation.Empty -> subscribe()
|
||||
else -> Unit
|
||||
}
|
||||
} else {
|
||||
subscribe()
|
||||
|
||||
@@ -196,7 +196,6 @@ class ConversationListFilterPullView @JvmOverloads constructor(
|
||||
}
|
||||
|
||||
fun openImmediate() {
|
||||
println("openImmediate from $state")
|
||||
if (state == FilterPullState.CLOSED) {
|
||||
setState(FilterPullState.OPEN_APEX, source)
|
||||
setState(FilterPullState.OPENING, source)
|
||||
|
||||
@@ -24,6 +24,7 @@ import org.whispersystems.signalservice.api.crypto.SealedSenderAccess;
|
||||
import org.whispersystems.signalservice.api.crypto.UnidentifiedAccess;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.Iterator;
|
||||
@@ -185,8 +186,15 @@ public class SealedSenderAccessUtil {
|
||||
|
||||
private static CertificateValidator buildCertificateValidator() {
|
||||
try {
|
||||
ECPublicKey unidentifiedSenderTrustRoot = new ECPublicKey(Base64.decode(BuildConfig.UNIDENTIFIED_SENDER_TRUST_ROOT));
|
||||
return new CertificateValidator(unidentifiedSenderTrustRoot);
|
||||
String[] base64Strings = BuildConfig.UNIDENTIFIED_SENDER_TRUST_ROOTS;
|
||||
ArrayList<ECPublicKey> roots = new ArrayList<>(base64Strings.length);
|
||||
|
||||
for (String base64String: base64Strings) {
|
||||
ECPublicKey unidentifiedSenderTrustRoot = new ECPublicKey(Base64.decode(base64String));
|
||||
roots.add(unidentifiedSenderTrustRoot);
|
||||
}
|
||||
|
||||
return new CertificateValidator(roots);
|
||||
} catch (InvalidKeyException | IOException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ import com.bumptech.glide.Glide
|
||||
import com.fasterxml.jackson.annotation.JsonProperty
|
||||
import kotlinx.parcelize.IgnoredOnParcel
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import kotlinx.serialization.Serializable
|
||||
import okio.ByteString
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import org.json.JSONArray
|
||||
@@ -73,6 +74,7 @@ import org.thoughtcrime.securesms.attachments.DatabaseAttachment
|
||||
import org.thoughtcrime.securesms.attachments.LocalStickerAttachment
|
||||
import org.thoughtcrime.securesms.attachments.WallpaperAttachment
|
||||
import org.thoughtcrime.securesms.audio.AudioHash
|
||||
import org.thoughtcrime.securesms.backup.v2.ArchivedMediaObject
|
||||
import org.thoughtcrime.securesms.backup.v2.exporters.ChatItemArchiveExporter
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.BackupDebugInfo
|
||||
import org.thoughtcrime.securesms.blurhash.BlurHash
|
||||
@@ -429,34 +431,27 @@ class AttachmentTable(
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a cursor (with just the plaintextHash+remoteKey+archive_cdn) for all full-size attachments that are slated to be included in the current archive upload.
|
||||
* Used for snapshotting data in [BackupMediaSnapshotTable].
|
||||
* Returns a list that has any permanently-failed thumbnails removed.
|
||||
*/
|
||||
fun getFullSizeAttachmentsThatWillBeIncludedInArchive(): Cursor {
|
||||
return readableDatabase
|
||||
.select(DATA_HASH_END, REMOTE_KEY, ARCHIVE_CDN, QUOTE, CONTENT_TYPE)
|
||||
.from("$TABLE_NAME LEFT JOIN ${MessageTable.TABLE_NAME} ON $TABLE_NAME.$MESSAGE_ID = ${MessageTable.TABLE_NAME}.${MessageTable.ID}")
|
||||
.where(buildAttachmentsThatNeedUploadQuery(transferStateFilter = "$ARCHIVE_TRANSFER_STATE != ${ArchiveTransferState.PERMANENT_FAILURE.value}"))
|
||||
.run()
|
||||
}
|
||||
fun filterPermanentlyFailedThumbnails(entries: Set<BackupMediaSnapshotTable.MediaEntry>): Set<BackupMediaSnapshotTable.MediaEntry> {
|
||||
val entriesByMediaName: MutableMap<String, BackupMediaSnapshotTable.MediaEntry> = entries
|
||||
.associateBy { MediaName.fromPlaintextHashAndRemoteKeyForThumbnail(it.plaintextHash, it.remoteKey).name }
|
||||
.toMutableMap()
|
||||
|
||||
/**
|
||||
* Returns a cursor (with just the plaintextHash+remoteKey+archive_cdn) for all thumbnail attachments that are slated to be included in the current archive upload.
|
||||
* Used for snapshotting data in [BackupMediaSnapshotTable].
|
||||
*/
|
||||
fun getThumbnailAttachmentsThatWillBeIncludedInArchive(): Cursor {
|
||||
return readableDatabase
|
||||
.select(DATA_HASH_END, REMOTE_KEY, ARCHIVE_CDN, QUOTE, CONTENT_TYPE)
|
||||
.from("$TABLE_NAME LEFT JOIN ${MessageTable.TABLE_NAME} ON $TABLE_NAME.$MESSAGE_ID = ${MessageTable.TABLE_NAME}.${MessageTable.ID}")
|
||||
.where(
|
||||
"""
|
||||
${buildAttachmentsThatNeedUploadQuery(transferStateFilter = "$ARCHIVE_THUMBNAIL_TRANSFER_STATE != ${ArchiveTransferState.PERMANENT_FAILURE.value}")} AND
|
||||
$QUOTE = 0 AND
|
||||
($CONTENT_TYPE LIKE 'image/%' OR $CONTENT_TYPE LIKE 'video/%') AND
|
||||
$CONTENT_TYPE != 'image/svg+xml'
|
||||
"""
|
||||
)
|
||||
readableDatabase
|
||||
.select(DATA_HASH_END, REMOTE_KEY)
|
||||
.from(TABLE_NAME)
|
||||
.where("$DATA_HASH_END NOT NULL AND $REMOTE_KEY NOT NULL AND $ARCHIVE_THUMBNAIL_TRANSFER_STATE = ${ArchiveTransferState.PERMANENT_FAILURE.value}")
|
||||
.run()
|
||||
.forEach { cursor ->
|
||||
val hashEnd = cursor.requireNonNullString(DATA_HASH_END)
|
||||
val remoteKey = cursor.requireNonNullString(REMOTE_KEY)
|
||||
val thumbnailMediaName = MediaName.fromPlaintextHashAndRemoteKeyForThumbnail(Base64.decode(hashEnd), Base64.decode(remoteKey)).name
|
||||
|
||||
entriesByMediaName.remove(thumbnailMediaName)
|
||||
}
|
||||
|
||||
return entriesByMediaName.values.toSet()
|
||||
}
|
||||
|
||||
fun hasData(attachmentId: AttachmentId): Boolean {
|
||||
@@ -564,6 +559,25 @@ class AttachmentTable(
|
||||
.flatten()
|
||||
}
|
||||
|
||||
fun getLocalArchivableAttachment(plaintextHash: String, remoteKey: String): LocalArchivableAttachment? {
|
||||
return readableDatabase
|
||||
.select(*PROJECTION)
|
||||
.from(TABLE_NAME)
|
||||
.where("$DATA_HASH_END = ? AND $REMOTE_KEY = ?")
|
||||
.orderBy("$ID DESC")
|
||||
.limit(1)
|
||||
.run()
|
||||
.readToSingleObject {
|
||||
LocalArchivableAttachment(
|
||||
file = File(it.requireNonNullString(DATA_FILE)),
|
||||
random = it.requireNonNullBlob(DATA_RANDOM),
|
||||
size = it.requireLong(DATA_SIZE),
|
||||
remoteKey = Base64.decode(it.requireNonNullString(REMOTE_KEY)),
|
||||
plaintextHash = Base64.decode(it.requireNonNullString(DATA_HASH_END))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun getLocalArchivableAttachments(): List<LocalArchivableAttachment> {
|
||||
return readableDatabase
|
||||
.select(*PROJECTION)
|
||||
@@ -2995,28 +3009,40 @@ class AttachmentTable(
|
||||
.readToList { AttachmentId(it.requireLong(ID)) }
|
||||
}
|
||||
|
||||
fun getEstimatedArchiveMediaSize(): Long {
|
||||
val estimatedThumbnailCount = readableDatabase
|
||||
.select("COUNT(*)")
|
||||
.from(
|
||||
"""
|
||||
(
|
||||
SELECT DISTINCT $DATA_HASH_END, $REMOTE_KEY
|
||||
FROM $TABLE_NAME INNER JOIN ${MessageTable.TABLE_NAME} AS m ON $TABLE_NAME.$MESSAGE_ID = m.${MessageTable.ID}
|
||||
WHERE
|
||||
$DATA_FILE NOT NULL AND
|
||||
$DATA_HASH_END NOT NULL AND
|
||||
$REMOTE_KEY NOT NULL AND
|
||||
$TRANSFER_STATE = $TRANSFER_PROGRESS_DONE AND
|
||||
$ARCHIVE_TRANSFER_STATE != ${ArchiveTransferState.PERMANENT_FAILURE.value} AND
|
||||
($CONTENT_TYPE LIKE 'image/%' OR $CONTENT_TYPE LIKE 'video/%') AND
|
||||
$CONTENT_TYPE != 'image/svg+xml' AND
|
||||
${getMessageDoesNotExpireWithinTimeoutClause(tablePrefix = "m")}
|
||||
fun getPaidEstimatedArchiveMediaSize(): Long {
|
||||
return getEstimatedArchiveMediaSize()
|
||||
}
|
||||
|
||||
fun getFreeEstimatedArchiveMediaSize(afterTimestamp: Long): Long {
|
||||
return getEstimatedArchiveMediaSize(afterTimestamp)
|
||||
}
|
||||
|
||||
private fun getEstimatedArchiveMediaSize(afterTimestamp: Long = 0L): Long {
|
||||
val estimatedThumbnailCount = if (afterTimestamp == 0L) {
|
||||
readableDatabase
|
||||
.select("COUNT(*)")
|
||||
.from(
|
||||
"""
|
||||
(
|
||||
SELECT DISTINCT $DATA_HASH_END, $REMOTE_KEY
|
||||
FROM $TABLE_NAME INNER JOIN ${MessageTable.TABLE_NAME} AS m ON $TABLE_NAME.$MESSAGE_ID = m.${MessageTable.ID}
|
||||
WHERE
|
||||
$DATA_FILE NOT NULL AND
|
||||
$DATA_HASH_END NOT NULL AND
|
||||
$REMOTE_KEY NOT NULL AND
|
||||
$TRANSFER_STATE = $TRANSFER_PROGRESS_DONE AND
|
||||
$ARCHIVE_TRANSFER_STATE != ${ArchiveTransferState.PERMANENT_FAILURE.value} AND
|
||||
($CONTENT_TYPE LIKE 'image/%' OR $CONTENT_TYPE LIKE 'video/%') AND
|
||||
$CONTENT_TYPE != 'image/svg+xml' AND
|
||||
${getMessageDoesNotExpireWithinTimeoutClause(tablePrefix = "m")}
|
||||
)
|
||||
"""
|
||||
)
|
||||
"""
|
||||
)
|
||||
.run()
|
||||
.readToSingleLong(0L)
|
||||
.run()
|
||||
.readToSingleLong(0L)
|
||||
} else {
|
||||
0
|
||||
}
|
||||
|
||||
val uploadedAttachmentBytes = readableDatabase
|
||||
.rawQuery(
|
||||
@@ -3031,6 +3057,7 @@ class AttachmentTable(
|
||||
$REMOTE_KEY NOT NULL AND
|
||||
$TRANSFER_STATE = $TRANSFER_PROGRESS_DONE AND
|
||||
$ARCHIVE_TRANSFER_STATE != ${ArchiveTransferState.PERMANENT_FAILURE.value} AND
|
||||
${if (afterTimestamp > 0) "m.${MessageTable.DATE_RECEIVED} >= $afterTimestamp AND" else ""}
|
||||
${getMessageDoesNotExpireWithinTimeoutClause(tablePrefix = "m")}
|
||||
)
|
||||
"""
|
||||
@@ -3174,6 +3201,32 @@ class AttachmentTable(
|
||||
}
|
||||
}
|
||||
|
||||
fun getMediaObjectsThatCantBeFound(objects: Set<ArchivedMediaObject>): Set<ArchivedMediaObject> {
|
||||
if (objects.isEmpty()) {
|
||||
return emptySet()
|
||||
}
|
||||
|
||||
val objectsByMediaId: MutableMap<String, ArchivedMediaObject> = objects.associateBy { it.mediaId }.toMutableMap()
|
||||
|
||||
readableDatabase
|
||||
.select(*PROJECTION)
|
||||
.from(TABLE_NAME)
|
||||
.where("$REMOTE_KEY NOT NULL AND $DATA_HASH_END NOT NULL")
|
||||
.groupBy("$DATA_HASH_END, $REMOTE_KEY")
|
||||
.run()
|
||||
.forEach { cursor ->
|
||||
val remoteKey = Base64.decode(cursor.requireNonNullString(REMOTE_KEY))
|
||||
val plaintextHash = Base64.decode(cursor.requireNonNullString(DATA_HASH_END))
|
||||
val mediaId = MediaName.fromPlaintextHashAndRemoteKey(plaintextHash, remoteKey).toMediaId(SignalStore.backup.mediaRootBackupKey).encode()
|
||||
val mediaIdThumbnail = MediaName.fromPlaintextHashAndRemoteKeyForThumbnail(plaintextHash, remoteKey).toMediaId(SignalStore.backup.mediaRootBackupKey).encode()
|
||||
|
||||
objectsByMediaId.remove(mediaId)
|
||||
objectsByMediaId.remove(mediaIdThumbnail)
|
||||
}
|
||||
|
||||
return objectsByMediaId.values.toSet()
|
||||
}
|
||||
|
||||
/**
|
||||
* Important: This is an expensive query that involves iterating over every row in the table. Only call this for debug stuff!
|
||||
*/
|
||||
@@ -3186,10 +3239,9 @@ class AttachmentTable(
|
||||
.select(*PROJECTION)
|
||||
.from(TABLE_NAME)
|
||||
.where("$REMOTE_KEY NOT NULL AND $DATA_HASH_END NOT NULL")
|
||||
.groupBy(DATA_HASH_END)
|
||||
.groupBy("$DATA_HASH_END, $REMOTE_KEY")
|
||||
.run()
|
||||
.forEach { cursor ->
|
||||
|
||||
val remoteKey = Base64.decode(cursor.requireNonNullString(REMOTE_KEY))
|
||||
val plaintextHash = Base64.decode(cursor.requireNonNullString(DATA_HASH_END))
|
||||
val mediaId = MediaName.fromPlaintextHashAndRemoteKey(plaintextHash, remoteKey).toMediaId(SignalStore.backup.mediaRootBackupKey).value.toByteString()
|
||||
@@ -3212,7 +3264,6 @@ class AttachmentTable(
|
||||
|
||||
fun debugGetAttachmentStats(): DebugAttachmentStats {
|
||||
val totalAttachmentRows = readableDatabase.count().from(TABLE_NAME).run().readToSingleLong(0)
|
||||
val totalEligibleForUploadRows = getFullSizeAttachmentsThatWillBeIncludedInArchive().count
|
||||
|
||||
val totalUniqueDataFiles = readableDatabase.select("COUNT(DISTINCT $DATA_FILE)").from(TABLE_NAME).run().readToSingleLong(0)
|
||||
val totalUniqueMediaNames = readableDatabase.query("SELECT COUNT(*) FROM (SELECT DISTINCT $DATA_HASH_END, $REMOTE_KEY FROM $TABLE_NAME WHERE $DATA_HASH_END NOT NULL AND $REMOTE_KEY NOT NULL)").readToSingleLong(0)
|
||||
@@ -3282,15 +3333,19 @@ class AttachmentTable(
|
||||
val uploadedThumbnailCount = archiveStatusMediaNameThumbnailCounts.getOrDefault(ArchiveTransferState.FINISHED, 0L)
|
||||
val uploadedThumbnailBytes = uploadedThumbnailCount * RemoteConfig.backupMaxThumbnailFileSize.inWholeBytes
|
||||
|
||||
val lastSnapshotFullSizeCount = SignalDatabase.backupMediaSnapshots.debugGetFullSizeAttachmentCountForMostRecentSnapshot()
|
||||
val lastSnapshotThumbnailCount = SignalDatabase.backupMediaSnapshots.debugGetThumbnailAttachmentCountForMostRecentSnapshot()
|
||||
|
||||
return DebugAttachmentStats(
|
||||
totalAttachmentRows = totalAttachmentRows,
|
||||
totalEligibleForUploadRows = totalEligibleForUploadRows.toLong(),
|
||||
totalUniqueMediaNamesEligibleForUpload = totalUniqueMediaNamesEligibleForUpload,
|
||||
totalUniqueDataFiles = totalUniqueDataFiles,
|
||||
totalUniqueMediaNames = totalUniqueMediaNames,
|
||||
archiveStatusMediaNameCounts = archiveStatusMediaNameCounts,
|
||||
mediaNamesWithThumbnailsCount = uniqueEligibleMediaNamesWithThumbnailsCount,
|
||||
archiveStatusMediaNameThumbnailCounts = archiveStatusMediaNameThumbnailCounts,
|
||||
lastSnapshotFullSizeCount = lastSnapshotFullSizeCount.toLong(),
|
||||
lastSnapshotThumbnailCount = lastSnapshotThumbnailCount.toLong(),
|
||||
pendingAttachmentUploadBytes = pendingAttachmentUploadBytes,
|
||||
uploadedAttachmentBytes = uploadedAttachmentBytes,
|
||||
uploadedThumbnailBytes = uploadedThumbnailBytes
|
||||
@@ -3493,6 +3548,7 @@ class AttachmentTable(
|
||||
val random: ByteArray
|
||||
)
|
||||
|
||||
@Serializable
|
||||
@Parcelize
|
||||
data class TransformProperties(
|
||||
@JsonProperty("skipTransform")
|
||||
@@ -3699,13 +3755,14 @@ class AttachmentTable(
|
||||
|
||||
data class DebugAttachmentStats(
|
||||
val totalAttachmentRows: Long = 0L,
|
||||
val totalEligibleForUploadRows: Long = 0L,
|
||||
val totalUniqueMediaNamesEligibleForUpload: Long = 0L,
|
||||
val totalUniqueDataFiles: Long = 0L,
|
||||
val totalUniqueMediaNames: Long = 0L,
|
||||
val archiveStatusMediaNameCounts: Map<ArchiveTransferState, Long> = emptyMap(),
|
||||
val mediaNamesWithThumbnailsCount: Long = 0L,
|
||||
val archiveStatusMediaNameThumbnailCounts: Map<ArchiveTransferState, Long> = emptyMap(),
|
||||
val lastSnapshotFullSizeCount: Long = 0L,
|
||||
val lastSnapshotThumbnailCount: Long = 0L,
|
||||
val pendingAttachmentUploadBytes: Long = 0L,
|
||||
val uploadedAttachmentBytes: Long = 0L,
|
||||
val uploadedThumbnailBytes: Long = 0L
|
||||
@@ -3719,12 +3776,13 @@ class AttachmentTable(
|
||||
fun prettyString(): String {
|
||||
return buildString {
|
||||
appendLine("Total attachment rows: $totalAttachmentRows")
|
||||
appendLine("Total eligible for upload rows: $totalEligibleForUploadRows")
|
||||
appendLine("Total unique media names eligible for upload: $totalUniqueMediaNamesEligibleForUpload")
|
||||
appendLine("Total unique data files: $totalUniqueDataFiles")
|
||||
appendLine("Total unique media names: $totalUniqueMediaNames")
|
||||
appendLine("Media names with thumbnails count: $mediaNamesWithThumbnailsCount")
|
||||
appendLine("Pending attachment upload bytes: $pendingAttachmentUploadBytes")
|
||||
appendLine("Last snapshot full-size count: $lastSnapshotFullSizeCount")
|
||||
appendLine("Last snapshot thumbnail count : $lastSnapshotFullSizeCount")
|
||||
appendLine("Uploaded attachment bytes: $uploadedAttachmentBytes")
|
||||
appendLine("Uploaded thumbnail bytes: $uploadedThumbnailBytes")
|
||||
appendLine("Total upload count: $totalUploadCount")
|
||||
@@ -3748,10 +3806,11 @@ class AttachmentTable(
|
||||
|
||||
fun shortPrettyString(): String {
|
||||
return buildString {
|
||||
appendLine("Total eligible for upload rows: $totalEligibleForUploadRows")
|
||||
appendLine("Total unique media names eligible for upload: $totalUniqueMediaNamesEligibleForUpload")
|
||||
appendLine("Total unique data files: $totalUniqueDataFiles")
|
||||
appendLine("Total unique media names: $totalUniqueMediaNames")
|
||||
appendLine("Last snapshot full-size count: $lastSnapshotFullSizeCount")
|
||||
appendLine("Last snapshot thumbnail count : $lastSnapshotFullSizeCount")
|
||||
appendLine("Pending attachment upload bytes: $pendingAttachmentUploadBytes")
|
||||
|
||||
if (archiveStatusMediaNameCounts.isNotEmpty()) {
|
||||
|
||||
@@ -10,9 +10,12 @@ import android.database.Cursor
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import androidx.core.content.contentValuesOf
|
||||
import org.signal.core.util.SqlUtil
|
||||
import org.signal.core.util.count
|
||||
import org.signal.core.util.delete
|
||||
import org.signal.core.util.forEach
|
||||
import org.signal.core.util.readToList
|
||||
import org.signal.core.util.readToSet
|
||||
import org.signal.core.util.readToSingleInt
|
||||
import org.signal.core.util.readToSingleLong
|
||||
import org.signal.core.util.requireBoolean
|
||||
import org.signal.core.util.requireInt
|
||||
@@ -128,33 +131,43 @@ class BackupMediaSnapshotTable(context: Context, database: SignalDatabase) : Dat
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the set of full-size media items that are slated to be referenced in the next backup, updating their pending sync time.
|
||||
* Writes a set of [MediaEntry] that are slated to be referenced in the next backup, updating their pending sync time.
|
||||
*/
|
||||
fun writeFullSizePendingMediaObjects(mediaObjects: Sequence<ArchiveMediaItem>) {
|
||||
mediaObjects
|
||||
.chunked(SqlUtil.MAX_QUERY_ARGS)
|
||||
.forEach { chunk ->
|
||||
writePendingMediaObjectsChunk(
|
||||
chunk.map { MediaEntry(it.mediaId, it.cdn, it.plaintextHash, it.remoteKey, isThumbnail = false) }
|
||||
)
|
||||
}
|
||||
fun writePendingMediaEntries(entries: Collection<MediaEntry>) {
|
||||
if (entries.isEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
val values = entries.map {
|
||||
contentValuesOf(
|
||||
MEDIA_ID to it.mediaId,
|
||||
CDN to it.cdn,
|
||||
PLAINTEXT_HASH to it.plaintextHash,
|
||||
REMOTE_KEY to it.remoteKey,
|
||||
IS_THUMBNAIL to it.isThumbnail.toInt(),
|
||||
SNAPSHOT_VERSION to UNKNOWN_VERSION,
|
||||
IS_PENDING to 1
|
||||
)
|
||||
}
|
||||
|
||||
SqlUtil.buildBulkInsert(TABLE_NAME, arrayOf(MEDIA_ID, CDN, PLAINTEXT_HASH, REMOTE_KEY, IS_THUMBNAIL, SNAPSHOT_VERSION, IS_PENDING), values).forEach { query ->
|
||||
writableDatabase.execSQL(
|
||||
query.where +
|
||||
"""
|
||||
ON CONFLICT($MEDIA_ID) DO UPDATE SET
|
||||
$CDN = excluded.$CDN,
|
||||
$PLAINTEXT_HASH = excluded.$PLAINTEXT_HASH,
|
||||
$REMOTE_KEY = excluded.$REMOTE_KEY,
|
||||
$IS_THUMBNAIL = excluded.$IS_THUMBNAIL,
|
||||
$IS_PENDING = excluded.$IS_PENDING
|
||||
""",
|
||||
query.whereArgs
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the set of thumbnail media items that are slated to be referenced in the next backup, updating their pending sync time.
|
||||
*/
|
||||
fun writeThumbnailPendingMediaObjects(mediaObjects: Sequence<ArchiveMediaItem>) {
|
||||
mediaObjects
|
||||
.chunked(SqlUtil.MAX_QUERY_ARGS)
|
||||
.forEach { chunk ->
|
||||
writePendingMediaObjectsChunk(
|
||||
chunk.map { MediaEntry(it.thumbnailMediaId, it.cdn, it.plaintextHash, it.remoteKey, isThumbnail = true) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Commits all pending entries (written via [writePendingMediaObjects]) to have a concrete [SNAPSHOT_VERSION]. The version will be 1 higher than the previous
|
||||
* Commits all pending entries (written via [writePendingMediaEntries]) to have a concrete [SNAPSHOT_VERSION]. The version will be 1 higher than the previous
|
||||
* snapshot version.
|
||||
*/
|
||||
fun commitPendingRows() {
|
||||
@@ -214,6 +227,8 @@ class BackupMediaSnapshotTable(context: Context, database: SignalDatabase) : Dat
|
||||
return emptySet()
|
||||
}
|
||||
|
||||
val objectsByMediaId: MutableMap<String, ArchivedMediaObject> = objects.associateBy { it.mediaId }.toMutableMap()
|
||||
|
||||
val queries: List<SqlUtil.Query> = SqlUtil.buildCollectionQuery(
|
||||
column = MEDIA_ID,
|
||||
values = objects.map { it.mediaId },
|
||||
@@ -221,20 +236,19 @@ class BackupMediaSnapshotTable(context: Context, database: SignalDatabase) : Dat
|
||||
prefix = "$SNAPSHOT_VERSION = $MAX_VERSION AND "
|
||||
)
|
||||
|
||||
val foundObjects: MutableSet<String> = mutableSetOf()
|
||||
|
||||
for (query in queries) {
|
||||
foundObjects += readableDatabase
|
||||
readableDatabase
|
||||
.select(MEDIA_ID, CDN)
|
||||
.from(TABLE_NAME)
|
||||
.where(query.where, query.whereArgs)
|
||||
.run()
|
||||
.readToSet {
|
||||
it.requireNonNullString(MEDIA_ID)
|
||||
.forEach {
|
||||
val mediaId = it.requireNonNullString(MEDIA_ID)
|
||||
objectsByMediaId.remove(mediaId)
|
||||
}
|
||||
}
|
||||
|
||||
return objects.filterNot { foundObjects.contains(it.mediaId) }.toSet()
|
||||
return objectsByMediaId.values.toSet()
|
||||
}
|
||||
|
||||
fun getMediaEntriesForObjects(objects: List<ArchivedMediaObject>): Set<MediaEntry> {
|
||||
@@ -324,37 +338,22 @@ class BackupMediaSnapshotTable(context: Context, database: SignalDatabase) : Dat
|
||||
.run()
|
||||
}
|
||||
|
||||
private fun writePendingMediaObjectsChunk(chunk: List<MediaEntry>) {
|
||||
if (chunk.isEmpty()) {
|
||||
return
|
||||
}
|
||||
fun debugGetFullSizeAttachmentCountForMostRecentSnapshot(): Int {
|
||||
return readableDatabase
|
||||
.count()
|
||||
.from(TABLE_NAME)
|
||||
.where("$IS_THUMBNAIL = 0 AND $SNAPSHOT_VERSION = $MAX_VERSION")
|
||||
.run()
|
||||
.readToSingleInt()
|
||||
}
|
||||
|
||||
val values = chunk.map {
|
||||
contentValuesOf(
|
||||
MEDIA_ID to it.mediaId,
|
||||
CDN to it.cdn,
|
||||
PLAINTEXT_HASH to it.plaintextHash,
|
||||
REMOTE_KEY to it.remoteKey,
|
||||
IS_THUMBNAIL to it.isThumbnail.toInt(),
|
||||
SNAPSHOT_VERSION to UNKNOWN_VERSION,
|
||||
IS_PENDING to 1
|
||||
)
|
||||
}
|
||||
|
||||
val query = SqlUtil.buildSingleBulkInsert(TABLE_NAME, arrayOf(MEDIA_ID, CDN, PLAINTEXT_HASH, REMOTE_KEY, IS_THUMBNAIL, SNAPSHOT_VERSION, IS_PENDING), values)
|
||||
|
||||
writableDatabase.execSQL(
|
||||
query.where +
|
||||
"""
|
||||
ON CONFLICT($MEDIA_ID) DO UPDATE SET
|
||||
$CDN = excluded.$CDN,
|
||||
$PLAINTEXT_HASH = excluded.$PLAINTEXT_HASH,
|
||||
$REMOTE_KEY = excluded.$REMOTE_KEY,
|
||||
$IS_THUMBNAIL = excluded.$IS_THUMBNAIL,
|
||||
$IS_PENDING = excluded.$IS_PENDING
|
||||
""",
|
||||
query.whereArgs
|
||||
)
|
||||
fun debugGetThumbnailAttachmentCountForMostRecentSnapshot(): Int {
|
||||
return readableDatabase
|
||||
.count()
|
||||
.from(TABLE_NAME)
|
||||
.where("$IS_THUMBNAIL != 0 AND $SNAPSHOT_VERSION = $MAX_VERSION")
|
||||
.run()
|
||||
.readToSingleInt()
|
||||
}
|
||||
|
||||
class ArchiveMediaItem(
|
||||
|
||||
@@ -93,21 +93,26 @@ class EmojiSearchTable(context: Context, databaseHelper: SignalDatabase) : Datab
|
||||
/**
|
||||
* Deletes the content of the current search index and replaces it with the new one.
|
||||
*/
|
||||
fun setSearchIndex(searchIndex: List<EmojiSearchData>) {
|
||||
val db = databaseHelper.signalReadableDatabase
|
||||
|
||||
db.withinTransaction {
|
||||
fun setSearchIndex(
|
||||
localizedSearchIndex: List<EmojiSearchData>,
|
||||
englishSearchIndex: List<EmojiSearchData>
|
||||
) {
|
||||
databaseHelper.signalReadableDatabase.withinTransaction { db ->
|
||||
db.delete(TABLE_NAME, null, null)
|
||||
db.insert(localizedSearchIndex)
|
||||
db.insert(englishSearchIndex)
|
||||
}
|
||||
}
|
||||
|
||||
for (searchData in searchIndex) {
|
||||
for (label in searchData.tags) {
|
||||
val values = contentValuesOf(
|
||||
LABEL to label,
|
||||
EMOJI to searchData.emoji,
|
||||
RANK to if (searchData.rank == 0) Int.MAX_VALUE else searchData.rank
|
||||
)
|
||||
db.insert(TABLE_NAME, null, values)
|
||||
}
|
||||
private fun SQLiteDatabase.insert(searchIndex: List<EmojiSearchData>) {
|
||||
for (searchData in searchIndex) {
|
||||
for (label in searchData.tags) {
|
||||
val values = contentValuesOf(
|
||||
LABEL to label,
|
||||
EMOJI to searchData.emoji,
|
||||
RANK to if (searchData.rank == 0) Int.MAX_VALUE else searchData.rank
|
||||
)
|
||||
insert(TABLE_NAME, null, values)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3745,9 +3745,9 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
|
||||
}
|
||||
|
||||
if (blockedGroupIds.isNotEmpty()) {
|
||||
val groupIds: List<GroupId.V1> = blockedGroupIds.mapNotNull { raw ->
|
||||
val groupIds: List<GroupId.V1> = blockedGroupIds.filterNotNull().mapNotNull { raw ->
|
||||
try {
|
||||
GroupId.v1(raw)
|
||||
raw?.let { GroupId.v1(it) }
|
||||
} catch (e: BadGroupIdException) {
|
||||
Log.w(TAG, "[applyBlockedUpdate] Bad GV1 ID!")
|
||||
null
|
||||
|
||||
@@ -306,7 +306,11 @@ object SignalDatabaseMigrations {
|
||||
fun migrate(context: Application, db: SignalSqliteDatabase, oldVersion: Int, newVersion: Int) {
|
||||
val initialForeignKeyState = db.areForeignKeyConstraintsEnabled()
|
||||
|
||||
val eligibleMigrations = migrations.filter { (version, _) -> version > oldVersion && version <= newVersion }
|
||||
val eligibleMigrations = if (newVersion < 0) {
|
||||
migrations.filter { (version, _) -> version > oldVersion }
|
||||
} else {
|
||||
migrations.filter { (version, _) -> version > oldVersion && version <= newVersion }
|
||||
}
|
||||
|
||||
for (migrationData in eligibleMigrations) {
|
||||
val (version, migration) = migrationData
|
||||
|
||||
@@ -174,10 +174,9 @@ public class ApplicationDependencyProvider implements AppDependencies.Provider {
|
||||
keysApi,
|
||||
Optional.of(new SecurityEventListener(context)),
|
||||
SignalExecutors.newCachedBoundedExecutor("signal-messages", ThreadUtil.PRIORITY_IMPORTANT_BACKGROUND_THREAD, 1, 16, 30),
|
||||
ByteUnit.KILOBYTES.toBytes(256),
|
||||
RemoteConfig.maxEnvelopeSizeBytes(),
|
||||
RemoteConfig::useMessageSendRestFallback,
|
||||
RemoteConfig.usePqRatchet(),
|
||||
RemoteConfig.internalUser() ? Optional.of(ByteUnit.KILOBYTES.toBytes(96)) : Optional.empty());
|
||||
RemoteConfig.usePqRatchet());
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -276,7 +275,7 @@ public class ApplicationDependencyProvider implements AppDependencies.Provider {
|
||||
public @NonNull Network provideLibsignalNetwork(@NonNull SignalServiceConfiguration config) {
|
||||
Network network = new Network(BuildConfig.LIBSIGNAL_NET_ENV, StandardUserAgentInterceptor.USER_AGENT);
|
||||
LibSignalNetworkExtensions.applyConfiguration(network, config);
|
||||
LibSignalNetworkExtensions.buildAndSetRemoteConfig(network, RemoteConfig.libsignalEnforceMinTlsVersion());
|
||||
network.setRemoteConfig(RemoteConfig.getLibsignalConfigs());
|
||||
|
||||
return network;
|
||||
}
|
||||
@@ -511,7 +510,7 @@ public class ApplicationDependencyProvider implements AppDependencies.Provider {
|
||||
|
||||
@Override
|
||||
public @NonNull BillingApi provideBillingApi() {
|
||||
return BillingFactory.create(GooglePlayBillingDependencies.INSTANCE, RemoteConfig.messageBackups() && Environment.Backups.supportsGooglePlayBilling());
|
||||
return BillingFactory.create(GooglePlayBillingDependencies.INSTANCE, Environment.Backups.supportsGooglePlayBilling());
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -44,12 +44,7 @@ final class NewDeviceServerTask implements ServerTask {
|
||||
DataRestoreConstraint.setRestoringData(true);
|
||||
SQLiteDatabase database = SignalDatabase.getBackupDatabase();
|
||||
|
||||
String passphrase;
|
||||
if (RemoteConfig.restoreAfterRegistration()) {
|
||||
passphrase = SignalStore.account().getAccountEntropyPool().getValue();
|
||||
} else {
|
||||
passphrase = "deadbeef";
|
||||
}
|
||||
String passphrase = SignalStore.account().getAccountEntropyPool().getValue();
|
||||
|
||||
BackupPassphrase.set(context, passphrase);
|
||||
FullBackupImporter.importFile(context,
|
||||
@@ -57,7 +52,7 @@ final class NewDeviceServerTask implements ServerTask {
|
||||
database,
|
||||
inputStream,
|
||||
passphrase,
|
||||
RemoteConfig.restoreAfterRegistration());
|
||||
true);
|
||||
|
||||
SignalDatabase.runPostBackupRestoreTasks(database);
|
||||
NotificationChannels.getInstance().restoreContactNotificationChannels();
|
||||
|
||||
@@ -16,8 +16,8 @@ import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.jobs.ReclaimUsernameAndLinkJob
|
||||
import org.thoughtcrime.securesms.keyvalue.Completed
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.registration.data.RegistrationRepository
|
||||
import org.thoughtcrime.securesms.registration.util.RegistrationUtil
|
||||
import org.thoughtcrime.securesms.registrationv3.data.RegistrationRepository
|
||||
|
||||
class NewDeviceTransferViewModel : ViewModel() {
|
||||
fun onRestoreComplete(context: Context, onComplete: () -> Unit) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user