mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-14 22:13:19 +01:00
Compare commits
243 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e87b73cc19 | ||
|
|
45e1ecd07e | ||
|
|
7b043d4143 | ||
|
|
046d439887 | ||
|
|
b34bf4b8b0 | ||
|
|
fddc99ab4f | ||
|
|
1007111310 | ||
|
|
3346a1e918 | ||
|
|
9c391eb2c9 | ||
|
|
f714e038a0 | ||
|
|
61405a62c2 | ||
|
|
d424a60345 | ||
|
|
3c10966a36 | ||
|
|
e210d5939c | ||
|
|
1fafcc69ff | ||
|
|
3184368fa7 | ||
|
|
c622b7fdb1 | ||
|
|
29ead80e17 | ||
|
|
9a72833e06 | ||
|
|
d0baf1dc95 | ||
|
|
7f1227ee19 | ||
|
|
34c95dc082 | ||
|
|
10ad73f201 | ||
|
|
21fab7c5ba | ||
|
|
e9c2f96bb9 | ||
|
|
a950462451 | ||
|
|
809317c0fd | ||
|
|
6634540183 | ||
|
|
89bfba3ee9 | ||
|
|
97974291d2 | ||
|
|
6daee5719b | ||
|
|
58443c46be | ||
|
|
8cbecc2992 | ||
|
|
4c0ca48af3 | ||
|
|
91eeda6c6e | ||
|
|
04e75c18dd | ||
|
|
f5fbfbc7fd | ||
|
|
0e1df94b54 | ||
|
|
dd923629f6 | ||
|
|
df8992aaca | ||
|
|
63b9700865 | ||
|
|
d309877d63 | ||
|
|
f247fd78c6 | ||
|
|
4f96cb7439 | ||
|
|
6e6e3a5eba | ||
|
|
9166ed64fb | ||
|
|
25e4eaa8e8 | ||
|
|
6e742ce770 | ||
|
|
c134c3033e | ||
|
|
fb43a8257c | ||
|
|
cecfe80d61 | ||
|
|
6c302b708a | ||
|
|
6a22919c50 | ||
|
|
9073ce5c7b | ||
|
|
9024c19169 | ||
|
|
60a0565ba8 | ||
|
|
383f7556e3 | ||
|
|
94795599e2 | ||
|
|
84fbb7c466 | ||
|
|
c339f39b70 | ||
|
|
41a3609f06 | ||
|
|
f5abd7acdf | ||
|
|
414368e251 | ||
|
|
a3d1197aef | ||
|
|
d91760eefc | ||
|
|
ee20ced238 | ||
|
|
066892c11a | ||
|
|
69fd4f79db | ||
|
|
f49e2768c1 | ||
|
|
70378b85d7 | ||
|
|
585401a98e | ||
|
|
cf7ebfa03d | ||
|
|
aec0a9951a | ||
|
|
b113eec940 | ||
|
|
a966812bfc | ||
|
|
3879a8ffdb | ||
|
|
5b949b0116 | ||
|
|
3c13619ce8 | ||
|
|
24bba98122 | ||
|
|
a96e5e6ae6 | ||
|
|
4cfdfab31e | ||
|
|
77d3116431 | ||
|
|
b943df1ce4 | ||
|
|
8bbb7d56e0 | ||
|
|
079a3d4fee | ||
|
|
176e0e7765 | ||
|
|
c73e80f8d9 | ||
|
|
47cd1b568f | ||
|
|
058c523329 | ||
|
|
84515482a6 | ||
|
|
02629020df | ||
|
|
58d769b21f | ||
|
|
9dc67e0466 | ||
|
|
72d02104dc | ||
|
|
371a39049d | ||
|
|
47e4a6cf5a | ||
|
|
4a41e9f9a1 | ||
|
|
9fa1b58019 | ||
|
|
c24473e176 | ||
|
|
1311ec498f | ||
|
|
251cec5dee | ||
|
|
1e15a8c1d3 | ||
|
|
9c5c58794b | ||
|
|
50063854d7 | ||
|
|
79d2041e46 | ||
|
|
479b27ce94 | ||
|
|
a66857a7cc | ||
|
|
37815a3f39 | ||
|
|
b55ba67b66 | ||
|
|
37a2d5fbca | ||
|
|
d7b5c6bff3 | ||
|
|
f11028529e | ||
|
|
93ec322bb9 | ||
|
|
71e0468d2c | ||
|
|
816e3442a0 | ||
|
|
c37ed722dc | ||
|
|
e08c2966c3 | ||
|
|
976f80ff7e | ||
|
|
34a4bda331 | ||
|
|
a4077ccb4a | ||
|
|
21ada2a503 | ||
|
|
57a70c3085 | ||
|
|
16c8b88f0f | ||
|
|
b806952430 | ||
|
|
c0da0bd272 | ||
|
|
45239c2264 | ||
|
|
690236c4e5 | ||
|
|
ebee3f72e6 | ||
|
|
37cec7d44f | ||
|
|
187fd63a75 | ||
|
|
362cdfc463 | ||
|
|
863b443317 | ||
|
|
341c474610 | ||
|
|
cbb3c0911c | ||
|
|
890facc6f6 | ||
|
|
6fa8337058 | ||
|
|
3f1cb65e02 | ||
|
|
3551e7ec00 | ||
|
|
5ecf60a306 | ||
|
|
6659700a1c | ||
|
|
070174fee6 | ||
|
|
09003d85b1 | ||
|
|
ea87108def | ||
|
|
7a696f9a62 | ||
|
|
8ba57a2733 | ||
|
|
9824cc2cbe | ||
|
|
ad60cc72cb | ||
|
|
1950b80402 | ||
|
|
2acb47952b | ||
|
|
14cacaef86 | ||
|
|
958e815933 | ||
|
|
6b50be78c0 | ||
|
|
680223c4b6 | ||
|
|
1af914d5ef | ||
|
|
a2fc710261 | ||
|
|
10922594b3 | ||
|
|
abd80c5204 | ||
|
|
ff589e3b91 | ||
|
|
c80ccd70ec | ||
|
|
d22d18da47 | ||
|
|
75b41c34ea | ||
|
|
11557e4815 | ||
|
|
d698f74d0b | ||
|
|
ecbea9fd95 | ||
|
|
13f7a64139 | ||
|
|
39cb1c638e | ||
|
|
489b58ad67 | ||
|
|
f20fe33af9 | ||
|
|
6adddf4a0c | ||
|
|
16773c9b17 | ||
|
|
4b50365fa9 | ||
|
|
98766b9ebb | ||
|
|
45a739ce92 | ||
|
|
c0d7145ada | ||
|
|
f94c007af8 | ||
|
|
df19cb5795 | ||
|
|
e6ceb55092 | ||
|
|
bfe2b5cba9 | ||
|
|
571004df50 | ||
|
|
f32b59f0aa | ||
|
|
e4019d8595 | ||
|
|
0b66a8701e | ||
|
|
e62b8de1bc | ||
|
|
d5cd790871 | ||
|
|
664c22d8f1 | ||
|
|
143a61e312 | ||
|
|
baaad0e475 | ||
|
|
7086709082 | ||
|
|
7bd5ad8c0b | ||
|
|
df19c91ae2 | ||
|
|
e5872037e0 | ||
|
|
b782fabbb6 | ||
|
|
485b466bd2 | ||
|
|
3beac6dfa9 | ||
|
|
98290a9fa3 | ||
|
|
13dd59f226 | ||
|
|
d9c42a4135 | ||
|
|
644b93e5a3 | ||
|
|
3ff218f9c6 | ||
|
|
f572eb5322 | ||
|
|
d3eb480d31 | ||
|
|
ac52b5b992 | ||
|
|
5c181e774f | ||
|
|
05d25718da | ||
|
|
66c50bef44 | ||
|
|
26bd59c378 | ||
|
|
e90eae6080 | ||
|
|
86a7db7653 | ||
|
|
230de7e9dc | ||
|
|
4b8546a151 | ||
|
|
ecd214b91b | ||
|
|
6b5de6e3e5 | ||
|
|
58b6e49aae | ||
|
|
c480512600 | ||
|
|
3a5b6476aa | ||
|
|
cb171092cf | ||
|
|
71979b34db | ||
|
|
73142cea39 | ||
|
|
2ab2c6f039 | ||
|
|
0bea15c0af | ||
|
|
cbd78d78ba | ||
|
|
ac0604a753 | ||
|
|
0e57335be1 | ||
|
|
c4e64f6fa3 | ||
|
|
bf9716f206 | ||
|
|
057ffdbaaf | ||
|
|
65dc0d3f34 | ||
|
|
173ee95e62 | ||
|
|
789339afa7 | ||
|
|
21b518da7a | ||
|
|
57b6b8dcf1 | ||
|
|
543a85316e | ||
|
|
2fedb3a0ee | ||
|
|
ae450aed67 | ||
|
|
0abb4727fc | ||
|
|
4bc6eb96ff | ||
|
|
e6a126d416 | ||
|
|
fdf858f379 | ||
|
|
4151d123cd | ||
|
|
c8a9759eba | ||
|
|
c59b74627f | ||
|
|
f2191d2996 | ||
|
|
7dfffbd50b |
@@ -21,17 +21,10 @@ plugins {
|
||||
|
||||
apply(from = "static-ips.gradle.kts")
|
||||
|
||||
val canonicalVersionCode = 1422
|
||||
val canonicalVersionName = "7.9.0"
|
||||
|
||||
val postFixSize = 100
|
||||
val abiPostFix: Map<String, Int> = mapOf(
|
||||
"universal" to 0,
|
||||
"armeabi-v7a" to 1,
|
||||
"arm64-v8a" to 2,
|
||||
"x86" to 3,
|
||||
"x86_64" to 4
|
||||
)
|
||||
val canonicalVersionCode = 1438
|
||||
val canonicalVersionName = "7.12.1"
|
||||
val currentHotfixVersion = 0
|
||||
val maxHotfixVersions = 100
|
||||
|
||||
val keystores: Map<String, Properties?> = mapOf("debug" to loadKeystoreProperties("keystore.debug.properties"))
|
||||
|
||||
@@ -92,6 +85,8 @@ android {
|
||||
useLibrary("org.apache.http.legacy")
|
||||
testBuildType = "instrumentation"
|
||||
|
||||
android.bundle.language.enableSplit = false
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = signalKotlinJvmTarget
|
||||
}
|
||||
@@ -142,7 +137,7 @@ android {
|
||||
|
||||
packagingOptions {
|
||||
resources {
|
||||
excludes += setOf("LICENSE.txt", "LICENSE", "NOTICE", "asm-license.txt", "META-INF/LICENSE", "META-INF/LICENSE.md", "META-INF/NOTICE", "META-INF/LICENSE-notice.md", "META-INF/proguard/androidx-annotations.pro", "libsignal_jni.dylib", "signal_jni.dll")
|
||||
excludes += setOf("LICENSE.txt", "LICENSE", "NOTICE", "asm-license.txt", "META-INF/LICENSE", "META-INF/LICENSE.md", "META-INF/NOTICE", "META-INF/LICENSE-notice.md", "META-INF/proguard/androidx-annotations.pro", "libsignal_jni.dylib", "signal_jni.dll", "libsignal_jni_testing.dylib", "signal_jni_testing.dll")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,7 +151,7 @@ android {
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
versionCode = canonicalVersionCode * postFixSize
|
||||
versionCode = (canonicalVersionCode * maxHotfixVersions) + currentHotfixVersion
|
||||
versionName = canonicalVersionName
|
||||
|
||||
minSdk = signalMinSdkVersion
|
||||
@@ -208,6 +203,7 @@ android {
|
||||
buildConfigField("String", "SIGNAL_CAPTCHA_URL", "\"https://signalcaptchas.org/registration/generate.html\"")
|
||||
buildConfigField("String", "RECAPTCHA_PROOF_URL", "\"https://signalcaptchas.org/challenge/generate.html\"")
|
||||
buildConfigField("org.signal.libsignal.net.Network.Environment", "LIBSIGNAL_NET_ENV", "org.signal.libsignal.net.Network.Environment.PRODUCTION")
|
||||
buildConfigField("int", "LIBSIGNAL_LOG_LEVEL", "org.signal.libsignal.protocol.logging.SignalProtocolLogger.INFO")
|
||||
|
||||
buildConfigField("String", "BUILD_DISTRIBUTION_TYPE", "\"unset\"")
|
||||
buildConfigField("String", "BUILD_ENVIRONMENT_TYPE", "\"unset\"")
|
||||
@@ -386,6 +382,7 @@ android {
|
||||
buildConfigField("String", "SIGNAL_CAPTCHA_URL", "\"https://signalcaptchas.org/staging/registration/generate.html\"")
|
||||
buildConfigField("String", "RECAPTCHA_PROOF_URL", "\"https://signalcaptchas.org/staging/challenge/generate.html\"")
|
||||
buildConfigField("org.signal.libsignal.net.Network.Environment", "LIBSIGNAL_NET_ENV", "org.signal.libsignal.net.Network.Environment.STAGING")
|
||||
buildConfigField("int", "LIBSIGNAL_LOG_LEVEL", "org.signal.libsignal.protocol.logging.SignalProtocolLogger.DEBUG")
|
||||
|
||||
buildConfigField("String", "BUILD_ENVIRONMENT_TYPE", "\"Staging\"")
|
||||
buildConfigField("String", "STRIPE_PUBLISHABLE_KEY", "\"pk_test_sngOd8FnXNkpce9nPXawKrJD00kIDngZkD\"")
|
||||
@@ -405,7 +402,6 @@ android {
|
||||
.map { it as com.android.build.gradle.internal.api.ApkVariantOutputImpl }
|
||||
.forEach { output ->
|
||||
if (output.baseName.contains("nightly")) {
|
||||
output.versionCodeOverride = canonicalVersionCode * postFixSize + 5
|
||||
var tag = getCurrentGitTag()
|
||||
if (!tag.isNullOrEmpty()) {
|
||||
if (tag.startsWith("v")) {
|
||||
@@ -419,14 +415,9 @@ android {
|
||||
} else {
|
||||
output.outputFileName = output.outputFileName.replace(".apk", "-$versionName.apk")
|
||||
|
||||
val abiName: String = output.getFilter("ABI") ?: "universal"
|
||||
val postFix: Int = abiPostFix[abiName]!!
|
||||
|
||||
if (postFix >= postFixSize) {
|
||||
throw AssertionError("postFix is too large")
|
||||
if (currentHotfixVersion >= maxHotfixVersions) {
|
||||
throw AssertionError("Hotfix version is too large!")
|
||||
}
|
||||
|
||||
output.versionCodeOverride = canonicalVersionCode * postFixSize + postFix
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -435,6 +426,12 @@ android {
|
||||
beforeVariants { variant ->
|
||||
variant.enable = variant.name in selectableVariants
|
||||
}
|
||||
onVariants { variant ->
|
||||
// Include the test-only library on debug builds.
|
||||
if (variant.buildType != "debug") {
|
||||
variant.packaging.jniLibs.excludes.add("**/libsignal_jni_testing.so")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val releaseDir = "$projectDir/src/release/java"
|
||||
@@ -509,6 +506,7 @@ dependencies {
|
||||
implementation(libs.androidx.profileinstaller)
|
||||
implementation(libs.androidx.asynclayoutinflater)
|
||||
implementation(libs.androidx.asynclayoutinflater.appcompat)
|
||||
implementation(libs.androidx.emoji2)
|
||||
implementation(libs.firebase.messaging) {
|
||||
exclude(group = "com.google.firebase", module = "firebase-core")
|
||||
exclude(group = "com.google.firebase", module = "firebase-analytics")
|
||||
@@ -540,6 +538,7 @@ dependencies {
|
||||
}
|
||||
implementation(libs.stream)
|
||||
implementation(libs.lottie)
|
||||
implementation(libs.lottie.compose)
|
||||
implementation(libs.signal.android.database.sqlcipher)
|
||||
implementation(libs.androidx.sqlite)
|
||||
implementation(libs.google.ez.vcard) {
|
||||
|
||||
BIN
app/src/androidTest/assets/backupTests/account-data.binproto
Normal file
BIN
app/src/androidTest/assets/backupTests/account-data.binproto
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -10,6 +10,7 @@ import android.database.Cursor
|
||||
import androidx.core.content.contentValuesOf
|
||||
import net.zetetic.database.sqlcipher.SQLiteDatabase
|
||||
import org.junit.Before
|
||||
import org.junit.Ignore
|
||||
import org.junit.Test
|
||||
import org.signal.core.util.Hex
|
||||
import org.signal.core.util.SqlUtil
|
||||
@@ -47,6 +48,7 @@ import org.whispersystems.signalservice.api.push.ServiceId.ACI
|
||||
import org.whispersystems.signalservice.api.push.ServiceId.PNI
|
||||
import org.whispersystems.signalservice.api.subscriptions.SubscriberId
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.util.Currency
|
||||
import java.util.UUID
|
||||
import kotlin.random.Random
|
||||
|
||||
@@ -81,18 +83,20 @@ class BackupTest {
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
SignalStore.account().setE164(SELF_E164)
|
||||
SignalStore.account().setAci(SELF_ACI)
|
||||
SignalStore.account().setPni(SELF_PNI)
|
||||
SignalStore.account().generateAciIdentityKeyIfNecessary()
|
||||
SignalStore.account().generatePniIdentityKeyIfNecessary()
|
||||
SignalStore.account.setE164(SELF_E164)
|
||||
SignalStore.account.setAci(SELF_ACI)
|
||||
SignalStore.account.setPni(SELF_PNI)
|
||||
SignalStore.account.generateAciIdentityKeyIfNecessary()
|
||||
SignalStore.account.generatePniIdentityKeyIfNecessary()
|
||||
}
|
||||
|
||||
@Ignore("Will likely be removed soon")
|
||||
@Test
|
||||
fun emptyDatabase() {
|
||||
backupTest { }
|
||||
}
|
||||
|
||||
@Ignore("Will likely be removed soon")
|
||||
@Test
|
||||
fun noteToSelf() {
|
||||
backupTest {
|
||||
@@ -104,6 +108,7 @@ class BackupTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Ignore("Will likely be removed soon")
|
||||
@Test
|
||||
fun individualChat() {
|
||||
backupTest {
|
||||
@@ -120,6 +125,7 @@ class BackupTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Ignore("Will likely be removed soon")
|
||||
@Test
|
||||
fun individualRecipients() {
|
||||
backupTest {
|
||||
@@ -151,6 +157,7 @@ class BackupTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Ignore("Will likely be removed soon")
|
||||
@Test
|
||||
fun individualCallLogs() {
|
||||
backupTest {
|
||||
@@ -233,6 +240,7 @@ class BackupTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Ignore("Will likely be removed soon")
|
||||
@Test
|
||||
fun accountData() {
|
||||
val context = AppDependencies.application
|
||||
@@ -243,34 +251,34 @@ class BackupTest {
|
||||
// TODO note-to-self archived
|
||||
// TODO note-to-self unread
|
||||
|
||||
SignalStore.account().setAci(SELF_ACI)
|
||||
SignalStore.account().setPni(SELF_PNI)
|
||||
SignalStore.account().setE164(SELF_E164)
|
||||
SignalStore.account().generateAciIdentityKeyIfNecessary()
|
||||
SignalStore.account().generatePniIdentityKeyIfNecessary()
|
||||
SignalStore.account.setAci(SELF_ACI)
|
||||
SignalStore.account.setPni(SELF_PNI)
|
||||
SignalStore.account.setE164(SELF_E164)
|
||||
SignalStore.account.generateAciIdentityKeyIfNecessary()
|
||||
SignalStore.account.generatePniIdentityKeyIfNecessary()
|
||||
|
||||
SignalDatabase.recipients.setProfileKey(self.id, ProfileKey(Random.nextBytes(32)))
|
||||
SignalDatabase.recipients.setProfileName(self.id, ProfileName.fromParts("Peter", "Parker"))
|
||||
SignalDatabase.recipients.setProfileAvatar(self.id, "https://example.com/")
|
||||
|
||||
InAppPaymentsRepository.setSubscriber(InAppPaymentSubscriberRecord(SubscriberId.generate(), "USD", InAppPaymentSubscriberRecord.Type.DONATION, false, InAppPaymentData.PaymentMethodType.UNKNOWN))
|
||||
SignalStore.donationsValues().setDisplayBadgesOnProfile(false)
|
||||
InAppPaymentsRepository.setSubscriber(InAppPaymentSubscriberRecord(SubscriberId.generate(), Currency.getInstance("USD"), InAppPaymentSubscriberRecord.Type.DONATION, false, InAppPaymentData.PaymentMethodType.UNKNOWN))
|
||||
SignalStore.inAppPayments.setDisplayBadgesOnProfile(false)
|
||||
|
||||
SignalStore.phoneNumberPrivacy().phoneNumberDiscoverabilityMode = PhoneNumberPrivacyValues.PhoneNumberDiscoverabilityMode.NOT_DISCOVERABLE
|
||||
SignalStore.phoneNumberPrivacy().phoneNumberSharingMode = PhoneNumberPrivacyValues.PhoneNumberSharingMode.NOBODY
|
||||
SignalStore.phoneNumberPrivacy.phoneNumberDiscoverabilityMode = PhoneNumberPrivacyValues.PhoneNumberDiscoverabilityMode.NOT_DISCOVERABLE
|
||||
SignalStore.phoneNumberPrivacy.phoneNumberSharingMode = PhoneNumberPrivacyValues.PhoneNumberSharingMode.NOBODY
|
||||
|
||||
SignalStore.settings().isLinkPreviewsEnabled = false
|
||||
SignalStore.settings().isPreferSystemContactPhotos = true
|
||||
SignalStore.settings().universalExpireTimer = 42
|
||||
SignalStore.settings().setKeepMutedChatsArchived(true)
|
||||
SignalStore.settings.isLinkPreviewsEnabled = false
|
||||
SignalStore.settings.isPreferSystemContactPhotos = true
|
||||
SignalStore.settings.universalExpireTimer = 42
|
||||
SignalStore.settings.setKeepMutedChatsArchived(true)
|
||||
|
||||
SignalStore.storyValues().viewedReceiptsEnabled = false
|
||||
SignalStore.storyValues().userHasViewedOnboardingStory = true
|
||||
SignalStore.storyValues().isFeatureDisabled = false
|
||||
SignalStore.storyValues().userHasBeenNotifiedAboutStories = true
|
||||
SignalStore.storyValues().userHasSeenGroupStoryEducationSheet = true
|
||||
SignalStore.story.viewedReceiptsEnabled = false
|
||||
SignalStore.story.userHasViewedOnboardingStory = true
|
||||
SignalStore.story.isFeatureDisabled = false
|
||||
SignalStore.story.userHasBeenNotifiedAboutStories = true
|
||||
SignalStore.story.userHasSeenGroupStoryEducationSheet = true
|
||||
|
||||
SignalStore.emojiValues().reactions = listOf("a", "b", "c")
|
||||
SignalStore.emoji.reactions = listOf("a", "b", "c")
|
||||
|
||||
TextSecurePreferences.setTypingIndicatorsEnabled(context, false)
|
||||
TextSecurePreferences.setReadReceiptsEnabled(context, false)
|
||||
|
||||
@@ -5,7 +5,10 @@
|
||||
|
||||
package org.thoughtcrime.securesms.backup.v2
|
||||
|
||||
import android.Manifest
|
||||
import android.app.UiAutomation
|
||||
import android.content.Context
|
||||
import android.os.Environment
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import org.junit.Assert
|
||||
@@ -26,12 +29,17 @@ import org.thoughtcrime.securesms.backup.v2.proto.Chat
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.ChatItem
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.ChatUpdateMessage
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.Contact
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.ContactAttachment
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.ContactMessage
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.DistributionList
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.DistributionListItem
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.ExpirationTimerChatUpdate
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.FilePointer
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.Frame
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.GiftBadge
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.Group
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.IndividualCall
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.LinkPreview
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.MessageAttachment
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.ProfileChangeChatUpdate
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.Quote
|
||||
@@ -46,9 +54,12 @@ import org.thoughtcrime.securesms.backup.v2.proto.StandardMessage
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.StickerPack
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.Text
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.ThreadMergeChatUpdate
|
||||
import org.thoughtcrime.securesms.backup.v2.stream.BackupExportWriter
|
||||
import org.thoughtcrime.securesms.backup.v2.stream.EncryptedBackupReader
|
||||
import org.thoughtcrime.securesms.backup.v2.stream.EncryptedBackupWriter
|
||||
import org.thoughtcrime.securesms.backup.v2.stream.PlainTextBackupWriter
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.util.Util
|
||||
import org.whispersystems.signalservice.api.kbs.MasterKey
|
||||
import org.whispersystems.signalservice.api.push.DistributionId
|
||||
import org.whispersystems.signalservice.api.push.ServiceId
|
||||
@@ -69,6 +80,14 @@ import kotlin.time.Duration.Companion.days
|
||||
*/
|
||||
class ImportExportTest {
|
||||
companion object {
|
||||
/**
|
||||
* Output the frames as a plaintext .binproto for sharing tests
|
||||
*
|
||||
* This only seems to work on API 28 emulators, You can find the generated files
|
||||
* at /sdcard/backup-tests/
|
||||
* */
|
||||
val OUTPUT_FILES = false
|
||||
|
||||
val SELF_ACI = ServiceId.ACI.from(UUID.fromString("77770000-b477-4f35-a824-d92987a63641"))
|
||||
val SELF_PNI = ServiceId.PNI.from(UUID.fromString("77771111-b014-41fb-bf73-05cb2ec52910"))
|
||||
const val SELF_E164 = "+10000000000"
|
||||
@@ -77,7 +96,17 @@ class ImportExportTest {
|
||||
|
||||
val defaultBackupInfo = BackupInfo(version = 1L, backupTimeMs = 123456L)
|
||||
val selfRecipient = Recipient(id = 1, self = Self())
|
||||
val releaseNotes = Recipient(id = 2, releaseNotes = ReleaseNotes())
|
||||
val myStory = Recipient(
|
||||
id = 2,
|
||||
distributionList = DistributionListItem(
|
||||
distributionId = DistributionId.MY_STORY.asUuid().toByteArray().toByteString(),
|
||||
distributionList = DistributionList(
|
||||
name = DistributionId.MY_STORY.toString(),
|
||||
privacyMode = DistributionList.PrivacyMode.ALL
|
||||
)
|
||||
)
|
||||
)
|
||||
val releaseNotes = Recipient(id = 3, releaseNotes = ReleaseNotes())
|
||||
val standardAccountData = AccountData(
|
||||
profileKey = SELF_PROFILE_KEY.serialize().toByteString(),
|
||||
username = "self.01",
|
||||
@@ -85,9 +114,11 @@ class ImportExportTest {
|
||||
givenName = "Peter",
|
||||
familyName = "Parker",
|
||||
avatarUrlPath = "https://example.com/",
|
||||
subscriberId = SubscriberId.generate().bytes.toByteString(),
|
||||
subscriberCurrencyCode = "USD",
|
||||
subscriptionManuallyCancelled = true,
|
||||
donationSubscriberData = AccountData.SubscriberData(
|
||||
subscriberId = SubscriberId.generate().bytes.toByteString(),
|
||||
currencyCode = "USD",
|
||||
manuallyCancelled = true
|
||||
),
|
||||
accountSettings = AccountData.AccountSettings(
|
||||
readReceipts = true,
|
||||
sealedSenderIndicators = true,
|
||||
@@ -109,16 +140,15 @@ class ImportExportTest {
|
||||
)
|
||||
)
|
||||
val alice = Recipient(
|
||||
id = 3,
|
||||
id = 4,
|
||||
contact = Contact(
|
||||
aci = TestRecipientUtils.nextAci().toByteString(),
|
||||
pni = TestRecipientUtils.nextPni().toByteString(),
|
||||
username = "cool.01",
|
||||
e164 = 141255501234,
|
||||
blocked = false,
|
||||
hidden = false,
|
||||
registered = Contact.Registered.REGISTERED,
|
||||
unregisteredTimestamp = 0L,
|
||||
visibility = Contact.Visibility.VISIBLE,
|
||||
registered = Contact.Registered(),
|
||||
profileKey = TestRecipientUtils.generateProfileKey().toByteString(),
|
||||
profileSharing = true,
|
||||
profileGivenName = "Alexa",
|
||||
@@ -128,9 +158,9 @@ class ImportExportTest {
|
||||
)
|
||||
|
||||
/**
|
||||
* When using standardFrames you must start recipient ids at 3.
|
||||
* When using standardFrames you must start recipient ids at 4.
|
||||
*/
|
||||
private val standardFrames = arrayOf(defaultBackupInfo, standardAccountData, selfRecipient, releaseNotes)
|
||||
private val standardFrames = arrayOf(defaultBackupInfo, standardAccountData, selfRecipient, myStory, releaseNotes)
|
||||
}
|
||||
|
||||
private val context: Context
|
||||
@@ -142,12 +172,12 @@ class ImportExportTest {
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
SignalStore.svr().setMasterKey(MasterKey(MASTER_KEY), "1234")
|
||||
SignalStore.account().setE164(SELF_E164)
|
||||
SignalStore.account().setAci(SELF_ACI)
|
||||
SignalStore.account().setPni(SELF_PNI)
|
||||
SignalStore.account().generateAciIdentityKeyIfNecessary()
|
||||
SignalStore.account().generatePniIdentityKeyIfNecessary()
|
||||
SignalStore.svr.setMasterKey(MasterKey(MASTER_KEY), "1234")
|
||||
SignalStore.account.setE164(SELF_E164)
|
||||
SignalStore.account.setAci(SELF_ACI)
|
||||
SignalStore.account.setPni(SELF_PNI)
|
||||
SignalStore.account.generateAciIdentityKeyIfNecessary()
|
||||
SignalStore.account.generatePniIdentityKeyIfNecessary()
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -159,7 +189,7 @@ class ImportExportTest {
|
||||
fun largeNumberOfRecipientsAndChats() {
|
||||
val recipients = ArrayList<Recipient>(5000)
|
||||
val chats = ArrayList<Chat>(5000)
|
||||
var id = 3L
|
||||
var id = 4L
|
||||
for (i in 0..5000) {
|
||||
val recipientId = id++
|
||||
recipients.add(
|
||||
@@ -171,9 +201,8 @@ class ImportExportTest {
|
||||
username = "rec$i.01",
|
||||
e164 = 14125550000 + i,
|
||||
blocked = false,
|
||||
hidden = false,
|
||||
registered = Contact.Registered.REGISTERED,
|
||||
unregisteredTimestamp = 0L,
|
||||
visibility = Contact.Visibility.VISIBLE,
|
||||
registered = Contact.Registered(),
|
||||
profileKey = TestRecipientUtils.generateProfileKey().toByteString(),
|
||||
profileSharing = true,
|
||||
profileGivenName = "Test",
|
||||
@@ -237,9 +266,8 @@ class ImportExportTest {
|
||||
username = if (random.trueWithProbability(0.2f)) "rec$i.01" else null,
|
||||
e164 = 14125550000 + i,
|
||||
blocked = random.trueWithProbability(0.1f),
|
||||
hidden = random.trueWithProbability(0.1f),
|
||||
registered = Contact.Registered.REGISTERED,
|
||||
unregisteredTimestamp = 0L,
|
||||
visibility = if (random.trueWithProbability(0.1f)) Contact.Visibility.HIDDEN else Contact.Visibility.VISIBLE,
|
||||
registered = Contact.Registered(),
|
||||
profileKey = TestRecipientUtils.generateProfileKey().toByteString(),
|
||||
profileSharing = random.trueWithProbability(0.9f),
|
||||
profileGivenName = "Test",
|
||||
@@ -383,16 +411,15 @@ class ImportExportTest {
|
||||
importExport(
|
||||
*standardFrames,
|
||||
Recipient(
|
||||
id = 3,
|
||||
id = 4,
|
||||
contact = Contact(
|
||||
aci = TestRecipientUtils.nextAci().toByteString(),
|
||||
pni = TestRecipientUtils.nextPni().toByteString(),
|
||||
username = "cool.01",
|
||||
e164 = 141255501234,
|
||||
blocked = true,
|
||||
hidden = true,
|
||||
registered = Contact.Registered.REGISTERED,
|
||||
unregisteredTimestamp = 0L,
|
||||
visibility = Contact.Visibility.VISIBLE,
|
||||
registered = Contact.Registered(),
|
||||
profileKey = TestRecipientUtils.generateProfileKey().toByteString(),
|
||||
profileSharing = true,
|
||||
profileGivenName = "Alexa",
|
||||
@@ -401,16 +428,15 @@ class ImportExportTest {
|
||||
)
|
||||
),
|
||||
Recipient(
|
||||
id = 4,
|
||||
id = 5,
|
||||
contact = Contact(
|
||||
aci = null,
|
||||
pni = null,
|
||||
username = null,
|
||||
e164 = 141255501235,
|
||||
blocked = true,
|
||||
hidden = true,
|
||||
registered = Contact.Registered.NOT_REGISTERED,
|
||||
unregisteredTimestamp = 1234568927398L,
|
||||
visibility = Contact.Visibility.HIDDEN,
|
||||
notRegistered = Contact.NotRegistered(unregisteredTimestamp = 1234568927398L),
|
||||
profileKey = TestRecipientUtils.generateProfileKey().toByteString(),
|
||||
profileSharing = false,
|
||||
profileGivenName = "Peter",
|
||||
@@ -426,7 +452,7 @@ class ImportExportTest {
|
||||
importExport(
|
||||
*standardFrames,
|
||||
Recipient(
|
||||
id = 3,
|
||||
id = 4,
|
||||
group = Group(
|
||||
masterKey = TestRecipientUtils.generateGroupMasterKey().toByteString(),
|
||||
whitelisted = true,
|
||||
@@ -441,7 +467,7 @@ class ImportExportTest {
|
||||
)
|
||||
),
|
||||
Recipient(
|
||||
id = 4,
|
||||
id = 5,
|
||||
group = Group(
|
||||
masterKey = TestRecipientUtils.generateGroupMasterKey().toByteString(),
|
||||
whitelisted = false,
|
||||
@@ -463,16 +489,15 @@ class ImportExportTest {
|
||||
importExport(
|
||||
*standardFrames,
|
||||
Recipient(
|
||||
id = 3,
|
||||
id = 4,
|
||||
contact = Contact(
|
||||
aci = TestRecipientUtils.nextAci().toByteString(),
|
||||
pni = TestRecipientUtils.nextPni().toByteString(),
|
||||
username = "cool.01",
|
||||
e164 = 141255501234,
|
||||
blocked = true,
|
||||
hidden = true,
|
||||
registered = Contact.Registered.REGISTERED,
|
||||
unregisteredTimestamp = 0L,
|
||||
visibility = Contact.Visibility.HIDDEN,
|
||||
registered = Contact.Registered(),
|
||||
profileKey = TestRecipientUtils.generateProfileKey().toByteString(),
|
||||
profileSharing = true,
|
||||
profileGivenName = "Alexa",
|
||||
@@ -480,35 +505,33 @@ class ImportExportTest {
|
||||
hideStory = true
|
||||
)
|
||||
),
|
||||
Recipient(
|
||||
id = 4,
|
||||
contact = Contact(
|
||||
aci = null,
|
||||
pni = null,
|
||||
username = null,
|
||||
e164 = 141255501235,
|
||||
blocked = true,
|
||||
hidden = true,
|
||||
registered = Contact.Registered.REGISTERED,
|
||||
unregisteredTimestamp = 0L,
|
||||
profileKey = TestRecipientUtils.generateProfileKey().toByteString(),
|
||||
profileSharing = true,
|
||||
profileGivenName = "Peter",
|
||||
profileFamilyName = "Kim",
|
||||
hideStory = true
|
||||
)
|
||||
),
|
||||
Recipient(
|
||||
id = 5,
|
||||
contact = Contact(
|
||||
aci = null,
|
||||
pni = null,
|
||||
username = null,
|
||||
e164 = 141255501235,
|
||||
blocked = true,
|
||||
visibility = Contact.Visibility.HIDDEN,
|
||||
registered = Contact.Registered(),
|
||||
profileKey = TestRecipientUtils.generateProfileKey().toByteString(),
|
||||
profileSharing = true,
|
||||
profileGivenName = "Peter",
|
||||
profileFamilyName = "Kim",
|
||||
hideStory = true
|
||||
)
|
||||
),
|
||||
Recipient(
|
||||
id = 6,
|
||||
contact = Contact(
|
||||
aci = null,
|
||||
pni = null,
|
||||
username = null,
|
||||
e164 = 141255501236,
|
||||
blocked = true,
|
||||
hidden = true,
|
||||
registered = Contact.Registered.REGISTERED,
|
||||
unregisteredTimestamp = 0L,
|
||||
visibility = Contact.Visibility.HIDDEN,
|
||||
registered = Contact.Registered(),
|
||||
profileKey = TestRecipientUtils.generateProfileKey().toByteString(),
|
||||
profileSharing = true,
|
||||
profileGivenName = "Father",
|
||||
@@ -517,14 +540,15 @@ class ImportExportTest {
|
||||
)
|
||||
),
|
||||
Recipient(
|
||||
id = 6,
|
||||
distributionList = DistributionList(
|
||||
name = "Kim Family",
|
||||
id = 7,
|
||||
distributionList = DistributionListItem(
|
||||
distributionId = DistributionId.create().asUuid().toByteArray().toByteString(),
|
||||
allowReplies = true,
|
||||
deletionTimestamp = 0L,
|
||||
privacyMode = DistributionList.PrivacyMode.ONLY_WITH,
|
||||
memberRecipientIds = listOf(3, 4, 5)
|
||||
distributionList = DistributionList(
|
||||
name = "Kim Family",
|
||||
allowReplies = true,
|
||||
privacyMode = DistributionList.PrivacyMode.ONLY_WITH,
|
||||
memberRecipientIds = listOf(3, 4, 5)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
@@ -533,16 +557,15 @@ class ImportExportTest {
|
||||
@Test
|
||||
fun deletedDistributionList() {
|
||||
val alexa = Recipient(
|
||||
id = 3,
|
||||
id = 4,
|
||||
contact = Contact(
|
||||
aci = TestRecipientUtils.nextAci().toByteString(),
|
||||
pni = TestRecipientUtils.nextPni().toByteString(),
|
||||
username = "cool.01",
|
||||
e164 = 141255501234,
|
||||
blocked = true,
|
||||
hidden = true,
|
||||
registered = Contact.Registered.REGISTERED,
|
||||
unregisteredTimestamp = 0L,
|
||||
visibility = Contact.Visibility.HIDDEN,
|
||||
registered = Contact.Registered(),
|
||||
profileKey = TestRecipientUtils.generateProfileKey().toByteString(),
|
||||
profileSharing = true,
|
||||
profileGivenName = "Alexa",
|
||||
@@ -555,13 +578,9 @@ class ImportExportTest {
|
||||
alexa,
|
||||
Recipient(
|
||||
id = 6,
|
||||
distributionList = DistributionList(
|
||||
name = "Deleted list",
|
||||
distributionList = DistributionListItem(
|
||||
distributionId = DistributionId.create().asUuid().toByteArray().toByteString(),
|
||||
allowReplies = true,
|
||||
deletionTimestamp = 12345L,
|
||||
privacyMode = DistributionList.PrivacyMode.ONLY_WITH,
|
||||
memberRecipientIds = listOf(3)
|
||||
deletionTimestamp = 12345L
|
||||
)
|
||||
)
|
||||
)
|
||||
@@ -580,16 +599,15 @@ class ImportExportTest {
|
||||
importExport(
|
||||
*standardFrames,
|
||||
Recipient(
|
||||
id = 3,
|
||||
id = 4,
|
||||
contact = Contact(
|
||||
aci = TestRecipientUtils.nextAci().toByteString(),
|
||||
pni = TestRecipientUtils.nextPni().toByteString(),
|
||||
username = "cool.01",
|
||||
e164 = 141255501234,
|
||||
blocked = false,
|
||||
hidden = false,
|
||||
registered = Contact.Registered.REGISTERED,
|
||||
unregisteredTimestamp = 0L,
|
||||
visibility = Contact.Visibility.VISIBLE,
|
||||
registered = Contact.Registered(),
|
||||
profileKey = TestRecipientUtils.generateProfileKey().toByteString(),
|
||||
profileSharing = true,
|
||||
profileGivenName = "Alexa",
|
||||
@@ -598,7 +616,7 @@ class ImportExportTest {
|
||||
)
|
||||
),
|
||||
Recipient(
|
||||
id = 4,
|
||||
id = 5,
|
||||
group = Group(
|
||||
masterKey = TestRecipientUtils.generateGroupMasterKey().toByteString(),
|
||||
whitelisted = true,
|
||||
@@ -608,14 +626,13 @@ class ImportExportTest {
|
||||
),
|
||||
Chat(
|
||||
id = 1,
|
||||
recipientId = 3,
|
||||
recipientId = 4,
|
||||
archived = true,
|
||||
pinnedOrder = 1,
|
||||
expirationTimerMs = 1.days.inWholeMilliseconds,
|
||||
muteUntilMs = System.currentTimeMillis(),
|
||||
markedUnread = true,
|
||||
dontNotifyForMentionsIfMuted = true,
|
||||
wallpaper = null
|
||||
dontNotifyForMentionsIfMuted = true
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -682,16 +699,15 @@ class ImportExportTest {
|
||||
importExport(
|
||||
*standardFrames,
|
||||
Recipient(
|
||||
id = 3,
|
||||
id = 4,
|
||||
contact = Contact(
|
||||
aci = startedAci,
|
||||
pni = TestRecipientUtils.nextPni().toByteString(),
|
||||
username = "cool.01",
|
||||
e164 = 141255501234,
|
||||
blocked = false,
|
||||
hidden = false,
|
||||
registered = Contact.Registered.REGISTERED,
|
||||
unregisteredTimestamp = 0L,
|
||||
visibility = Contact.Visibility.VISIBLE,
|
||||
registered = Contact.Registered(),
|
||||
profileKey = TestRecipientUtils.generateProfileKey().toByteString(),
|
||||
profileSharing = true,
|
||||
profileGivenName = "Alexa",
|
||||
@@ -700,7 +716,7 @@ class ImportExportTest {
|
||||
)
|
||||
),
|
||||
Recipient(
|
||||
id = 4,
|
||||
id = 5,
|
||||
group = Group(
|
||||
masterKey = TestRecipientUtils.generateGroupMasterKey().toByteString(),
|
||||
whitelisted = true,
|
||||
@@ -710,14 +726,13 @@ class ImportExportTest {
|
||||
),
|
||||
Chat(
|
||||
id = 1,
|
||||
recipientId = 3,
|
||||
recipientId = 4,
|
||||
archived = true,
|
||||
pinnedOrder = 1,
|
||||
expirationTimerMs = 1.days.inWholeMilliseconds,
|
||||
muteUntilMs = System.currentTimeMillis(),
|
||||
markedUnread = true,
|
||||
dontNotifyForMentionsIfMuted = true,
|
||||
wallpaper = null
|
||||
dontNotifyForMentionsIfMuted = true
|
||||
),
|
||||
*individualCalls.toArray()
|
||||
)
|
||||
@@ -1051,23 +1066,175 @@ class ImportExportTest {
|
||||
incrementalMacChunkSize = 0
|
||||
),
|
||||
wasDownloaded = false
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun linkPreviewMessages() {
|
||||
var dateSent = System.currentTimeMillis()
|
||||
val sendStatuses = enumerateSendStatuses(alice.id)
|
||||
val incomingMessageDetails = enumerateIncomingMessageDetails(dateSent + 200)
|
||||
val outgoingMessages = ArrayList<ChatItem>()
|
||||
val incomingMessages = ArrayList<ChatItem>()
|
||||
for (sendStatus in sendStatuses) {
|
||||
outgoingMessages.add(
|
||||
ChatItem(
|
||||
chatId = 1,
|
||||
authorId = selfRecipient.id,
|
||||
dateSent = dateSent++,
|
||||
expireStartDate = dateSent + 1000,
|
||||
expiresInMs = TimeUnit.DAYS.toMillis(2),
|
||||
sms = false,
|
||||
outgoing = ChatItem.OutgoingMessageDetails(
|
||||
sendStatus = listOf(sendStatus)
|
||||
),
|
||||
standardMessage = StandardMessage(
|
||||
text = Text(
|
||||
body = "Text only body"
|
||||
),
|
||||
MessageAttachment(
|
||||
pointer = FilePointer(
|
||||
backupLocator = FilePointer.BackupLocator(
|
||||
"digestherebutimlazy",
|
||||
cdnNumber = 3,
|
||||
linkPreview = listOf(
|
||||
LinkPreview(
|
||||
url = "https://signal.org/",
|
||||
title = "Signal Messenger: Speak Freely",
|
||||
description = "Say \"hello\" to a different messaging experience. An unexpected focus on privacy, combined with all the features you expect.",
|
||||
date = System.currentTimeMillis(),
|
||||
image = FilePointer(
|
||||
invalidAttachmentLocator = FilePointer.InvalidAttachmentLocator(),
|
||||
contentType = "image/png",
|
||||
width = 100,
|
||||
height = 200,
|
||||
caption = "Love this cool picture! Too bad u cant download it",
|
||||
incrementalMacChunkSize = 0
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
dateSent++
|
||||
for (incomingDetail in incomingMessageDetails) {
|
||||
incomingMessages.add(
|
||||
ChatItem(
|
||||
chatId = 1,
|
||||
authorId = alice.id,
|
||||
dateSent = dateSent++,
|
||||
expireStartDate = dateSent + 1000,
|
||||
expiresInMs = TimeUnit.DAYS.toMillis(2),
|
||||
sms = false,
|
||||
incoming = incomingDetail,
|
||||
standardMessage = StandardMessage(
|
||||
text = Text(
|
||||
body = "Text only body"
|
||||
),
|
||||
linkPreview = listOf(
|
||||
LinkPreview(
|
||||
url = "https://signal.org/",
|
||||
title = "Signal Messenger: Speak Freely",
|
||||
description = "Say \"hello\" to a different messaging experience. An unexpected focus on privacy, combined with all the features you expect.",
|
||||
date = System.currentTimeMillis(),
|
||||
image = FilePointer(
|
||||
invalidAttachmentLocator = FilePointer.InvalidAttachmentLocator(),
|
||||
contentType = "image/png",
|
||||
width = 100,
|
||||
height = 200,
|
||||
caption = "Love this cool picture! Too bad u cant download it",
|
||||
incrementalMacChunkSize = 0
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
importExport(
|
||||
*standardFrames,
|
||||
alice,
|
||||
buildChat(alice, 1),
|
||||
*outgoingMessages.toArray(),
|
||||
*incomingMessages.toArray()
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun contactMessageWithAllFields() {
|
||||
importExport(
|
||||
*standardFrames,
|
||||
alice,
|
||||
buildChat(alice, 1),
|
||||
ChatItem(
|
||||
chatId = 1,
|
||||
authorId = selfRecipient.id,
|
||||
dateSent = 150L,
|
||||
sms = false,
|
||||
outgoing = ChatItem.OutgoingMessageDetails(
|
||||
sendStatus = listOf(SendStatus(alice.id, deliveryStatus = SendStatus.Status.READ, lastStatusUpdateTimestamp = -1))
|
||||
),
|
||||
contactMessage = ContactMessage(
|
||||
contact = listOf(
|
||||
ContactAttachment(
|
||||
name = ContactAttachment.Name(
|
||||
givenName = "Given",
|
||||
familyName = "Family",
|
||||
prefix = "Prefix",
|
||||
suffix = "Suffix",
|
||||
middleName = "Middle",
|
||||
displayName = "Display Name"
|
||||
),
|
||||
organization = "Organization",
|
||||
email = listOf(
|
||||
ContactAttachment.Email(
|
||||
value_ = "coolemail@gmail.com",
|
||||
label = "Label",
|
||||
type = ContactAttachment.Email.Type.HOME
|
||||
),
|
||||
ContactAttachment.Email(
|
||||
value_ = "coolemail2@gmail.com",
|
||||
label = "Label2",
|
||||
type = ContactAttachment.Email.Type.MOBILE
|
||||
)
|
||||
),
|
||||
address = listOf(
|
||||
ContactAttachment.PostalAddress(
|
||||
type = ContactAttachment.PostalAddress.Type.HOME,
|
||||
label = "Label",
|
||||
street = "Street",
|
||||
pobox = "POBOX",
|
||||
neighborhood = "Neighborhood",
|
||||
city = "City",
|
||||
region = "Region",
|
||||
postcode = "15213",
|
||||
country = "United States"
|
||||
)
|
||||
),
|
||||
number = listOf(
|
||||
ContactAttachment.Phone(
|
||||
value_ = "+14155551234",
|
||||
type = ContactAttachment.Phone.Type.CUSTOM,
|
||||
label = "Label"
|
||||
)
|
||||
),
|
||||
avatar = FilePointer(
|
||||
attachmentLocator = FilePointer.AttachmentLocator(
|
||||
cdnKey = "coolCdnKey",
|
||||
cdnNumber = 2,
|
||||
uploadTimestamp = System.currentTimeMillis(),
|
||||
key = (1..32).map { it.toByte() }.toByteArray().toByteString(),
|
||||
digest = (1..64).map { it.toByte() }.toByteArray().toByteString(),
|
||||
size = 12345
|
||||
size = 12345,
|
||||
digest = (1..32).map { it.toByte() }.toByteArray().toByteString()
|
||||
),
|
||||
contentType = "image/png",
|
||||
fileName = "very_cool_picture.png",
|
||||
width = 100,
|
||||
height = 200,
|
||||
caption = "Love this cool picture! Too bad u cant download it",
|
||||
caption = "Love this cool picture!",
|
||||
incrementalMacChunkSize = 0
|
||||
),
|
||||
wasDownloaded = true
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
@@ -1256,6 +1423,76 @@ class ImportExportTest {
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun giftBadgeMessage() {
|
||||
var dateSentStart = 100L
|
||||
importExport(
|
||||
*standardFrames,
|
||||
alice,
|
||||
buildChat(alice, 1),
|
||||
ChatItem(
|
||||
chatId = 1,
|
||||
authorId = alice.id,
|
||||
dateSent = dateSentStart++,
|
||||
incoming = ChatItem.IncomingMessageDetails(
|
||||
dateReceived = dateSentStart,
|
||||
dateServerSent = dateSentStart,
|
||||
read = true,
|
||||
sealedSender = true
|
||||
),
|
||||
giftBadge = GiftBadge(
|
||||
receiptCredentialPresentation = Util.getSecretBytes(32).toByteString(),
|
||||
state = GiftBadge.State.OPENED
|
||||
)
|
||||
),
|
||||
ChatItem(
|
||||
chatId = 1,
|
||||
authorId = alice.id,
|
||||
dateSent = dateSentStart++,
|
||||
incoming = ChatItem.IncomingMessageDetails(
|
||||
dateReceived = dateSentStart,
|
||||
dateServerSent = dateSentStart,
|
||||
read = true,
|
||||
sealedSender = true
|
||||
),
|
||||
giftBadge = GiftBadge(
|
||||
receiptCredentialPresentation = Util.getSecretBytes(32).toByteString(),
|
||||
state = GiftBadge.State.FAILED
|
||||
)
|
||||
),
|
||||
ChatItem(
|
||||
chatId = 1,
|
||||
authorId = alice.id,
|
||||
dateSent = dateSentStart++,
|
||||
incoming = ChatItem.IncomingMessageDetails(
|
||||
dateReceived = dateSentStart,
|
||||
dateServerSent = dateSentStart,
|
||||
read = true,
|
||||
sealedSender = true
|
||||
),
|
||||
giftBadge = GiftBadge(
|
||||
receiptCredentialPresentation = Util.getSecretBytes(32).toByteString(),
|
||||
state = GiftBadge.State.REDEEMED
|
||||
)
|
||||
),
|
||||
ChatItem(
|
||||
chatId = 1,
|
||||
authorId = alice.id,
|
||||
dateSent = dateSentStart++,
|
||||
incoming = ChatItem.IncomingMessageDetails(
|
||||
dateReceived = dateSentStart,
|
||||
dateServerSent = dateSentStart,
|
||||
read = true,
|
||||
sealedSender = true
|
||||
),
|
||||
giftBadge = GiftBadge(
|
||||
receiptCredentialPresentation = Util.getSecretBytes(32).toByteString(),
|
||||
state = GiftBadge.State.UNOPENED
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun enumerateIncomingMessageDetails(dateSent: Long): List<ChatItem.IncomingMessageDetails> {
|
||||
val details = mutableListOf<ChatItem.IncomingMessageDetails>()
|
||||
details.add(
|
||||
@@ -1361,8 +1598,7 @@ class ImportExportTest {
|
||||
expirationTimerMs = 0,
|
||||
muteUntilMs = 0,
|
||||
markedUnread = false,
|
||||
dontNotifyForMentionsIfMuted = false,
|
||||
wallpaper = null
|
||||
dontNotifyForMentionsIfMuted = false
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1371,27 +1607,17 @@ class ImportExportTest {
|
||||
* any standard frames (e.g. backup header).
|
||||
*/
|
||||
private fun exportFrames(vararg objects: Any): ByteArray {
|
||||
outputBinProto(*objects)
|
||||
val outputStream = ByteArrayOutputStream()
|
||||
val writer = EncryptedBackupWriter(
|
||||
key = SignalStore.svr().getOrCreateMasterKey().deriveBackupKey(),
|
||||
aci = SignalStore.account().aci!!,
|
||||
key = SignalStore.svr.getOrCreateMasterKey().deriveBackupKey(),
|
||||
aci = SignalStore.account.aci!!,
|
||||
outputStream = outputStream,
|
||||
append = { mac -> outputStream.write(mac) }
|
||||
)
|
||||
|
||||
writer.use {
|
||||
for (obj in objects) {
|
||||
when (obj) {
|
||||
is BackupInfo -> writer.write(obj)
|
||||
is AccountData -> writer.write(Frame(account = obj))
|
||||
is Recipient -> writer.write(Frame(recipient = obj))
|
||||
is Chat -> writer.write(Frame(chat = obj))
|
||||
is ChatItem -> writer.write(Frame(chatItem = obj))
|
||||
is AdHocCall -> writer.write(Frame(adHocCall = obj))
|
||||
is StickerPack -> writer.write(Frame(stickerPack = obj))
|
||||
else -> Assert.fail("invalid object $obj")
|
||||
}
|
||||
}
|
||||
writer.writeFrames(*objects)
|
||||
}
|
||||
return outputStream.toByteArray()
|
||||
}
|
||||
@@ -1402,7 +1628,7 @@ class ImportExportTest {
|
||||
|
||||
private fun validate(importData: ByteArray): MessageBackup.ValidationResult {
|
||||
val factory = { ByteArrayInputStream(importData) }
|
||||
val masterKey = SignalStore.svr().getOrCreateMasterKey()
|
||||
val masterKey = SignalStore.svr.getOrCreateMasterKey()
|
||||
val key = MessageBackupKey(masterKey.serialize(), org.signal.libsignal.protocol.ServiceId.Aci.parseFromBinary(SELF_ACI.toByteArray()))
|
||||
|
||||
return MessageBackup.validate(key, MessageBackup.Purpose.REMOTE_BACKUP, factory, importData.size.toLong())
|
||||
@@ -1417,37 +1643,51 @@ class ImportExportTest {
|
||||
* 4. Assert that (A) and (B) are identical. Or, in other words, assert that importing and exporting again results in the original backup data.
|
||||
*/
|
||||
private fun importExport(vararg objects: Any) {
|
||||
val outputStream = ByteArrayOutputStream()
|
||||
val writer = EncryptedBackupWriter(
|
||||
key = SignalStore.svr().getOrCreateMasterKey().deriveBackupKey(),
|
||||
aci = SignalStore.account().aci!!,
|
||||
outputStream = outputStream,
|
||||
append = { mac -> outputStream.write(mac) }
|
||||
)
|
||||
val originalBackupData = exportFrames(*objects)
|
||||
|
||||
writer.use {
|
||||
for (obj in objects) {
|
||||
when (obj) {
|
||||
is BackupInfo -> writer.write(obj)
|
||||
is AccountData -> writer.write(Frame(account = obj))
|
||||
is Recipient -> writer.write(Frame(recipient = obj))
|
||||
is Chat -> writer.write(Frame(chat = obj))
|
||||
is ChatItem -> writer.write(Frame(chatItem = obj))
|
||||
is AdHocCall -> writer.write(Frame(adHocCall = obj))
|
||||
is StickerPack -> writer.write(Frame(stickerPack = obj))
|
||||
else -> Assert.fail("invalid object $obj")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val originalBackupData = outputStream.toByteArray()
|
||||
|
||||
BackupRepository.import(length = originalBackupData.size.toLong(), inputStreamFactory = { ByteArrayInputStream(originalBackupData) }, selfData = BackupRepository.SelfData(SELF_ACI, SELF_PNI, SELF_E164, SELF_PROFILE_KEY))
|
||||
import(originalBackupData)
|
||||
|
||||
val generatedBackupData = BackupRepository.export()
|
||||
compare(originalBackupData, generatedBackupData)
|
||||
}
|
||||
|
||||
private fun BackupExportWriter.writeFrames(vararg objects: Any) {
|
||||
for (obj in objects) {
|
||||
when (obj) {
|
||||
is BackupInfo -> write(obj)
|
||||
is AccountData -> write(Frame(account = obj))
|
||||
is Recipient -> write(Frame(recipient = obj))
|
||||
is Chat -> write(Frame(chat = obj))
|
||||
is ChatItem -> write(Frame(chatItem = obj))
|
||||
is AdHocCall -> write(Frame(adHocCall = obj))
|
||||
is StickerPack -> write(Frame(stickerPack = obj))
|
||||
else -> Assert.fail("invalid object $obj")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun outputBinProto(vararg objects: Any) {
|
||||
if (!OUTPUT_FILES) return
|
||||
|
||||
val outputStream = ByteArrayOutputStream()
|
||||
val plaintextWriter = PlainTextBackupWriter(
|
||||
outputStream = outputStream
|
||||
)
|
||||
|
||||
plaintextWriter.use {
|
||||
it.writeFrames(*objects)
|
||||
}
|
||||
|
||||
grantPermissions(Manifest.permission.WRITE_EXTERNAL_STORAGE, Manifest.permission.READ_EXTERNAL_STORAGE)
|
||||
val dir = File(Environment.getExternalStorageDirectory(), "backup-tests")
|
||||
if (dir.mkdirs() || dir.exists()) {
|
||||
FileOutputStream(File(dir, testName.methodName + ".binproto")).use {
|
||||
it.write(outputStream.toByteArray())
|
||||
it.flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun compare(import: ByteArray, export: ByteArray) {
|
||||
val selfData = BackupRepository.SelfData(SELF_ACI, SELF_PNI, SELF_E164, SELF_PROFILE_KEY)
|
||||
val framesImported = readAllFrames(import, selfData)
|
||||
@@ -1484,7 +1724,14 @@ class ImportExportTest {
|
||||
for (f in framesExported) {
|
||||
when {
|
||||
f.account != null -> accountImported.add(f.account!!)
|
||||
f.recipient != null -> recipientsExported.add(f.recipient!!)
|
||||
f.recipient != null -> {
|
||||
val frameRecipient = f.recipient!!
|
||||
if (frameRecipient.distributionList != null && frameRecipient.distributionList!!.distributionId == DistributionId.MY_STORY.asUuid().toByteArray().toByteString()) {
|
||||
recipientsExported.add(frameRecipient.copy(distributionList = frameRecipient.distributionList!!.copyWithoutMembers()))
|
||||
} else {
|
||||
recipientsExported.add(f.recipient!!)
|
||||
}
|
||||
}
|
||||
f.chat != null -> chatsExported.add(f.chat!!)
|
||||
f.chatItem != null -> chatItemsExported.add(f.chatItem!!)
|
||||
f.adHocCall != null -> callsExported.add(f.adHocCall!!)
|
||||
@@ -1499,6 +1746,14 @@ class ImportExportTest {
|
||||
prettyAssertEquals(stickersImported, stickersExported) { it.packId }
|
||||
}
|
||||
|
||||
private fun DistributionListItem.copyWithoutMembers(): DistributionListItem {
|
||||
return this.copy(
|
||||
distributionList = this.distributionList?.copy(
|
||||
memberRecipientIds = emptyList()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private inline fun <reified T : Any> prettyAssertEquals(import: List<T>, export: List<T>) {
|
||||
Assert.assertEquals(import.size, export.size)
|
||||
import.zip(export).forEach { (a1, a2) ->
|
||||
@@ -1514,17 +1769,29 @@ class ImportExportTest {
|
||||
|
||||
private inline fun <reified T : Any, R : Comparable<R>> prettyAssertEquals(import: List<T>, export: List<T>, crossinline selector: (T) -> R?) {
|
||||
if (import.size != export.size) {
|
||||
var msg = StringBuilder()
|
||||
val msg = StringBuilder()
|
||||
msg.append("There's a different number of items in the lists!\n\n")
|
||||
|
||||
msg.append("Imported:\n")
|
||||
for (i in import) {
|
||||
msg.append(i)
|
||||
msg.append("\n")
|
||||
}
|
||||
if (import.isEmpty()) {
|
||||
msg.append("<None>")
|
||||
}
|
||||
msg.append("\n")
|
||||
msg.append("Exported:\n")
|
||||
for (i in export) {
|
||||
msg.append(i)
|
||||
msg.append("\n")
|
||||
}
|
||||
if (export.isEmpty()) {
|
||||
msg.append("<None>")
|
||||
}
|
||||
Assert.fail(msg.toString())
|
||||
}
|
||||
|
||||
Assert.assertEquals(import.size, export.size)
|
||||
val sortedImport = import.sortedBy(selector)
|
||||
val sortedExport = export.sortedBy(selector)
|
||||
@@ -1535,9 +1802,9 @@ class ImportExportTest {
|
||||
private fun readAllFrames(import: ByteArray, selfData: BackupRepository.SelfData): List<Frame> {
|
||||
val inputFactory = { ByteArrayInputStream(import) }
|
||||
val frameReader = EncryptedBackupReader(
|
||||
key = SignalStore.svr().getOrCreateMasterKey().deriveBackupKey(),
|
||||
key = SignalStore.svr.getOrCreateMasterKey().deriveBackupKey(),
|
||||
aci = selfData.aci,
|
||||
streamLength = import.size.toLong(),
|
||||
length = import.size.toLong(),
|
||||
dataStream = inputFactory
|
||||
)
|
||||
val frames = ArrayList<Frame>()
|
||||
@@ -1548,20 +1815,12 @@ class ImportExportTest {
|
||||
return frames
|
||||
}
|
||||
|
||||
private fun writeToOutputFile(importBytes: ByteArray, resultBytes: ByteArray? = null) {
|
||||
val dir = File(context.filesDir, "backup-tests")
|
||||
if (dir.mkdirs() || dir.exists()) {
|
||||
FileOutputStream(File(dir, testName.methodName + ".import")).use {
|
||||
it.write(importBytes)
|
||||
it.flush()
|
||||
}
|
||||
private fun grantPermissions(vararg permissions: String?) {
|
||||
if (!OUTPUT_FILES) return
|
||||
|
||||
if (resultBytes != null) {
|
||||
FileOutputStream(File(dir, testName.methodName + ".result")).use {
|
||||
it.write(resultBytes)
|
||||
it.flush()
|
||||
}
|
||||
}
|
||||
val auto: UiAutomation = InstrumentationRegistry.getInstrumentation().uiAutomation
|
||||
for (perm in permissions) {
|
||||
auto.grantRuntimePermissionAsUser(InstrumentationRegistry.getInstrumentation().targetContext.packageName, perm, android.os.Process.myUserHandle())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.backup.v2
|
||||
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.junit.runners.Parameterized
|
||||
import org.signal.core.util.Base64
|
||||
import org.signal.core.util.StreamUtil
|
||||
import org.signal.libsignal.zkgroup.profiles.ProfileKey
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.whispersystems.signalservice.api.kbs.MasterKey
|
||||
import org.whispersystems.signalservice.api.push.ServiceId
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.util.UUID
|
||||
import kotlin.random.Random
|
||||
|
||||
@RunWith(Parameterized::class)
|
||||
class ImportExportTestSuite(private val path: String) {
|
||||
companion object {
|
||||
val SELF_ACI = ServiceId.ACI.from(UUID.fromString("77770000-b477-4f35-a824-d92987a63641"))
|
||||
val SELF_PNI = ServiceId.PNI.from(UUID.fromString("77771111-b014-41fb-bf73-05cb2ec52910"))
|
||||
const val SELF_E164 = "+10000000000"
|
||||
val SELF_PROFILE_KEY = ProfileKey(Random.nextBytes(32))
|
||||
val MASTER_KEY = Base64.decode("sHuBMP4ToZk4tcNU+S8eBUeCt8Am5EZnvuqTBJIR4Do")
|
||||
|
||||
const val TESTS_FOLDER = "backupTests"
|
||||
|
||||
@JvmStatic
|
||||
@Parameterized.Parameters(name = "{0}")
|
||||
fun data(): Collection<Array<String>> {
|
||||
val testFiles = InstrumentationRegistry.getInstrumentation().context.resources.assets.list(TESTS_FOLDER)
|
||||
return testFiles?.map { arrayOf(it) }!!.toList()
|
||||
}
|
||||
}
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
SignalStore.svr.setMasterKey(MasterKey(MASTER_KEY), "1234")
|
||||
SignalStore.account.setE164(SELF_E164)
|
||||
SignalStore.account.setAci(SELF_ACI)
|
||||
SignalStore.account.setPni(SELF_PNI)
|
||||
SignalStore.account.generateAciIdentityKeyIfNecessary()
|
||||
SignalStore.account.generatePniIdentityKeyIfNecessary()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testBinProto() {
|
||||
val binProtoBytes: ByteArray = InstrumentationRegistry.getInstrumentation().context.resources.assets.open("${TESTS_FOLDER}/$path").use {
|
||||
StreamUtil.readFully(it)
|
||||
}
|
||||
import(binProtoBytes)
|
||||
val generatedBackupData = BackupRepository.export()
|
||||
compare(binProtoBytes, generatedBackupData)
|
||||
}
|
||||
|
||||
private fun import(importData: ByteArray) {
|
||||
BackupRepository.import(
|
||||
length = importData.size.toLong(),
|
||||
inputStreamFactory = { ByteArrayInputStream(importData) },
|
||||
selfData = BackupRepository.SelfData(SELF_ACI, SELF_PNI, SELF_E164, SELF_PROFILE_KEY),
|
||||
plaintext = true
|
||||
)
|
||||
}
|
||||
|
||||
// TODO compare with libsignal's library
|
||||
private fun compare(import: ByteArray, export: ByteArray) {
|
||||
}
|
||||
}
|
||||
@@ -1,408 +0,0 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.changenumber
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.filters.FlakyTest
|
||||
import io.mockk.every
|
||||
import io.mockk.mockkObject
|
||||
import io.mockk.unmockkObject
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Ignore
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.signal.core.util.ThreadUtil
|
||||
import org.signal.libsignal.protocol.state.SignedPreKeyRecord
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.dependencies.InstrumentationApplicationDependencyProvider
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.pin.SvrRepository
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.registration.VerifyAccountRepository
|
||||
import org.thoughtcrime.securesms.registration.VerifyResponseProcessor
|
||||
import org.thoughtcrime.securesms.testing.Get
|
||||
import org.thoughtcrime.securesms.testing.MockProvider
|
||||
import org.thoughtcrime.securesms.testing.Post
|
||||
import org.thoughtcrime.securesms.testing.Put
|
||||
import org.thoughtcrime.securesms.testing.SignalActivityRule
|
||||
import org.thoughtcrime.securesms.testing.assertIs
|
||||
import org.thoughtcrime.securesms.testing.assertIsNot
|
||||
import org.thoughtcrime.securesms.testing.assertIsNotNull
|
||||
import org.thoughtcrime.securesms.testing.assertIsNull
|
||||
import org.thoughtcrime.securesms.testing.assertIsSize
|
||||
import org.thoughtcrime.securesms.testing.connectionFailure
|
||||
import org.thoughtcrime.securesms.testing.failure
|
||||
import org.thoughtcrime.securesms.testing.parsedRequestBody
|
||||
import org.thoughtcrime.securesms.testing.success
|
||||
import org.thoughtcrime.securesms.testing.timeout
|
||||
import org.whispersystems.signalservice.api.account.ChangePhoneNumberRequest
|
||||
import org.whispersystems.signalservice.api.kbs.MasterKey
|
||||
import org.whispersystems.signalservice.api.push.ServiceId
|
||||
import org.whispersystems.signalservice.api.push.ServiceId.PNI
|
||||
import org.whispersystems.signalservice.api.svr.SecureValueRecovery
|
||||
import org.whispersystems.signalservice.internal.push.AuthCredentials
|
||||
import org.whispersystems.signalservice.internal.push.MismatchedDevices
|
||||
import org.whispersystems.signalservice.internal.push.PreKeyState
|
||||
import java.security.SecureRandom
|
||||
import java.util.UUID
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ChangeNumberViewModelTest {
|
||||
|
||||
@get:Rule
|
||||
val harness = SignalActivityRule()
|
||||
|
||||
private lateinit var viewModel: ChangeNumberViewModel
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
ThreadUtil.runOnMainSync {
|
||||
viewModel = ChangeNumberViewModel(
|
||||
localNumber = harness.self.requireE164(),
|
||||
changeNumberRepository = ChangeNumberRepository(),
|
||||
savedState = SavedStateHandle(),
|
||||
password = SignalStore.account().servicePassword!!,
|
||||
verifyAccountRepository = VerifyAccountRepository(harness.application)
|
||||
)
|
||||
|
||||
viewModel.setNewCountry(1)
|
||||
viewModel.setNewNationalNumber("5555550102")
|
||||
}
|
||||
|
||||
mockkObject(SvrRepository)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
unmockkObject(SvrRepository)
|
||||
InstrumentationApplicationDependencyProvider.clearHandlers()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testChangeNumber_givenOnlyPrimaryAndNoRegLock() {
|
||||
// GIVEN
|
||||
val aci = Recipient.self().requireServiceId()
|
||||
val newPni = PNI.from(UUID.randomUUID())
|
||||
lateinit var changeNumberRequest: ChangePhoneNumberRequest
|
||||
lateinit var setPreKeysRequest: PreKeyState
|
||||
|
||||
InstrumentationApplicationDependencyProvider.addMockWebRequestHandlers(
|
||||
Post("/v1/verification/session") { MockResponse().success(MockProvider.sessionMetadataJson.copy(verified = false)) },
|
||||
Put("/v1/verification/session/${MockProvider.sessionMetadataJson.id}/code") { MockResponse().success(MockProvider.sessionMetadataJson) },
|
||||
Get("/v1/devices") { MockResponse().success(MockProvider.primaryOnlyDeviceList) },
|
||||
Put("/v2/accounts/number") { r ->
|
||||
changeNumberRequest = r.parsedRequestBody()
|
||||
MockResponse().success(MockProvider.createVerifyAccountResponse(aci, newPni))
|
||||
},
|
||||
Put("/v2/keys") { r ->
|
||||
setPreKeysRequest = r.parsedRequestBody()
|
||||
MockResponse().success()
|
||||
},
|
||||
Get("/v1/certificate/delivery") { MockResponse().success(MockProvider.senderCertificate) }
|
||||
)
|
||||
|
||||
// WHEN
|
||||
viewModel.requestVerificationCode(VerifyAccountRepository.Mode.SMS_WITHOUT_LISTENER, null, null).blockingGet().resultOrThrow
|
||||
viewModel.verifyCodeWithoutRegistrationLock("123456").blockingGet().resultOrThrow
|
||||
|
||||
// THEN
|
||||
assertSuccess(newPni, changeNumberRequest, setPreKeysRequest)
|
||||
}
|
||||
|
||||
/**
|
||||
* If we encounter a server error, this means the server ack our request and rejected it. In this
|
||||
* case we know the change *did not* take on the server and can reset to a clean state.
|
||||
*/
|
||||
@Test
|
||||
fun testChangeNumber_givenServerFailedApiCall() {
|
||||
// GIVEN
|
||||
val oldPni = Recipient.self().requirePni()
|
||||
val oldE164 = Recipient.self().requireE164()
|
||||
|
||||
InstrumentationApplicationDependencyProvider.addMockWebRequestHandlers(
|
||||
Post("/v1/verification/session") { MockResponse().success(MockProvider.sessionMetadataJson.copy(verified = false)) },
|
||||
Put("/v1/verification/session/${MockProvider.sessionMetadataJson.id}/code") { MockResponse().success(MockProvider.sessionMetadataJson) },
|
||||
Get("/v1/devices") { MockResponse().success(MockProvider.primaryOnlyDeviceList) },
|
||||
Put("/v2/accounts/number") { MockResponse().failure(500) }
|
||||
)
|
||||
|
||||
// WHEN
|
||||
viewModel.requestVerificationCode(VerifyAccountRepository.Mode.SMS_WITHOUT_LISTENER, null, null).blockingGet().resultOrThrow
|
||||
val processor: VerifyResponseProcessor = viewModel.verifyCodeWithoutRegistrationLock("123456").blockingGet()
|
||||
|
||||
// THEN
|
||||
processor.isServerSentError() assertIs true
|
||||
Recipient.self().requireE164() assertIs oldE164
|
||||
Recipient.self().requirePni() assertIs oldPni
|
||||
SignalStore.misc().pendingChangeNumberMetadata.assertIsNull()
|
||||
}
|
||||
|
||||
/**
|
||||
* If we encounter a non-server error like a timeout or bad SSL, we do not know the state of our change
|
||||
* number on the server side. We have to do a whoami call to query the server for our details and then
|
||||
* respond accordingly.
|
||||
*
|
||||
* In this case, the whoami is our old details, so we can know the change *did not* take on the server
|
||||
* and can reset to a clean state.
|
||||
*/
|
||||
@Test
|
||||
fun testChangeNumber_givenNetworkFailedApiCallEnRouteToServer() {
|
||||
// GIVEN
|
||||
val aci = Recipient.self().requireServiceId()
|
||||
val oldPni = Recipient.self().requirePni()
|
||||
val oldE164 = Recipient.self().requireE164()
|
||||
|
||||
InstrumentationApplicationDependencyProvider.addMockWebRequestHandlers(
|
||||
Post("/v1/verification/session") { MockResponse().success(MockProvider.sessionMetadataJson.copy(verified = false)) },
|
||||
Put("/v1/verification/session/${MockProvider.sessionMetadataJson.id}/code") { MockResponse().success(MockProvider.sessionMetadataJson) },
|
||||
Get("/v1/devices") { MockResponse().success(MockProvider.primaryOnlyDeviceList) },
|
||||
Put("/v2/accounts/number") { MockResponse().connectionFailure() },
|
||||
Get("/v1/accounts/whoami") { MockResponse().success(MockProvider.createWhoAmIResponse(aci, oldPni, oldE164)) }
|
||||
)
|
||||
|
||||
// WHEN
|
||||
viewModel.requestVerificationCode(VerifyAccountRepository.Mode.SMS_WITHOUT_LISTENER, null, null).blockingGet().resultOrThrow
|
||||
val processor: VerifyResponseProcessor = viewModel.verifyCodeWithoutRegistrationLock("123456").blockingGet()
|
||||
|
||||
// THEN
|
||||
processor.isServerSentError() assertIs false
|
||||
Recipient.self().requireE164() assertIs oldE164
|
||||
Recipient.self().requirePni() assertIs oldPni
|
||||
SignalStore.misc().isChangeNumberLocked assertIs false
|
||||
SignalStore.misc().pendingChangeNumberMetadata.assertIsNull()
|
||||
}
|
||||
|
||||
/**
|
||||
* If we encounter a non-server error like a timeout or bad SSL, we do not know the state of our change
|
||||
* number on the server side. We have to do a whoami call to query the server for our details and then
|
||||
* respond accordingly.
|
||||
*
|
||||
* In this case, the whoami is our new details, so we can know the change *did* take on the server
|
||||
* and need to keep the app in a locked state. The test then uses the ChangeNumberLockActivity to unlock
|
||||
* and apply the pending state after confirming the change on the server.
|
||||
*/
|
||||
@Test
|
||||
@FlakyTest
|
||||
@Ignore("Test sometimes requires manual intervention to continue.")
|
||||
fun testChangeNumber_givenNetworkFailedApiCallEnRouteToClient() {
|
||||
// GIVEN
|
||||
val aci = Recipient.self().requireServiceId()
|
||||
val oldPni = Recipient.self().requirePni()
|
||||
val oldE164 = Recipient.self().requireE164()
|
||||
val newPni = PNI.from(UUID.randomUUID())
|
||||
|
||||
lateinit var changeNumberRequest: ChangePhoneNumberRequest
|
||||
lateinit var setPreKeysRequest: PreKeyState
|
||||
|
||||
InstrumentationApplicationDependencyProvider.addMockWebRequestHandlers(
|
||||
Post("/v1/verification/session") { MockResponse().success(MockProvider.sessionMetadataJson.copy(verified = false)) },
|
||||
Put("/v1/verification/session/${MockProvider.sessionMetadataJson.id}/code") { MockResponse().success(MockProvider.sessionMetadataJson) },
|
||||
Get("/v1/devices") { MockResponse().success(MockProvider.primaryOnlyDeviceList) },
|
||||
Put("/v2/accounts/number") { r ->
|
||||
changeNumberRequest = r.parsedRequestBody()
|
||||
MockResponse().timeout()
|
||||
},
|
||||
Get("/v1/accounts/whoami") { MockResponse().success(MockProvider.createWhoAmIResponse(aci, newPni, "+15555550102")) },
|
||||
Put("/v2/keys") { r ->
|
||||
setPreKeysRequest = r.parsedRequestBody()
|
||||
MockResponse().success()
|
||||
},
|
||||
Get("/v1/certificate/delivery") { MockResponse().success(MockProvider.senderCertificate) }
|
||||
)
|
||||
|
||||
// WHEN
|
||||
viewModel.requestVerificationCode(VerifyAccountRepository.Mode.SMS_WITHOUT_LISTENER, null, null).blockingGet().resultOrThrow
|
||||
val processor: VerifyResponseProcessor = viewModel.verifyCodeWithoutRegistrationLock("123456").blockingGet()
|
||||
|
||||
// THEN
|
||||
processor.isServerSentError() assertIs false
|
||||
Recipient.self().requireE164() assertIs oldE164
|
||||
Recipient.self().requirePni() assertIs oldPni
|
||||
SignalStore.misc().isChangeNumberLocked assertIs true
|
||||
SignalStore.misc().pendingChangeNumberMetadata.assertIsNotNull()
|
||||
|
||||
// WHEN AGAIN Processing lock
|
||||
val scenario = harness.launchActivity<ChangeNumberLockActivity>()
|
||||
scenario.onActivity {}
|
||||
ThreadUtil.sleep(500)
|
||||
|
||||
// THEN AGAIN
|
||||
assertSuccess(newPni, changeNumberRequest, setPreKeysRequest)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testChangeNumber_givenOnlyPrimaryAndRegistrationLock() {
|
||||
// GIVEN
|
||||
val aci = Recipient.self().requireServiceId()
|
||||
val newPni = PNI.from(UUID.randomUUID())
|
||||
|
||||
lateinit var changeNumberRequest: ChangePhoneNumberRequest
|
||||
lateinit var setPreKeysRequest: PreKeyState
|
||||
|
||||
InstrumentationApplicationDependencyProvider.addMockWebRequestHandlers(
|
||||
Post("/v1/verification/session") { MockResponse().success(MockProvider.sessionMetadataJson.copy(verified = false)) },
|
||||
Put("/v1/verification/session/${MockProvider.sessionMetadataJson.id}/code") { MockResponse().success(MockProvider.sessionMetadataJson) },
|
||||
Get("/v1/devices") { MockResponse().success(MockProvider.primaryOnlyDeviceList) },
|
||||
Put("/v2/accounts/number") { r ->
|
||||
changeNumberRequest = r.parsedRequestBody()
|
||||
if (changeNumberRequest.registrationLock.isNullOrEmpty()) {
|
||||
MockResponse().failure(423, MockProvider.lockedFailure)
|
||||
} else {
|
||||
MockResponse().success(MockProvider.createVerifyAccountResponse(aci, newPni))
|
||||
}
|
||||
},
|
||||
Put("/v2/keys") { r ->
|
||||
setPreKeysRequest = r.parsedRequestBody()
|
||||
MockResponse().success()
|
||||
},
|
||||
Get("/v1/certificate/delivery") { MockResponse().success(MockProvider.senderCertificate) }
|
||||
)
|
||||
|
||||
every { SvrRepository.restoreMasterKeyPreRegistration(any(), any(), any()) } returns SecureValueRecovery.RestoreResponse.Success(MasterKey.createNew(SecureRandom()), AuthCredentials.create("username", "password"))
|
||||
|
||||
// WHEN
|
||||
viewModel.requestVerificationCode(VerifyAccountRepository.Mode.SMS_WITHOUT_LISTENER, null, null).blockingGet().resultOrThrow
|
||||
viewModel.verifyCodeWithoutRegistrationLock("123456").blockingGet().also { processor ->
|
||||
processor.registrationLock() assertIs true
|
||||
Recipient.self().requirePni() assertIsNot newPni
|
||||
SignalStore.misc().pendingChangeNumberMetadata.assertIsNull()
|
||||
}
|
||||
|
||||
viewModel.verifyCodeAndRegisterAccountWithRegistrationLock("pin").blockingGet().resultOrThrow
|
||||
|
||||
// THEN
|
||||
assertSuccess(newPni, changeNumberRequest, setPreKeysRequest)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testChangeNumber_givenMismatchedDevicesOnFirstCall() {
|
||||
// GIVEN
|
||||
val aci = Recipient.self().requireServiceId()
|
||||
val newPni = PNI.from(UUID.randomUUID())
|
||||
lateinit var changeNumberRequest: ChangePhoneNumberRequest
|
||||
lateinit var setPreKeysRequest: PreKeyState
|
||||
|
||||
InstrumentationApplicationDependencyProvider.addMockWebRequestHandlers(
|
||||
Post("/v1/verification/session") { MockResponse().success(MockProvider.sessionMetadataJson.copy(verified = false)) },
|
||||
Put("/v1/verification/session/${MockProvider.sessionMetadataJson.id}/code") { MockResponse().success(MockProvider.sessionMetadataJson) },
|
||||
Get("/v1/devices") { MockResponse().success(MockProvider.primaryOnlyDeviceList) },
|
||||
Put("/v2/accounts/number") { r ->
|
||||
changeNumberRequest = r.parsedRequestBody()
|
||||
if (changeNumberRequest.deviceMessages.isEmpty()) {
|
||||
MockResponse().failure(
|
||||
409,
|
||||
MismatchedDevices().apply {
|
||||
missingDevices = listOf(2)
|
||||
extraDevices = emptyList()
|
||||
}
|
||||
)
|
||||
} else {
|
||||
MockResponse().success(MockProvider.createVerifyAccountResponse(aci, newPni))
|
||||
}
|
||||
},
|
||||
Get("/v2/keys/$aci/2") {
|
||||
MockResponse().success(MockProvider.createPreKeyResponse(deviceId = 2))
|
||||
},
|
||||
Put("/v2/keys") { r ->
|
||||
setPreKeysRequest = r.parsedRequestBody()
|
||||
MockResponse().success()
|
||||
},
|
||||
Get("/v1/certificate/delivery") { MockResponse().success(MockProvider.senderCertificate) }
|
||||
)
|
||||
|
||||
// WHEN
|
||||
viewModel.requestVerificationCode(VerifyAccountRepository.Mode.SMS_WITHOUT_LISTENER, null, null).blockingGet().resultOrThrow
|
||||
viewModel.verifyCodeWithoutRegistrationLock("123456").blockingGet().resultOrThrow
|
||||
|
||||
// THEN
|
||||
assertSuccess(newPni, changeNumberRequest, setPreKeysRequest)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testChangeNumber_givenRegLockAndMismatchedDevicesOnFirstTwoCalls() {
|
||||
// GIVEN
|
||||
val aci = Recipient.self().requireServiceId()
|
||||
val newPni = PNI.from(UUID.randomUUID())
|
||||
|
||||
lateinit var changeNumberRequest: ChangePhoneNumberRequest
|
||||
lateinit var setPreKeysRequest: PreKeyState
|
||||
|
||||
InstrumentationApplicationDependencyProvider.addMockWebRequestHandlers(
|
||||
Post("/v1/verification/session") { MockResponse().success(MockProvider.sessionMetadataJson.copy(verified = false)) },
|
||||
Put("/v1/verification/session/${MockProvider.sessionMetadataJson.id}/code") { MockResponse().success(MockProvider.sessionMetadataJson) },
|
||||
Put("/v2/accounts/number") { r ->
|
||||
changeNumberRequest = r.parsedRequestBody()
|
||||
if (changeNumberRequest.registrationLock.isNullOrEmpty()) {
|
||||
MockResponse().failure(423, MockProvider.lockedFailure)
|
||||
} else if (changeNumberRequest.deviceMessages.isEmpty()) {
|
||||
MockResponse().failure(
|
||||
409,
|
||||
MismatchedDevices().apply {
|
||||
missingDevices = listOf(2)
|
||||
extraDevices = emptyList()
|
||||
}
|
||||
)
|
||||
} else if (changeNumberRequest.deviceMessages.size == 1) {
|
||||
MockResponse().failure(
|
||||
409,
|
||||
MismatchedDevices().apply {
|
||||
missingDevices = listOf(2, 3)
|
||||
extraDevices = emptyList()
|
||||
}
|
||||
)
|
||||
} else {
|
||||
MockResponse().success(MockProvider.createVerifyAccountResponse(aci, newPni))
|
||||
}
|
||||
},
|
||||
Get("/v2/keys/$aci/2") {
|
||||
MockResponse().success(MockProvider.createPreKeyResponse(deviceId = 2))
|
||||
},
|
||||
Get("/v2/keys/$aci/3") {
|
||||
MockResponse().success(MockProvider.createPreKeyResponse(deviceId = 3))
|
||||
},
|
||||
Put("/v2/keys") { r ->
|
||||
setPreKeysRequest = r.parsedRequestBody()
|
||||
MockResponse().success()
|
||||
},
|
||||
Get("/v1/certificate/delivery") { MockResponse().success(MockProvider.senderCertificate) }
|
||||
)
|
||||
|
||||
every { SvrRepository.restoreMasterKeyPreRegistration(any(), any(), any()) } returns SecureValueRecovery.RestoreResponse.Success(MasterKey.createNew(SecureRandom()), AuthCredentials.create("username", "password"))
|
||||
|
||||
// WHEN
|
||||
viewModel.requestVerificationCode(VerifyAccountRepository.Mode.SMS_WITHOUT_LISTENER, null, null).blockingGet().resultOrThrow
|
||||
viewModel.verifyCodeWithoutRegistrationLock("123456").blockingGet().also { processor ->
|
||||
processor.registrationLock() assertIs true
|
||||
Recipient.self().requirePni() assertIsNot newPni
|
||||
SignalStore.misc().pendingChangeNumberMetadata.assertIsNull()
|
||||
}
|
||||
|
||||
viewModel.verifyCodeAndRegisterAccountWithRegistrationLock("pin").blockingGet().resultOrThrow
|
||||
|
||||
// THEN
|
||||
assertSuccess(newPni, changeNumberRequest, setPreKeysRequest)
|
||||
}
|
||||
|
||||
private fun assertSuccess(newPni: ServiceId, changeNumberRequest: ChangePhoneNumberRequest, setPreKeysRequest: PreKeyState) {
|
||||
val pniProtocolStore = AppDependencies.protocolStore.pni()
|
||||
val pniMetadataStore = SignalStore.account().pniPreKeys
|
||||
|
||||
Recipient.self().requireE164() assertIs "+15555550102"
|
||||
Recipient.self().requirePni() assertIs newPni
|
||||
|
||||
SignalStore.account().pniRegistrationId assertIs changeNumberRequest.pniRegistrationIds["1"]!!
|
||||
SignalStore.account().pniIdentityKey.publicKey assertIs changeNumberRequest.pniIdentityKey
|
||||
pniMetadataStore.activeSignedPreKeyId assertIs changeNumberRequest.devicePniSignedPrekeys["1"]!!.keyId
|
||||
|
||||
val activeSignedPreKey: SignedPreKeyRecord = pniProtocolStore.loadSignedPreKey(pniMetadataStore.activeSignedPreKeyId)
|
||||
activeSignedPreKey.keyPair.publicKey assertIs changeNumberRequest.devicePniSignedPrekeys["1"]!!.publicKey
|
||||
activeSignedPreKey.signature assertIs changeNumberRequest.devicePniSignedPrekeys["1"]!!.signature
|
||||
|
||||
setPreKeysRequest.signedPreKey.publicKey assertIs activeSignedPreKey.keyPair.publicKey
|
||||
setPreKeysRequest.preKeys assertIsSize 100
|
||||
|
||||
SignalStore.misc().pendingChangeNumberMetadata.assertIsNull()
|
||||
}
|
||||
}
|
||||
@@ -154,7 +154,8 @@ class ConversationItemPreviewer {
|
||||
false,
|
||||
Optional.empty(),
|
||||
Optional.empty(),
|
||||
System.currentTimeMillis()
|
||||
System.currentTimeMillis(),
|
||||
null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ package org.thoughtcrime.securesms.conversation.v2.items
|
||||
|
||||
import android.net.Uri
|
||||
import android.view.View
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.Observer
|
||||
import com.bumptech.glide.RequestManager
|
||||
import io.mockk.mockk
|
||||
@@ -203,8 +204,8 @@ class V2ConversationItemShapeTest {
|
||||
|
||||
private val colorizer = Colorizer()
|
||||
|
||||
override val lifecycleOwner: LifecycleOwner = mockk(relaxed = true)
|
||||
override val displayMode: ConversationItemDisplayMode = ConversationItemDisplayMode.Standard
|
||||
|
||||
override val clickListener: ConversationAdapter.ItemClickListener = FakeConversationItemClickListener
|
||||
override val selectedItems: Set<MultiselectPart> = emptySet()
|
||||
override val isMessageRequestAccepted: Boolean = true
|
||||
@@ -289,6 +290,8 @@ class V2ConversationItemShapeTest {
|
||||
|
||||
override fun onChangeNumberUpdateContact(recipient: Recipient) = Unit
|
||||
|
||||
override fun onChangeProfileNameUpdateContact(recipient: Recipient) = Unit
|
||||
|
||||
override fun onCallToAction(action: String) = Unit
|
||||
|
||||
override fun onDonateClicked() = Unit
|
||||
|
||||
@@ -50,9 +50,9 @@ class AttachmentTableTest_deduping {
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
SignalStore.account().setAci(ServiceId.ACI.from(UUID.randomUUID()))
|
||||
SignalStore.account().setPni(ServiceId.PNI.from(UUID.randomUUID()))
|
||||
SignalStore.account().setE164("+15558675309")
|
||||
SignalStore.account.setAci(ServiceId.ACI.from(UUID.randomUUID()))
|
||||
SignalStore.account.setPni(ServiceId.PNI.from(UUID.randomUUID()))
|
||||
SignalStore.account.setE164("+15558675309")
|
||||
|
||||
SignalDatabase.attachments.deleteAllAttachments()
|
||||
}
|
||||
@@ -824,7 +824,8 @@ class AttachmentTableTest_deduping {
|
||||
uploadTimestamp,
|
||||
databaseAttachment.caption,
|
||||
databaseAttachment.stickerLocator,
|
||||
databaseAttachment.blurHash
|
||||
databaseAttachment.blurHash,
|
||||
databaseAttachment.uuid
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ class CallTableTest {
|
||||
)
|
||||
|
||||
val call = SignalDatabase.calls.getCallById(callId, groupRecipientId)
|
||||
SignalDatabase.calls.deleteGroupCall(call!!)
|
||||
SignalDatabase.calls.markCallDeletedFromSyncEvent(call!!)
|
||||
|
||||
val deletedCall = SignalDatabase.calls.getCallById(callId, groupRecipientId)
|
||||
val oldestDeletionTimestamp = SignalDatabase.calls.getOldestDeletionTimestamp()
|
||||
@@ -69,9 +69,10 @@ class CallTableTest {
|
||||
@Test
|
||||
fun givenNoPreExistingEvent_whenIDeleteGroupCall_thenIInsertAndMarkCallDeleted() {
|
||||
val callId = 1L
|
||||
SignalDatabase.calls.insertDeletedGroupCallFromSyncEvent(
|
||||
SignalDatabase.calls.insertDeletedCallFromSyncEvent(
|
||||
callId,
|
||||
groupRecipientId,
|
||||
CallTable.Type.GROUP_CALL,
|
||||
CallTable.Direction.OUTGOING,
|
||||
System.currentTimeMillis()
|
||||
)
|
||||
@@ -438,11 +439,12 @@ class CallTableTest {
|
||||
@Test
|
||||
fun givenADeletedCallEvent_whenIReceiveARingUpdate_thenIIgnoreTheRingUpdate() {
|
||||
val callId = 1L
|
||||
SignalDatabase.calls.insertDeletedGroupCallFromSyncEvent(
|
||||
SignalDatabase.calls.insertDeletedCallFromSyncEvent(
|
||||
callId = callId,
|
||||
recipientId = groupRecipientId,
|
||||
direction = CallTable.Direction.INCOMING,
|
||||
timestamp = System.currentTimeMillis()
|
||||
timestamp = System.currentTimeMillis(),
|
||||
type = CallTable.Type.GROUP_CALL
|
||||
)
|
||||
|
||||
SignalDatabase.calls.insertOrUpdateGroupCallFromRingState(
|
||||
|
||||
@@ -167,8 +167,8 @@ class GroupTableTest {
|
||||
|
||||
@Test
|
||||
fun givenTwoGroupsWithoutMembers_whenIQueryThem_thenIExpectEach() {
|
||||
val g1 = insertPushGroup(listOf())
|
||||
val g2 = insertPushGroup(listOf())
|
||||
val g1 = insertPushGroup(members = emptyList())
|
||||
val g2 = insertPushGroup(members = emptyList())
|
||||
|
||||
val gr1 = groupTable.getGroup(g1)
|
||||
val gr2 = groupTable.getGroup(g2)
|
||||
@@ -195,6 +195,85 @@ class GroupTableTest {
|
||||
assertEquals(groups[0].id, groupInCommon)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenTwoGroupsWithANameThatSharesAToken_whenISearchForTheSharedToken_thenIExpectBothGroups() {
|
||||
insertPushGroup("Group Alice")
|
||||
insertPushGroup("Group Bob")
|
||||
|
||||
SignalDatabase.groups.queryGroupsByTitle(
|
||||
inputQuery = "Group",
|
||||
includeInactive = false,
|
||||
excludeV1 = false,
|
||||
excludeMms = false
|
||||
).use {
|
||||
assertEquals(2, it.cursor?.count)
|
||||
|
||||
val firstGroup = it.getNext()
|
||||
val secondGroup = it.getNext()
|
||||
|
||||
assertEquals("Group Alice", firstGroup?.title)
|
||||
assertEquals("Group Bob", secondGroup?.title)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenTwoGroupsWithANameThatSharesAToken_whenISearchForAnUnsharedToken_thenIExpectOneGroup() {
|
||||
insertPushGroup("Group Alice")
|
||||
insertPushGroup("Group Bob")
|
||||
|
||||
SignalDatabase.groups.queryGroupsByTitle(
|
||||
inputQuery = "Alice",
|
||||
includeInactive = false,
|
||||
excludeV1 = false,
|
||||
excludeMms = false
|
||||
).use {
|
||||
assertEquals(1, it.cursor?.count)
|
||||
|
||||
val firstGroup = it.getNext()
|
||||
|
||||
assertEquals("Group Alice", firstGroup?.title)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenAGroupWithThreeTokens_whenISearchForTheFirstAndLastToken_thenIExpectThatGroup() {
|
||||
insertPushGroup("Group & Alice")
|
||||
|
||||
SignalDatabase.groups.queryGroupsByTitle(
|
||||
inputQuery = "Group Alice",
|
||||
includeInactive = false,
|
||||
excludeV1 = false,
|
||||
excludeMms = false
|
||||
).use {
|
||||
assertEquals(1, it.cursor?.count)
|
||||
|
||||
val firstGroup = it.getNext()
|
||||
|
||||
assertEquals("Group & Alice", firstGroup?.title)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenTwoGroupsWithSharedTokens_whenISearchForAnExactMatch_thenIExpectThatGroupFirst() {
|
||||
insertPushGroup("Group Alice Bob")
|
||||
insertPushGroup("Group Bob")
|
||||
|
||||
SignalDatabase.groups.queryGroupsByTitle(
|
||||
inputQuery = "Group Bob",
|
||||
includeInactive = false,
|
||||
excludeV1 = false,
|
||||
excludeMms = false
|
||||
).use {
|
||||
assertEquals(2, it.cursor?.count)
|
||||
|
||||
val firstGroup = it.getNext()
|
||||
val second = it.getNext()
|
||||
|
||||
assertEquals("Group Bob", firstGroup?.title)
|
||||
assertEquals("Group Alice Bob", second?.title)
|
||||
}
|
||||
}
|
||||
|
||||
private fun insertThread(groupId: GroupId): Long {
|
||||
val groupRecipient = SignalDatabase.recipients.getByGroupId(groupId).get()
|
||||
return SignalDatabase.threads.getOrCreateThreadIdFor(Recipient.resolved(groupRecipient))
|
||||
@@ -214,6 +293,7 @@ class GroupTableTest {
|
||||
}
|
||||
|
||||
private fun insertPushGroup(
|
||||
title: String = "Test Group",
|
||||
members: List<DecryptedMember> = listOf(
|
||||
DecryptedMember.Builder()
|
||||
.aciBytes(harness.self.requireAci().toByteString())
|
||||
@@ -229,11 +309,12 @@ class GroupTableTest {
|
||||
): GroupId {
|
||||
val groupMasterKey = GroupMasterKey(Random.nextBytes(GroupMasterKey.SIZE))
|
||||
val decryptedGroupState = DecryptedGroup.Builder()
|
||||
.title(title)
|
||||
.members(members)
|
||||
.revision(0)
|
||||
.build()
|
||||
|
||||
return groupTable.create(groupMasterKey, decryptedGroupState)!!
|
||||
return groupTable.create(groupMasterKey, decryptedGroupState, null)!!
|
||||
}
|
||||
|
||||
private fun insertPushGroupWithSelfAndOthers(others: List<RecipientId>): GroupId {
|
||||
@@ -258,6 +339,6 @@ class GroupTableTest {
|
||||
.revision(0)
|
||||
.build()
|
||||
|
||||
return groupTable.create(groupMasterKey, decryptedGroupState)!!
|
||||
return groupTable.create(groupMasterKey, decryptedGroupState, null)!!
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,8 +30,8 @@ class MessageTableTest_gifts {
|
||||
|
||||
mms.deleteAllThreads()
|
||||
|
||||
SignalStore.account().setAci(localAci)
|
||||
SignalStore.account().setPni(localPni)
|
||||
SignalStore.account.setAci(localAci)
|
||||
SignalStore.account.setPni(localPni)
|
||||
|
||||
recipients = (0 until 5).map { SignalDatabase.recipients.getOrInsertFromServiceId(ACI.from(UUID.randomUUID())) }
|
||||
}
|
||||
|
||||
@@ -40,14 +40,14 @@ class MmsTableTest_stories {
|
||||
|
||||
mms.deleteAllThreads()
|
||||
|
||||
SignalStore.account().setAci(localAci)
|
||||
SignalStore.account().setPni(localPni)
|
||||
SignalStore.account.setAci(localAci)
|
||||
SignalStore.account.setPni(localPni)
|
||||
|
||||
myStory = Recipient.resolved(SignalDatabase.recipients.getOrInsertFromDistributionListId(DistributionListId.MY_STORY))
|
||||
recipients = (0 until 5).map { SignalDatabase.recipients.getOrInsertFromServiceId(ACI.from(UUID.randomUUID())) }
|
||||
releaseChannelRecipient = Recipient.resolved(SignalDatabase.recipients.insertReleaseChannelRecipient())
|
||||
|
||||
SignalStore.releaseChannelValues().setReleaseChannelRecipientId(releaseChannelRecipient.id)
|
||||
SignalStore.releaseChannel.setReleaseChannelRecipientId(releaseChannelRecipient.id)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -53,9 +53,9 @@ class RecipientTableTest_getAndPossiblyMerge {
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
SignalStore.account().setE164(E164_SELF)
|
||||
SignalStore.account().setAci(ACI_SELF)
|
||||
SignalStore.account().setPni(PNI_SELF)
|
||||
SignalStore.account.setE164(E164_SELF)
|
||||
SignalStore.account.setAci(ACI_SELF)
|
||||
SignalStore.account.setPni(PNI_SELF)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -10,7 +10,9 @@ import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.signal.core.util.Hex
|
||||
import org.signal.libsignal.zkgroup.groups.GroupMasterKey
|
||||
import org.thoughtcrime.securesms.database.model.GroupsV2UpdateMessageConverter
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.DecryptedGroupV2Context
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.GV2UpdateDescription
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.addMember
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.addRequestingMember
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.deleteRequestingMember
|
||||
@@ -44,8 +46,8 @@ class SmsDatabaseTest_collapseJoinRequestEventsIfPossible {
|
||||
recipients = SignalDatabase.recipients
|
||||
sms = SignalDatabase.messages
|
||||
|
||||
SignalStore.account().setAci(localAci)
|
||||
SignalStore.account().setPni(localPni)
|
||||
SignalStore.account.setAci(localAci)
|
||||
SignalStore.account.setPni(localPni)
|
||||
|
||||
alice = recipients.getOrInsertFromServiceId(aliceServiceId)
|
||||
bob = recipients.getOrInsertFromServiceId(bobServiceId)
|
||||
@@ -286,11 +288,18 @@ class SmsDatabaseTest_collapseJoinRequestEventsIfPossible {
|
||||
|
||||
private fun groupUpdateMessage(sender: RecipientId, groupContext: DecryptedGroupV2Context): IncomingMessage {
|
||||
wallClock++
|
||||
|
||||
val updateDescription = GV2UpdateDescription(
|
||||
gv2ChangeDescription = groupContext,
|
||||
groupChangeUpdate = GroupsV2UpdateMessageConverter.translateDecryptedChangeUpdate(SignalStore.account.getServiceIds(), groupContext)
|
||||
)
|
||||
|
||||
return IncomingMessage.groupUpdate(
|
||||
from = sender,
|
||||
timestamp = wallClock,
|
||||
groupId = groupId,
|
||||
groupContext = groupContext,
|
||||
update = updateDescription,
|
||||
isGroupAdd = false,
|
||||
serverGuid = null
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import org.thoughtcrime.securesms.attachments.UriAttachment
|
||||
import org.thoughtcrime.securesms.audio.AudioHash
|
||||
import org.thoughtcrime.securesms.blurhash.BlurHash
|
||||
import org.thoughtcrime.securesms.stickers.StickerLocator
|
||||
import java.util.UUID
|
||||
|
||||
object UriAttachmentBuilder {
|
||||
fun build(
|
||||
@@ -22,23 +23,28 @@ object UriAttachmentBuilder {
|
||||
stickerLocator: StickerLocator? = null,
|
||||
blurHash: BlurHash? = null,
|
||||
audioHash: AudioHash? = null,
|
||||
transformProperties: AttachmentTable.TransformProperties? = null
|
||||
transformProperties: AttachmentTable.TransformProperties? = null,
|
||||
uuid: UUID? = UUID.randomUUID()
|
||||
): UriAttachment {
|
||||
return UriAttachment(
|
||||
uri,
|
||||
contentType,
|
||||
transferState,
|
||||
size,
|
||||
fileName,
|
||||
voiceNote,
|
||||
borderless,
|
||||
videoGif,
|
||||
quote,
|
||||
caption,
|
||||
stickerLocator,
|
||||
blurHash,
|
||||
audioHash,
|
||||
transformProperties
|
||||
dataUri = uri,
|
||||
contentType = contentType,
|
||||
transferState = transferState,
|
||||
size = size,
|
||||
width = 0,
|
||||
height = 0,
|
||||
fileName = fileName,
|
||||
fastPreflightId = null,
|
||||
voiceNote = voiceNote,
|
||||
borderless = borderless,
|
||||
videoGif = videoGif,
|
||||
quote = quote,
|
||||
caption = caption,
|
||||
stickerLocator = stickerLocator,
|
||||
blurHash = blurHash,
|
||||
audioHash = audioHash,
|
||||
transformProperties = transformProperties,
|
||||
uuid = uuid
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.database.helpers.migration
|
||||
|
||||
import android.app.Application
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.signal.core.util.readToSingleObject
|
||||
import org.signal.core.util.requireNonNullString
|
||||
import org.signal.core.util.select
|
||||
import org.signal.core.util.update
|
||||
import org.signal.donations.InAppPaymentType
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationSerializationHelper.toDecimalValue
|
||||
import org.thoughtcrime.securesms.database.InAppPaymentSubscriberTable
|
||||
import org.thoughtcrime.securesms.database.InAppPaymentTable
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.FiatValue
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
|
||||
import org.thoughtcrime.securesms.testing.SignalDatabaseRule
|
||||
import org.thoughtcrime.securesms.testing.assertIs
|
||||
import org.whispersystems.signalservice.api.subscriptions.SubscriberId
|
||||
import java.math.BigDecimal
|
||||
import java.util.Currency
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class FixInAppCurrencyIfAbleTest {
|
||||
|
||||
@get:Rule
|
||||
val harness = SignalDatabaseRule(deleteAllThreadsOnEachRun = false)
|
||||
|
||||
@Test
|
||||
fun givenNoSubscribers_whenIMigrate_thenIDoNothing() {
|
||||
migrate()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenASubscriberButNoPayment_whenIMigrate_thenIDoNothing() {
|
||||
val subscriber = insertSubscriber("USD")
|
||||
clearCurrencyCode(subscriber)
|
||||
migrate()
|
||||
|
||||
getCurrencyCode(subscriber) assertIs ""
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenASubscriberAndMismatchedPayment_whenIMigrate_thenIDoNothing() {
|
||||
val subscriber = insertSubscriber("USD")
|
||||
val otherSubscriber = insertSubscriber("EUR")
|
||||
insertPayment(otherSubscriber)
|
||||
clearCurrencyCode(subscriber)
|
||||
migrate()
|
||||
|
||||
getCurrencyCode(subscriber) assertIs ""
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenASubscriberAndPaymentWithNoSubscriber_whenIMigrate_thenDoNothing() {
|
||||
val subscriber = insertSubscriber("USD")
|
||||
insertPayment(null)
|
||||
clearCurrencyCode(subscriber)
|
||||
migrate()
|
||||
|
||||
getCurrencyCode(subscriber) assertIs ""
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenASubscriberAndMatchingPayment_whenIMigrate_thenUpdateCurrencyCode() {
|
||||
val subscriber = insertSubscriber("USD")
|
||||
insertPayment(subscriber)
|
||||
clearCurrencyCode(subscriber)
|
||||
migrate()
|
||||
|
||||
getCurrencyCode(subscriber) assertIs "USD"
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenASupercededSubscriber_whenIMigrate_thenIDoNothing() {
|
||||
val oldSubscriber = insertSubscriber("USD")
|
||||
insertPayment(oldSubscriber)
|
||||
clearCurrencyCode(oldSubscriber)
|
||||
insertSubscriber("USD")
|
||||
migrate()
|
||||
}
|
||||
|
||||
private fun migrate() {
|
||||
V236_FixInAppSubscriberCurrencyIfAble.migrate(
|
||||
context = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as Application,
|
||||
db = SignalDatabase.rawDatabase,
|
||||
oldVersion = 0,
|
||||
newVersion = 0
|
||||
)
|
||||
}
|
||||
|
||||
private fun insertSubscriber(currencyCode: String): InAppPaymentSubscriberRecord {
|
||||
val record = InAppPaymentSubscriberRecord(
|
||||
subscriberId = SubscriberId.generate(),
|
||||
currency = Currency.getInstance(currencyCode),
|
||||
type = InAppPaymentSubscriberRecord.Type.DONATION,
|
||||
requiresCancel = false,
|
||||
paymentMethodType = InAppPaymentData.PaymentMethodType.PAYPAL
|
||||
)
|
||||
|
||||
SignalDatabase.inAppPaymentSubscribers.insertOrReplace(record)
|
||||
|
||||
return record
|
||||
}
|
||||
|
||||
private fun clearCurrencyCode(inAppPaymentSubscriberRecord: InAppPaymentSubscriberRecord) {
|
||||
SignalDatabase.rawDatabase.update(InAppPaymentSubscriberTable.TABLE_NAME)
|
||||
.values(InAppPaymentSubscriberTable.CURRENCY_CODE to "")
|
||||
.where("${InAppPaymentSubscriberTable.SUBSCRIBER_ID} = ?", inAppPaymentSubscriberRecord.subscriberId.serialize())
|
||||
.run()
|
||||
}
|
||||
|
||||
private fun getCurrencyCode(inAppPaymentSubscriberRecord: InAppPaymentSubscriberRecord): String {
|
||||
return SignalDatabase.rawDatabase.select(InAppPaymentSubscriberTable.CURRENCY_CODE)
|
||||
.from(InAppPaymentSubscriberTable.TABLE_NAME)
|
||||
.where("${InAppPaymentSubscriberTable.SUBSCRIBER_ID} = ?", inAppPaymentSubscriberRecord.subscriberId.serialize())
|
||||
.run()
|
||||
.readToSingleObject { it.requireNonNullString(InAppPaymentSubscriberTable.CURRENCY_CODE) }!!
|
||||
}
|
||||
|
||||
private fun insertPayment(inAppPaymentSubscriberRecord: InAppPaymentSubscriberRecord?): InAppPaymentTable.InAppPayment {
|
||||
val id = SignalDatabase.inAppPayments.insert(
|
||||
type = InAppPaymentType.RECURRING_DONATION,
|
||||
state = InAppPaymentTable.State.END,
|
||||
subscriberId = inAppPaymentSubscriberRecord?.subscriberId,
|
||||
endOfPeriod = null,
|
||||
inAppPaymentData = InAppPaymentData(
|
||||
amount = FiatValue(
|
||||
currencyCode = inAppPaymentSubscriberRecord?.currency?.currencyCode ?: "USD",
|
||||
amount = BigDecimal.ONE.toDecimalValue()
|
||||
),
|
||||
level = 200,
|
||||
paymentMethodType = inAppPaymentSubscriberRecord?.paymentMethodType ?: InAppPaymentData.PaymentMethodType.UNKNOWN
|
||||
)
|
||||
)
|
||||
|
||||
return SignalDatabase.inAppPayments.getById(id)!!
|
||||
}
|
||||
}
|
||||
@@ -58,18 +58,21 @@ class InstrumentationApplicationDependencyProvider(val application: Application,
|
||||
Get("/v1/websocket/?login=") {
|
||||
MockResponse().success().withWebSocketUpgrade(mockIdentifiedWebSocket)
|
||||
},
|
||||
Get("/v1/websocket", { !it.path.contains("login") }) {
|
||||
Get("/v1/websocket", {
|
||||
val path = it.path
|
||||
return@Get path == null || !path.contains("login")
|
||||
}) {
|
||||
MockResponse().success().withWebSocketUpgrade(object : WebSocketListener() {})
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
webServer.setDispatcher(object : Dispatcher() {
|
||||
webServer.dispatcher = object : Dispatcher() {
|
||||
override fun dispatch(request: RecordedRequest): MockResponse {
|
||||
val handler = handlers.firstOrNull { it.requestPredicate(request) }
|
||||
return handler?.responseFactory?.invoke(request) ?: MockResponse().setResponseCode(500)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
serviceTrustStore = SignalServiceTrustStore(application)
|
||||
uncensoredConfiguration = SignalServiceConfiguration(
|
||||
|
||||
@@ -1,168 +0,0 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.jobs
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import io.mockk.CapturingSlot
|
||||
import io.mockk.every
|
||||
import io.mockk.mockkStatic
|
||||
import io.mockk.slot
|
||||
import io.mockk.unmockkStatic
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.jobs.protos.DeleteSyncJobData
|
||||
import org.thoughtcrime.securesms.messages.MessageHelper
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.testing.SignalActivityRule
|
||||
import org.thoughtcrime.securesms.testing.assertIs
|
||||
import org.thoughtcrime.securesms.testing.assertIsNotNull
|
||||
import org.thoughtcrime.securesms.testing.assertIsSize
|
||||
import org.thoughtcrime.securesms.util.MessageTableTestUtils
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences
|
||||
import org.whispersystems.signalservice.api.messages.SendMessageResult
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress
|
||||
import org.whispersystems.signalservice.internal.push.Content
|
||||
import java.util.Optional
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class MultiDeviceDeleteSendSyncJobTest {
|
||||
|
||||
@get:Rule
|
||||
val harness = SignalActivityRule(createGroup = true)
|
||||
|
||||
private lateinit var messageHelper: MessageHelper
|
||||
|
||||
private lateinit var success: SendMessageResult
|
||||
private lateinit var failure: SendMessageResult
|
||||
private lateinit var content: CapturingSlot<Content>
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
messageHelper = MessageHelper(harness)
|
||||
|
||||
mockkStatic(TextSecurePreferences::class)
|
||||
every { TextSecurePreferences.isMultiDevice(any()) } answers {
|
||||
true
|
||||
}
|
||||
|
||||
success = SendMessageResult.success(SignalServiceAddress(Recipient.self().requireServiceId()), listOf(2), true, false, 0, Optional.empty())
|
||||
failure = SendMessageResult.networkFailure(SignalServiceAddress(Recipient.self().requireServiceId()))
|
||||
content = slot<Content>()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
messageHelper.tearDown()
|
||||
|
||||
unmockkStatic(TextSecurePreferences::class)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun messageDeletes() {
|
||||
// GIVEN
|
||||
val messages = mutableListOf<MessageHelper.MessageData>()
|
||||
messages += messageHelper.incomingText()
|
||||
messages += messageHelper.incomingText()
|
||||
messages += messageHelper.outgoingText()
|
||||
|
||||
val threadId = SignalDatabase.threads.getThreadIdFor(messageHelper.alice)!!
|
||||
val records: Set<MessageRecord> = MessageTableTestUtils.getMessages(threadId).toSet()
|
||||
|
||||
// WHEN
|
||||
every { AppDependencies.signalServiceMessageSender.sendSyncMessage(capture(content), any(), any()) } returns success
|
||||
|
||||
val job = MultiDeviceDeleteSendSyncJob.createMessageDeletes(records)
|
||||
val result = job.run()
|
||||
|
||||
// THEN
|
||||
result.isSuccess assertIs true
|
||||
assertDeleteSync(messageHelper.alice, messages)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun groupMessageDeletes() {
|
||||
// GIVEN
|
||||
val messages = mutableListOf<MessageHelper.MessageData>()
|
||||
messages += messageHelper.incomingText(destination = messageHelper.group.recipientId)
|
||||
messages += messageHelper.incomingText(destination = messageHelper.group.recipientId)
|
||||
messages += messageHelper.outgoingText(conversationId = messageHelper.group.recipientId)
|
||||
|
||||
val threadId = SignalDatabase.threads.getThreadIdFor(messageHelper.group.recipientId)!!
|
||||
val records: Set<MessageRecord> = MessageTableTestUtils.getMessages(threadId).toSet()
|
||||
|
||||
// WHEN
|
||||
every { AppDependencies.signalServiceMessageSender.sendSyncMessage(capture(content), any(), any()) } returns success
|
||||
|
||||
val job = MultiDeviceDeleteSendSyncJob.createMessageDeletes(records)
|
||||
val result = job.run()
|
||||
|
||||
// THEN
|
||||
result.isSuccess assertIs true
|
||||
assertDeleteSync(messageHelper.group.recipientId, messages)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun retryOfDeletes() {
|
||||
// GIVEN
|
||||
val alice = messageHelper.alice.toLong()
|
||||
|
||||
// WHEN
|
||||
every { AppDependencies.signalServiceMessageSender.sendSyncMessage(capture(content), any(), any()) } returns failure
|
||||
|
||||
val job = MultiDeviceDeleteSendSyncJob(
|
||||
messages = listOf(DeleteSyncJobData.AddressableMessage(alice, 1, alice)),
|
||||
threads = listOf(DeleteSyncJobData.ThreadDelete(alice, listOf(DeleteSyncJobData.AddressableMessage(alice, 1, alice)))),
|
||||
localOnlyThreads = listOf(DeleteSyncJobData.ThreadDelete(alice))
|
||||
)
|
||||
|
||||
val result = job.run()
|
||||
val data = DeleteSyncJobData.ADAPTER.decode(job.serialize())
|
||||
|
||||
// THEN
|
||||
result.isRetry assertIs true
|
||||
data.messageDeletes.assertIsSize(1)
|
||||
data.threadDeletes.assertIsSize(1)
|
||||
data.localOnlyThreadDeletes.assertIsSize(1)
|
||||
}
|
||||
|
||||
private fun assertDeleteSync(conversation: RecipientId, inputMessages: List<MessageHelper.MessageData>) {
|
||||
val messagesMap = inputMessages.associateBy { it.timestamp }
|
||||
|
||||
val content = this.content.captured
|
||||
|
||||
content.syncMessage?.padding.assertIsNotNull()
|
||||
content.syncMessage?.deleteForMe.assertIsNotNull()
|
||||
|
||||
val deleteForMe = content.syncMessage!!.deleteForMe!!
|
||||
deleteForMe.messageDeletes.assertIsSize(1)
|
||||
deleteForMe.conversationDeletes.assertIsSize(0)
|
||||
deleteForMe.localOnlyConversationDeletes.assertIsSize(0)
|
||||
|
||||
val messageDeletes = deleteForMe.messageDeletes[0]
|
||||
val conversationRecipient = Recipient.resolved(conversation)
|
||||
if (conversationRecipient.isGroup) {
|
||||
messageDeletes.conversation!!.threadGroupId assertIs conversationRecipient.requireGroupId().decodedId.toByteString()
|
||||
} else {
|
||||
messageDeletes.conversation!!.threadAci assertIs conversationRecipient.requireAci().toString()
|
||||
}
|
||||
|
||||
messageDeletes
|
||||
.messages
|
||||
.forEach { delete ->
|
||||
val messageData = messagesMap[delete.sentTimestamp]
|
||||
delete.sentTimestamp assertIs messageData!!.timestamp
|
||||
delete.authorAci assertIs Recipient.resolved(messageData.author).requireAci().toString()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,23 +5,31 @@
|
||||
|
||||
package org.thoughtcrime.securesms.messages
|
||||
|
||||
import android.net.Uri
|
||||
import io.mockk.every
|
||||
import io.mockk.mockkStatic
|
||||
import io.mockk.slot
|
||||
import io.mockk.unmockkStatic
|
||||
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.UriAttachmentBuilder
|
||||
import org.thoughtcrime.securesms.database.model.GroupsV2UpdateMessageConverter
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.DecryptedGroupV2Context
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.GV2UpdateDescription
|
||||
import org.thoughtcrime.securesms.jobs.ThreadUpdateJob
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.mms.OutgoingMessage
|
||||
import org.thoughtcrime.securesms.providers.BlobProvider
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.testing.GroupTestingUtils
|
||||
import org.thoughtcrime.securesms.testing.MessageContentFuzzer
|
||||
import org.thoughtcrime.securesms.testing.SignalActivityRule
|
||||
import org.thoughtcrime.securesms.util.MediaUtil
|
||||
import java.util.UUID
|
||||
import kotlin.random.Random
|
||||
|
||||
/**
|
||||
* Makes inserting messages through the "normal" code paths simpler. Mostly focused on incoming messages.
|
||||
@@ -68,7 +76,7 @@ class MessageHelper(private val harness: SignalActivityRule, var startTime: Long
|
||||
return messageData
|
||||
}
|
||||
|
||||
fun outgoingText(conversationId: RecipientId = alice, successfulSend: Boolean = true, updateMessage: (OutgoingMessage.() -> OutgoingMessage)? = null): MessageData {
|
||||
fun outgoingText(conversationId: RecipientId = alice, successfulSend: Boolean = true, updateMessage: ((OutgoingMessage) -> OutgoingMessage)? = null): MessageData {
|
||||
startTime = nextStartTime()
|
||||
|
||||
val messageData = MessageData(author = harness.self.id, timestamp = startTime)
|
||||
@@ -80,7 +88,7 @@ class MessageHelper(private val harness: SignalActivityRule, var startTime: Long
|
||||
sentTimeMillis = messageData.timestamp,
|
||||
isUrgent = true,
|
||||
isSecure = true
|
||||
).apply { updateMessage?.invoke(this) }
|
||||
).let { updateMessage?.invoke(it) ?: it }
|
||||
|
||||
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(threadRecipient)
|
||||
val messageId = SignalDatabase.messages.insertMessageOutbox(message, threadId, false, null)
|
||||
@@ -111,6 +119,20 @@ class MessageHelper(private val harness: SignalActivityRule, var startTime: Long
|
||||
return messageData.copy(messageId = messageId)
|
||||
}
|
||||
|
||||
fun outgoingAttachment(data: ByteArray, uuid: UUID? = UUID.randomUUID()): Attachment {
|
||||
val uri: Uri = BlobProvider.getInstance().forData(data).createForSingleSessionInMemory()
|
||||
|
||||
val attachment: UriAttachment = UriAttachmentBuilder.build(
|
||||
id = Random.nextLong(),
|
||||
uri = uri,
|
||||
contentType = MediaUtil.IMAGE_JPEG,
|
||||
transformProperties = AttachmentTable.TransformProperties(),
|
||||
uuid = uuid
|
||||
)
|
||||
|
||||
return attachment
|
||||
}
|
||||
|
||||
fun outgoingGroupChange(): MessageData {
|
||||
startTime = nextStartTime()
|
||||
|
||||
@@ -123,7 +145,7 @@ class MessageHelper(private val harness: SignalActivityRule, var startTime: Long
|
||||
|
||||
val updateDescription = GV2UpdateDescription.Builder()
|
||||
.gv2ChangeDescription(decryptedGroupV2Context)
|
||||
.groupChangeUpdate(GroupsV2UpdateMessageConverter.translateDecryptedChange(SignalStore.account().getServiceIds(), decryptedGroupV2Context))
|
||||
.groupChangeUpdate(GroupsV2UpdateMessageConverter.translateDecryptedChange(SignalStore.account.getServiceIds(), decryptedGroupV2Context))
|
||||
.build()
|
||||
|
||||
val outgoingMessage = OutgoingMessage.groupUpdateMessage(groupRecipient, updateDescription, startTime)
|
||||
@@ -238,6 +260,20 @@ class MessageHelper(private val harness: SignalActivityRule, var startTime: Long
|
||||
return messageData
|
||||
}
|
||||
|
||||
fun syncDeleteForMeAttachment(conversationId: RecipientId, message: Pair<RecipientId, Long>, uuid: UUID?, digest: ByteArray?, plainTextHash: String?): MessageData {
|
||||
startTime = nextStartTime()
|
||||
val messageData = MessageData(timestamp = startTime)
|
||||
|
||||
processor.process(
|
||||
envelope = MessageContentFuzzer.envelope(messageData.timestamp, serverGuid = messageData.serverGuid),
|
||||
content = MessageContentFuzzer.syncDeleteForMeAttachment(conversationId, message, uuid, digest, plainTextHash),
|
||||
metadata = MessageContentFuzzer.envelopeMetadata(harness.self.id, harness.self.id, sourceDeviceId = 2),
|
||||
serverDeliveredTimestamp = messageData.timestamp + 10
|
||||
)
|
||||
|
||||
return messageData
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the next "sentTimestamp" for current + [nextMessageOffset]th message. Useful for early message processing and future message timestamps.
|
||||
*/
|
||||
|
||||
@@ -16,7 +16,7 @@ import org.signal.core.util.logging.Log
|
||||
import org.signal.libsignal.protocol.ecc.Curve
|
||||
import org.signal.libsignal.protocol.ecc.ECKeyPair
|
||||
import org.signal.libsignal.zkgroup.profiles.ProfileKey
|
||||
import org.thoughtcrime.securesms.crypto.UnidentifiedAccessUtil
|
||||
import org.thoughtcrime.securesms.crypto.SealedSenderAccessUtil
|
||||
import org.thoughtcrime.securesms.dependencies.InstrumentationApplicationDependencyProvider
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.testing.AliceClient
|
||||
@@ -55,8 +55,8 @@ class MessageProcessingPerformanceTest {
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
mockkStatic(UnidentifiedAccessUtil::class)
|
||||
every { UnidentifiedAccessUtil.getCertificateValidator() } returns FakeClientHelpers.noOpCertificateValidator
|
||||
mockkStatic(SealedSenderAccessUtil::class)
|
||||
every { SealedSenderAccessUtil.getCertificateValidator() } returns FakeClientHelpers.noOpCertificateValidator
|
||||
|
||||
mockkObject(MessageContentProcessor)
|
||||
every { MessageContentProcessor.create(harness.application) } returns TimingMessageContentProcessor(harness.application)
|
||||
@@ -64,7 +64,7 @@ class MessageProcessingPerformanceTest {
|
||||
|
||||
@After
|
||||
fun after() {
|
||||
unmockkStatic(UnidentifiedAccessUtil::class)
|
||||
unmockkStatic(SealedSenderAccessUtil::class)
|
||||
unmockkStatic(MessageContentProcessor::class)
|
||||
}
|
||||
|
||||
|
||||
@@ -6,15 +6,19 @@
|
||||
package org.thoughtcrime.securesms.messages
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import io.mockk.every
|
||||
import io.mockk.mockkStatic
|
||||
import io.mockk.unmockkStatic
|
||||
import org.hamcrest.Matchers.greaterThan
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Ignore
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.core.util.update
|
||||
import org.signal.core.util.withinTransaction
|
||||
import org.thoughtcrime.securesms.attachments.Attachment
|
||||
import org.thoughtcrime.securesms.attachments.DatabaseAttachment
|
||||
import org.thoughtcrime.securesms.database.AttachmentTable
|
||||
import org.thoughtcrime.securesms.database.CallTable
|
||||
import org.thoughtcrime.securesms.database.MessageTable
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
@@ -25,14 +29,20 @@ import org.thoughtcrime.securesms.testing.MessageContentFuzzer.DeleteForMeSync
|
||||
import org.thoughtcrime.securesms.testing.SignalActivityRule
|
||||
import org.thoughtcrime.securesms.testing.assert
|
||||
import org.thoughtcrime.securesms.testing.assertIs
|
||||
import org.thoughtcrime.securesms.testing.assertIsNot
|
||||
import org.thoughtcrime.securesms.testing.assertIsNotNull
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags
|
||||
import org.thoughtcrime.securesms.testing.assertIsSize
|
||||
import org.thoughtcrime.securesms.util.IdentityUtil
|
||||
import java.util.UUID
|
||||
|
||||
@Suppress("ClassName")
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class SyncMessageProcessorTest_synchronizeDeleteForMe {
|
||||
|
||||
companion object {
|
||||
private val TAG = "SyncDeleteForMeTest"
|
||||
}
|
||||
|
||||
@get:Rule
|
||||
val harness = SignalActivityRule(createGroup = true)
|
||||
|
||||
@@ -41,16 +51,11 @@ class SyncMessageProcessorTest_synchronizeDeleteForMe {
|
||||
@Before
|
||||
fun setUp() {
|
||||
messageHelper = MessageHelper(harness)
|
||||
|
||||
mockkStatic(FeatureFlags::class)
|
||||
every { FeatureFlags.deleteSyncEnabled() } returns true
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
messageHelper.tearDown()
|
||||
|
||||
unmockkStatic(FeatureFlags::class)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -256,7 +261,7 @@ class SyncMessageProcessorTest_synchronizeDeleteForMe {
|
||||
}
|
||||
|
||||
messageHelper.syncDeleteForMeConversation(
|
||||
DeleteForMeSync(conversationId = messageHelper.alice, randomFutureMessages, true)
|
||||
DeleteForMeSync(conversationId = messageHelper.alice, randomFutureMessages, isFullDelete = true)
|
||||
)
|
||||
|
||||
// THEN
|
||||
@@ -268,7 +273,7 @@ class SyncMessageProcessorTest_synchronizeDeleteForMe {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun localOnlyRemainingAfterConversationDeleteWithFullDelete() {
|
||||
fun singleConversationNoRecentsFoundNonExpiringRecentsFoundDelete() {
|
||||
// GIVEN
|
||||
val messages = mutableListOf<MessageTable.SyncMessageId>()
|
||||
|
||||
@@ -277,15 +282,52 @@ class SyncMessageProcessorTest_synchronizeDeleteForMe {
|
||||
messages += MessageTable.SyncMessageId(harness.self.id, messageHelper.outgoingText().timestamp)
|
||||
}
|
||||
|
||||
val threadId = SignalDatabase.threads.getThreadIdFor(messageHelper.alice)!!
|
||||
SignalDatabase.messages.getMessageCountForThread(threadId) assertIs 20
|
||||
|
||||
// WHEN
|
||||
val nonExpiringMessages = messages.takeLast(5).map { it.recipientId to it.timetamp }
|
||||
|
||||
val randomFutureMessages = (1..5).map {
|
||||
messageHelper.alice to messageHelper.nextStartTime(it)
|
||||
}
|
||||
|
||||
messageHelper.syncDeleteForMeConversation(
|
||||
DeleteForMeSync(conversationId = messageHelper.alice, randomFutureMessages, nonExpiringMessages, true)
|
||||
)
|
||||
|
||||
// THEN
|
||||
SignalDatabase.messages.getMessageCountForThread(threadId) assertIs 0
|
||||
SignalDatabase.threads.getThreadRecord(threadId) assertIs null
|
||||
|
||||
harness.inMemoryLogger.flush()
|
||||
harness.inMemoryLogger.entries().filter { it.message?.contains("Using backup non-expiring messages") == true }.size assertIs 1
|
||||
}
|
||||
|
||||
@Test
|
||||
fun localOnlyRemainingAfterConversationDeleteWithFullDelete() {
|
||||
// GIVEN
|
||||
val messages = mutableListOf<MessageTable.SyncMessageId>()
|
||||
|
||||
Log.v(TAG, "Adding normal messages")
|
||||
for (i in 0 until 10) {
|
||||
messages += MessageTable.SyncMessageId(messageHelper.alice, messageHelper.incomingText().timestamp)
|
||||
messages += MessageTable.SyncMessageId(harness.self.id, messageHelper.outgoingText().timestamp)
|
||||
}
|
||||
|
||||
val alice = Recipient.resolved(messageHelper.alice)
|
||||
Log.v(TAG, "Adding identity message")
|
||||
IdentityUtil.markIdentityVerified(harness.context, alice, true, true)
|
||||
Log.v(TAG, "Adding profile message")
|
||||
SignalDatabase.messages.insertProfileNameChangeMessages(alice, "new name", "previous name")
|
||||
Log.v(TAG, "Adding call message")
|
||||
SignalDatabase.calls.insertOneToOneCall(1, System.currentTimeMillis(), alice.id, CallTable.Type.AUDIO_CALL, CallTable.Direction.OUTGOING, CallTable.Event.ACCEPTED)
|
||||
|
||||
val threadId = SignalDatabase.threads.getThreadIdFor(messageHelper.alice)!!
|
||||
SignalDatabase.messages.getMessageCountForThread(threadId) assertIs 23
|
||||
|
||||
// WHEN
|
||||
Log.v(TAG, "Processing sync message")
|
||||
messageHelper.syncDeleteForMeConversation(
|
||||
DeleteForMeSync(
|
||||
conversationId = messageHelper.alice,
|
||||
@@ -380,8 +422,8 @@ class SyncMessageProcessorTest_synchronizeDeleteForMe {
|
||||
|
||||
// WHEN
|
||||
messageHelper.syncDeleteForMeConversation(
|
||||
DeleteForMeSync(conversationId = messageHelper.alice, allMessages[messageHelper.alice]!!.takeLast(5).map { it.recipientId to it.timetamp }, true),
|
||||
DeleteForMeSync(conversationId = messageHelper.bob, allMessages[messageHelper.bob]!!.takeLast(5).map { it.recipientId to it.timetamp }, true)
|
||||
DeleteForMeSync(conversationId = messageHelper.alice, allMessages[messageHelper.alice]!!.takeLast(5).map { it.recipientId to it.timetamp }, isFullDelete = true),
|
||||
DeleteForMeSync(conversationId = messageHelper.bob, allMessages[messageHelper.bob]!!.takeLast(5).map { it.recipientId to it.timetamp }, isFullDelete = true)
|
||||
)
|
||||
|
||||
// THEN
|
||||
@@ -418,6 +460,7 @@ class SyncMessageProcessorTest_synchronizeDeleteForMe {
|
||||
SignalDatabase.threads.getThreadRecord(aliceThreadId) assertIs null
|
||||
}
|
||||
|
||||
@Ignore("counts are consistent for some reason")
|
||||
@Test
|
||||
fun multipleLocalOnlyConversation() {
|
||||
// GIVEN
|
||||
@@ -468,8 +511,10 @@ class SyncMessageProcessorTest_synchronizeDeleteForMe {
|
||||
SignalDatabase.messages.deleteMessage(messageId = oneToOnePlaceHolderMessage, threadId = aliceThreadId, notify = false, updateThread = false)
|
||||
SignalDatabase.messages.deleteMessage(messageId = groupPlaceholderMessage, threadId = aliceThreadId, notify = false, updateThread = false)
|
||||
|
||||
SignalDatabase.messages.getMessageCountForThread(aliceThreadId) assertIs 16
|
||||
SignalDatabase.messages.getMessageCountForThread(groupThreadId) assertIs 10
|
||||
SignalDatabase.rawDatabase.withinTransaction {
|
||||
SignalDatabase.messages.getMessageCountForThread(aliceThreadId) assertIs 16
|
||||
SignalDatabase.messages.getMessageCountForThread(groupThreadId) assertIs 10
|
||||
}
|
||||
|
||||
// WHEN
|
||||
messageHelper.syncDeleteForMeLocalOnlyConversation(messageHelper.alice, messageHelper.group.recipientId)
|
||||
@@ -505,4 +550,154 @@ class SyncMessageProcessorTest_synchronizeDeleteForMe {
|
||||
harness.inMemoryLogger.flush()
|
||||
harness.inMemoryLogger.entries().filter { it.message?.contains("Thread is not local only") == true }.size assertIs 1
|
||||
}
|
||||
|
||||
@Test
|
||||
fun singleAttachmentDeletes() {
|
||||
// GIVEN
|
||||
val message1 = messageHelper.outgoingText { message ->
|
||||
message.copy(
|
||||
attachments = listOf(
|
||||
messageHelper.outgoingAttachment(byteArrayOf(1, 2, 3)),
|
||||
messageHelper.outgoingAttachment(byteArrayOf(2, 3, 4), null),
|
||||
messageHelper.outgoingAttachment(byteArrayOf(5, 6, 7), null),
|
||||
messageHelper.outgoingAttachment(byteArrayOf(10, 11, 12))
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
var attachments = SignalDatabase.attachments.getAttachmentsForMessage(message1.messageId)
|
||||
attachments assertIsSize 4
|
||||
|
||||
val threadId = SignalDatabase.threads.getThreadIdFor(messageHelper.alice)!!
|
||||
SignalDatabase.messages.getMessageCountForThread(threadId) assertIs 1
|
||||
|
||||
// Has all three
|
||||
SignalDatabase.attachments.finalizeAttachmentAfterUpload(
|
||||
id = attachments[0].attachmentId,
|
||||
attachment = attachments[0].copy(digest = byteArrayOf(attachments[0].attachmentId.id.toByte())),
|
||||
uploadTimestamp = message1.timestamp + 1
|
||||
)
|
||||
|
||||
// Missing uuid and digest
|
||||
SignalDatabase.attachments.finalizeAttachmentAfterUpload(
|
||||
id = attachments[1].attachmentId,
|
||||
attachment = attachments[1],
|
||||
uploadTimestamp = message1.timestamp + 1
|
||||
)
|
||||
|
||||
// Missing uuid and plain text
|
||||
SignalDatabase.attachments.finalizeAttachmentAfterUpload(
|
||||
id = attachments[2].attachmentId,
|
||||
attachment = attachments[2].copy(digest = byteArrayOf(attachments[2].attachmentId.id.toByte())),
|
||||
uploadTimestamp = message1.timestamp + 1
|
||||
)
|
||||
SignalDatabase.rawDatabase.update(AttachmentTable.TABLE_NAME).values(AttachmentTable.DATA_HASH_END to null).where("${AttachmentTable.ID} = ?", attachments[2].attachmentId).run()
|
||||
|
||||
// Different has all three
|
||||
SignalDatabase.attachments.finalizeAttachmentAfterUpload(
|
||||
id = attachments[3].attachmentId,
|
||||
attachment = attachments[3].copy(digest = byteArrayOf(attachments[3].attachmentId.id.toByte())),
|
||||
uploadTimestamp = message1.timestamp + 1
|
||||
)
|
||||
|
||||
attachments = SignalDatabase.attachments.getAttachmentsForMessage(message1.messageId)
|
||||
|
||||
// WHEN
|
||||
messageHelper.syncDeleteForMeAttachment(
|
||||
conversationId = messageHelper.alice,
|
||||
message = message1.author to message1.timestamp,
|
||||
attachments[0].uuid,
|
||||
attachments[0].remoteDigest,
|
||||
attachments[0].dataHash
|
||||
)
|
||||
|
||||
SignalDatabase.messages.getMessageCountForThread(threadId) assertIs 1
|
||||
var updatedAttachments = SignalDatabase.attachments.getAttachmentsForMessage(message1.messageId)
|
||||
updatedAttachments assertIsSize 3
|
||||
updatedAttachments.forEach { it.attachmentId assertIsNot attachments[0].attachmentId }
|
||||
|
||||
messageHelper.syncDeleteForMeAttachment(
|
||||
conversationId = messageHelper.alice,
|
||||
message = message1.author to message1.timestamp,
|
||||
attachments[1].uuid,
|
||||
attachments[1].remoteDigest,
|
||||
attachments[1].dataHash
|
||||
)
|
||||
|
||||
SignalDatabase.messages.getMessageCountForThread(threadId) assertIs 1
|
||||
updatedAttachments = SignalDatabase.attachments.getAttachmentsForMessage(message1.messageId)
|
||||
updatedAttachments assertIsSize 2
|
||||
updatedAttachments.forEach { it.attachmentId assertIsNot attachments[1].attachmentId }
|
||||
|
||||
messageHelper.syncDeleteForMeAttachment(
|
||||
conversationId = messageHelper.alice,
|
||||
message = message1.author to message1.timestamp,
|
||||
attachments[2].uuid,
|
||||
attachments[2].remoteDigest,
|
||||
attachments[2].dataHash
|
||||
)
|
||||
|
||||
SignalDatabase.messages.getMessageCountForThread(threadId) assertIs 1
|
||||
updatedAttachments = SignalDatabase.attachments.getAttachmentsForMessage(message1.messageId)
|
||||
updatedAttachments assertIsSize 1
|
||||
updatedAttachments.forEach { it.attachmentId assertIsNot attachments[2].attachmentId }
|
||||
|
||||
messageHelper.syncDeleteForMeAttachment(
|
||||
conversationId = messageHelper.alice,
|
||||
message = message1.author to message1.timestamp,
|
||||
attachments[3].uuid,
|
||||
attachments[3].remoteDigest,
|
||||
attachments[3].dataHash
|
||||
)
|
||||
|
||||
SignalDatabase.messages.getMessageCountForThread(threadId) assertIs 0
|
||||
updatedAttachments = SignalDatabase.attachments.getAttachmentsForMessage(message1.messageId)
|
||||
updatedAttachments assertIsSize 0
|
||||
|
||||
SignalDatabase.threads.getThreadRecord(threadId) assertIs null
|
||||
}
|
||||
|
||||
private fun DatabaseAttachment.copy(
|
||||
uuid: UUID? = this.uuid,
|
||||
digest: ByteArray? = this.remoteDigest
|
||||
): Attachment {
|
||||
return DatabaseAttachment(
|
||||
attachmentId = this.attachmentId,
|
||||
mmsId = this.mmsId,
|
||||
hasData = this.hasData,
|
||||
hasThumbnail = false,
|
||||
hasArchiveThumbnail = false,
|
||||
contentType = this.contentType,
|
||||
transferProgress = this.transferState,
|
||||
size = this.size,
|
||||
fileName = this.fileName,
|
||||
cdn = this.cdn,
|
||||
location = this.remoteLocation,
|
||||
key = this.remoteKey,
|
||||
digest = digest,
|
||||
incrementalDigest = this.incrementalDigest,
|
||||
incrementalMacChunkSize = this.incrementalMacChunkSize,
|
||||
fastPreflightId = this.fastPreflightId,
|
||||
voiceNote = this.voiceNote,
|
||||
borderless = this.borderless,
|
||||
videoGif = this.videoGif,
|
||||
width = this.width,
|
||||
height = this.height,
|
||||
quote = this.quote,
|
||||
caption = this.caption,
|
||||
stickerLocator = this.stickerLocator,
|
||||
blurHash = this.blurHash,
|
||||
audioHash = this.audioHash,
|
||||
transformProperties = this.transformProperties,
|
||||
displayOrder = this.displayOrder,
|
||||
uploadTimestamp = this.uploadTimestamp,
|
||||
dataHash = this.dataHash,
|
||||
archiveCdn = this.archiveCdn,
|
||||
archiveThumbnailCdn = this.archiveThumbnailCdn,
|
||||
archiveMediaName = this.archiveMediaName,
|
||||
archiveMediaId = this.archiveMediaId,
|
||||
thumbnailRestoreState = this.thumbnailRestoreState,
|
||||
uuid = uuid
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.testing.assertIs
|
||||
import org.thoughtcrime.securesms.testing.assertIsNotNull
|
||||
import org.whispersystems.signalservice.api.subscriptions.SubscriberId
|
||||
import java.util.Currency
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class SubscriberIdMigrationJobTest {
|
||||
@@ -35,10 +36,10 @@ class SubscriberIdMigrationJobTest {
|
||||
@Test
|
||||
fun givenUSDSubscriber_whenIRunSubscriberIdMigrationJob_thenIExpectASingleEntry() {
|
||||
val subscriberId = SubscriberId.generate()
|
||||
SignalStore.donationsValues().setSubscriberCurrency("USD", InAppPaymentSubscriberRecord.Type.DONATION)
|
||||
SignalStore.donationsValues().setSubscriber("USD", subscriberId)
|
||||
SignalStore.donationsValues().setSubscriptionPaymentSourceType(PaymentSourceType.PayPal)
|
||||
SignalStore.donationsValues().shouldCancelSubscriptionBeforeNextSubscribeAttempt = true
|
||||
SignalStore.inAppPayments.setSubscriberCurrency(Currency.getInstance("USD"), InAppPaymentSubscriberRecord.Type.DONATION)
|
||||
SignalStore.inAppPayments.setSubscriber("USD", subscriberId)
|
||||
SignalStore.inAppPayments.setSubscriptionPaymentSourceType(PaymentSourceType.PayPal)
|
||||
SignalStore.inAppPayments.shouldCancelSubscriptionBeforeNextSubscribeAttempt = true
|
||||
|
||||
testSubject.run()
|
||||
|
||||
@@ -48,7 +49,7 @@ class SubscriberIdMigrationJobTest {
|
||||
actual!!.subscriberId.bytes assertIs subscriberId.bytes
|
||||
actual.paymentMethodType assertIs InAppPaymentData.PaymentMethodType.PAYPAL
|
||||
actual.requiresCancel assertIs true
|
||||
actual.currencyCode assertIs "USD"
|
||||
actual.currency assertIs Currency.getInstance("USD")
|
||||
actual.type assertIs InAppPaymentSubscriberRecord.Type.DONATION
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,9 +24,9 @@ class ContactRecordProcessorTest {
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
SignalStore.account().setE164(E164_SELF)
|
||||
SignalStore.account().setAci(ACI_SELF)
|
||||
SignalStore.account().setPni(PNI_SELF)
|
||||
SignalStore.account.setE164(E164_SELF)
|
||||
SignalStore.account.setAci(ACI_SELF)
|
||||
SignalStore.account.setPni(PNI_SELF)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -3,7 +3,6 @@ package org.thoughtcrime.securesms.testing
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.libsignal.protocol.ecc.ECKeyPair
|
||||
import org.signal.libsignal.zkgroup.profiles.ProfileKey
|
||||
import org.thoughtcrime.securesms.crypto.ProfileKeyUtil
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.messages.protocol.BufferedProtocolStore
|
||||
@@ -30,7 +29,7 @@ class AliceClient(val serviceId: ServiceId, val e164: String, val trustRoot: ECK
|
||||
uuid = serviceId.rawUuid,
|
||||
e164 = e164,
|
||||
deviceId = 1,
|
||||
identityKey = SignalStore.account().aciIdentityKey.publicKey.publicKey,
|
||||
identityKey = SignalStore.account.aciIdentityKey.publicKey.publicKey,
|
||||
expires = 31337
|
||||
)
|
||||
|
||||
@@ -50,7 +49,7 @@ class AliceClient(val serviceId: ServiceId, val e164: String, val trustRoot: ECK
|
||||
fun encrypt(now: Long, destination: Recipient): Envelope {
|
||||
return AppDependencies.signalServiceMessageSender.getEncryptedMessage(
|
||||
SignalServiceAddress(destination.requireServiceId(), destination.requireE164()),
|
||||
FakeClientHelpers.getTargetUnidentifiedAccess(ProfileKeyUtil.getSelfProfileKey(), ProfileKey(destination.profileKey), aliceSenderCertificate),
|
||||
FakeClientHelpers.getSealedSenderAccess(ProfileKey(destination.profileKey), aliceSenderCertificate),
|
||||
1,
|
||||
FakeClientHelpers.encryptedTextMessage(now),
|
||||
false
|
||||
|
||||
@@ -17,7 +17,7 @@ import org.signal.libsignal.protocol.state.SignedPreKeyRecord
|
||||
import org.signal.libsignal.protocol.util.KeyHelper
|
||||
import org.signal.libsignal.zkgroup.profiles.ProfileKey
|
||||
import org.thoughtcrime.securesms.crypto.ProfileKeyUtil
|
||||
import org.thoughtcrime.securesms.crypto.UnidentifiedAccessUtil
|
||||
import org.thoughtcrime.securesms.crypto.SealedSenderAccessUtil
|
||||
import org.thoughtcrime.securesms.database.OneTimePreKeyTable
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.SignedPreKeyTable
|
||||
@@ -25,14 +25,13 @@ import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.testing.FakeClientHelpers.toEnvelope
|
||||
import org.whispersystems.signalservice.api.SignalServiceAccountDataStore
|
||||
import org.whispersystems.signalservice.api.SignalSessionLock
|
||||
import org.whispersystems.signalservice.api.crypto.SealedSenderAccess
|
||||
import org.whispersystems.signalservice.api.crypto.SignalServiceCipher
|
||||
import org.whispersystems.signalservice.api.crypto.SignalSessionBuilder
|
||||
import org.whispersystems.signalservice.api.crypto.UnidentifiedAccess
|
||||
import org.whispersystems.signalservice.api.push.DistributionId
|
||||
import org.whispersystems.signalservice.api.push.ServiceId
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress
|
||||
import org.whispersystems.signalservice.internal.push.Envelope
|
||||
import java.util.Optional
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.locks.ReentrantLock
|
||||
|
||||
@@ -75,12 +74,12 @@ class BobClient(val serviceId: ServiceId, val e164: String, val identityKeyPair:
|
||||
}
|
||||
|
||||
fun decrypt(envelope: Envelope, serverDeliveredTimestamp: Long) {
|
||||
val cipher = SignalServiceCipher(serviceAddress, 1, aciStore, sessionLock, UnidentifiedAccessUtil.getCertificateValidator())
|
||||
val cipher = SignalServiceCipher(serviceAddress, 1, aciStore, sessionLock, SealedSenderAccessUtil.getCertificateValidator())
|
||||
cipher.decrypt(envelope, serverDeliveredTimestamp)
|
||||
}
|
||||
|
||||
private fun getAliceServiceId(): ServiceId {
|
||||
return SignalStore.account().requireAci()
|
||||
return SignalStore.account.requireAci()
|
||||
}
|
||||
|
||||
private fun getAlicePreKeyBundle(): PreKeyBundle {
|
||||
@@ -103,7 +102,7 @@ class BobClient(val serviceId: ServiceId, val e164: String, val identityKeyPair:
|
||||
val selfSignedPreKeyRecord = SignalDatabase.signedPreKeys.get(getAliceServiceId(), selfSignedPreKeyId)!!
|
||||
|
||||
return PreKeyBundle(
|
||||
SignalStore.account().registrationId,
|
||||
SignalStore.account.registrationId,
|
||||
1,
|
||||
selfPreKeyId,
|
||||
selfPreKeyRecord.keyPair.publicKey,
|
||||
@@ -115,19 +114,19 @@ class BobClient(val serviceId: ServiceId, val e164: String, val identityKeyPair:
|
||||
}
|
||||
|
||||
private fun getAliceProtocolAddress(): SignalProtocolAddress {
|
||||
return SignalProtocolAddress(SignalStore.account().requireAci().toString(), 1)
|
||||
return SignalProtocolAddress(SignalStore.account.requireAci().toString(), 1)
|
||||
}
|
||||
|
||||
private fun getAlicePublicKey(): IdentityKey {
|
||||
return SignalStore.account().aciIdentityKey.publicKey
|
||||
return SignalStore.account.aciIdentityKey.publicKey
|
||||
}
|
||||
|
||||
private fun getAliceProfileKey(): ProfileKey {
|
||||
return ProfileKeyUtil.getSelfProfileKey()
|
||||
}
|
||||
|
||||
private fun getAliceUnidentifiedAccess(): Optional<UnidentifiedAccess> {
|
||||
return FakeClientHelpers.getTargetUnidentifiedAccess(profileKey, getAliceProfileKey(), senderCertificate)
|
||||
private fun getAliceUnidentifiedAccess(): SealedSenderAccess? {
|
||||
return FakeClientHelpers.getSealedSenderAccess(getAliceProfileKey(), senderCertificate)
|
||||
}
|
||||
|
||||
private class BobSignalServiceAccountDataStore(private val registrationId: Int, private val identityKeyPair: IdentityKeyPair) : SignalServiceAccountDataStore {
|
||||
@@ -144,7 +143,7 @@ class BobClient(val serviceId: ServiceId, val e164: String, val identityKeyPair:
|
||||
}
|
||||
override fun getSubDeviceSessions(name: String?): List<Int> = emptyList()
|
||||
override fun containsSession(address: SignalProtocolAddress?): Boolean = aliceSessionRecord != null
|
||||
override fun getIdentity(address: SignalProtocolAddress?): IdentityKey = SignalStore.account().aciIdentityKey.publicKey
|
||||
override fun getIdentity(address: SignalProtocolAddress?): IdentityKey = SignalStore.account.aciIdentityKey.publicKey
|
||||
override fun loadPreKey(preKeyId: Int): PreKeyRecord = throw UnsupportedOperationException()
|
||||
override fun storePreKey(preKeyId: Int, record: PreKeyRecord?) = throw UnsupportedOperationException()
|
||||
override fun containsPreKey(preKeyId: Int): Boolean = throw UnsupportedOperationException()
|
||||
|
||||
@@ -14,8 +14,8 @@ import org.signal.libsignal.zkgroup.profiles.ProfileKey
|
||||
import org.thoughtcrime.securesms.messages.SignalServiceProtoUtil.buildWith
|
||||
import org.whispersystems.signalservice.api.crypto.ContentHint
|
||||
import org.whispersystems.signalservice.api.crypto.EnvelopeContent
|
||||
import org.whispersystems.signalservice.api.crypto.SealedSenderAccess
|
||||
import org.whispersystems.signalservice.api.crypto.UnidentifiedAccess
|
||||
import org.whispersystems.signalservice.api.crypto.UnidentifiedAccessPair
|
||||
import org.whispersystems.signalservice.api.push.ServiceId
|
||||
import org.whispersystems.signalservice.internal.push.Content
|
||||
import org.whispersystems.signalservice.internal.push.DataMessage
|
||||
@@ -46,11 +46,10 @@ object FakeClientHelpers {
|
||||
}
|
||||
}
|
||||
|
||||
fun getTargetUnidentifiedAccess(myProfileKey: ProfileKey, theirProfileKey: ProfileKey, senderCertificate: SenderCertificate): Optional<UnidentifiedAccess> {
|
||||
val selfUnidentifiedAccessKey = UnidentifiedAccess.deriveAccessKeyFrom(myProfileKey)
|
||||
val themUnidentifiedAccessKey = UnidentifiedAccess.deriveAccessKeyFrom(theirProfileKey)
|
||||
fun getSealedSenderAccess(theirProfileKey: ProfileKey, senderCertificate: SenderCertificate): SealedSenderAccess? {
|
||||
val themUnidentifiedAccessKey = UnidentifiedAccess(UnidentifiedAccess.deriveAccessKeyFrom(theirProfileKey), senderCertificate.serialized, false)
|
||||
|
||||
return UnidentifiedAccessPair(UnidentifiedAccess(selfUnidentifiedAccessKey, senderCertificate.serialized, false), UnidentifiedAccess(themUnidentifiedAccessKey, senderCertificate.serialized, false)).targetUnidentifiedAccess
|
||||
return SealedSenderAccess.forIndividual(themUnidentifiedAccessKey)
|
||||
}
|
||||
|
||||
fun encryptedTextMessage(now: Long, message: String = "Test body message"): EnvelopeContent {
|
||||
|
||||
@@ -33,7 +33,7 @@ object GroupTestingUtils {
|
||||
.title(MessageContentFuzzer.string())
|
||||
.build()
|
||||
|
||||
val groupId = SignalDatabase.groups.create(groupMasterKey, decryptedGroupState)!!
|
||||
val groupId = SignalDatabase.groups.create(groupMasterKey, decryptedGroupState, null)!!
|
||||
val groupRecipientId = SignalDatabase.recipients.getOrInsertFromGroupId(groupId)
|
||||
SignalDatabase.recipients.setProfileSharing(groupRecipientId, true)
|
||||
|
||||
|
||||
@@ -2,12 +2,15 @@ package org.thoughtcrime.securesms.testing
|
||||
|
||||
import okio.ByteString
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import org.signal.core.util.Base64
|
||||
import org.thoughtcrime.securesms.database.AttachmentTable
|
||||
import org.thoughtcrime.securesms.groups.GroupId
|
||||
import org.thoughtcrime.securesms.messages.SignalServiceProtoUtil.buildWith
|
||||
import org.thoughtcrime.securesms.messages.TestMessage
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.whispersystems.signalservice.api.crypto.EnvelopeMetadata
|
||||
import org.whispersystems.signalservice.api.util.UuidUtil
|
||||
import org.whispersystems.signalservice.internal.push.AttachmentPointer
|
||||
import org.whispersystems.signalservice.internal.push.BodyRange
|
||||
import org.whispersystems.signalservice.internal.push.Content
|
||||
@@ -162,12 +165,12 @@ object MessageContentFuzzer {
|
||||
conversation = if (conversation.isGroup) {
|
||||
SyncMessage.DeleteForMe.ConversationIdentifier(threadGroupId = conversation.requireGroupId().decodedId.toByteString())
|
||||
} else {
|
||||
SyncMessage.DeleteForMe.ConversationIdentifier(threadAci = conversation.requireAci().toString())
|
||||
SyncMessage.DeleteForMe.ConversationIdentifier(threadServiceId = conversation.requireAci().toString())
|
||||
},
|
||||
|
||||
messages = conversationDeletes.map { (author, timestamp) ->
|
||||
SyncMessage.DeleteForMe.AddressableMessage(
|
||||
authorAci = Recipient.resolved(author).requireAci().toString(),
|
||||
authorServiceId = Recipient.resolved(author).requireAci().toString(),
|
||||
sentTimestamp = timestamp
|
||||
)
|
||||
}
|
||||
@@ -184,23 +187,30 @@ object MessageContentFuzzer {
|
||||
.syncMessage(
|
||||
SyncMessage(
|
||||
deleteForMe = SyncMessage.DeleteForMe(
|
||||
conversationDeletes = allDeletes.map { (conversationId, conversationDeletes, isFullDelete) ->
|
||||
val conversation = Recipient.resolved(conversationId)
|
||||
conversationDeletes = allDeletes.map { delete ->
|
||||
val conversation = Recipient.resolved(delete.conversationId)
|
||||
SyncMessage.DeleteForMe.ConversationDelete(
|
||||
conversation = if (conversation.isGroup) {
|
||||
SyncMessage.DeleteForMe.ConversationIdentifier(threadGroupId = conversation.requireGroupId().decodedId.toByteString())
|
||||
} else {
|
||||
SyncMessage.DeleteForMe.ConversationIdentifier(threadAci = conversation.requireAci().toString())
|
||||
SyncMessage.DeleteForMe.ConversationIdentifier(threadServiceId = conversation.requireAci().toString())
|
||||
},
|
||||
|
||||
mostRecentMessages = conversationDeletes.map { (author, timestamp) ->
|
||||
mostRecentMessages = delete.messages.map { (author, timestamp) ->
|
||||
SyncMessage.DeleteForMe.AddressableMessage(
|
||||
authorAci = Recipient.resolved(author).requireAci().toString(),
|
||||
authorServiceId = Recipient.resolved(author).requireAci().toString(),
|
||||
sentTimestamp = timestamp
|
||||
)
|
||||
},
|
||||
|
||||
isFullDelete = isFullDelete
|
||||
mostRecentNonExpiringMessages = delete.nonExpiringMessages.map { (author, timestamp) ->
|
||||
SyncMessage.DeleteForMe.AddressableMessage(
|
||||
authorServiceId = Recipient.resolved(author).requireAci().toString(),
|
||||
sentTimestamp = timestamp
|
||||
)
|
||||
},
|
||||
|
||||
isFullDelete = delete.isFullDelete
|
||||
)
|
||||
}
|
||||
)
|
||||
@@ -220,7 +230,7 @@ object MessageContentFuzzer {
|
||||
conversation = if (conversation.isGroup) {
|
||||
SyncMessage.DeleteForMe.ConversationIdentifier(threadGroupId = conversation.requireGroupId().decodedId.toByteString())
|
||||
} else {
|
||||
SyncMessage.DeleteForMe.ConversationIdentifier(threadAci = conversation.requireAci().toString())
|
||||
SyncMessage.DeleteForMe.ConversationIdentifier(threadServiceId = conversation.requireAci().toString())
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -229,6 +239,35 @@ object MessageContentFuzzer {
|
||||
).build()
|
||||
}
|
||||
|
||||
fun syncDeleteForMeAttachment(conversationId: RecipientId, message: Pair<RecipientId, Long>, uuid: UUID?, digest: ByteArray?, plainTextHash: String?): Content {
|
||||
val conversation = Recipient.resolved(conversationId)
|
||||
|
||||
return Content
|
||||
.Builder()
|
||||
.syncMessage(
|
||||
SyncMessage(
|
||||
deleteForMe = SyncMessage.DeleteForMe(
|
||||
attachmentDeletes = listOf(
|
||||
SyncMessage.DeleteForMe.AttachmentDelete(
|
||||
conversation = if (conversation.isGroup) {
|
||||
SyncMessage.DeleteForMe.ConversationIdentifier(threadGroupId = conversation.requireGroupId().decodedId.toByteString())
|
||||
} else {
|
||||
SyncMessage.DeleteForMe.ConversationIdentifier(threadServiceId = conversation.requireAci().toString())
|
||||
},
|
||||
targetMessage = SyncMessage.DeleteForMe.AddressableMessage(
|
||||
authorServiceId = Recipient.resolved(message.first).requireAci().toString(),
|
||||
sentTimestamp = message.second
|
||||
),
|
||||
uuid = uuid?.let { UuidUtil.toByteString(it) },
|
||||
fallbackDigest = digest?.toByteString(),
|
||||
fallbackPlaintextHash = plainTextHash?.let { Base64.decodeOrNull(it)?.toByteString() }
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
).build()
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a random media message that may be:
|
||||
* - A text body
|
||||
@@ -373,7 +412,9 @@ object MessageContentFuzzer {
|
||||
data class DeleteForMeSync(
|
||||
val conversationId: RecipientId,
|
||||
val messages: List<Pair<RecipientId, Long>>,
|
||||
val isFullDelete: Boolean = true
|
||||
val nonExpiringMessages: List<Pair<RecipientId, Long>> = emptyList(),
|
||||
val isFullDelete: Boolean = true,
|
||||
val attachments: List<Pair<Long, AttachmentTable.SyncAttachmentId>> = emptyList()
|
||||
) {
|
||||
constructor(conversationId: RecipientId, vararg messages: Pair<RecipientId, Long>) : this(conversationId, messages.toList())
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@ object MockProvider {
|
||||
}
|
||||
}
|
||||
|
||||
fun createPreKeyResponse(identity: IdentityKeyPair = SignalStore.account().aciIdentityKey, deviceId: Int): PreKeyResponse {
|
||||
fun createPreKeyResponse(identity: IdentityKeyPair = SignalStore.account.aciIdentityKey, deviceId: Int): PreKeyResponse {
|
||||
val signedPreKeyRecord = PreKeyUtil.generateSignedPreKey(SecureRandom().nextInt(Medium.MAX_VALUE), identity.privateKey)
|
||||
val oneTimePreKey = PreKeyRecord(SecureRandom().nextInt(Medium.MAX_VALUE), Curve.generateKeyPair())
|
||||
|
||||
|
||||
@@ -55,5 +55,5 @@ inline fun <reified T> RecordedRequest.parsedRequestBody(): T {
|
||||
}
|
||||
|
||||
private fun defaultRequestPredicate(verb: String, path: String, predicate: RequestPredicate = { true }): RequestPredicate = { request ->
|
||||
request.method == verb && request.path.startsWith("/$path") && predicate(request)
|
||||
request.method == verb && request.path?.startsWith("/$path") == true && predicate(request)
|
||||
}
|
||||
|
||||
@@ -90,8 +90,8 @@ class SignalActivityRule(private val othersCount: Int = 4, private val createGro
|
||||
val preferences: SharedPreferences = application.getSharedPreferences(MasterSecretUtil.PREFERENCES_NAME, 0)
|
||||
preferences.edit().putBoolean("passphrase_initialized", true).commit()
|
||||
|
||||
SignalStore.account().generateAciIdentityKeyIfNecessary()
|
||||
SignalStore.account().generatePniIdentityKeyIfNecessary()
|
||||
SignalStore.account.generateAciIdentityKeyIfNecessary()
|
||||
SignalStore.account.generatePniIdentityKeyIfNecessary()
|
||||
|
||||
val registrationRepository = RegistrationRepository(application)
|
||||
|
||||
@@ -111,19 +111,19 @@ class SignalActivityRule(private val othersCount: Int = 4, private val createGro
|
||||
verifyAccountResponse = VerifyAccountResponse(UUID.randomUUID().toString(), UUID.randomUUID().toString(), false),
|
||||
masterKey = null,
|
||||
pin = null,
|
||||
aciPreKeyCollection = RegistrationRepository.generateSignedAndLastResortPreKeys(SignalStore.account().aciIdentityKey, SignalStore.account().aciPreKeys),
|
||||
pniPreKeyCollection = RegistrationRepository.generateSignedAndLastResortPreKeys(SignalStore.account().aciIdentityKey, SignalStore.account().pniPreKeys)
|
||||
aciPreKeyCollection = RegistrationRepository.generateSignedAndLastResortPreKeys(SignalStore.account.aciIdentityKey, SignalStore.account.aciPreKeys),
|
||||
pniPreKeyCollection = RegistrationRepository.generateSignedAndLastResortPreKeys(SignalStore.account.aciIdentityKey, SignalStore.account.pniPreKeys)
|
||||
),
|
||||
false
|
||||
).blockingGet()
|
||||
|
||||
ServiceResponseProcessor.DefaultProcessor(response).resultOrThrow
|
||||
|
||||
SignalStore.svr().optOut()
|
||||
SignalStore.svr.optOut()
|
||||
RegistrationUtil.maybeMarkRegistrationComplete()
|
||||
SignalDatabase.recipients.setProfileName(Recipient.self().id, ProfileName.fromParts("Tester", "McTesterson"))
|
||||
|
||||
SignalStore.settings().isMessageNotificationsEnabled = false
|
||||
SignalStore.settings.isMessageNotificationsEnabled = false
|
||||
|
||||
return Recipient.self()
|
||||
}
|
||||
@@ -141,7 +141,7 @@ class SignalActivityRule(private val othersCount: Int = 4, private val createGro
|
||||
val recipientId = RecipientId.from(SignalServiceAddress(aci, "+15555551%03d".format(i)))
|
||||
SignalDatabase.recipients.setProfileName(recipientId, ProfileName.fromParts("Buddy", "#$i"))
|
||||
SignalDatabase.recipients.setProfileKeyIfAbsent(recipientId, ProfileKeyUtil.createNew())
|
||||
SignalDatabase.recipients.setCapabilities(recipientId, SignalServiceProfile.Capabilities(true, true))
|
||||
SignalDatabase.recipients.setCapabilities(recipientId, SignalServiceProfile.Capabilities(true, false))
|
||||
SignalDatabase.recipients.setProfileSharing(recipientId, true)
|
||||
SignalDatabase.recipients.markRegistered(recipientId, aci)
|
||||
val otherIdentity = IdentityKeyUtil.generateIdentityKeyPair()
|
||||
|
||||
@@ -26,8 +26,8 @@ class SignalDatabaseRule(
|
||||
override fun starting(description: Description?) {
|
||||
deleteAllThreads()
|
||||
|
||||
SignalStore.account().setAci(localAci)
|
||||
SignalStore.account().setPni(localPni)
|
||||
SignalStore.account.setAci(localAci)
|
||||
SignalStore.account.setPni(localPni)
|
||||
}
|
||||
|
||||
override fun finished(description: Description?) {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package org.thoughtcrime.securesms.testing
|
||||
|
||||
import android.database.Cursor
|
||||
import android.util.Base64
|
||||
import org.hamcrest.Matcher
|
||||
import org.hamcrest.MatcherAssert.assertThat
|
||||
import org.hamcrest.Matchers.hasSize
|
||||
@@ -9,6 +8,7 @@ import org.hamcrest.Matchers.`is`
|
||||
import org.hamcrest.Matchers.not
|
||||
import org.hamcrest.Matchers.notNullValue
|
||||
import org.hamcrest.Matchers.nullValue
|
||||
import org.signal.core.util.Hex
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.core.util.readToList
|
||||
import org.signal.core.util.select
|
||||
@@ -67,27 +67,31 @@ fun CountDownLatch.awaitFor(duration: Duration) {
|
||||
}
|
||||
}
|
||||
|
||||
fun dumpTableToLogs(tag: String = "TestUtils", table: String) {
|
||||
dumpTable(table).forEach { Log.d(tag, it.toString()) }
|
||||
fun dumpTableToLogs(tag: String = "TestUtils", table: String, columns: Set<String>? = null) {
|
||||
dumpTable(table, columns).forEach { Log.d(tag, it.toString()) }
|
||||
}
|
||||
|
||||
fun dumpTable(table: String): List<List<Pair<String, String?>>> {
|
||||
fun dumpTable(table: String, columns: Set<String>?): List<List<Pair<String, String?>>> {
|
||||
return SignalDatabase.rawDatabase
|
||||
.select()
|
||||
.from(table)
|
||||
.run()
|
||||
.readToList { cursor ->
|
||||
val map: List<Pair<String, String?>> = cursor.columnNames.map { column ->
|
||||
val index = cursor.getColumnIndex(column)
|
||||
var data: String? = when (cursor.getType(index)) {
|
||||
Cursor.FIELD_TYPE_BLOB -> Base64.encodeToString(cursor.getBlob(index), 0)
|
||||
else -> cursor.getString(index)
|
||||
}
|
||||
if (table == MessageTable.TABLE_NAME && column == MessageTable.TYPE) {
|
||||
data = MessageTableTestUtils.typeColumnToString(cursor.getLong(index))
|
||||
}
|
||||
val map: List<Pair<String, String?>> = cursor.columnNames.mapNotNull { column ->
|
||||
if (columns == null || columns.contains(column)) {
|
||||
val index = cursor.getColumnIndex(column)
|
||||
var data: String? = when (cursor.getType(index)) {
|
||||
Cursor.FIELD_TYPE_BLOB -> Hex.toStringCondensed(cursor.getBlob(index))
|
||||
else -> cursor.getString(index)
|
||||
}
|
||||
if (table == MessageTable.TABLE_NAME && column == MessageTable.TYPE) {
|
||||
data = MessageTableTestUtils.typeColumnToString(cursor.getLong(index))
|
||||
}
|
||||
|
||||
column to data
|
||||
column to data
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
map
|
||||
}
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
package org.thoughtcrime.securesms.util;
|
||||
|
||||
/**
|
||||
* A class that allows us to inject feature flags during tests.
|
||||
*/
|
||||
public final class FeatureFlagsAccessor {
|
||||
|
||||
public static void forceValue(String key, Object value) {
|
||||
FeatureFlags.FORCED_VALUES.put(key, value);
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import android.content.Context
|
||||
import org.thoughtcrime.securesms.BuildConfig
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.push.AccountManagerFactory
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags
|
||||
import org.thoughtcrime.securesms.util.RemoteConfig
|
||||
import org.whispersystems.signalservice.api.SignalServiceAccountManager
|
||||
import org.whispersystems.signalservice.api.account.PreKeyUpload
|
||||
import org.whispersystems.signalservice.api.push.ServiceId.ACI
|
||||
@@ -23,8 +23,8 @@ class DummyAccountManagerFactory : AccountManagerFactory() {
|
||||
deviceId,
|
||||
password,
|
||||
BuildConfig.SIGNAL_AGENT,
|
||||
FeatureFlags.okHttpAutomaticRetry(),
|
||||
FeatureFlags.groupLimits().hardLimit
|
||||
RemoteConfig.okHttpAutomaticRetry,
|
||||
RemoteConfig.groupLimits.hardLimit
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -161,7 +161,8 @@ object TestMessages {
|
||||
false,
|
||||
Optional.empty(),
|
||||
Optional.empty(),
|
||||
System.currentTimeMillis()
|
||||
System.currentTimeMillis(),
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
@@ -184,7 +185,8 @@ object TestMessages {
|
||||
false,
|
||||
Optional.empty(),
|
||||
Optional.empty(),
|
||||
System.currentTimeMillis()
|
||||
System.currentTimeMillis(),
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -44,8 +44,8 @@ object TestUsers {
|
||||
val preferences: SharedPreferences = application.getSharedPreferences(MasterSecretUtil.PREFERENCES_NAME, 0)
|
||||
preferences.edit().putBoolean("passphrase_initialized", true).commit()
|
||||
|
||||
SignalStore.account().generateAciIdentityKeyIfNecessary()
|
||||
SignalStore.account().generatePniIdentityKeyIfNecessary()
|
||||
SignalStore.account.generateAciIdentityKeyIfNecessary()
|
||||
SignalStore.account.generatePniIdentityKeyIfNecessary()
|
||||
|
||||
val registrationRepository = RegistrationRepository(application)
|
||||
val registrationData = RegistrationData(
|
||||
@@ -63,8 +63,8 @@ object TestUsers {
|
||||
VerifyAccountResponse(UUID.randomUUID().toString(), UUID.randomUUID().toString(), false),
|
||||
masterKey = null,
|
||||
pin = null,
|
||||
aciPreKeyCollection = RegistrationRepository.generateSignedAndLastResortPreKeys(SignalStore.account().aciIdentityKey, SignalStore.account().aciPreKeys),
|
||||
pniPreKeyCollection = RegistrationRepository.generateSignedAndLastResortPreKeys(SignalStore.account().aciIdentityKey, SignalStore.account().pniPreKeys)
|
||||
aciPreKeyCollection = RegistrationRepository.generateSignedAndLastResortPreKeys(SignalStore.account.aciIdentityKey, SignalStore.account.aciPreKeys),
|
||||
pniPreKeyCollection = RegistrationRepository.generateSignedAndLastResortPreKeys(SignalStore.account.aciIdentityKey, SignalStore.account.pniPreKeys)
|
||||
)
|
||||
|
||||
AccountManagerFactory.setInstance(DummyAccountManagerFactory())
|
||||
@@ -77,7 +77,7 @@ object TestUsers {
|
||||
|
||||
ServiceResponseProcessor.DefaultProcessor(response).resultOrThrow
|
||||
|
||||
SignalStore.svr().optOut()
|
||||
SignalStore.svr.optOut()
|
||||
RegistrationUtil.maybeMarkRegistrationComplete()
|
||||
SignalDatabase.recipients.setProfileName(Recipient.self().id, ProfileName.fromParts("Tester", "McTesterson"))
|
||||
|
||||
|
||||
@@ -231,6 +231,10 @@ class InternalConversationTestFragment : Fragment(R.layout.conversation_test_fra
|
||||
Toast.makeText(requireContext(), "Can't touch this.", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
override fun onChangeProfileNameUpdateContact(recipient: Recipient) {
|
||||
Toast.makeText(requireContext(), "Can't touch this.", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
override fun onCallToAction(action: String) {
|
||||
Toast.makeText(requireContext(), "Can't touch this.", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
@@ -610,7 +610,7 @@
|
||||
<activity android:name=".conversation.v2.ConversationActivity"
|
||||
android:windowSoftInputMode="stateUnchanged"
|
||||
android:launchMode="singleTask"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize|uiMode"
|
||||
android:parentActivityName=".MainActivity"
|
||||
android:resizeableActivity="true"
|
||||
android:exported="false">
|
||||
@@ -661,7 +661,7 @@
|
||||
|
||||
<activity android:name=".PassphrasePromptActivity"
|
||||
android:launchMode="singleTask"
|
||||
android:theme="@style/TextSecure.LightIntroTheme"
|
||||
android:theme="@style/Theme.Signal.DayNight.NoActionBar"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
|
||||
android:exported="false"/>
|
||||
|
||||
@@ -748,13 +748,6 @@
|
||||
android:windowSoftInputMode="stateAlwaysHidden"
|
||||
android:exported="false"/>
|
||||
|
||||
<activity
|
||||
android:name=".backup.v2.ui.subscription.MessageBackupsFlowActivity"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
|
||||
android:exported="false"
|
||||
android:theme="@style/Signal.DayNight.NoActionBar"
|
||||
android:windowSoftInputMode="adjustResize" />
|
||||
|
||||
<activity
|
||||
android:name=".stories.settings.StorySettingsActivity"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
|
||||
@@ -783,12 +776,6 @@
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
|
||||
android:exported="false"/>
|
||||
|
||||
<activity
|
||||
android:name=".components.settings.app.changenumber.v2.ChangeNumberLockV2Activity"
|
||||
android:theme="@style/Signal.DayNight.NoActionBar"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
|
||||
android:exported="false"/>
|
||||
|
||||
<activity
|
||||
android:name=".components.settings.conversation.ConversationSettingsActivity"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|screenLayout|screenSize"
|
||||
@@ -803,13 +790,6 @@
|
||||
android:windowSoftInputMode="stateAlwaysHidden"
|
||||
android:exported="false"/>
|
||||
|
||||
<activity
|
||||
android:name=".badges.gifts.flow.GiftFlowActivity"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|screenLayout|screenSize"
|
||||
android:theme="@style/Signal.DayNight.NoActionBar"
|
||||
android:windowSoftInputMode="stateAlwaysHidden"
|
||||
android:exported="false"/>
|
||||
|
||||
<activity
|
||||
android:name=".wallpaper.ChatWallpaperActivity"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
|
||||
@@ -836,14 +816,7 @@
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
|
||||
android:exported="false"/>
|
||||
|
||||
<activity android:name=".registration.RegistrationNavigationActivity"
|
||||
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=".registration.v2.ui.RegistrationV2Activity"
|
||||
<activity android:name=".registration.ui.RegistrationActivity"
|
||||
android:launchMode="singleTask"
|
||||
android:theme="@style/Theme.Signal.DayNight.NoActionBar"
|
||||
android:windowSoftInputMode="stateHidden"
|
||||
@@ -988,8 +961,8 @@
|
||||
android:windowSoftInputMode="stateVisible|adjustResize"
|
||||
android:exported="false"/>
|
||||
|
||||
<activity android:name=".backup.v2.ui.subscription.MessageBackupsTestRestoreActivity"
|
||||
android:theme="@style/TextSecure.LightRegistrationTheme"
|
||||
<activity android:name=".registration.ui.restore.RemoteRestoreActivity"
|
||||
android:theme="@style/Signal.DayNight.NoActionBar"
|
||||
android:exported="false"/>
|
||||
|
||||
<activity android:name=".profiles.manage.EditProfileActivity"
|
||||
@@ -1105,7 +1078,7 @@
|
||||
android:theme="@style/Theme.Signal.WallpaperCropper"
|
||||
android:exported="false"/>
|
||||
|
||||
<activity android:name=".components.settings.app.usernamelinks.main.UsernameQrImageSelectionActivity"
|
||||
<activity android:name=".components.settings.app.usernamelinks.main.QrImageSelectionActivity"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
|
||||
android:theme="@style/TextSecure.DarkNoActionBar"
|
||||
android:exported="false"/>
|
||||
@@ -1120,10 +1093,10 @@
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
|
||||
android:exported="false"/>
|
||||
|
||||
<activity android:name=".components.settings.app.subscription.donate.DonateToSignalActivity"
|
||||
android:theme="@style/Theme.Signal.DayNight.NoActionBar"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
|
||||
android:exported="false"/>
|
||||
<activity android:name=".components.settings.app.subscription.donate.CheckoutFlowActivity"
|
||||
android:theme="@style/Theme.Signal.DayNight.NoActionBar"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
|
||||
android:exported="false"/>
|
||||
|
||||
<service
|
||||
android:enabled="true"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -11,13 +11,7 @@ object AppCapabilities {
|
||||
fun getCapabilities(storageCapable: Boolean): AccountAttributes.Capabilities {
|
||||
return AccountAttributes.Capabilities(
|
||||
storage = storageCapable,
|
||||
senderKey = true,
|
||||
announcementGroup = true,
|
||||
changeNumber = true,
|
||||
stories = true,
|
||||
giftBadges = true,
|
||||
pni = true,
|
||||
paymentActivation = true
|
||||
deleteSync = true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,6 +53,7 @@ import org.thoughtcrime.securesms.emoji.EmojiSource;
|
||||
import org.thoughtcrime.securesms.emoji.JumboEmoji;
|
||||
import org.thoughtcrime.securesms.gcm.FcmFetchManager;
|
||||
import org.thoughtcrime.securesms.jobs.AccountConsistencyWorkerJob;
|
||||
import org.thoughtcrime.securesms.jobs.BuildExpirationConfirmationJob;
|
||||
import org.thoughtcrime.securesms.jobs.CheckServiceReachabilityJob;
|
||||
import org.thoughtcrime.securesms.jobs.DownloadLatestEmojiDataJob;
|
||||
import org.thoughtcrime.securesms.jobs.EmojiSearchIndexDownloadJob;
|
||||
@@ -76,6 +77,7 @@ import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.logging.CustomSignalProtocolLogger;
|
||||
import org.thoughtcrime.securesms.logging.PersistentLogger;
|
||||
import org.thoughtcrime.securesms.messageprocessingalarm.RoutineMessageFetchReceiver;
|
||||
import org.thoughtcrime.securesms.messages.GroupSendEndorsementInternalNotifier;
|
||||
import org.thoughtcrime.securesms.migrations.ApplicationMigrations;
|
||||
import org.thoughtcrime.securesms.mms.SignalGlideComponents;
|
||||
import org.thoughtcrime.securesms.mms.SignalGlideModule;
|
||||
@@ -97,7 +99,7 @@ import org.thoughtcrime.securesms.storage.StorageSyncHelper;
|
||||
import org.thoughtcrime.securesms.util.AppForegroundObserver;
|
||||
import org.thoughtcrime.securesms.util.AppStartup;
|
||||
import org.thoughtcrime.securesms.util.DynamicTheme;
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags;
|
||||
import org.thoughtcrime.securesms.util.RemoteConfig;
|
||||
import org.thoughtcrime.securesms.util.SignalLocalMetrics;
|
||||
import org.thoughtcrime.securesms.util.SignalUncaughtExceptionHandler;
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
@@ -143,10 +145,6 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
|
||||
|
||||
long startTime = System.currentTimeMillis();
|
||||
|
||||
if (FeatureFlags.internalUser()) {
|
||||
Tracer.getInstance().setMaxBufferSize(35_000);
|
||||
}
|
||||
|
||||
super.onCreate();
|
||||
|
||||
AppStartup.getInstance().addBlocking("sqlcipher-init", () -> {
|
||||
@@ -159,12 +157,12 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
|
||||
initializeLogging();
|
||||
Log.i(TAG, "onCreate()");
|
||||
})
|
||||
.addBlocking("app-dependencies", this::initializeAppDependencies)
|
||||
.addBlocking("anr-detector", this::startAnrDetector)
|
||||
.addBlocking("security-provider", this::initializeSecurityProvider)
|
||||
.addBlocking("crash-handling", this::initializeCrashHandling)
|
||||
.addBlocking("rx-init", this::initializeRx)
|
||||
.addBlocking("event-bus", () -> EventBus.builder().logNoSubscriberMessages(false).installDefaultEventBus())
|
||||
.addBlocking("app-dependencies", this::initializeAppDependencies)
|
||||
.addBlocking("scrubber", () -> Scrubber.setIdentifierHmacKeyProvider(() -> SignalStore.svr().getOrCreateMasterKey().deriveLoggingKey()))
|
||||
.addBlocking("first-launch", this::initializeFirstEverAppLaunch)
|
||||
.addBlocking("app-migrations", this::initializeApplicationMigrations)
|
||||
@@ -178,9 +176,10 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
|
||||
}
|
||||
})
|
||||
.addBlocking("blob-provider", this::initializeBlobProvider)
|
||||
.addBlocking("feature-flags", FeatureFlags::init)
|
||||
.addBlocking("remote-config", RemoteConfig::init)
|
||||
.addBlocking("ring-rtc", this::initializeRingRtc)
|
||||
.addBlocking("glide", () -> SignalGlideModule.setRegisterGlideComponents(new SignalGlideComponents()))
|
||||
.addBlocking("tracer", this::initializeTracer)
|
||||
.addNonBlocking(() -> RegistrationUtil.maybeMarkRegistrationComplete())
|
||||
.addNonBlocking(() -> Glide.get(this))
|
||||
.addNonBlocking(this::cleanAvatarStorage)
|
||||
@@ -206,7 +205,7 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
|
||||
.addPostRender(RefreshSvrCredentialsJob::enqueueIfNecessary)
|
||||
.addPostRender(() -> DownloadLatestEmojiDataJob.scheduleIfNecessary(this))
|
||||
.addPostRender(EmojiSearchIndexDownloadJob::scheduleIfNecessary)
|
||||
.addPostRender(() -> SignalDatabase.messageLog().trimOldMessages(System.currentTimeMillis(), FeatureFlags.retryRespondMaxAge()))
|
||||
.addPostRender(() -> SignalDatabase.messageLog().trimOldMessages(System.currentTimeMillis(), RemoteConfig.retryRespondMaxAge()))
|
||||
.addPostRender(() -> JumboEmoji.updateCurrentVersion(this))
|
||||
.addPostRender(RetrieveRemoteAnnouncementsJob::enqueue)
|
||||
.addPostRender(() -> AndroidTelecomUtil.registerPhoneAccount())
|
||||
@@ -221,6 +220,7 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
|
||||
.addPostRender(GroupRingCleanupJob::enqueue)
|
||||
.addPostRender(LinkedDeviceInactiveCheckJob::enqueueIfNecessary)
|
||||
.addPostRender(() -> ActiveCallManager.clearNotifications(this))
|
||||
.addPostRender(() -> GroupSendEndorsementInternalNotifier.init())
|
||||
.execute();
|
||||
|
||||
Log.d(TAG, "onCreate() took " + (System.currentTimeMillis() - startTime) + " ms");
|
||||
@@ -237,12 +237,12 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
|
||||
AppDependencies.getMegaphoneRepository().onAppForegrounded();
|
||||
AppDependencies.getDeadlockDetector().start();
|
||||
InAppPaymentKeepAliveJob.enqueueAndTrackTimeIfNecessary();
|
||||
AppDependencies.getJobManager().add(new InAppPaymentAuthCheckJob());
|
||||
FcmFetchManager.onForeground(this);
|
||||
startAnrDetector();
|
||||
|
||||
SignalExecutors.BOUNDED.execute(() -> {
|
||||
FeatureFlags.refreshIfNecessary();
|
||||
InAppPaymentAuthCheckJob.enqueueIfNeeded();
|
||||
RemoteConfig.refreshIfNecessary();
|
||||
RetrieveProfileJob.enqueueRoutineFetchIfNecessary();
|
||||
executePendingContactSync();
|
||||
KeyCachingService.onAppForegrounded(this);
|
||||
@@ -255,7 +255,7 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
|
||||
long timeDiff = currentTime - lastForegroundTime;
|
||||
|
||||
if (timeDiff < 0) {
|
||||
Log.w(TAG, "Time travel! The system clock has moved backwards. (currentTime: " + currentTime + " ms, lastForegroundTime: " + lastForegroundTime + " ms, diff: " + timeDiff + " ms)");
|
||||
Log.w(TAG, "Time travel! The system clock has moved backwards. (currentTime: " + currentTime + " ms, lastForegroundTime: " + lastForegroundTime + " ms, diff: " + timeDiff + " ms)", true);
|
||||
}
|
||||
|
||||
SignalStore.misc().setLastForegroundTime(currentTime);
|
||||
@@ -277,9 +277,9 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
|
||||
}
|
||||
|
||||
public void checkBuildExpiration() {
|
||||
if (Util.getTimeUntilBuildExpiry() <= 0 && !SignalStore.misc().isClientDeprecated()) {
|
||||
Log.w(TAG, "Build expired!");
|
||||
SignalStore.misc().setClientDeprecated(true);
|
||||
if (Util.getTimeUntilBuildExpiry(SignalStore.misc().getEstimatedServerTime()) <= 0 && !SignalStore.misc().isClientDeprecated()) {
|
||||
Log.w(TAG, "Build potentially expired! Enqueing job to check.", true);
|
||||
AppDependencies.getJobManager().add(new BuildExpirationConfirmationJob());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -288,7 +288,7 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
|
||||
* This is so we can capture ANR's that happen on boot before the foreground event.
|
||||
*/
|
||||
private void startAnrDetector() {
|
||||
AnrDetector.start(TimeUnit.SECONDS.toMillis(5), FeatureFlags::internalUser, (dumps) -> {
|
||||
AnrDetector.start(TimeUnit.SECONDS.toMillis(5), RemoteConfig::internalUser, (dumps) -> {
|
||||
LogDatabase.getInstance(this).anrs().save(System.currentTimeMillis(), dumps);
|
||||
return Unit.INSTANCE;
|
||||
});
|
||||
@@ -313,9 +313,10 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
|
||||
|
||||
@VisibleForTesting
|
||||
protected void initializeLogging() {
|
||||
Log.initialize(FeatureFlags::internalUser, new AndroidLogger(), new PersistentLogger(this));
|
||||
Log.initialize(RemoteConfig::internalUser, new AndroidLogger(), new PersistentLogger(this));
|
||||
|
||||
SignalProtocolLoggerProvider.setProvider(new CustomSignalProtocolLogger());
|
||||
SignalProtocolLoggerProvider.initializeLogging(BuildConfig.LIBSIGNAL_LOG_LEVEL);
|
||||
|
||||
SignalExecutors.UNBOUNDED.execute(() -> {
|
||||
Log.blockUntilAllWritesFinished();
|
||||
@@ -386,9 +387,11 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
|
||||
|
||||
private void initializeFcmCheck() {
|
||||
if (SignalStore.account().isRegistered()) {
|
||||
long nextSetTime = SignalStore.account().getFcmTokenLastSetTime() + TimeUnit.HOURS.toMillis(6);
|
||||
long lastSetTime = SignalStore.account().getFcmTokenLastSetTime();
|
||||
long nextSetTime = lastSetTime + TimeUnit.HOURS.toMillis(6);
|
||||
long now = System.currentTimeMillis();
|
||||
|
||||
if (SignalStore.account().getFcmToken() == null || nextSetTime <= System.currentTimeMillis()) {
|
||||
if (SignalStore.account().getFcmToken() == null || nextSetTime <= now || lastSetTime > now) {
|
||||
AppDependencies.getJobManager().add(new FcmRefreshJob());
|
||||
}
|
||||
}
|
||||
@@ -417,6 +420,12 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
|
||||
}
|
||||
}
|
||||
|
||||
private void initializeTracer() {
|
||||
if (RemoteConfig.internalUser()) {
|
||||
Tracer.getInstance().setMaxBufferSize(35_000);
|
||||
}
|
||||
}
|
||||
|
||||
private void initializePeriodicTasks() {
|
||||
RotateSignedPreKeyListener.schedule(this);
|
||||
DirectoryRefreshListener.schedule(this);
|
||||
@@ -434,12 +443,9 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
|
||||
private void initializeRingRtc() {
|
||||
try {
|
||||
Map<String, String> fieldTrials = new HashMap<>();
|
||||
if (FeatureFlags.callingFieldTrialAnyAddressPortsKillSwitch()) {
|
||||
if (RemoteConfig.callingFieldTrialAnyAddressPortsKillSwitch()) {
|
||||
fieldTrials.put("RingRTC-AnyAddressPortsKillSwitch", "Enabled");
|
||||
}
|
||||
if (!SignalStore.internalValues().callingDisableLBRed()) {
|
||||
fieldTrials.put("RingRTC-Audio-LBRed-For-Opus", "Enabled,bitrate_pri:22000");
|
||||
}
|
||||
CallManager.initialize(this, new RingRtcLogger(), fieldTrials);
|
||||
} catch (UnsatisfiedLinkError e) {
|
||||
throw new AssertionError("Unable to load ringrtc library", e);
|
||||
@@ -458,7 +464,7 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
|
||||
}
|
||||
|
||||
private void ensureProfileUploaded() {
|
||||
if (SignalStore.account().isRegistered() && !SignalStore.registrationValues().hasUploadedProfile() && !Recipient.self().getProfileName().isEmpty()) {
|
||||
if (SignalStore.account().isRegistered() && !SignalStore.registration().hasUploadedProfile() && !Recipient.self().getProfileName().isEmpty()) {
|
||||
Log.w(TAG, "User has a profile, but has not uploaded one. Uploading now.");
|
||||
AppDependencies.getJobManager().add(new ProfileUploadJob());
|
||||
}
|
||||
|
||||
@@ -28,11 +28,11 @@ import com.bumptech.glide.request.target.Target;
|
||||
import com.bumptech.glide.request.transition.Transition;
|
||||
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.avatar.fallback.FallbackAvatar;
|
||||
import org.thoughtcrime.securesms.avatar.fallback.FallbackAvatarDrawable;
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiTextView;
|
||||
import org.thoughtcrime.securesms.contacts.avatars.ContactPhoto;
|
||||
import org.thoughtcrime.securesms.contacts.avatars.FallbackContactPhoto;
|
||||
import org.thoughtcrime.securesms.contacts.avatars.ProfileContactPhoto;
|
||||
import org.thoughtcrime.securesms.contacts.avatars.ResourceContactPhoto;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.util.FullscreenHelper;
|
||||
@@ -91,16 +91,18 @@ public final class AvatarPreviewActivity extends PassphraseRequiredActivity {
|
||||
Recipient.live(recipientId).observe(this, recipient -> {
|
||||
ContactPhoto contactPhoto = recipient.isSelf() ? new ProfileContactPhoto(recipient)
|
||||
: recipient.getContactPhoto();
|
||||
FallbackContactPhoto fallbackPhoto = recipient.isSelf() ? new ResourceContactPhoto(R.drawable.ic_profile_outline_40, R.drawable.ic_profile_outline_20, R.drawable.ic_person_large)
|
||||
: recipient.getFallbackContactPhoto();
|
||||
FallbackAvatar fallbackAvatar = recipient.isSelf() ? new FallbackAvatar.Resource.Person(recipient.getAvatarColor())
|
||||
: recipient.getFallbackAvatar();
|
||||
|
||||
Drawable fallbackDrawable = new FallbackAvatarDrawable(context, fallbackAvatar);
|
||||
|
||||
Resources resources = this.getResources();
|
||||
|
||||
Glide.with(this)
|
||||
.asBitmap()
|
||||
.load(contactPhoto)
|
||||
.fallback(fallbackPhoto.asCallCard(this))
|
||||
.error(fallbackPhoto.asCallCard(this))
|
||||
.fallback(fallbackDrawable)
|
||||
.error(fallbackDrawable)
|
||||
.diskCacheStrategy(DiskCacheStrategy.ALL)
|
||||
.addListener(new RequestListener<Bitmap>() {
|
||||
@Override
|
||||
|
||||
@@ -112,6 +112,7 @@ public interface BindableConversationItem extends Unbindable, GiphyMp4Playable,
|
||||
void onInMemoryMessageClicked(@NonNull InMemoryMessageRecord messageRecord);
|
||||
void onViewGroupDescriptionChange(@Nullable GroupId groupId, @NonNull String description, boolean isMessageRequestAccepted);
|
||||
void onChangeNumberUpdateContact(@NonNull Recipient recipient);
|
||||
void onChangeProfileNameUpdateContact(@NonNull Recipient recipient);
|
||||
void onCallToAction(@NonNull String action);
|
||||
void onDonateClicked();
|
||||
void onBlockJoinRequest(@NonNull Recipient recipient);
|
||||
|
||||
@@ -35,6 +35,10 @@ class BiometricDeviceAuthentication(
|
||||
private val DISALLOWED_BIOMETRIC_VERSIONS = setOf(28, 29)
|
||||
}
|
||||
|
||||
fun canAuthenticate(): Boolean {
|
||||
return biometricManager.canAuthenticate(ALLOWED_AUTHENTICATORS) == BiometricManager.BIOMETRIC_SUCCESS
|
||||
}
|
||||
|
||||
fun authenticate(context: Context, force: Boolean, showConfirmDeviceCredentialIntent: () -> Unit): Boolean {
|
||||
val isKeyGuardSecure = ServiceUtil.getKeyguardManager(context).isKeyguardSecure
|
||||
|
||||
|
||||
@@ -29,14 +29,10 @@ import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.components.ContactFilterView;
|
||||
import org.thoughtcrime.securesms.contacts.ContactSelectionDisplayMode;
|
||||
import org.thoughtcrime.securesms.contacts.sync.ContactDiscovery;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.util.DisplayMetricsUtil;
|
||||
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme;
|
||||
import org.thoughtcrime.securesms.util.DynamicTheme;
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags;
|
||||
import org.thoughtcrime.securesms.util.ServiceUtil;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.lang.ref.WeakReference;
|
||||
|
||||
@@ -337,7 +337,6 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
selectionLimit,
|
||||
new ContactSearchAdapter.DisplayOptions(
|
||||
isMulti,
|
||||
ContactSearchAdapter.DisplaySmsTag.DEFAULT,
|
||||
ContactSearchAdapter.DisplaySecondaryInformation.ALWAYS,
|
||||
newCallCallback != null,
|
||||
false
|
||||
|
||||
@@ -20,6 +20,7 @@ import org.signal.core.util.concurrent.LifecycleDisposable;
|
||||
import org.signal.donations.StripeApi;
|
||||
import org.thoughtcrime.securesms.components.DebugLogsPromptDialogFragment;
|
||||
import org.thoughtcrime.securesms.components.PromptBatterySaverDialogFragment;
|
||||
import org.thoughtcrime.securesms.components.DeviceSpecificNotificationBottomSheet;
|
||||
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity;
|
||||
import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaController;
|
||||
import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaControllerOwner;
|
||||
@@ -112,7 +113,10 @@ public class MainActivity extends PassphraseRequiredActivity implements VoiceNot
|
||||
switch (state) {
|
||||
case NONE:
|
||||
break;
|
||||
case PROMPT_BATTERY_SAVER_DIALOG:
|
||||
case PROMPT_SPECIFIC_BATTERY_SAVER_DIALOG:
|
||||
DeviceSpecificNotificationBottomSheet.show(getSupportFragmentManager());
|
||||
break;
|
||||
case PROMPT_GENERAL_BATTERY_SAVER_DIALOG:
|
||||
PromptBatterySaverDialogFragment.show(getSupportFragmentManager());
|
||||
break;
|
||||
case PROMPT_DEBUGLOGS_FOR_NOTIFICATIONS:
|
||||
|
||||
@@ -44,10 +44,13 @@ import android.widget.ImageView;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.widget.Toolbar;
|
||||
import androidx.biometric.BiometricManager;
|
||||
import androidx.biometric.BiometricPrompt;
|
||||
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
|
||||
|
||||
import org.signal.core.util.ThreadUtil;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.animation.AnimationCompleteListener;
|
||||
@@ -55,6 +58,7 @@ import org.thoughtcrime.securesms.components.AnimatingToggle;
|
||||
import org.thoughtcrime.securesms.crypto.InvalidPassphraseException;
|
||||
import org.thoughtcrime.securesms.crypto.MasterSecret;
|
||||
import org.thoughtcrime.securesms.crypto.MasterSecretUtil;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.logsubmit.SubmitDebugLogActivity;
|
||||
import org.thoughtcrime.securesms.util.CommunicationActions;
|
||||
import org.thoughtcrime.securesms.util.DynamicIntroTheme;
|
||||
@@ -74,7 +78,8 @@ public class PassphrasePromptActivity extends PassphraseActivity {
|
||||
private static final String TAG = Log.tag(PassphrasePromptActivity.class);
|
||||
private static final short AUTHENTICATE_REQUEST_CODE = 1007;
|
||||
private static final String BUNDLE_ALREADY_SHOWN = "bundle_already_shown";
|
||||
public static final String FROM_FOREGROUND = "from_foreground";
|
||||
public static final String FROM_FOREGROUND = "from_foreground";
|
||||
private static final int HELP_COUNT_THRESHOLD = 3;
|
||||
|
||||
private DynamicIntroTheme dynamicTheme = new DynamicIntroTheme();
|
||||
private DynamicLanguage dynamicLanguage = new DynamicLanguage();
|
||||
@@ -188,6 +193,7 @@ public class PassphrasePromptActivity extends PassphraseActivity {
|
||||
} else {
|
||||
Log.w(TAG, "Authentication failed");
|
||||
hadFailure = true;
|
||||
incrementAttemptCountAndShowHelpIfNecessary();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -207,6 +213,7 @@ public class PassphrasePromptActivity extends PassphraseActivity {
|
||||
passphraseText.setText("");
|
||||
passphraseText.setError(
|
||||
getString(R.string.PassphrasePromptActivity_invalid_passphrase_exclamation));
|
||||
incrementAttemptCountAndShowHelpIfNecessary();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -216,6 +223,7 @@ public class PassphrasePromptActivity extends PassphraseActivity {
|
||||
|
||||
MasterSecret masterSecret = MasterSecretUtil.getMasterSecret(this, MasterSecretUtil.UNENCRYPTED_PASSPHRASE);
|
||||
setMasterSecret(masterSecret);
|
||||
SignalStore.misc().setLockScreenAttemptCount(0);
|
||||
} catch (InvalidPassphraseException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
@@ -272,6 +280,10 @@ public class PassphrasePromptActivity extends PassphraseActivity {
|
||||
fingerprintPrompt.getBackground().setColorFilter(getResources().getColor(R.color.core_ultramarine), PorterDuff.Mode.SRC_IN);
|
||||
|
||||
lockScreenButton.setOnClickListener(v -> resumeScreenLock(true));
|
||||
|
||||
if (SignalStore.misc().getLockScreenAttemptCount() > HELP_COUNT_THRESHOLD) {
|
||||
showHelpDialogAndResetAttemptCount(null);
|
||||
}
|
||||
}
|
||||
|
||||
private void setLockTypeVisibility() {
|
||||
@@ -288,6 +300,10 @@ public class PassphrasePromptActivity extends PassphraseActivity {
|
||||
}
|
||||
|
||||
private void resumeScreenLock(boolean force) {
|
||||
if (incrementAttemptCountAndShowHelpIfNecessary(() -> resumeScreenLock(force))) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!biometricAuth.authenticate(getApplicationContext(), force, this::showConfirmDeviceCredentialIntent)) {
|
||||
handleAuthenticated();
|
||||
}
|
||||
@@ -312,6 +328,33 @@ public class PassphrasePromptActivity extends PassphraseActivity {
|
||||
return Unit.INSTANCE;
|
||||
}
|
||||
|
||||
private boolean incrementAttemptCountAndShowHelpIfNecessary() {
|
||||
return incrementAttemptCountAndShowHelpIfNecessary(null);
|
||||
}
|
||||
|
||||
private boolean incrementAttemptCountAndShowHelpIfNecessary(Runnable onDismissed) {
|
||||
SignalStore.misc().incrementLockScreenAttemptCount();
|
||||
|
||||
if (SignalStore.misc().getLockScreenAttemptCount() > HELP_COUNT_THRESHOLD) {
|
||||
showHelpDialogAndResetAttemptCount(onDismissed);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private void showHelpDialogAndResetAttemptCount(@Nullable Runnable onDismissed) {
|
||||
new MaterialAlertDialogBuilder(this)
|
||||
.setMessage(R.string.PassphrasePromptActivity_help_prompt_body)
|
||||
.setPositiveButton(android.R.string.ok, (dialog, which) -> {
|
||||
SignalStore.misc().setLockScreenAttemptCount(0);
|
||||
if (onDismissed != null) {
|
||||
onDismissed.run();
|
||||
}
|
||||
})
|
||||
.show();
|
||||
}
|
||||
|
||||
private class PassphraseActionListener implements TextView.OnEditorActionListener {
|
||||
@Override
|
||||
public boolean onEditorAction(TextView exampleView, int actionId, KeyEvent keyEvent) {
|
||||
@@ -366,6 +409,8 @@ public class PassphrasePromptActivity extends PassphraseActivity {
|
||||
Log.w(TAG, "Authentication error: " + errorCode);
|
||||
hadFailure = true;
|
||||
|
||||
incrementAttemptCountAndShowHelpIfNecessary();
|
||||
|
||||
if (errorCode != BiometricPrompt.ERROR_CANCELED && errorCode != BiometricPrompt.ERROR_USER_CANCELED) {
|
||||
onAuthenticationFailed();
|
||||
}
|
||||
|
||||
@@ -16,7 +16,6 @@ import org.signal.core.util.logging.Log;
|
||||
import org.signal.core.util.tracing.Tracer;
|
||||
import org.signal.devicetransfer.TransferStatus;
|
||||
import org.thoughtcrime.securesms.components.settings.app.changenumber.ChangeNumberLockActivity;
|
||||
import org.thoughtcrime.securesms.components.settings.app.changenumber.v2.ChangeNumberLockV2Activity;
|
||||
import org.thoughtcrime.securesms.crypto.MasterSecretUtil;
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies;
|
||||
import org.thoughtcrime.securesms.devicetransfer.olddevice.OldDeviceTransferActivity;
|
||||
@@ -28,12 +27,11 @@ import org.thoughtcrime.securesms.pin.PinRestoreActivity;
|
||||
import org.thoughtcrime.securesms.profiles.edit.CreateProfileActivity;
|
||||
import org.thoughtcrime.securesms.push.SignalServiceNetworkAccess;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.registration.RegistrationNavigationActivity;
|
||||
import org.thoughtcrime.securesms.registration.v2.ui.RegistrationV2Activity;
|
||||
import org.thoughtcrime.securesms.registration.ui.RegistrationActivity;
|
||||
import org.thoughtcrime.securesms.restore.RestoreActivity;
|
||||
import org.thoughtcrime.securesms.service.KeyCachingService;
|
||||
import org.thoughtcrime.securesms.util.AppStartup;
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags;
|
||||
import org.thoughtcrime.securesms.util.RemoteConfig;
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
|
||||
import java.util.Locale;
|
||||
@@ -55,7 +53,7 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
|
||||
private static final int STATE_TRANSFER_ONGOING = 8;
|
||||
private static final int STATE_TRANSFER_LOCKED = 9;
|
||||
private static final int STATE_CHANGE_NUMBER_LOCK = 10;
|
||||
private static final int STATE_RESTORE_BACKUP = 11;
|
||||
private static final int STATE_TRANSFER_OR_RESTORE = 11;
|
||||
|
||||
private SignalServiceNetworkAccess networkAccess;
|
||||
private BroadcastReceiver clearKeyReceiver;
|
||||
@@ -153,7 +151,7 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
|
||||
case STATE_TRANSFER_ONGOING: return getOldDeviceTransferIntent();
|
||||
case STATE_TRANSFER_LOCKED: return getOldDeviceTransferLockedIntent();
|
||||
case STATE_CHANGE_NUMBER_LOCK: return getChangeNumberLockIntent();
|
||||
case STATE_RESTORE_BACKUP: return getRestoreIntent();
|
||||
case STATE_TRANSFER_OR_RESTORE: return getTransferOrRestoreIntent();
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
@@ -167,12 +165,12 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
|
||||
return STATE_UI_BLOCKING_UPGRADE;
|
||||
} else if (!TextSecurePreferences.hasPromptedPushRegistration(this)) {
|
||||
return STATE_WELCOME_PUSH_SCREEN;
|
||||
} else if (SignalStore.internalValues().enterRestoreV2Flow()) {
|
||||
return STATE_RESTORE_BACKUP;
|
||||
} else if (SignalStore.storageService().needsAccountRestore()) {
|
||||
return STATE_ENTER_SIGNAL_PIN;
|
||||
} else if (userHasSkippedOrForgottenPin()) {
|
||||
return STATE_CREATE_SIGNAL_PIN;
|
||||
} else if (userCanTransferOrRestore()) {
|
||||
return STATE_TRANSFER_OR_RESTORE;
|
||||
} else if (userMustSetProfileName()) {
|
||||
return STATE_CREATE_PROFILE_NAME;
|
||||
} else if (userMustCreateSignalPin()) {
|
||||
@@ -181,23 +179,27 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
|
||||
return STATE_TRANSFER_ONGOING;
|
||||
} else if (SignalStore.misc().isOldDeviceTransferLocked()) {
|
||||
return STATE_TRANSFER_LOCKED;
|
||||
} else if (SignalStore.misc().isChangeNumberLocked() && getClass() != ChangeNumberLockActivity.class && getClass() != ChangeNumberLockV2Activity.class) {
|
||||
} else if (SignalStore.misc().isChangeNumberLocked() && getClass() != ChangeNumberLockActivity.class) {
|
||||
return STATE_CHANGE_NUMBER_LOCK;
|
||||
} else {
|
||||
return STATE_NORMAL;
|
||||
}
|
||||
}
|
||||
|
||||
private boolean userCanTransferOrRestore() {
|
||||
return !SignalStore.registration().isRegistrationComplete() && RemoteConfig.restoreAfterRegistration() && !SignalStore.registration().hasSkippedTransferOrRestore();
|
||||
}
|
||||
|
||||
private boolean userMustCreateSignalPin() {
|
||||
return !SignalStore.registrationValues().isRegistrationComplete() && !SignalStore.svr().hasPin() && !SignalStore.svr().lastPinCreateFailed() && !SignalStore.svr().hasOptedOut();
|
||||
return !SignalStore.registration().isRegistrationComplete() && !SignalStore.svr().hasPin() && !SignalStore.svr().lastPinCreateFailed() && !SignalStore.svr().hasOptedOut();
|
||||
}
|
||||
|
||||
private boolean userHasSkippedOrForgottenPin() {
|
||||
return !SignalStore.registrationValues().isRegistrationComplete() && !SignalStore.svr().hasPin() && !SignalStore.svr().hasOptedOut() && SignalStore.svr().isPinForgottenOrSkipped();
|
||||
return !SignalStore.registration().isRegistrationComplete() && !SignalStore.svr().hasPin() && !SignalStore.svr().hasOptedOut() && SignalStore.svr().isPinForgottenOrSkipped();
|
||||
}
|
||||
|
||||
private boolean userMustSetProfileName() {
|
||||
return !SignalStore.registrationValues().isRegistrationComplete() && Recipient.self().getProfileName().isEmpty();
|
||||
return !SignalStore.registration().isRegistrationComplete() && Recipient.self().getProfileName().isEmpty();
|
||||
}
|
||||
|
||||
private Intent getCreatePassphraseIntent() {
|
||||
@@ -218,11 +220,7 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
|
||||
}
|
||||
|
||||
private Intent getPushRegistrationIntent() {
|
||||
if (FeatureFlags.registrationV2()) {
|
||||
return RegistrationV2Activity.newIntentForNewRegistration(this, getIntent());
|
||||
} else {
|
||||
return RegistrationNavigationActivity.newIntentForNewRegistration(this, getIntent());
|
||||
}
|
||||
return RegistrationActivity.newIntentForNewRegistration(this, getIntent());
|
||||
}
|
||||
|
||||
private Intent getEnterSignalPinIntent() {
|
||||
@@ -241,8 +239,8 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
|
||||
return getRoutedIntent(CreateSvrPinActivity.class, intent);
|
||||
}
|
||||
|
||||
private Intent getRestoreIntent() {
|
||||
Intent intent = RestoreActivity.getIntentForRestore(this);
|
||||
private Intent getTransferOrRestoreIntent() {
|
||||
Intent intent = RestoreActivity.getIntentForTransferOrRestore(this);
|
||||
return getRoutedIntent(intent, getIntent());
|
||||
}
|
||||
|
||||
@@ -265,11 +263,7 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
|
||||
}
|
||||
|
||||
private Intent getChangeNumberLockIntent() {
|
||||
if (FeatureFlags.registrationV2()) {
|
||||
return ChangeNumberLockV2Activity.createIntent(this);
|
||||
} else {
|
||||
return ChangeNumberLockActivity.createIntent(this);
|
||||
}
|
||||
return ChangeNumberLockActivity.createIntent(this);
|
||||
}
|
||||
|
||||
private Intent getRoutedIntent(Intent destination, @Nullable Intent nextIntent) {
|
||||
|
||||
@@ -94,7 +94,7 @@ import org.thoughtcrime.securesms.service.webrtc.SignalCallManager;
|
||||
import org.thoughtcrime.securesms.sms.MessageSender;
|
||||
import org.thoughtcrime.securesms.util.BottomSheetUtil;
|
||||
import org.thoughtcrime.securesms.util.EllapsedTimeFormatter;
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags;
|
||||
import org.thoughtcrime.securesms.util.RemoteConfig;
|
||||
import org.thoughtcrime.securesms.util.FullscreenHelper;
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
import org.thoughtcrime.securesms.util.ThrottledDebouncer;
|
||||
@@ -415,7 +415,7 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
||||
}
|
||||
|
||||
private void initializePendingParticipantFragmentListener() {
|
||||
if (!FeatureFlags.adHocCalling()) {
|
||||
if (!RemoteConfig.adHocCalling()) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -822,7 +822,7 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
||||
}
|
||||
|
||||
public void handleGroupMemberCountChange(int count) {
|
||||
boolean canRing = count <= FeatureFlags.maxGroupCallRingSize();
|
||||
boolean canRing = count <= RemoteConfig.maxGroupCallRingSize();
|
||||
callScreen.enableRingGroup(canRing);
|
||||
AppDependencies.getSignalCallManager().setRingGroup(canRing);
|
||||
}
|
||||
@@ -1102,7 +1102,7 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
||||
@Override
|
||||
public void toggleControls() {
|
||||
WebRtcControls controlState = viewModel.getWebRtcControls().getValue();
|
||||
if (controlState != null && !controlState.displayIncomingCallButtons()) {
|
||||
if (controlState != null && !controlState.displayIncomingCallButtons() && !controlState.displayErrorControls()) {
|
||||
controlsAndInfo.toggleControls();
|
||||
}
|
||||
}
|
||||
@@ -1287,9 +1287,12 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
||||
|
||||
@Override
|
||||
public void onHidden() {
|
||||
fullscreenHelper.hideSystemUI();
|
||||
if (videoTooltip != null) {
|
||||
videoTooltip.dismiss();
|
||||
WebRtcControls controlState = viewModel.getWebRtcControls().getValue();
|
||||
if (controlState == null || !controlState.displayErrorControls()) {
|
||||
fullscreenHelper.hideSystemUI();
|
||||
if (videoTooltip != null) {
|
||||
videoTooltip.dismiss();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,19 +17,19 @@ object SvrAuthTokens : AndroidBackupItem {
|
||||
}
|
||||
|
||||
override fun getDataForBackup(): ByteArray {
|
||||
val proto = SvrAuthToken(tokens = SignalStore.svr().authTokenList)
|
||||
val proto = SvrAuthToken(svr2Tokens = SignalStore.svr.svr2AuthTokens)
|
||||
return proto.encode()
|
||||
}
|
||||
|
||||
override fun restoreData(data: ByteArray) {
|
||||
if (SignalStore.svr().authTokenList.isNotEmpty()) {
|
||||
if (SignalStore.svr.svr2AuthTokens.isNotEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
val proto = SvrAuthToken.ADAPTER.decode(data)
|
||||
|
||||
SignalStore.svr().putAuthTokenList(proto.tokens)
|
||||
SignalStore.svr.putSvr2AuthTokens(proto.svr2Tokens)
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "Cannot restore KbsAuthToken from backup service.")
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ class ApkUpdateDownloadManagerReceiver : BroadcastReceiver() {
|
||||
}
|
||||
|
||||
val downloadId = intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -2)
|
||||
if (downloadId != SignalStore.apkUpdate().downloadId) {
|
||||
if (downloadId != SignalStore.apkUpdate.downloadId) {
|
||||
Log.w(TAG, "downloadId doesn't match the one we're waiting for! Ignoring.")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -38,30 +38,30 @@ object ApkUpdateInstaller {
|
||||
* [userInitiated] = true, and then everything installs.
|
||||
*/
|
||||
fun installOrPromptForInstall(context: Context, downloadId: Long, userInitiated: Boolean) {
|
||||
if (downloadId != SignalStore.apkUpdate().downloadId) {
|
||||
Log.w(TAG, "DownloadId doesn't match the one we're waiting for (current: $downloadId, expected: ${SignalStore.apkUpdate().downloadId})! We likely have newer data. Ignoring.")
|
||||
if (downloadId != SignalStore.apkUpdate.downloadId) {
|
||||
Log.w(TAG, "DownloadId doesn't match the one we're waiting for (current: $downloadId, expected: ${SignalStore.apkUpdate.downloadId})! We likely have newer data. Ignoring.")
|
||||
ApkUpdateNotifications.dismissInstallPrompt(context)
|
||||
AppDependencies.jobManager.add(ApkUpdateJob())
|
||||
return
|
||||
}
|
||||
|
||||
val digest = SignalStore.apkUpdate().digest
|
||||
val digest = SignalStore.apkUpdate.digest
|
||||
if (digest == null) {
|
||||
Log.w(TAG, "DownloadId matches, but digest is null! Inconsistent state. Failing and clearing state.")
|
||||
SignalStore.apkUpdate().clearDownloadAttributes()
|
||||
SignalStore.apkUpdate.clearDownloadAttributes()
|
||||
ApkUpdateNotifications.showInstallFailed(context, ApkUpdateNotifications.FailureReason.UNKNOWN)
|
||||
return
|
||||
}
|
||||
|
||||
if (!isMatchingDigest(context, downloadId, digest)) {
|
||||
Log.w(TAG, "DownloadId matches, but digest does not! Bad download or inconsistent state. Failing and clearing state.")
|
||||
SignalStore.apkUpdate().clearDownloadAttributes()
|
||||
SignalStore.apkUpdate.clearDownloadAttributes()
|
||||
ApkUpdateNotifications.showInstallFailed(context, ApkUpdateNotifications.FailureReason.UNKNOWN)
|
||||
return
|
||||
}
|
||||
|
||||
if (!userInitiated && !shouldAutoUpdate()) {
|
||||
Log.w(TAG, "Not user-initiated and not eligible for auto-update. Prompting. (API=${Build.VERSION.SDK_INT}, Foreground=${AppDependencies.appForegroundObserver.isForegrounded}, AutoUpdate=${SignalStore.apkUpdate().autoUpdate})")
|
||||
Log.w(TAG, "Not user-initiated and not eligible for auto-update. Prompting. (API=${Build.VERSION.SDK_INT}, Foreground=${AppDependencies.appForegroundObserver.isForegrounded}, AutoUpdate=${SignalStore.apkUpdate.autoUpdate})")
|
||||
ApkUpdateNotifications.showInstallPrompt(context, downloadId)
|
||||
return
|
||||
}
|
||||
@@ -70,11 +70,11 @@ object ApkUpdateInstaller {
|
||||
installApk(context, downloadId, userInitiated)
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "Hit IOException when trying to install APK!", e)
|
||||
SignalStore.apkUpdate().clearDownloadAttributes()
|
||||
SignalStore.apkUpdate.clearDownloadAttributes()
|
||||
ApkUpdateNotifications.showInstallFailed(context, ApkUpdateNotifications.FailureReason.UNKNOWN)
|
||||
} catch (e: SecurityException) {
|
||||
Log.w(TAG, "Hit SecurityException when trying to install APK!", e)
|
||||
SignalStore.apkUpdate().clearDownloadAttributes()
|
||||
SignalStore.apkUpdate.clearDownloadAttributes()
|
||||
ApkUpdateNotifications.showInstallFailed(context, ApkUpdateNotifications.FailureReason.UNKNOWN)
|
||||
}
|
||||
}
|
||||
@@ -145,6 +145,6 @@ object ApkUpdateInstaller {
|
||||
|
||||
private fun shouldAutoUpdate(): Boolean {
|
||||
// TODO Auto-updates temporarily restricted to nightlies. Once we have designs for allowing users to opt-out of auto-updates, we can re-enable this
|
||||
return Environment.IS_NIGHTLY && Build.VERSION.SDK_INT >= 31 && SignalStore.apkUpdate().autoUpdate && !AppDependencies.appForegroundObserver.isForegrounded
|
||||
return Environment.IS_NIGHTLY && Build.VERSION.SDK_INT >= 31 && SignalStore.apkUpdate.autoUpdate && !AppDependencies.appForegroundObserver.isForegrounded
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,9 +36,9 @@ class ApkUpdatePackageInstallerReceiver : BroadcastReceiver() {
|
||||
|
||||
when (statusCode) {
|
||||
PackageInstaller.STATUS_SUCCESS -> {
|
||||
if (SignalStore.apkUpdate().lastApkUploadTime != SignalStore.apkUpdate().pendingApkUploadTime) {
|
||||
Log.i(TAG, "Update installed successfully! Updating our lastApkUploadTime to ${SignalStore.apkUpdate().pendingApkUploadTime}")
|
||||
SignalStore.apkUpdate().lastApkUploadTime = SignalStore.apkUpdate().pendingApkUploadTime
|
||||
if (SignalStore.apkUpdate.lastApkUploadTime != SignalStore.apkUpdate.pendingApkUploadTime) {
|
||||
Log.i(TAG, "Update installed successfully! Updating our lastApkUploadTime to ${SignalStore.apkUpdate.pendingApkUploadTime}")
|
||||
SignalStore.apkUpdate.lastApkUploadTime = SignalStore.apkUpdate.pendingApkUploadTime
|
||||
ApkUpdateNotifications.showAutoUpdateSuccess(context)
|
||||
} else {
|
||||
Log.i(TAG, "Spurious 'success' notification?")
|
||||
|
||||
@@ -10,6 +10,8 @@ import android.os.Parcel
|
||||
import org.signal.core.util.Base64
|
||||
import org.thoughtcrime.securesms.blurhash.BlurHash
|
||||
import org.thoughtcrime.securesms.database.AttachmentTable
|
||||
import org.thoughtcrime.securesms.stickers.StickerLocator
|
||||
import java.util.UUID
|
||||
|
||||
class ArchivedAttachment : Attachment {
|
||||
|
||||
@@ -44,8 +46,10 @@ class ArchivedAttachment : Attachment {
|
||||
blurHash: String?,
|
||||
voiceNote: Boolean,
|
||||
borderless: Boolean,
|
||||
stickerLocator: StickerLocator?,
|
||||
gif: Boolean,
|
||||
quote: Boolean
|
||||
quote: Boolean,
|
||||
uuid: UUID?
|
||||
) : super(
|
||||
contentType = contentType ?: "",
|
||||
quote = quote,
|
||||
@@ -66,10 +70,11 @@ class ArchivedAttachment : Attachment {
|
||||
incrementalMacChunkSize = incrementalMacChunkSize ?: 0,
|
||||
uploadTimestamp = 0,
|
||||
caption = caption,
|
||||
stickerLocator = null,
|
||||
stickerLocator = stickerLocator,
|
||||
blurHash = BlurHash.parseOrNull(blurHash),
|
||||
audioHash = null,
|
||||
transformProperties = null
|
||||
transformProperties = null,
|
||||
uuid = uuid
|
||||
) {
|
||||
this.archiveCdn = archiveCdn ?: Cdn.CDN_3.cdnNumber
|
||||
this.archiveMediaName = archiveMediaName
|
||||
|
||||
@@ -14,6 +14,8 @@ import org.thoughtcrime.securesms.database.AttachmentTable
|
||||
import org.thoughtcrime.securesms.database.AttachmentTable.TransformProperties
|
||||
import org.thoughtcrime.securesms.stickers.StickerLocator
|
||||
import org.thoughtcrime.securesms.util.ParcelUtil
|
||||
import org.whispersystems.signalservice.api.util.UuidUtil
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Note: We have to use our own Parcelable implementation because we need to do custom stuff to preserve
|
||||
@@ -65,7 +67,9 @@ abstract class Attachment(
|
||||
@JvmField
|
||||
val audioHash: AudioHash?,
|
||||
@JvmField
|
||||
val transformProperties: TransformProperties?
|
||||
val transformProperties: TransformProperties?,
|
||||
@JvmField
|
||||
val uuid: UUID?
|
||||
) : Parcelable {
|
||||
|
||||
abstract val uri: Uri?
|
||||
@@ -97,7 +101,8 @@ abstract class Attachment(
|
||||
stickerLocator = ParcelCompat.readParcelable(parcel, StickerLocator::class.java.classLoader, StickerLocator::class.java),
|
||||
blurHash = ParcelCompat.readParcelable(parcel, BlurHash::class.java.classLoader, BlurHash::class.java),
|
||||
audioHash = ParcelCompat.readParcelable(parcel, AudioHash::class.java.classLoader, AudioHash::class.java),
|
||||
transformProperties = ParcelCompat.readParcelable(parcel, TransformProperties::class.java.classLoader, TransformProperties::class.java)
|
||||
transformProperties = ParcelCompat.readParcelable(parcel, TransformProperties::class.java.classLoader, TransformProperties::class.java),
|
||||
uuid = UuidUtil.parseOrNull(parcel.readString())
|
||||
)
|
||||
|
||||
override fun writeToParcel(dest: Parcel, flags: Int) {
|
||||
@@ -125,6 +130,7 @@ abstract class Attachment(
|
||||
dest.writeParcelable(blurHash, 0)
|
||||
dest.writeParcelable(audioHash, 0)
|
||||
dest.writeParcelable(transformProperties, 0)
|
||||
dest.writeString(uuid?.toString())
|
||||
}
|
||||
|
||||
override fun describeContents(): Int {
|
||||
|
||||
@@ -55,6 +55,7 @@ object AttachmentUploadUtil {
|
||||
.withResumableUploadSpec(ResumableUploadSpec.from(uploadSpec))
|
||||
.withCancelationSignal(cancellationSignal)
|
||||
.withListener(progressListener)
|
||||
.withUuid(attachment.uuid)
|
||||
|
||||
if (MediaUtil.isImageType(attachment.contentType)) {
|
||||
builder.withBlurHash(getImageBlurHash(context, attachment))
|
||||
|
||||
@@ -10,6 +10,7 @@ import org.thoughtcrime.securesms.database.AttachmentTable.TransformProperties
|
||||
import org.thoughtcrime.securesms.mms.PartAuthority
|
||||
import org.thoughtcrime.securesms.stickers.StickerLocator
|
||||
import org.thoughtcrime.securesms.util.ParcelUtil
|
||||
import java.util.UUID
|
||||
|
||||
class DatabaseAttachment : Attachment {
|
||||
|
||||
@@ -79,7 +80,8 @@ class DatabaseAttachment : Attachment {
|
||||
archiveThumbnailCdn: Int,
|
||||
archiveMediaName: String?,
|
||||
archiveMediaId: String?,
|
||||
thumbnailRestoreState: AttachmentTable.ThumbnailRestoreState
|
||||
thumbnailRestoreState: AttachmentTable.ThumbnailRestoreState,
|
||||
uuid: UUID?
|
||||
) : super(
|
||||
contentType = contentType!!,
|
||||
transferState = transferProgress,
|
||||
@@ -102,7 +104,8 @@ class DatabaseAttachment : Attachment {
|
||||
stickerLocator = stickerLocator,
|
||||
blurHash = blurHash,
|
||||
audioHash = audioHash,
|
||||
transformProperties = transformProperties
|
||||
transformProperties = transformProperties,
|
||||
uuid = uuid
|
||||
) {
|
||||
this.attachmentId = attachmentId
|
||||
this.mmsId = mmsId
|
||||
|
||||
@@ -12,6 +12,7 @@ import org.whispersystems.signalservice.api.messages.SignalServiceAttachment
|
||||
import org.whispersystems.signalservice.api.util.AttachmentPointerUtil
|
||||
import org.whispersystems.signalservice.internal.push.DataMessage
|
||||
import java.util.Optional
|
||||
import java.util.UUID
|
||||
|
||||
class PointerAttachment : Attachment {
|
||||
@VisibleForTesting
|
||||
@@ -35,7 +36,8 @@ class PointerAttachment : Attachment {
|
||||
uploadTimestamp: Long,
|
||||
caption: String?,
|
||||
stickerLocator: StickerLocator?,
|
||||
blurHash: BlurHash?
|
||||
blurHash: BlurHash?,
|
||||
uuid: UUID?
|
||||
) : super(
|
||||
contentType = contentType,
|
||||
transferState = transferState,
|
||||
@@ -59,7 +61,8 @@ class PointerAttachment : Attachment {
|
||||
stickerLocator = stickerLocator,
|
||||
blurHash = blurHash,
|
||||
audioHash = null,
|
||||
transformProperties = null
|
||||
transformProperties = null,
|
||||
uuid = uuid
|
||||
)
|
||||
|
||||
constructor(parcel: Parcel) : super(parcel)
|
||||
@@ -115,7 +118,8 @@ class PointerAttachment : Attachment {
|
||||
uploadTimestamp = pointer.get().asPointer().uploadTimestamp,
|
||||
caption = pointer.get().asPointer().caption.orElse(null),
|
||||
stickerLocator = stickerLocator,
|
||||
blurHash = BlurHash.parseOrNull(pointer.get().asPointer().blurHash.orElse(null))
|
||||
blurHash = BlurHash.parseOrNull(pointer.get().asPointer().blurHash.orElse(null)),
|
||||
uuid = pointer.get().asPointer().uuid
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -152,7 +156,8 @@ class PointerAttachment : Attachment {
|
||||
uploadTimestamp = thumbnail?.asPointer()?.uploadTimestamp ?: 0,
|
||||
caption = thumbnail?.asPointer()?.caption?.orElse(null),
|
||||
stickerLocator = null,
|
||||
blurHash = null
|
||||
blurHash = null,
|
||||
uuid = thumbnail?.asPointer()?.uuid
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import android.net.Uri
|
||||
import android.os.Parcel
|
||||
import org.thoughtcrime.securesms.blurhash.BlurHash
|
||||
import org.thoughtcrime.securesms.database.AttachmentTable
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* An attachment that represents where an attachment used to be. Useful when you need to know that
|
||||
@@ -35,7 +36,8 @@ class TombstoneAttachment : Attachment {
|
||||
stickerLocator = null,
|
||||
blurHash = null,
|
||||
audioHash = null,
|
||||
transformProperties = null
|
||||
transformProperties = null,
|
||||
uuid = null
|
||||
)
|
||||
|
||||
constructor(
|
||||
@@ -49,7 +51,8 @@ class TombstoneAttachment : Attachment {
|
||||
voiceNote: Boolean = false,
|
||||
borderless: Boolean = false,
|
||||
gif: Boolean = false,
|
||||
quote: Boolean
|
||||
quote: Boolean,
|
||||
uuid: UUID?
|
||||
) : super(
|
||||
contentType = contentType ?: "",
|
||||
quote = quote,
|
||||
@@ -73,7 +76,8 @@ class TombstoneAttachment : Attachment {
|
||||
stickerLocator = null,
|
||||
blurHash = BlurHash.parseOrNull(blurHash),
|
||||
audioHash = null,
|
||||
transformProperties = null
|
||||
transformProperties = null,
|
||||
uuid = uuid
|
||||
)
|
||||
|
||||
constructor(parcel: Parcel) : super(parcel)
|
||||
|
||||
@@ -8,6 +8,7 @@ import org.thoughtcrime.securesms.blurhash.BlurHash
|
||||
import org.thoughtcrime.securesms.database.AttachmentTable.TransformProperties
|
||||
import org.thoughtcrime.securesms.stickers.StickerLocator
|
||||
import java.util.Objects
|
||||
import java.util.UUID
|
||||
|
||||
class UriAttachment : Attachment {
|
||||
|
||||
@@ -46,6 +47,7 @@ class UriAttachment : Attachment {
|
||||
transformProperties = transformProperties
|
||||
)
|
||||
|
||||
@JvmOverloads
|
||||
constructor(
|
||||
dataUri: Uri,
|
||||
contentType: String,
|
||||
@@ -63,7 +65,8 @@ class UriAttachment : Attachment {
|
||||
stickerLocator: StickerLocator?,
|
||||
blurHash: BlurHash?,
|
||||
audioHash: AudioHash?,
|
||||
transformProperties: TransformProperties?
|
||||
transformProperties: TransformProperties?,
|
||||
uuid: UUID? = UUID.randomUUID()
|
||||
) : super(
|
||||
contentType = contentType,
|
||||
transferState = transferState,
|
||||
@@ -87,7 +90,8 @@ class UriAttachment : Attachment {
|
||||
stickerLocator = stickerLocator,
|
||||
blurHash = blurHash,
|
||||
audioHash = audioHash,
|
||||
transformProperties = transformProperties
|
||||
transformProperties = transformProperties,
|
||||
uuid = uuid
|
||||
) {
|
||||
uri = Objects.requireNonNull(dataUri)
|
||||
}
|
||||
|
||||
@@ -134,6 +134,6 @@ 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())
|
||||
return Media(uri, MediaUtil.IMAGE_JPEG, System.currentTimeMillis(), DIMENSIONS, DIMENSIONS, size, 0, false, false, Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.avatar.fallback
|
||||
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.annotation.Px
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.signal.core.util.DimensionUnit
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.conversation.colors.AvatarColor
|
||||
import org.thoughtcrime.securesms.util.NameUtil
|
||||
|
||||
/**
|
||||
* Specifies what kind of avatar should be generated for a given recipient.
|
||||
*/
|
||||
sealed interface FallbackAvatar {
|
||||
|
||||
val color: AvatarColor
|
||||
|
||||
/**
|
||||
* Transparent avatar
|
||||
*/
|
||||
data object Transparent : FallbackAvatar {
|
||||
override val color: AvatarColor = AvatarColor.UNKNOWN
|
||||
}
|
||||
|
||||
/**
|
||||
* Generated avatars utilize the initials of the given recipient
|
||||
*/
|
||||
data class Text(val content: String, override val color: AvatarColor) : FallbackAvatar {
|
||||
init {
|
||||
check(content.isNotEmpty())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback avatars that are backed by resources.
|
||||
*/
|
||||
sealed interface Resource : FallbackAvatar {
|
||||
|
||||
@DrawableRes
|
||||
fun getIconBySize(size: Size): Int
|
||||
|
||||
/**
|
||||
* Local user
|
||||
*/
|
||||
data class Local(override val color: AvatarColor) : Resource {
|
||||
override fun getIconBySize(size: Size): Int {
|
||||
return when (size) {
|
||||
Size.SMALL -> R.drawable.symbol_note_compact_16
|
||||
Size.MEDIUM -> R.drawable.symbol_note_24
|
||||
Size.LARGE -> R.drawable.symbol_note_display_bold_40
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Individual user without a display name.
|
||||
*/
|
||||
data class Person(override val color: AvatarColor) : Resource {
|
||||
override fun getIconBySize(size: Size): Int {
|
||||
return when (size) {
|
||||
Size.SMALL -> R.drawable.symbol_person_compact_16
|
||||
Size.MEDIUM -> R.drawable.symbol_person_24
|
||||
Size.LARGE -> R.drawable.symbol_person_display_bold_40
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A group
|
||||
*/
|
||||
data class Group(override val color: AvatarColor) : Resource {
|
||||
override fun getIconBySize(size: Size): Int {
|
||||
return when (size) {
|
||||
Size.SMALL -> R.drawable.symbol_group_compact_16
|
||||
Size.MEDIUM -> R.drawable.symbol_group_24
|
||||
Size.LARGE -> R.drawable.symbol_group_display_bold_40
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Story distribution lists
|
||||
*/
|
||||
data class DistributionList(override val color: AvatarColor) : Resource {
|
||||
override fun getIconBySize(size: Size): Int {
|
||||
return when (size) {
|
||||
Size.SMALL -> R.drawable.symbol_stories_compact_16
|
||||
Size.MEDIUM -> R.drawable.symbol_stories_24
|
||||
Size.LARGE -> R.drawable.symbol_stories_display_bold_40
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Call Links
|
||||
*/
|
||||
data class CallLink(override val color: AvatarColor) : Resource {
|
||||
override fun getIconBySize(size: Size): Int {
|
||||
return when (size) {
|
||||
Size.SMALL -> R.drawable.symbol_video_compact_16
|
||||
Size.MEDIUM -> R.drawable.symbol_video_24
|
||||
Size.LARGE -> R.drawable.symbol_video_display_bold_40
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum class Size {
|
||||
/**
|
||||
* Smaller than 32dp
|
||||
*/
|
||||
SMALL,
|
||||
|
||||
/**
|
||||
* 32dp and larger
|
||||
*/
|
||||
MEDIUM,
|
||||
|
||||
/**
|
||||
* 80dp and larger
|
||||
*/
|
||||
LARGE
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val ICON_TO_BACKGROUND_SCALE = 0.625
|
||||
|
||||
@JvmStatic
|
||||
@JvmOverloads
|
||||
fun forTextOrDefault(text: String, avatarColor: AvatarColor, default: FallbackAvatar = Resource.Person(avatarColor)): FallbackAvatar {
|
||||
val abbreviation = NameUtil.getAbbreviation(text)
|
||||
return if (abbreviation != null) {
|
||||
Text(abbreviation, avatarColor)
|
||||
} else {
|
||||
default
|
||||
}
|
||||
}
|
||||
|
||||
fun getSizeByPx(@Px px: Int): Size {
|
||||
return getSizeByDp(DimensionUnit.PIXELS.toDp(px.toFloat()).dp)
|
||||
}
|
||||
|
||||
fun getSizeByDp(dp: Dp): Size {
|
||||
val rawDp = dp.value
|
||||
return when {
|
||||
rawDp >= 80.0 -> Size.LARGE
|
||||
rawDp < 32.0 -> Size.SMALL
|
||||
else -> Size.MEDIUM
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.avatar.fallback
|
||||
|
||||
import android.content.Context
|
||||
import android.content.res.ColorStateList
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Rect
|
||||
import android.graphics.drawable.Drawable
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.airbnb.lottie.SimpleColorFilter
|
||||
import com.google.android.material.shape.MaterialShapeDrawable
|
||||
import com.google.android.material.shape.RelativeCornerSize
|
||||
import com.google.android.material.shape.RoundedCornerTreatment
|
||||
import com.google.android.material.shape.ShapeAppearanceModel
|
||||
import org.thoughtcrime.securesms.avatar.Avatar
|
||||
import org.thoughtcrime.securesms.avatar.Avatars
|
||||
import org.thoughtcrime.securesms.avatar.TextAvatarDrawable
|
||||
import org.thoughtcrime.securesms.conversation.colors.AvatarColorPair
|
||||
|
||||
class FallbackAvatarDrawable(
|
||||
private val context: Context,
|
||||
private val fallbackAvatar: FallbackAvatar
|
||||
) : MaterialShapeDrawable() {
|
||||
|
||||
private val avatarColorPair: AvatarColorPair = AvatarColorPair.create(context, fallbackAvatar.color)
|
||||
private var avatarSize: FallbackAvatar.Size = FallbackAvatar.Size.SMALL
|
||||
private var icon: Drawable? = null
|
||||
|
||||
init {
|
||||
fillColor = ColorStateList.valueOf(avatarColorPair.backgroundColor)
|
||||
}
|
||||
|
||||
fun circleCrop(): FallbackAvatarDrawable {
|
||||
shapeAppearanceModel = ShapeAppearanceModel.builder()
|
||||
.setAllCorners(RoundedCornerTreatment())
|
||||
.setAllCornerSizes(RelativeCornerSize(0.5f))
|
||||
.build()
|
||||
return this
|
||||
}
|
||||
|
||||
override fun onBoundsChange(bounds: Rect) {
|
||||
super.onBoundsChange(bounds)
|
||||
|
||||
avatarSize = FallbackAvatar.getSizeByPx(bounds.width())
|
||||
icon = when (fallbackAvatar) {
|
||||
is FallbackAvatar.Resource -> {
|
||||
val resourceIcon = ContextCompat.getDrawable(context, fallbackAvatar.getIconBySize(avatarSize))!!
|
||||
|
||||
val iconBounds = Rect(bounds)
|
||||
iconBounds.inset(
|
||||
((bounds.width() - (bounds.width() * FallbackAvatar.ICON_TO_BACKGROUND_SCALE)) / 2f).toInt(),
|
||||
((bounds.height() - (bounds.height() * FallbackAvatar.ICON_TO_BACKGROUND_SCALE)) / 2f).toInt()
|
||||
)
|
||||
|
||||
resourceIcon.bounds = iconBounds
|
||||
resourceIcon
|
||||
}
|
||||
|
||||
is FallbackAvatar.Text -> TextAvatarDrawable(
|
||||
context = context,
|
||||
avatar = Avatar.Text(
|
||||
fallbackAvatar.content,
|
||||
Avatars.ColorPair(avatarColorPair.backgroundColor, avatarColorPair.foregroundColor, ""),
|
||||
Avatar.DatabaseId.DoNotPersist
|
||||
),
|
||||
size = bounds.width()
|
||||
)
|
||||
|
||||
FallbackAvatar.Transparent -> null
|
||||
}
|
||||
|
||||
icon?.alpha = alpha
|
||||
icon?.colorFilter = SimpleColorFilter(avatarColorPair.foregroundColor)
|
||||
}
|
||||
|
||||
override fun draw(canvas: Canvas) {
|
||||
if (icon == null) return
|
||||
|
||||
super.draw(canvas)
|
||||
icon?.draw(canvas)
|
||||
}
|
||||
|
||||
override fun setAlpha(alpha: Int) {
|
||||
super.setAlpha(alpha)
|
||||
icon?.alpha = alpha
|
||||
invalidateSelf()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.avatar.fallback
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.Icon
|
||||
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.graphics.Color
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.TextUnitType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.signal.core.ui.Previews
|
||||
import org.signal.core.ui.SignalPreview
|
||||
import org.signal.core.util.DimensionUnit
|
||||
import org.thoughtcrime.securesms.avatar.AvatarRenderer
|
||||
import org.thoughtcrime.securesms.avatar.Avatars
|
||||
import org.thoughtcrime.securesms.conversation.colors.AvatarColor
|
||||
import org.thoughtcrime.securesms.conversation.colors.AvatarColorPair
|
||||
|
||||
@Composable
|
||||
fun FallbackAvatarImage(
|
||||
fallbackAvatar: FallbackAvatar,
|
||||
modifier: Modifier = Modifier,
|
||||
shape: Shape = CircleShape
|
||||
) {
|
||||
if (fallbackAvatar is FallbackAvatar.Transparent) {
|
||||
Box(modifier = modifier)
|
||||
return
|
||||
}
|
||||
|
||||
val context = LocalContext.current
|
||||
val colorPair = remember(fallbackAvatar) {
|
||||
AvatarColorPair.create(context, fallbackAvatar.color)
|
||||
}
|
||||
|
||||
BoxWithConstraints(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = modifier
|
||||
.background(Color(colorPair.backgroundColor), shape)
|
||||
) {
|
||||
when (fallbackAvatar) {
|
||||
is FallbackAvatar.Resource -> {
|
||||
val size = remember(maxWidth) {
|
||||
FallbackAvatar.getSizeByDp(maxWidth)
|
||||
}
|
||||
|
||||
val padding = remember(maxWidth) {
|
||||
((maxWidth.value - (maxWidth.value * FallbackAvatar.ICON_TO_BACKGROUND_SCALE)) / 2).dp
|
||||
}
|
||||
|
||||
Icon(
|
||||
painter = painterResource(fallbackAvatar.getIconBySize(size)),
|
||||
contentDescription = null,
|
||||
tint = Color(colorPair.foregroundColor),
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
)
|
||||
}
|
||||
|
||||
is FallbackAvatar.Text -> {
|
||||
val size = DimensionUnit.DP.toPixels(maxWidth.value) * 0.8f
|
||||
val textSize = DimensionUnit.PIXELS.toDp(Avatars.getTextSizeForLength(context, fallbackAvatar.content, size, size))
|
||||
|
||||
// TODO [alex] -- Handle emoji
|
||||
|
||||
Text(
|
||||
text = fallbackAvatar.content,
|
||||
color = Color(colorPair.foregroundColor),
|
||||
fontSize = TextUnit(textSize, TextUnitType.Sp),
|
||||
fontFamily = FontFamily(AvatarRenderer.getTypeface(context))
|
||||
)
|
||||
}
|
||||
FallbackAvatar.Transparent -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@Composable
|
||||
fun FallbackAvatarImagePreview() {
|
||||
Previews.Preview {
|
||||
Column {
|
||||
Text(text = "Compose - Large")
|
||||
FallbackAvatarImage(
|
||||
fallbackAvatar = FallbackAvatar.Text("AE", AvatarColor.A100),
|
||||
modifier = Modifier.size(160.dp)
|
||||
)
|
||||
Text(text = "Compose - Medium")
|
||||
FallbackAvatarImage(
|
||||
fallbackAvatar = FallbackAvatar.Text("AE", AvatarColor.A100),
|
||||
modifier = Modifier.size(64.dp)
|
||||
)
|
||||
Text(text = "Compose - Small")
|
||||
FallbackAvatarImage(
|
||||
fallbackAvatar = FallbackAvatar.Text("AE", AvatarColor.A100),
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -87,8 +87,8 @@ class AvatarView @JvmOverloads constructor(
|
||||
avatar.setRecipient(recipient)
|
||||
}
|
||||
|
||||
fun setFallbackPhotoProvider(fallbackPhotoProvider: Recipient.FallbackPhotoProvider) {
|
||||
avatar.setFallbackPhotoProvider(fallbackPhotoProvider)
|
||||
fun setFallbackAvatarProvider(fallbackAvatarProvider: AvatarImageView.FallbackAvatarProvider?) {
|
||||
avatar.setFallbackAvatarProvider(fallbackAvatarProvider)
|
||||
}
|
||||
|
||||
fun disableQuickContact() {
|
||||
|
||||
@@ -28,7 +28,7 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.registration.fragments.RestoreBackupFragment;
|
||||
import org.thoughtcrime.securesms.restore.restorelocalbackup.PassphraseAsYouTypeFormatter;
|
||||
import org.thoughtcrime.securesms.service.LocalBackupListener;
|
||||
import org.thoughtcrime.securesms.util.BackupUtil;
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
@@ -170,7 +170,7 @@ public class BackupDialog {
|
||||
Button positiveButton = dialog.getButton(AlertDialog.BUTTON_POSITIVE);
|
||||
positiveButton.setEnabled(false);
|
||||
|
||||
RestoreBackupFragment.PassphraseAsYouTypeFormatter formatter = new RestoreBackupFragment.PassphraseAsYouTypeFormatter();
|
||||
PassphraseAsYouTypeFormatter formatter = new PassphraseAsYouTypeFormatter();
|
||||
|
||||
prompt.addTextChangedListener(new AfterTextChanged(editable -> {
|
||||
formatter.afterTextChanged(editable);
|
||||
|
||||
@@ -5,16 +5,24 @@
|
||||
|
||||
package org.thoughtcrime.securesms.backup.v2
|
||||
|
||||
import androidx.annotation.WorkerThread
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.greenrobot.eventbus.EventBus
|
||||
import org.signal.core.util.Base64
|
||||
import org.signal.core.util.EventTimer
|
||||
import org.signal.core.util.LongSerializer
|
||||
import org.signal.core.util.fullWalCheckpoint
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.core.util.money.FiatMoney
|
||||
import org.signal.core.util.withinTransaction
|
||||
import org.signal.libsignal.messagebackup.MessageBackup
|
||||
import org.signal.libsignal.messagebackup.MessageBackup.ValidationResult
|
||||
import org.signal.libsignal.messagebackup.MessageBackupKey
|
||||
import org.signal.libsignal.protocol.ServiceId.Aci
|
||||
import org.signal.libsignal.zkgroup.backups.BackupLevel
|
||||
import org.signal.libsignal.zkgroup.profiles.ProfileKey
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.attachments.Attachment
|
||||
import org.thoughtcrime.securesms.attachments.AttachmentId
|
||||
import org.thoughtcrime.securesms.attachments.Cdn
|
||||
@@ -26,18 +34,29 @@ import org.thoughtcrime.securesms.backup.v2.processor.AdHocCallBackupProcessor
|
||||
import org.thoughtcrime.securesms.backup.v2.processor.ChatBackupProcessor
|
||||
import org.thoughtcrime.securesms.backup.v2.processor.ChatItemBackupProcessor
|
||||
import org.thoughtcrime.securesms.backup.v2.processor.RecipientBackupProcessor
|
||||
import org.thoughtcrime.securesms.backup.v2.processor.StickerBackupProcessor
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.BackupInfo
|
||||
import org.thoughtcrime.securesms.backup.v2.stream.BackupExportWriter
|
||||
import org.thoughtcrime.securesms.backup.v2.stream.EncryptedBackupReader
|
||||
import org.thoughtcrime.securesms.backup.v2.stream.EncryptedBackupWriter
|
||||
import org.thoughtcrime.securesms.backup.v2.stream.PlainTextBackupReader
|
||||
import org.thoughtcrime.securesms.backup.v2.stream.PlainTextBackupWriter
|
||||
import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsType
|
||||
import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsTypeFeature
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.RecurringInAppPaymentRepository
|
||||
import org.thoughtcrime.securesms.crypto.AttachmentSecretProvider
|
||||
import org.thoughtcrime.securesms.crypto.DatabaseSecretProvider
|
||||
import org.thoughtcrime.securesms.database.DistributionListTables
|
||||
import org.thoughtcrime.securesms.database.KeyValueDatabase
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.groups.GroupId
|
||||
import org.thoughtcrime.securesms.jobs.RequestGroupV2InfoJob
|
||||
import org.thoughtcrime.securesms.keyvalue.KeyValueStore
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.util.toMillis
|
||||
import org.whispersystems.signalservice.api.NetworkResult
|
||||
import org.whispersystems.signalservice.api.StatusCodeErrorAction
|
||||
import org.whispersystems.signalservice.api.archive.ArchiveGetMediaItemsResponse
|
||||
@@ -53,84 +72,181 @@ import org.whispersystems.signalservice.api.messages.SignalServiceAttachment.Pro
|
||||
import org.whispersystems.signalservice.api.push.ServiceId.ACI
|
||||
import org.whispersystems.signalservice.api.push.ServiceId.PNI
|
||||
import org.whispersystems.signalservice.internal.crypto.PaddingInputStream
|
||||
import org.whispersystems.signalservice.internal.push.SubscriptionsConfiguration
|
||||
import org.whispersystems.signalservice.internal.push.http.ResumableUploadSpec
|
||||
import java.io.ByteArrayOutputStream
|
||||
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
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
object BackupRepository {
|
||||
|
||||
private val TAG = Log.tag(BackupRepository::class.java)
|
||||
private const val VERSION = 1L
|
||||
private const val MAIN_DB_SNAPSHOT_NAME = "signal-snapshot.db"
|
||||
private const val KEYVALUE_DB_SNAPSHOT_NAME = "key-value-snapshot.db"
|
||||
|
||||
private val resetInitializedStateErrorAction: StatusCodeErrorAction = { error ->
|
||||
when (error.code) {
|
||||
401 -> {
|
||||
Log.i(TAG, "Resetting initialized state due to 401.")
|
||||
SignalStore.backup().backupsInitialized = false
|
||||
SignalStore.backup.backupsInitialized = false
|
||||
}
|
||||
|
||||
403 -> {
|
||||
Log.i(TAG, "Bad auth credential. Clearing stored credentials.")
|
||||
SignalStore.backup().clearAllCredentials()
|
||||
SignalStore.backup.clearAllCredentials()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
fun turnOffAndDeleteBackup() {
|
||||
RecurringInAppPaymentRepository.cancelActiveSubscriptionSync(InAppPaymentSubscriberRecord.Type.BACKUP)
|
||||
SignalStore.backup.areBackupsEnabled = false
|
||||
SignalStore.backup.backupTier = null
|
||||
}
|
||||
|
||||
private fun createSignalDatabaseSnapshot(): SignalDatabase {
|
||||
// Need to do a WAL checkpoint to ensure that the database file we're copying has all pending writes
|
||||
if (!SignalDatabase.rawDatabase.fullWalCheckpoint()) {
|
||||
Log.w(TAG, "Failed to checkpoint WAL for main database! Not guaranteed to be using the most recent data.")
|
||||
}
|
||||
|
||||
// We make a copy of the database within a transaction to ensure that no writes occur while we're copying the file
|
||||
return SignalDatabase.rawDatabase.withinTransaction {
|
||||
val context = AppDependencies.application
|
||||
|
||||
val existingDbFile = context.getDatabasePath(SignalDatabase.DATABASE_NAME)
|
||||
val targetFile = File(existingDbFile.parentFile, MAIN_DB_SNAPSHOT_NAME)
|
||||
|
||||
try {
|
||||
existingDbFile.copyTo(targetFile, overwrite = true)
|
||||
} catch (e: IOException) {
|
||||
// TODO [backup] Gracefully handle this error
|
||||
throw IllegalStateException("Failed to copy database file!", e)
|
||||
}
|
||||
|
||||
SignalDatabase(
|
||||
context = context,
|
||||
databaseSecret = DatabaseSecretProvider.getOrCreateDatabaseSecret(context),
|
||||
attachmentSecret = AttachmentSecretProvider.getInstance(context).getOrCreateAttachmentSecret(),
|
||||
name = MAIN_DB_SNAPSHOT_NAME
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createSignalStoreSnapshot(): SignalStore {
|
||||
val context = AppDependencies.application
|
||||
|
||||
// Need to do a WAL checkpoint to ensure that the database file we're copying has all pending writes
|
||||
if (!KeyValueDatabase.getInstance(context).writableDatabase.fullWalCheckpoint()) {
|
||||
Log.w(TAG, "Failed to checkpoint WAL for KeyValueDatabase! Not guaranteed to be using the most recent data.")
|
||||
}
|
||||
|
||||
// We make a copy of the database within a transaction to ensure that no writes occur while we're copying the file
|
||||
return KeyValueDatabase.getInstance(context).writableDatabase.withinTransaction {
|
||||
val existingDbFile = context.getDatabasePath(KeyValueDatabase.DATABASE_NAME)
|
||||
val targetFile = File(existingDbFile.parentFile, KEYVALUE_DB_SNAPSHOT_NAME)
|
||||
|
||||
try {
|
||||
existingDbFile.copyTo(targetFile, overwrite = true)
|
||||
} catch (e: IOException) {
|
||||
// TODO [backup] Gracefully handle this error
|
||||
throw IllegalStateException("Failed to copy database file!", e)
|
||||
}
|
||||
|
||||
val db = KeyValueDatabase.createWithName(context, KEYVALUE_DB_SNAPSHOT_NAME)
|
||||
SignalStore(KeyValueStore(db))
|
||||
}
|
||||
}
|
||||
|
||||
private fun deleteDatabaseSnapshot() {
|
||||
val targetFile = AppDependencies.application.getDatabasePath(MAIN_DB_SNAPSHOT_NAME)
|
||||
if (!targetFile.delete()) {
|
||||
Log.w(TAG, "Failed to delete main database snapshot!")
|
||||
}
|
||||
}
|
||||
|
||||
private fun deleteSignalStoreSnapshot() {
|
||||
val targetFile = AppDependencies.application.getDatabasePath(KEYVALUE_DB_SNAPSHOT_NAME)
|
||||
if (!targetFile.delete()) {
|
||||
Log.w(TAG, "Failed to delete key value database snapshot!")
|
||||
}
|
||||
}
|
||||
|
||||
fun export(outputStream: OutputStream, append: (ByteArray) -> Unit, plaintext: Boolean = false) {
|
||||
val eventTimer = EventTimer()
|
||||
val writer: BackupExportWriter = if (plaintext) {
|
||||
PlainTextBackupWriter(outputStream)
|
||||
} else {
|
||||
EncryptedBackupWriter(
|
||||
key = SignalStore.svr().getOrCreateMasterKey().deriveBackupKey(),
|
||||
aci = SignalStore.account().aci!!,
|
||||
outputStream = outputStream,
|
||||
append = append
|
||||
)
|
||||
}
|
||||
val dbSnapshot: SignalDatabase = createSignalDatabaseSnapshot()
|
||||
val signalStoreSnapshot: SignalStore = createSignalStoreSnapshot()
|
||||
|
||||
val exportState = ExportState(backupTime = System.currentTimeMillis(), allowMediaBackup = true)
|
||||
|
||||
writer.use {
|
||||
writer.write(
|
||||
BackupInfo(
|
||||
version = VERSION,
|
||||
backupTimeMs = exportState.backupTime
|
||||
try {
|
||||
val writer: BackupExportWriter = if (plaintext) {
|
||||
PlainTextBackupWriter(outputStream)
|
||||
} else {
|
||||
EncryptedBackupWriter(
|
||||
key = SignalStore.svr.getOrCreateMasterKey().deriveBackupKey(),
|
||||
aci = SignalStore.account.aci!!,
|
||||
outputStream = outputStream,
|
||||
append = append
|
||||
)
|
||||
)
|
||||
// Note: Without a transaction, we may export inconsistent state. But because we have a transaction,
|
||||
// writes from other threads are blocked. This is something to think more about.
|
||||
SignalDatabase.rawDatabase.withinTransaction {
|
||||
AccountDataProcessor.export {
|
||||
writer.write(it)
|
||||
eventTimer.emit("account")
|
||||
}
|
||||
}
|
||||
|
||||
RecipientBackupProcessor.export(exportState) {
|
||||
writer.write(it)
|
||||
eventTimer.emit("recipient")
|
||||
}
|
||||
val exportState = ExportState(backupTime = System.currentTimeMillis(), allowMediaBackup = SignalStore.backup.backsUpMedia)
|
||||
|
||||
ChatBackupProcessor.export(exportState) { frame ->
|
||||
writer.write(frame)
|
||||
eventTimer.emit("thread")
|
||||
}
|
||||
writer.use {
|
||||
writer.write(
|
||||
BackupInfo(
|
||||
version = VERSION,
|
||||
backupTimeMs = exportState.backupTime
|
||||
)
|
||||
)
|
||||
// Note: Without a transaction, we may export inconsistent state. But because we have a transaction,
|
||||
// writes from other threads are blocked. This is something to think more about.
|
||||
dbSnapshot.rawWritableDatabase.withinTransaction {
|
||||
AccountDataProcessor.export(dbSnapshot, signalStoreSnapshot) {
|
||||
writer.write(it)
|
||||
eventTimer.emit("account")
|
||||
}
|
||||
|
||||
AdHocCallBackupProcessor.export { frame ->
|
||||
writer.write(frame)
|
||||
eventTimer.emit("call")
|
||||
}
|
||||
RecipientBackupProcessor.export(dbSnapshot, signalStoreSnapshot, exportState) {
|
||||
writer.write(it)
|
||||
eventTimer.emit("recipient")
|
||||
}
|
||||
|
||||
ChatItemBackupProcessor.export(exportState) { frame ->
|
||||
writer.write(frame)
|
||||
eventTimer.emit("message")
|
||||
ChatBackupProcessor.export(dbSnapshot, exportState) { frame ->
|
||||
writer.write(frame)
|
||||
eventTimer.emit("thread")
|
||||
}
|
||||
|
||||
AdHocCallBackupProcessor.export(dbSnapshot) { frame ->
|
||||
writer.write(frame)
|
||||
eventTimer.emit("call")
|
||||
}
|
||||
|
||||
StickerBackupProcessor.export(dbSnapshot) { frame ->
|
||||
writer.write(frame)
|
||||
eventTimer.emit("sticker-pack")
|
||||
}
|
||||
|
||||
ChatItemBackupProcessor.export(dbSnapshot, exportState) { frame ->
|
||||
writer.write(frame)
|
||||
eventTimer.emit("message")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Log.d(TAG, "export() ${eventTimer.stop().summary}")
|
||||
Log.d(TAG, "export() ${eventTimer.stop().summary}")
|
||||
} finally {
|
||||
deleteDatabaseSnapshot()
|
||||
deleteSignalStoreSnapshot()
|
||||
}
|
||||
}
|
||||
|
||||
fun export(plaintext: Boolean = false): ByteArray {
|
||||
@@ -140,7 +256,7 @@ object BackupRepository {
|
||||
}
|
||||
|
||||
fun validate(length: Long, inputStreamFactory: () -> InputStream, selfData: SelfData): ValidationResult {
|
||||
val masterKey = SignalStore.svr().getOrCreateMasterKey()
|
||||
val masterKey = SignalStore.svr.getOrCreateMasterKey()
|
||||
val key = MessageBackupKey(masterKey.serialize(), Aci.parseFromBinary(selfData.aci.toByteArray()))
|
||||
|
||||
return MessageBackup.validate(key, MessageBackup.Purpose.REMOTE_BACKUP, inputStreamFactory, length)
|
||||
@@ -149,15 +265,15 @@ object BackupRepository {
|
||||
fun import(length: Long, inputStreamFactory: () -> InputStream, selfData: SelfData, plaintext: Boolean = false) {
|
||||
val eventTimer = EventTimer()
|
||||
|
||||
val backupKey = SignalStore.svr().getOrCreateMasterKey().deriveBackupKey()
|
||||
val backupKey = SignalStore.svr.getOrCreateMasterKey().deriveBackupKey()
|
||||
|
||||
val frameReader = if (plaintext) {
|
||||
PlainTextBackupReader(inputStreamFactory())
|
||||
PlainTextBackupReader(inputStreamFactory(), length)
|
||||
} else {
|
||||
EncryptedBackupReader(
|
||||
key = backupKey,
|
||||
aci = selfData.aci,
|
||||
streamLength = length,
|
||||
length = length,
|
||||
dataStream = inputStreamFactory
|
||||
)
|
||||
}
|
||||
@@ -180,16 +296,21 @@ object BackupRepository {
|
||||
SignalDatabase.threads.clearAllDataForBackupRestore()
|
||||
SignalDatabase.messages.clearAllDataForBackupRestore()
|
||||
SignalDatabase.attachments.clearAllDataForBackupRestore()
|
||||
SignalDatabase.stickers.clearAllDataForBackupRestore()
|
||||
|
||||
// Add back self after clearing data
|
||||
val selfId: RecipientId = SignalDatabase.recipients.getAndPossiblyMerge(selfData.aci, selfData.pni, selfData.e164, pniVerified = true, changeSelf = true)
|
||||
SignalDatabase.recipients.setProfileKey(selfId, selfData.profileKey)
|
||||
SignalDatabase.recipients.setProfileSharing(selfId, true)
|
||||
|
||||
// Add back my story after clearing data
|
||||
DistributionListTables.insertInitialDistributionListAtCreationTime(it)
|
||||
|
||||
eventTimer.emit("setup")
|
||||
val backupState = BackupState(backupKey)
|
||||
val chatItemInserter: ChatItemImportInserter = ChatItemBackupProcessor.beginImport(backupState)
|
||||
|
||||
val totalLength = frameReader.getStreamLength()
|
||||
for (frame in frameReader) {
|
||||
when {
|
||||
frame.account != null -> {
|
||||
@@ -212,6 +333,11 @@ object BackupRepository {
|
||||
eventTimer.emit("call")
|
||||
}
|
||||
|
||||
frame.stickerPack != null -> {
|
||||
StickerBackupProcessor.import(frame.stickerPack)
|
||||
eventTimer.emit("sticker-pack")
|
||||
}
|
||||
|
||||
frame.chatItem != null -> {
|
||||
chatItemInserter.insert(frame.chatItem)
|
||||
eventTimer.emit("chatItem")
|
||||
@@ -220,6 +346,7 @@ object BackupRepository {
|
||||
|
||||
else -> Log.w(TAG, "Unrecognized frame")
|
||||
}
|
||||
EventBus.getDefault().post(RestoreV2Event(RestoreV2Event.Type.PROGRESS_RESTORE, frameReader.getBytesRead(), totalLength))
|
||||
}
|
||||
|
||||
if (chatItemInserter.flush()) {
|
||||
@@ -244,7 +371,7 @@ object BackupRepository {
|
||||
|
||||
fun listRemoteMediaObjects(limit: Int, cursor: String? = null): NetworkResult<ArchiveGetMediaItemsResponse> {
|
||||
val api = AppDependencies.signalServiceAccountManager.archiveApi
|
||||
val backupKey = SignalStore.svr().getOrCreateMasterKey().deriveBackupKey()
|
||||
val backupKey = SignalStore.svr.getOrCreateMasterKey().deriveBackupKey()
|
||||
|
||||
return initBackupAndFetchAuth(backupKey)
|
||||
.then { credential ->
|
||||
@@ -254,7 +381,7 @@ object BackupRepository {
|
||||
|
||||
fun getRemoteBackupUsedSpace(): NetworkResult<Long?> {
|
||||
val api = AppDependencies.signalServiceAccountManager.archiveApi
|
||||
val backupKey = SignalStore.svr().getOrCreateMasterKey().deriveBackupKey()
|
||||
val backupKey = SignalStore.svr.getOrCreateMasterKey().deriveBackupKey()
|
||||
|
||||
return initBackupAndFetchAuth(backupKey)
|
||||
.then { credential ->
|
||||
@@ -263,12 +390,27 @@ object BackupRepository {
|
||||
}
|
||||
}
|
||||
|
||||
private fun getBackupTier(): NetworkResult<MessageBackupTier> {
|
||||
val api = AppDependencies.signalServiceAccountManager.archiveApi
|
||||
val backupKey = SignalStore.svr.getOrCreateMasterKey().deriveBackupKey()
|
||||
|
||||
return initBackupAndFetchAuth(backupKey)
|
||||
.map { credential ->
|
||||
val zkCredential = api.getZkCredential(backupKey, credential)
|
||||
if (zkCredential.backupLevel == BackupLevel.MEDIA) {
|
||||
MessageBackupTier.PAID
|
||||
} else {
|
||||
MessageBackupTier.FREE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an object with details about the remote backup state.
|
||||
*/
|
||||
fun getRemoteBackupState(): NetworkResult<BackupMetadata> {
|
||||
val api = AppDependencies.signalServiceAccountManager.archiveApi
|
||||
val backupKey = SignalStore.svr().getOrCreateMasterKey().deriveBackupKey()
|
||||
val backupKey = SignalStore.svr.getOrCreateMasterKey().deriveBackupKey()
|
||||
|
||||
return initBackupAndFetchAuth(backupKey)
|
||||
.then { credential ->
|
||||
@@ -295,7 +437,7 @@ object BackupRepository {
|
||||
*/
|
||||
fun uploadBackupFile(backupStream: InputStream, backupStreamLength: Long): Boolean {
|
||||
val api = AppDependencies.signalServiceAccountManager.archiveApi
|
||||
val backupKey = SignalStore.svr().getOrCreateMasterKey().deriveBackupKey()
|
||||
val backupKey = SignalStore.svr.getOrCreateMasterKey().deriveBackupKey()
|
||||
|
||||
return initBackupAndFetchAuth(backupKey)
|
||||
.then { credential ->
|
||||
@@ -317,7 +459,7 @@ object BackupRepository {
|
||||
|
||||
fun downloadBackupFile(destination: File, listener: ProgressListener? = null): Boolean {
|
||||
val api = AppDependencies.signalServiceAccountManager.archiveApi
|
||||
val backupKey = SignalStore.svr().getOrCreateMasterKey().deriveBackupKey()
|
||||
val backupKey = SignalStore.svr.getOrCreateMasterKey().deriveBackupKey()
|
||||
|
||||
return initBackupAndFetchAuth(backupKey)
|
||||
.then { credential ->
|
||||
@@ -331,12 +473,30 @@ object BackupRepository {
|
||||
} is NetworkResult.Success
|
||||
}
|
||||
|
||||
fun getBackupFileLastModified(): NetworkResult<ZonedDateTime?> {
|
||||
val api = AppDependencies.signalServiceAccountManager.archiveApi
|
||||
val backupKey = SignalStore.svr.getOrCreateMasterKey().deriveBackupKey()
|
||||
|
||||
return initBackupAndFetchAuth(backupKey)
|
||||
.then { credential ->
|
||||
api.getBackupInfo(backupKey, credential)
|
||||
}
|
||||
.then { info -> getCdnReadCredentials(info.cdn ?: Cdn.CDN_3.cdnNumber).map { it.headers to info } }
|
||||
.then { pair ->
|
||||
val (cdnCredentials, info) = pair
|
||||
val messageReceiver = AppDependencies.signalServiceMessageReceiver
|
||||
NetworkResult.fromFetch {
|
||||
messageReceiver.getCdnLastModifiedTime(info.cdn!!, cdnCredentials, "backups/${info.backupDir}/${info.backupName}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an object with details about the remote backup state.
|
||||
*/
|
||||
fun debugGetArchivedMediaState(): NetworkResult<List<ArchiveGetMediaItemsResponse.StoredMediaObject>> {
|
||||
val api = AppDependencies.signalServiceAccountManager.archiveApi
|
||||
val backupKey = SignalStore.svr().getOrCreateMasterKey().deriveBackupKey()
|
||||
val backupKey = SignalStore.svr.getOrCreateMasterKey().deriveBackupKey()
|
||||
|
||||
return initBackupAndFetchAuth(backupKey)
|
||||
.then { credential ->
|
||||
@@ -349,7 +509,7 @@ object BackupRepository {
|
||||
*/
|
||||
fun getMediaUploadSpec(secretKey: ByteArray? = null): NetworkResult<ResumableUploadSpec> {
|
||||
val api = AppDependencies.signalServiceAccountManager.archiveApi
|
||||
val backupKey = SignalStore.svr().getOrCreateMasterKey().deriveBackupKey()
|
||||
val backupKey = SignalStore.svr.getOrCreateMasterKey().deriveBackupKey()
|
||||
|
||||
return initBackupAndFetchAuth(backupKey)
|
||||
.then { credential ->
|
||||
@@ -362,7 +522,7 @@ object BackupRepository {
|
||||
|
||||
fun archiveThumbnail(thumbnailAttachment: Attachment, parentAttachment: DatabaseAttachment): NetworkResult<ArchiveMediaResponse> {
|
||||
val api = AppDependencies.signalServiceAccountManager.archiveApi
|
||||
val backupKey = SignalStore.svr().getOrCreateMasterKey().deriveBackupKey()
|
||||
val backupKey = SignalStore.svr.getOrCreateMasterKey().deriveBackupKey()
|
||||
val request = thumbnailAttachment.toArchiveMediaRequest(parentAttachment.getThumbnailMediaName(), backupKey)
|
||||
|
||||
return initBackupAndFetchAuth(backupKey)
|
||||
@@ -377,7 +537,7 @@ object BackupRepository {
|
||||
|
||||
fun archiveMedia(attachment: DatabaseAttachment): NetworkResult<Unit> {
|
||||
val api = AppDependencies.signalServiceAccountManager.archiveApi
|
||||
val backupKey = SignalStore.svr().getOrCreateMasterKey().deriveBackupKey()
|
||||
val backupKey = SignalStore.svr.getOrCreateMasterKey().deriveBackupKey()
|
||||
|
||||
return initBackupAndFetchAuth(backupKey)
|
||||
.then { credential ->
|
||||
@@ -400,7 +560,7 @@ object BackupRepository {
|
||||
|
||||
fun archiveMedia(databaseAttachments: List<DatabaseAttachment>): NetworkResult<BatchArchiveMediaResult> {
|
||||
val api = AppDependencies.signalServiceAccountManager.archiveApi
|
||||
val backupKey = SignalStore.svr().getOrCreateMasterKey().deriveBackupKey()
|
||||
val backupKey = SignalStore.svr.getOrCreateMasterKey().deriveBackupKey()
|
||||
|
||||
return initBackupAndFetchAuth(backupKey)
|
||||
.then { credential ->
|
||||
@@ -440,7 +600,7 @@ object BackupRepository {
|
||||
|
||||
fun deleteArchivedMedia(attachments: List<DatabaseAttachment>): NetworkResult<Unit> {
|
||||
val api = AppDependencies.signalServiceAccountManager.archiveApi
|
||||
val backupKey = SignalStore.svr().getOrCreateMasterKey().deriveBackupKey()
|
||||
val backupKey = SignalStore.svr.getOrCreateMasterKey().deriveBackupKey()
|
||||
|
||||
val mediaToDelete = attachments
|
||||
.filter { it.archiveMediaId != null }
|
||||
@@ -472,7 +632,7 @@ object BackupRepository {
|
||||
|
||||
fun deleteAbandonedMediaObjects(mediaObjects: Collection<ArchivedMediaObject>): NetworkResult<Unit> {
|
||||
val api = AppDependencies.signalServiceAccountManager.archiveApi
|
||||
val backupKey = SignalStore.svr().getOrCreateMasterKey().deriveBackupKey()
|
||||
val backupKey = SignalStore.svr.getOrCreateMasterKey().deriveBackupKey()
|
||||
|
||||
val mediaToDelete = mediaObjects
|
||||
.map {
|
||||
@@ -500,7 +660,7 @@ object BackupRepository {
|
||||
|
||||
fun debugDeleteAllArchivedMedia(): NetworkResult<Unit> {
|
||||
val api = AppDependencies.signalServiceAccountManager.archiveApi
|
||||
val backupKey = SignalStore.svr().getOrCreateMasterKey().deriveBackupKey()
|
||||
val backupKey = SignalStore.svr.getOrCreateMasterKey().deriveBackupKey()
|
||||
|
||||
return debugGetArchivedMediaState()
|
||||
.then { archivedMedia ->
|
||||
@@ -536,13 +696,13 @@ object BackupRepository {
|
||||
* Retrieve credentials for reading from the backup cdn.
|
||||
*/
|
||||
fun getCdnReadCredentials(cdnNumber: Int): NetworkResult<GetArchiveCdnCredentialsResponse> {
|
||||
val cached = SignalStore.backup().cdnReadCredentials
|
||||
val cached = SignalStore.backup.cdnReadCredentials
|
||||
if (cached != null) {
|
||||
return NetworkResult.Success(cached)
|
||||
}
|
||||
|
||||
val api = AppDependencies.signalServiceAccountManager.archiveApi
|
||||
val backupKey = SignalStore.svr().getOrCreateMasterKey().deriveBackupKey()
|
||||
val backupKey = SignalStore.svr.getOrCreateMasterKey().deriveBackupKey()
|
||||
|
||||
return initBackupAndFetchAuth(backupKey)
|
||||
.then { credential ->
|
||||
@@ -554,20 +714,41 @@ object BackupRepository {
|
||||
}
|
||||
.also {
|
||||
if (it is NetworkResult.Success) {
|
||||
SignalStore.backup().cdnReadCredentials = it.result
|
||||
SignalStore.backup.cdnReadCredentials = it.result
|
||||
}
|
||||
}
|
||||
.also { Log.i(TAG, "getCdnReadCredentialsResult: $it") }
|
||||
}
|
||||
|
||||
fun restoreBackupTier(): MessageBackupTier? {
|
||||
// TODO: more complete error handling
|
||||
try {
|
||||
val lastModified = getBackupFileLastModified().successOrThrow()
|
||||
if (lastModified != null) {
|
||||
SignalStore.backup.lastBackupTime = lastModified.toMillis()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.i(TAG, "Could not check for backup file.", e)
|
||||
SignalStore.backup.backupTier = null
|
||||
return null
|
||||
}
|
||||
SignalStore.backup.backupTier = try {
|
||||
getBackupTier().successOrThrow()
|
||||
} catch (e: Exception) {
|
||||
Log.i(TAG, "Could not retrieve backup tier.", e)
|
||||
null
|
||||
}
|
||||
return SignalStore.backup.backupTier
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves backupDir and mediaDir, preferring cached value if available.
|
||||
*
|
||||
* These will only ever change if the backup expires.
|
||||
*/
|
||||
fun getCdnBackupDirectories(): NetworkResult<BackupDirectories> {
|
||||
val cachedBackupDirectory = SignalStore.backup().cachedBackupDirectory
|
||||
val cachedBackupMediaDirectory = SignalStore.backup().cachedBackupMediaDirectory
|
||||
val cachedBackupDirectory = SignalStore.backup.cachedBackupDirectory
|
||||
val cachedBackupMediaDirectory = SignalStore.backup.cachedBackupMediaDirectory
|
||||
|
||||
if (cachedBackupDirectory != null && cachedBackupMediaDirectory != null) {
|
||||
return NetworkResult.Success(
|
||||
@@ -579,23 +760,99 @@ object BackupRepository {
|
||||
}
|
||||
|
||||
val api = AppDependencies.signalServiceAccountManager.archiveApi
|
||||
val backupKey = SignalStore.svr().getOrCreateMasterKey().deriveBackupKey()
|
||||
val backupKey = SignalStore.svr.getOrCreateMasterKey().deriveBackupKey()
|
||||
|
||||
return initBackupAndFetchAuth(backupKey)
|
||||
.then { credential ->
|
||||
api.getBackupInfo(backupKey, credential).map {
|
||||
SignalStore.backup().usedBackupMediaSpace = it.usedSpace ?: 0L
|
||||
SignalStore.backup.usedBackupMediaSpace = it.usedSpace ?: 0L
|
||||
BackupDirectories(it.backupDir!!, it.mediaDir!!)
|
||||
}
|
||||
}
|
||||
.also {
|
||||
if (it is NetworkResult.Success) {
|
||||
SignalStore.backup().cachedBackupDirectory = it.result.backupDir
|
||||
SignalStore.backup().cachedBackupMediaDirectory = it.result.mediaDir
|
||||
SignalStore.backup.cachedBackupDirectory = it.result.backupDir
|
||||
SignalStore.backup.cachedBackupMediaDirectory = it.result.mediaDir
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getAvailableBackupsTypes(availableBackupTiers: List<MessageBackupTier>): List<MessageBackupsType> {
|
||||
return availableBackupTiers.map { getBackupsType(it) }
|
||||
}
|
||||
|
||||
suspend fun getBackupsType(tier: MessageBackupTier): MessageBackupsType {
|
||||
val backupCurrency = SignalStore.inAppPayments.getSubscriptionCurrency(InAppPaymentSubscriberRecord.Type.BACKUP)
|
||||
return when (tier) {
|
||||
MessageBackupTier.FREE -> getFreeType(backupCurrency)
|
||||
MessageBackupTier.PAID -> getPaidType(backupCurrency)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getFreeType(currency: Currency): MessageBackupsType {
|
||||
return MessageBackupsType(
|
||||
tier = MessageBackupTier.FREE,
|
||||
pricePerMonth = FiatMoney(BigDecimal.ZERO, currency),
|
||||
title = "Text + 30 days of media", // TODO [message-backups] Finalize text (does this come from server?)
|
||||
features = persistentListOf(
|
||||
MessageBackupsTypeFeature(
|
||||
iconResourceId = R.drawable.symbol_thread_compact_bold_16,
|
||||
label = "Full text message backup" // TODO [message-backups] Finalize text (does this come from server?)
|
||||
),
|
||||
MessageBackupsTypeFeature(
|
||||
iconResourceId = R.drawable.symbol_album_compact_bold_16,
|
||||
label = "Last 30 days of media" // TODO [message-backups] Finalize text (does this come from server?)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun getPaidType(currency: Currency): MessageBackupsType {
|
||||
val serviceResponse = withContext(Dispatchers.IO) {
|
||||
AppDependencies
|
||||
.donationsService
|
||||
.getDonationsConfiguration(Locale.getDefault())
|
||||
}
|
||||
|
||||
if (serviceResponse.result.isEmpty) {
|
||||
if (serviceResponse.applicationError.isPresent) {
|
||||
throw serviceResponse.applicationError.get()
|
||||
}
|
||||
|
||||
if (serviceResponse.executionError.isPresent) {
|
||||
throw serviceResponse.executionError.get()
|
||||
}
|
||||
|
||||
error("Unhandled error occurred while downloading configuration.")
|
||||
}
|
||||
|
||||
val config = serviceResponse.result.get()
|
||||
|
||||
return MessageBackupsType(
|
||||
tier = MessageBackupTier.PAID,
|
||||
pricePerMonth = FiatMoney(config.currencies[currency.currencyCode.lowercase()]!!.backupSubscription[SubscriptionsConfiguration.BACKUPS_LEVEL]!!, currency),
|
||||
title = "Text + All your media", // TODO [message-backups] Finalize text (does this come from server?)
|
||||
features = persistentListOf(
|
||||
MessageBackupsTypeFeature(
|
||||
iconResourceId = R.drawable.symbol_thread_compact_bold_16,
|
||||
label = "Full text message backup" // TODO [message-backups] Finalize text (does this come from server?)
|
||||
),
|
||||
MessageBackupsTypeFeature(
|
||||
iconResourceId = R.drawable.symbol_album_compact_bold_16,
|
||||
label = "Full media backup" // TODO [message-backups] Finalize text (does this come from server?)
|
||||
),
|
||||
MessageBackupsTypeFeature(
|
||||
iconResourceId = R.drawable.symbol_thread_compact_bold_16,
|
||||
label = "1TB of storage (~250K photos)" // TODO [message-backups] Finalize text (does this come from server?)
|
||||
),
|
||||
MessageBackupsTypeFeature(
|
||||
iconResourceId = R.drawable.symbol_heart_compact_bold_16,
|
||||
label = "Thanks for supporting Signal!" // TODO [message-backups] Finalize text (does this come from server?)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures that the backupId has been reserved and that your public key has been set, while also returning an auth credential.
|
||||
* Should be the basis of all backup operations.
|
||||
@@ -603,14 +860,14 @@ object BackupRepository {
|
||||
private fun initBackupAndFetchAuth(backupKey: BackupKey): NetworkResult<ArchiveServiceCredential> {
|
||||
val api = AppDependencies.signalServiceAccountManager.archiveApi
|
||||
|
||||
return if (SignalStore.backup().backupsInitialized) {
|
||||
return if (SignalStore.backup.backupsInitialized) {
|
||||
getAuthCredential().runOnStatusCodeError(resetInitializedStateErrorAction)
|
||||
} else {
|
||||
return api
|
||||
.triggerBackupIdReservation(backupKey)
|
||||
.then { getAuthCredential() }
|
||||
.then { credential -> api.setPublicKey(backupKey, credential).map { credential } }
|
||||
.runIfSuccessful { SignalStore.backup().backupsInitialized = true }
|
||||
.runIfSuccessful { SignalStore.backup.backupsInitialized = true }
|
||||
.runOnStatusCodeError(resetInitializedStateErrorAction)
|
||||
}
|
||||
}
|
||||
@@ -621,7 +878,7 @@ object BackupRepository {
|
||||
private fun getAuthCredential(): NetworkResult<ArchiveServiceCredential> {
|
||||
val currentTime = System.currentTimeMillis()
|
||||
|
||||
val credential = SignalStore.backup().credentialsByDay.getForCurrentTime(currentTime.milliseconds)
|
||||
val credential = SignalStore.backup.credentialsByDay.getForCurrentTime(currentTime.milliseconds)
|
||||
|
||||
if (credential != null) {
|
||||
return NetworkResult.Success(credential)
|
||||
@@ -630,9 +887,9 @@ object BackupRepository {
|
||||
Log.w(TAG, "No credentials found for today, need to fetch new ones! This shouldn't happen under normal circumstances. We should ensure the routine fetch is running properly.")
|
||||
|
||||
return AppDependencies.signalServiceAccountManager.archiveApi.getServiceCredentials(currentTime).map { result ->
|
||||
SignalStore.backup().addCredentials(result.credentials.toList())
|
||||
SignalStore.backup().clearCredentialsOlderThan(currentTime)
|
||||
SignalStore.backup().credentialsByDay.getForCurrentTime(currentTime.milliseconds)!!
|
||||
SignalStore.backup.addCredentials(result.credentials.toList())
|
||||
SignalStore.backup.clearCredentialsOlderThan(currentTime)
|
||||
SignalStore.backup.credentialsByDay.getForCurrentTime(currentTime.milliseconds)!!
|
||||
}
|
||||
}
|
||||
|
||||
@@ -688,18 +945,3 @@ class BackupMetadata(
|
||||
val usedSpace: Long,
|
||||
val mediaCount: Long
|
||||
)
|
||||
|
||||
enum class MessageBackupTier(val value: Int) {
|
||||
FREE(0),
|
||||
PAID(1);
|
||||
|
||||
companion object Serializer : LongSerializer<MessageBackupTier> {
|
||||
override fun serialize(data: MessageBackupTier): Long {
|
||||
return data.value.toLong()
|
||||
}
|
||||
|
||||
override fun deserialize(data: Long): MessageBackupTier {
|
||||
return values().firstOrNull { it.value == data.toInt() } ?: FREE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.backup.v2
|
||||
|
||||
import org.signal.core.util.LongSerializer
|
||||
|
||||
/**
|
||||
* Serializable enum value for what we think a user's current backup tier is.
|
||||
*
|
||||
* We should not trust the stored value on its own, we should also verify it
|
||||
* against what the server knows, but it is a useful flag that helps avoid a
|
||||
* network call in some cases.
|
||||
*/
|
||||
enum class MessageBackupTier(val value: Int) {
|
||||
FREE(0),
|
||||
PAID(1);
|
||||
|
||||
companion object Serializer : LongSerializer<MessageBackupTier?> {
|
||||
override fun serialize(data: MessageBackupTier?): Long {
|
||||
return data?.value?.toLong() ?: -1
|
||||
}
|
||||
|
||||
override fun deserialize(data: Long): MessageBackupTier? {
|
||||
return entries.firstOrNull { it.value == data.toInt() }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.backup.v2
|
||||
|
||||
class RestoreV2Event(val type: Type, val count: Long, val estimatedTotalCount: Long) {
|
||||
enum class Type {
|
||||
PROGRESS_DOWNLOAD,
|
||||
PROGRESS_RESTORE,
|
||||
PROGRESS_MEDIA_RESTORE,
|
||||
FINISHED
|
||||
}
|
||||
|
||||
fun getProgress(): Float {
|
||||
if (estimatedTotalCount == 0L) {
|
||||
return 0f
|
||||
}
|
||||
return count.toFloat() / estimatedTotalCount.toFloat()
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
package org.thoughtcrime.securesms.backup.v2.database
|
||||
|
||||
import android.database.Cursor
|
||||
import okio.ByteString
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import org.signal.core.util.select
|
||||
import org.signal.ringrtc.CallLinkRootKey
|
||||
@@ -30,11 +31,17 @@ fun CallLinkTable.getCallLinksForBackup(): BackupCallLinkIterator {
|
||||
return BackupCallLinkIterator(cursor)
|
||||
}
|
||||
|
||||
fun CallLinkTable.restoreFromBackup(callLink: CallLink): RecipientId {
|
||||
fun CallLinkTable.restoreFromBackup(callLink: CallLink): RecipientId? {
|
||||
val rootKey: CallLinkRootKey
|
||||
try {
|
||||
rootKey = CallLinkRootKey(callLink.rootKey.toByteArray())
|
||||
} catch (e: Exception) {
|
||||
return null
|
||||
}
|
||||
return SignalDatabase.callLinks.insertCallLink(
|
||||
CallLinkTable.CallLink(
|
||||
recipientId = RecipientId.UNKNOWN,
|
||||
roomId = CallLinkRoomId.fromCallLinkRootKey(CallLinkRootKey(callLink.rootKey.toByteArray())),
|
||||
roomId = CallLinkRoomId.fromCallLinkRootKey(rootKey),
|
||||
credentials = CallLinkCredentials(callLink.rootKey.toByteArray(), callLink.adminKey?.toByteArray()),
|
||||
state = SignalCallLinkState(
|
||||
name = callLink.name,
|
||||
@@ -63,10 +70,12 @@ class BackupCallLinkIterator(private val cursor: Cursor) : Iterator<BackupRecipi
|
||||
return BackupRecipient(
|
||||
id = callLink.recipientId.toLong(),
|
||||
callLink = CallLink(
|
||||
rootKey = callLink.credentials!!.linkKeyBytes.toByteString(),
|
||||
adminKey = callLink.credentials.adminPassBytes?.toByteString(),
|
||||
rootKey = callLink.credentials?.linkKeyBytes?.toByteString() ?: ByteString.EMPTY,
|
||||
adminKey = callLink.credentials?.adminPassBytes?.toByteString(),
|
||||
name = callLink.state.name,
|
||||
expirationMs = callLink.state.expiration.toEpochMilli(),
|
||||
expirationMs = try {
|
||||
callLink.state.expiration.toEpochMilli()
|
||||
} catch (e: ArithmeticException) { Long.MAX_VALUE },
|
||||
restrictions = callLink.state.restrictions.toBackup()
|
||||
)
|
||||
)
|
||||
|
||||
@@ -38,7 +38,7 @@ fun CallTable.restoreCallLogFromBackup(call: AdHocCall, backupState: BackupState
|
||||
CallTable.TYPE to CallTable.Type.serialize(CallTable.Type.AD_HOC_CALL),
|
||||
CallTable.DIRECTION to CallTable.Direction.serialize(CallTable.Direction.OUTGOING),
|
||||
CallTable.EVENT to CallTable.Event.serialize(event),
|
||||
CallTable.TIMESTAMP to call.startedCallTimestamp
|
||||
CallTable.TIMESTAMP to call.callTimestamp
|
||||
)
|
||||
|
||||
writableDatabase.insert(CallTable.TABLE_NAME, SQLiteDatabase.CONFLICT_IGNORE, values)
|
||||
@@ -64,7 +64,7 @@ class CallLogIterator(private val cursor: Cursor) : Iterator<AdHocCall?>, Closea
|
||||
callId = callId,
|
||||
recipientId = cursor.requireLong(CallTable.PEER),
|
||||
state = AdHocCall.State.GENERIC,
|
||||
startedCallTimestamp = cursor.requireLong(CallTable.TIMESTAMP)
|
||||
callTimestamp = cursor.requireLong(CallTable.TIMESTAMP)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -7,21 +7,26 @@ package org.thoughtcrime.securesms.backup.v2.database
|
||||
|
||||
import android.database.Cursor
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONException
|
||||
import org.signal.core.util.Base64
|
||||
import org.signal.core.util.Base64.decode
|
||||
import org.signal.core.util.Base64.decodeOrThrow
|
||||
import org.signal.core.util.Hex
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.core.util.orNull
|
||||
import org.signal.core.util.requireBlob
|
||||
import org.signal.core.util.requireBoolean
|
||||
import org.signal.core.util.requireInt
|
||||
import org.signal.core.util.requireLong
|
||||
import org.signal.core.util.requireLongOrNull
|
||||
import org.signal.core.util.requireString
|
||||
import org.thoughtcrime.securesms.attachments.AttachmentId
|
||||
import org.thoughtcrime.securesms.attachments.Cdn
|
||||
import org.thoughtcrime.securesms.attachments.DatabaseAttachment
|
||||
import org.thoughtcrime.securesms.backup.v2.BackupRepository.getMediaName
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.ChatItem
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.ChatUpdateMessage
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.ContactAttachment
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.ContactMessage
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.ExpirationTimerChatUpdate
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.FilePointer
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.GroupCall
|
||||
@@ -37,8 +42,11 @@ import org.thoughtcrime.securesms.backup.v2.proto.SendStatus
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.SessionSwitchoverChatUpdate
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.SimpleChatUpdate
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.StandardMessage
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.Sticker
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.StickerMessage
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.Text
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.ThreadMergeChatUpdate
|
||||
import org.thoughtcrime.securesms.contactshare.Contact
|
||||
import org.thoughtcrime.securesms.database.AttachmentTable
|
||||
import org.thoughtcrime.securesms.database.CallTable
|
||||
import org.thoughtcrime.securesms.database.GroupReceiptTable
|
||||
@@ -56,11 +64,13 @@ import org.thoughtcrime.securesms.database.model.Mention
|
||||
import org.thoughtcrime.securesms.database.model.ReactionRecord
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.DecryptedGroupV2Context
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.MessageExtras
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.ProfileChangeDetails
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.SessionSwitchoverEvent
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.ThreadMergeEvent
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.linkpreview.LinkPreview
|
||||
import org.thoughtcrime.securesms.mms.QuoteModel
|
||||
import org.thoughtcrime.securesms.payments.FailureReason
|
||||
import org.thoughtcrime.securesms.payments.State
|
||||
@@ -76,6 +86,7 @@ import java.util.LinkedList
|
||||
import java.util.Queue
|
||||
import kotlin.jvm.optionals.getOrNull
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.BodyRange as BackupBodyRange
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.GiftBadge as BackupGiftBadge
|
||||
|
||||
/**
|
||||
* An iterator for chat items with a clever performance twist: rather than do the extra queries one at a time (for reactions,
|
||||
@@ -84,7 +95,7 @@ import org.thoughtcrime.securesms.backup.v2.proto.BodyRange as BackupBodyRange
|
||||
*
|
||||
* All of this complexity is hidden from the user -- they just get a normal iterator interface.
|
||||
*/
|
||||
class ChatItemExportIterator(private val cursor: Cursor, private val batchSize: Int, private val archiveMedia: Boolean) : Iterator<ChatItem>, Closeable {
|
||||
class ChatItemExportIterator(private val cursor: Cursor, private val batchSize: Int, private val archiveMedia: Boolean) : Iterator<ChatItem?>, Closeable {
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(ChatItemExportIterator::class.java)
|
||||
@@ -104,7 +115,7 @@ class ChatItemExportIterator(private val cursor: Cursor, private val batchSize:
|
||||
return buffer.isNotEmpty() || (cursor.count > 0 && !cursor.isLast && !cursor.isAfterLast)
|
||||
}
|
||||
|
||||
override fun next(): ChatItem {
|
||||
override fun next(): ChatItem? {
|
||||
if (buffer.isNotEmpty()) {
|
||||
return buffer.remove()
|
||||
}
|
||||
@@ -129,227 +140,89 @@ class ChatItemExportIterator(private val cursor: Cursor, private val batchSize:
|
||||
val builder = record.toBasicChatItemBuilder(groupReceiptsById[id])
|
||||
|
||||
when {
|
||||
record.remoteDeleted -> builder.remoteDeletedMessage = RemoteDeletedMessage()
|
||||
MessageTypes.isJoinedType(record.type) -> builder.updateMessage = ChatUpdateMessage(simpleUpdate = SimpleChatUpdate(type = SimpleChatUpdate.Type.JOINED_SIGNAL))
|
||||
MessageTypes.isIdentityUpdate(record.type) -> builder.updateMessage = ChatUpdateMessage(simpleUpdate = SimpleChatUpdate(type = SimpleChatUpdate.Type.IDENTITY_UPDATE))
|
||||
MessageTypes.isIdentityVerified(record.type) -> builder.updateMessage = ChatUpdateMessage(simpleUpdate = SimpleChatUpdate(type = SimpleChatUpdate.Type.IDENTITY_VERIFIED))
|
||||
MessageTypes.isIdentityDefault(record.type) -> builder.updateMessage = ChatUpdateMessage(simpleUpdate = SimpleChatUpdate(type = SimpleChatUpdate.Type.IDENTITY_DEFAULT))
|
||||
record.remoteDeleted -> {
|
||||
builder.remoteDeletedMessage = RemoteDeletedMessage()
|
||||
}
|
||||
MessageTypes.isJoinedType(record.type) -> {
|
||||
builder.updateMessage = simpleUpdate(SimpleChatUpdate.Type.JOINED_SIGNAL)
|
||||
}
|
||||
MessageTypes.isIdentityUpdate(record.type) -> {
|
||||
builder.updateMessage = simpleUpdate(SimpleChatUpdate.Type.IDENTITY_UPDATE)
|
||||
}
|
||||
MessageTypes.isIdentityVerified(record.type) -> {
|
||||
builder.updateMessage = simpleUpdate(SimpleChatUpdate.Type.IDENTITY_VERIFIED)
|
||||
}
|
||||
MessageTypes.isIdentityDefault(record.type) -> {
|
||||
builder.updateMessage = simpleUpdate(SimpleChatUpdate.Type.IDENTITY_DEFAULT)
|
||||
}
|
||||
MessageTypes.isChangeNumber(record.type) -> {
|
||||
builder.updateMessage = ChatUpdateMessage(simpleUpdate = SimpleChatUpdate(type = SimpleChatUpdate.Type.CHANGE_NUMBER))
|
||||
builder.sms = false
|
||||
builder.updateMessage = simpleUpdate(SimpleChatUpdate.Type.CHANGE_NUMBER)
|
||||
}
|
||||
MessageTypes.isBoostRequest(record.type) -> {
|
||||
builder.updateMessage = ChatUpdateMessage(simpleUpdate = SimpleChatUpdate(type = SimpleChatUpdate.Type.BOOST_REQUEST))
|
||||
builder.sms = false
|
||||
builder.updateMessage = simpleUpdate(SimpleChatUpdate.Type.BOOST_REQUEST)
|
||||
}
|
||||
MessageTypes.isEndSessionType(record.type) -> {
|
||||
builder.updateMessage = simpleUpdate(SimpleChatUpdate.Type.END_SESSION)
|
||||
}
|
||||
MessageTypes.isChatSessionRefresh(record.type) -> {
|
||||
builder.updateMessage = simpleUpdate(SimpleChatUpdate.Type.CHAT_SESSION_REFRESH)
|
||||
}
|
||||
MessageTypes.isBadDecryptType(record.type) -> {
|
||||
builder.updateMessage = simpleUpdate(SimpleChatUpdate.Type.BAD_DECRYPT)
|
||||
}
|
||||
MessageTypes.isPaymentsActivated(record.type) -> {
|
||||
builder.updateMessage = simpleUpdate(SimpleChatUpdate.Type.PAYMENTS_ACTIVATED)
|
||||
}
|
||||
MessageTypes.isPaymentsRequestToActivate(record.type) -> {
|
||||
builder.updateMessage = simpleUpdate(SimpleChatUpdate.Type.PAYMENT_ACTIVATION_REQUEST)
|
||||
}
|
||||
MessageTypes.isEndSessionType(record.type) -> builder.updateMessage = ChatUpdateMessage(simpleUpdate = SimpleChatUpdate(type = SimpleChatUpdate.Type.END_SESSION))
|
||||
MessageTypes.isChatSessionRefresh(record.type) -> builder.updateMessage = ChatUpdateMessage(simpleUpdate = SimpleChatUpdate(type = SimpleChatUpdate.Type.CHAT_SESSION_REFRESH))
|
||||
MessageTypes.isBadDecryptType(record.type) -> builder.updateMessage = ChatUpdateMessage(simpleUpdate = SimpleChatUpdate(type = SimpleChatUpdate.Type.BAD_DECRYPT))
|
||||
MessageTypes.isPaymentsActivated(record.type) -> builder.updateMessage = ChatUpdateMessage(simpleUpdate = SimpleChatUpdate(type = SimpleChatUpdate.Type.PAYMENTS_ACTIVATED))
|
||||
MessageTypes.isPaymentsRequestToActivate(record.type) -> builder.updateMessage = ChatUpdateMessage(simpleUpdate = SimpleChatUpdate(type = SimpleChatUpdate.Type.PAYMENT_ACTIVATION_REQUEST))
|
||||
MessageTypes.isExpirationTimerUpdate(record.type) -> {
|
||||
builder.updateMessage = ChatUpdateMessage(expirationTimerChange = ExpirationTimerChatUpdate(record.expiresIn.toInt()))
|
||||
builder.expiresInMs = 0
|
||||
}
|
||||
MessageTypes.isProfileChange(record.type) -> {
|
||||
val profileChangeDetails = if (record.messageExtras != null) {
|
||||
record.messageExtras.profileChangeDetails
|
||||
} else {
|
||||
Base64.decodeOrNull(record.body)?.let { ProfileChangeDetails.ADAPTER.decode(it) }
|
||||
}
|
||||
|
||||
builder.updateMessage = if (profileChangeDetails?.profileNameChange != null) {
|
||||
ChatUpdateMessage(profileChange = ProfileChangeChatUpdate(previousName = profileChangeDetails.profileNameChange.previous, newName = profileChangeDetails.profileNameChange.newValue))
|
||||
} else if (profileChangeDetails?.learnedProfileName != null) {
|
||||
ChatUpdateMessage(learnedProfileChange = LearnedProfileChatUpdate(e164 = profileChangeDetails.learnedProfileName.e164?.e164ToLong(), username = profileChangeDetails.learnedProfileName.username))
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
builder.sms = false
|
||||
builder.updateMessage = record.toProfileChangeUpdate()
|
||||
}
|
||||
MessageTypes.isSessionSwitchoverType(record.type) -> {
|
||||
builder.updateMessage = ChatUpdateMessage(
|
||||
sessionSwitchover = try {
|
||||
val event = SessionSwitchoverEvent.ADAPTER.decode(decodeOrThrow(record.body!!))
|
||||
SessionSwitchoverChatUpdate(event.e164.e164ToLong()!!)
|
||||
} catch (e: Exception) {
|
||||
SessionSwitchoverChatUpdate()
|
||||
}
|
||||
)
|
||||
builder.updateMessage = record.toSessionSwitchoverUpdate()
|
||||
}
|
||||
MessageTypes.isThreadMergeType(record.type) -> {
|
||||
builder.updateMessage = ChatUpdateMessage(
|
||||
threadMerge = try {
|
||||
val event = ThreadMergeEvent.ADAPTER.decode(decodeOrThrow(record.body!!))
|
||||
ThreadMergeChatUpdate(event.previousE164.e164ToLong()!!)
|
||||
} catch (e: Exception) {
|
||||
ThreadMergeChatUpdate()
|
||||
}
|
||||
)
|
||||
builder.updateMessage = record.toThreadMergeUpdate()
|
||||
}
|
||||
MessageTypes.isGroupV2(record.type) && MessageTypes.isGroupUpdate(record.type) -> {
|
||||
val groupChange = record.messageExtras?.gv2UpdateDescription?.groupChangeUpdate
|
||||
if (groupChange != null) {
|
||||
builder.updateMessage = ChatUpdateMessage(
|
||||
groupChange = groupChange
|
||||
)
|
||||
} else if (record.body != null) {
|
||||
try {
|
||||
val decoded: ByteArray = decode(record.body)
|
||||
val context = DecryptedGroupV2Context.ADAPTER.decode(decoded)
|
||||
builder.updateMessage = ChatUpdateMessage(
|
||||
groupChange = GroupsV2UpdateMessageConverter.translateDecryptedChange(selfIds = SignalStore.account().getServiceIds(), context)
|
||||
)
|
||||
} catch (e: IOException) {
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
builder.updateMessage = record.toGroupUpdate()
|
||||
}
|
||||
MessageTypes.isCallLog(record.type) -> {
|
||||
builder.sms = false
|
||||
val call = calls.getCallByMessageId(record.id)
|
||||
if (call != null) {
|
||||
if (call.type == CallTable.Type.GROUP_CALL) {
|
||||
builder.updateMessage = ChatUpdateMessage(
|
||||
groupCall = GroupCall(
|
||||
callId = record.id,
|
||||
state = when (call.event) {
|
||||
CallTable.Event.MISSED -> GroupCall.State.MISSED
|
||||
CallTable.Event.ONGOING -> GroupCall.State.GENERIC
|
||||
CallTable.Event.ACCEPTED -> GroupCall.State.ACCEPTED
|
||||
CallTable.Event.NOT_ACCEPTED -> GroupCall.State.GENERIC
|
||||
CallTable.Event.MISSED_NOTIFICATION_PROFILE -> GroupCall.State.MISSED_NOTIFICATION_PROFILE
|
||||
CallTable.Event.DELETE -> continue
|
||||
CallTable.Event.GENERIC_GROUP_CALL -> GroupCall.State.GENERIC
|
||||
CallTable.Event.JOINED -> GroupCall.State.JOINED
|
||||
CallTable.Event.RINGING -> GroupCall.State.RINGING
|
||||
CallTable.Event.DECLINED -> GroupCall.State.DECLINED
|
||||
CallTable.Event.OUTGOING_RING -> GroupCall.State.OUTGOING_RING
|
||||
},
|
||||
ringerRecipientId = call.ringerRecipient?.toLong(),
|
||||
startedCallRecipientId = call.ringerRecipient?.toLong(),
|
||||
startedCallTimestamp = call.timestamp
|
||||
)
|
||||
)
|
||||
} else if (call.type != CallTable.Type.AD_HOC_CALL) {
|
||||
builder.updateMessage = ChatUpdateMessage(
|
||||
individualCall = IndividualCall(
|
||||
callId = call.callId,
|
||||
type = if (call.type == CallTable.Type.VIDEO_CALL) IndividualCall.Type.VIDEO_CALL else IndividualCall.Type.AUDIO_CALL,
|
||||
direction = if (call.direction == CallTable.Direction.INCOMING) IndividualCall.Direction.INCOMING else IndividualCall.Direction.OUTGOING,
|
||||
state = when (call.event) {
|
||||
CallTable.Event.MISSED -> IndividualCall.State.MISSED
|
||||
CallTable.Event.MISSED_NOTIFICATION_PROFILE -> IndividualCall.State.MISSED_NOTIFICATION_PROFILE
|
||||
CallTable.Event.ACCEPTED -> IndividualCall.State.ACCEPTED
|
||||
CallTable.Event.NOT_ACCEPTED -> IndividualCall.State.NOT_ACCEPTED
|
||||
else -> IndividualCall.State.UNKNOWN_STATE
|
||||
},
|
||||
startedCallTimestamp = call.timestamp
|
||||
)
|
||||
)
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
when {
|
||||
MessageTypes.isMissedAudioCall(record.type) -> {
|
||||
builder.updateMessage = ChatUpdateMessage(
|
||||
individualCall = IndividualCall(
|
||||
type = IndividualCall.Type.AUDIO_CALL,
|
||||
state = IndividualCall.State.MISSED,
|
||||
direction = IndividualCall.Direction.INCOMING
|
||||
)
|
||||
)
|
||||
}
|
||||
MessageTypes.isMissedVideoCall(record.type) -> {
|
||||
builder.updateMessage = ChatUpdateMessage(
|
||||
individualCall = IndividualCall(
|
||||
type = IndividualCall.Type.VIDEO_CALL,
|
||||
state = IndividualCall.State.MISSED,
|
||||
direction = IndividualCall.Direction.INCOMING
|
||||
)
|
||||
)
|
||||
}
|
||||
MessageTypes.isIncomingAudioCall(record.type) -> {
|
||||
builder.updateMessage = ChatUpdateMessage(
|
||||
individualCall = IndividualCall(
|
||||
type = IndividualCall.Type.AUDIO_CALL,
|
||||
state = IndividualCall.State.ACCEPTED,
|
||||
direction = IndividualCall.Direction.INCOMING
|
||||
)
|
||||
)
|
||||
}
|
||||
MessageTypes.isIncomingVideoCall(record.type) -> {
|
||||
builder.updateMessage = ChatUpdateMessage(
|
||||
individualCall = IndividualCall(
|
||||
type = IndividualCall.Type.VIDEO_CALL,
|
||||
state = IndividualCall.State.ACCEPTED,
|
||||
direction = IndividualCall.Direction.INCOMING
|
||||
)
|
||||
)
|
||||
}
|
||||
MessageTypes.isOutgoingAudioCall(record.type) -> {
|
||||
builder.updateMessage = ChatUpdateMessage(
|
||||
individualCall = IndividualCall(
|
||||
type = IndividualCall.Type.AUDIO_CALL,
|
||||
state = IndividualCall.State.ACCEPTED,
|
||||
direction = IndividualCall.Direction.OUTGOING
|
||||
)
|
||||
)
|
||||
}
|
||||
MessageTypes.isOutgoingVideoCall(record.type) -> {
|
||||
builder.updateMessage = ChatUpdateMessage(
|
||||
individualCall = IndividualCall(
|
||||
type = IndividualCall.Type.VIDEO_CALL,
|
||||
state = IndividualCall.State.ACCEPTED,
|
||||
direction = IndividualCall.Direction.OUTGOING
|
||||
)
|
||||
)
|
||||
}
|
||||
MessageTypes.isGroupCall(record.type) -> {
|
||||
try {
|
||||
val groupCallUpdateDetails = GroupCallUpdateDetailsUtil.parse(record.body)
|
||||
builder.updateMessage = ChatUpdateMessage(
|
||||
groupCall = GroupCall(
|
||||
state = GroupCall.State.GENERIC,
|
||||
startedCallRecipientId = recipients.getByAci(ACI.from(UuidUtil.parseOrThrow(groupCallUpdateDetails.startedCallUuid))).getOrNull()?.toLong(),
|
||||
startedCallTimestamp = groupCallUpdateDetails.startedCallTimestamp,
|
||||
endedCallTimestamp = groupCallUpdateDetails.endedCallTimestamp
|
||||
)
|
||||
)
|
||||
} catch (exception: java.lang.Exception) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
builder.updateMessage = record.toCallUpdate()
|
||||
}
|
||||
MessageTypes.isPaymentsNotification(record.type) -> {
|
||||
val paymentUuid = UuidUtil.parseOrNull(record.body)
|
||||
val payment = if (paymentUuid != null) {
|
||||
SignalDatabase.payments.getPayment(paymentUuid)
|
||||
} else {
|
||||
null
|
||||
builder.paymentNotification = record.toPaymentNotificationUpdate()
|
||||
}
|
||||
MessageTypes.isGiftBadge(record.type) -> {
|
||||
builder.giftBadge = record.toGiftBadgeUpdate()
|
||||
}
|
||||
!record.sharedContacts.isNullOrEmpty() -> {
|
||||
builder.contactMessage = record.toContactMessage(reactionsById[id], attachmentsById[id])
|
||||
}
|
||||
else -> {
|
||||
if (record.body == null && !attachmentsById.containsKey(record.id)) {
|
||||
Log.w(TAG, "Record with ID ${record.id} missing a body and doesn't have attachments. Skipping.")
|
||||
continue
|
||||
}
|
||||
if (payment == null) {
|
||||
builder.paymentNotification = PaymentNotification()
|
||||
|
||||
val attachments = attachmentsById[record.id]
|
||||
val sticker = attachments?.firstOrNull { dbAttachment ->
|
||||
dbAttachment.isSticker
|
||||
}
|
||||
|
||||
if (sticker?.stickerLocator != null) {
|
||||
builder.stickerMessage = sticker.toStickerMessage(reactionsById[id])
|
||||
} else {
|
||||
builder.paymentNotification = PaymentNotification(
|
||||
amountMob = payment.amount.serializeAmountString(),
|
||||
feeMob = payment.fee.serializeAmountString(),
|
||||
note = payment.note,
|
||||
transactionDetails = payment.getTransactionDetails()
|
||||
)
|
||||
builder.standardMessage = record.toStandardMessage(reactionsById[id], mentions = mentionsById[id], attachments = attachmentsById[record.id])
|
||||
}
|
||||
}
|
||||
record.body == null && !attachmentsById.containsKey(record.id) -> {
|
||||
Log.w(TAG, "Record missing a body and doesnt have attachments, skipping")
|
||||
continue
|
||||
}
|
||||
else -> builder.standardMessage = record.toStandardMessage(reactionsById[id], mentions = mentionsById[id], attachments = attachmentsById[record.id])
|
||||
}
|
||||
|
||||
if (record.latestRevisionId == null) {
|
||||
val previousEdits = revisionMap.remove(record.id)
|
||||
if (previousEdits != null) {
|
||||
@@ -369,7 +242,7 @@ class ChatItemExportIterator(private val cursor: Cursor, private val batchSize:
|
||||
return if (buffer.isNotEmpty()) {
|
||||
buffer.remove()
|
||||
} else {
|
||||
throw NoSuchElementException()
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -377,14 +250,8 @@ class ChatItemExportIterator(private val cursor: Cursor, private val batchSize:
|
||||
cursor.close()
|
||||
}
|
||||
|
||||
private fun String.e164ToLong(): Long? {
|
||||
val fixed = if (this.startsWith("+")) {
|
||||
this.substring(1)
|
||||
} else {
|
||||
this
|
||||
}
|
||||
|
||||
return fixed.toLongOrNull()
|
||||
private fun simpleUpdate(type: SimpleChatUpdate.Type): ChatUpdateMessage {
|
||||
return ChatUpdateMessage(simpleUpdate = SimpleChatUpdate(type = type))
|
||||
}
|
||||
|
||||
private fun BackupMessageRecord.toBasicChatItemBuilder(groupReceipts: List<GroupReceiptTable.GroupReceiptInfo>?): ChatItem.Builder {
|
||||
@@ -397,7 +264,7 @@ class ChatItemExportIterator(private val cursor: Cursor, private val batchSize:
|
||||
expireStartDate = if (record.expireStarted > 0) record.expireStarted else 0
|
||||
expiresInMs = if (record.expiresIn > 0) record.expiresIn else 0
|
||||
revisions = emptyList()
|
||||
sms = !MessageTypes.isSecureType(record.type)
|
||||
sms = record.type.isSmsType()
|
||||
if (MessageTypes.isCallLog(record.type)) {
|
||||
directionless = ChatItem.DirectionlessMessageDetails()
|
||||
} else if (MessageTypes.isOutgoingMessageType(record.type)) {
|
||||
@@ -415,6 +282,385 @@ class ChatItemExportIterator(private val cursor: Cursor, private val batchSize:
|
||||
}
|
||||
}
|
||||
|
||||
private fun BackupMessageRecord.toProfileChangeUpdate(): ChatUpdateMessage? {
|
||||
val profileChangeDetails = if (this.messageExtras != null) {
|
||||
this.messageExtras.profileChangeDetails
|
||||
} else {
|
||||
Base64.decodeOrNull(this.body)?.let { ProfileChangeDetails.ADAPTER.decode(it) }
|
||||
}
|
||||
|
||||
return if (profileChangeDetails?.profileNameChange != null) {
|
||||
ChatUpdateMessage(profileChange = ProfileChangeChatUpdate(previousName = profileChangeDetails.profileNameChange.previous, newName = profileChangeDetails.profileNameChange.newValue))
|
||||
} else if (profileChangeDetails?.learnedProfileName != null) {
|
||||
ChatUpdateMessage(learnedProfileChange = LearnedProfileChatUpdate(e164 = profileChangeDetails.learnedProfileName.e164?.e164ToLong(), username = profileChangeDetails.learnedProfileName.username))
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun BackupMessageRecord.toSessionSwitchoverUpdate(): ChatUpdateMessage {
|
||||
if (this.body == null) {
|
||||
return ChatUpdateMessage(sessionSwitchover = SessionSwitchoverChatUpdate())
|
||||
}
|
||||
|
||||
return ChatUpdateMessage(
|
||||
sessionSwitchover = try {
|
||||
val event = SessionSwitchoverEvent.ADAPTER.decode(Base64.decodeOrThrow(this.body))
|
||||
SessionSwitchoverChatUpdate(event.e164.e164ToLong()!!)
|
||||
} catch (e: IOException) {
|
||||
SessionSwitchoverChatUpdate()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun BackupMessageRecord.toThreadMergeUpdate(): ChatUpdateMessage {
|
||||
if (this.body == null) {
|
||||
return ChatUpdateMessage(threadMerge = ThreadMergeChatUpdate())
|
||||
}
|
||||
|
||||
return ChatUpdateMessage(
|
||||
threadMerge = try {
|
||||
val event = ThreadMergeEvent.ADAPTER.decode(Base64.decodeOrThrow(this.body))
|
||||
ThreadMergeChatUpdate(event.previousE164.e164ToLong()!!)
|
||||
} catch (e: IOException) {
|
||||
ThreadMergeChatUpdate()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun BackupMessageRecord.toGroupUpdate(): ChatUpdateMessage? {
|
||||
val groupChange = this.messageExtras?.gv2UpdateDescription?.groupChangeUpdate
|
||||
return if (groupChange != null) {
|
||||
ChatUpdateMessage(
|
||||
groupChange = groupChange
|
||||
)
|
||||
} else if (this.body != null) {
|
||||
try {
|
||||
val decoded: ByteArray = Base64.decode(this.body)
|
||||
val context = DecryptedGroupV2Context.ADAPTER.decode(decoded)
|
||||
ChatUpdateMessage(
|
||||
groupChange = GroupsV2UpdateMessageConverter.translateDecryptedChange(selfIds = SignalStore.account.getServiceIds(), context)
|
||||
)
|
||||
} catch (e: IOException) {
|
||||
null
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun BackupMessageRecord.toCallUpdate(): ChatUpdateMessage? {
|
||||
val call = calls.getCallByMessageId(this.id)
|
||||
|
||||
return if (call != null) {
|
||||
call.toCallUpdate()
|
||||
} else {
|
||||
when {
|
||||
MessageTypes.isMissedAudioCall(this.type) -> {
|
||||
ChatUpdateMessage(
|
||||
individualCall = IndividualCall(
|
||||
type = IndividualCall.Type.AUDIO_CALL,
|
||||
state = IndividualCall.State.MISSED,
|
||||
direction = IndividualCall.Direction.INCOMING
|
||||
)
|
||||
)
|
||||
}
|
||||
MessageTypes.isMissedVideoCall(this.type) -> {
|
||||
ChatUpdateMessage(
|
||||
individualCall = IndividualCall(
|
||||
type = IndividualCall.Type.VIDEO_CALL,
|
||||
state = IndividualCall.State.MISSED,
|
||||
direction = IndividualCall.Direction.INCOMING
|
||||
)
|
||||
)
|
||||
}
|
||||
MessageTypes.isIncomingAudioCall(this.type) -> {
|
||||
ChatUpdateMessage(
|
||||
individualCall = IndividualCall(
|
||||
type = IndividualCall.Type.AUDIO_CALL,
|
||||
state = IndividualCall.State.ACCEPTED,
|
||||
direction = IndividualCall.Direction.INCOMING
|
||||
)
|
||||
)
|
||||
}
|
||||
MessageTypes.isIncomingVideoCall(this.type) -> {
|
||||
ChatUpdateMessage(
|
||||
individualCall = IndividualCall(
|
||||
type = IndividualCall.Type.VIDEO_CALL,
|
||||
state = IndividualCall.State.ACCEPTED,
|
||||
direction = IndividualCall.Direction.INCOMING
|
||||
)
|
||||
)
|
||||
}
|
||||
MessageTypes.isOutgoingAudioCall(this.type) -> {
|
||||
ChatUpdateMessage(
|
||||
individualCall = IndividualCall(
|
||||
type = IndividualCall.Type.AUDIO_CALL,
|
||||
state = IndividualCall.State.ACCEPTED,
|
||||
direction = IndividualCall.Direction.OUTGOING
|
||||
)
|
||||
)
|
||||
}
|
||||
MessageTypes.isOutgoingVideoCall(this.type) -> {
|
||||
ChatUpdateMessage(
|
||||
individualCall = IndividualCall(
|
||||
type = IndividualCall.Type.VIDEO_CALL,
|
||||
state = IndividualCall.State.ACCEPTED,
|
||||
direction = IndividualCall.Direction.OUTGOING
|
||||
)
|
||||
)
|
||||
}
|
||||
MessageTypes.isGroupCall(this.type) -> {
|
||||
try {
|
||||
val groupCallUpdateDetails = GroupCallUpdateDetailsUtil.parse(this.body)
|
||||
ChatUpdateMessage(
|
||||
groupCall = GroupCall(
|
||||
state = GroupCall.State.GENERIC,
|
||||
startedCallRecipientId = recipients.getByAci(ACI.from(UuidUtil.parseOrThrow(groupCallUpdateDetails.startedCallUuid))).getOrNull()?.toLong(),
|
||||
startedCallTimestamp = groupCallUpdateDetails.startedCallTimestamp,
|
||||
endedCallTimestamp = groupCallUpdateDetails.endedCallTimestamp
|
||||
)
|
||||
)
|
||||
} catch (exception: IOException) {
|
||||
null
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun CallTable.Call.toCallUpdate(): ChatUpdateMessage? {
|
||||
return if (this.type == CallTable.Type.GROUP_CALL) {
|
||||
ChatUpdateMessage(
|
||||
groupCall = GroupCall(
|
||||
callId = this.messageId,
|
||||
state = when (this.event) {
|
||||
CallTable.Event.MISSED -> GroupCall.State.MISSED
|
||||
CallTable.Event.ONGOING -> GroupCall.State.GENERIC
|
||||
CallTable.Event.ACCEPTED -> GroupCall.State.ACCEPTED
|
||||
CallTable.Event.NOT_ACCEPTED -> GroupCall.State.GENERIC
|
||||
CallTable.Event.MISSED_NOTIFICATION_PROFILE -> GroupCall.State.MISSED_NOTIFICATION_PROFILE
|
||||
CallTable.Event.GENERIC_GROUP_CALL -> GroupCall.State.GENERIC
|
||||
CallTable.Event.JOINED -> GroupCall.State.JOINED
|
||||
CallTable.Event.RINGING -> GroupCall.State.RINGING
|
||||
CallTable.Event.DECLINED -> GroupCall.State.DECLINED
|
||||
CallTable.Event.OUTGOING_RING -> GroupCall.State.OUTGOING_RING
|
||||
CallTable.Event.DELETE -> return null
|
||||
},
|
||||
ringerRecipientId = this.ringerRecipient?.toLong(),
|
||||
startedCallRecipientId = this.ringerRecipient?.toLong(),
|
||||
startedCallTimestamp = this.timestamp
|
||||
)
|
||||
)
|
||||
} else if (this.type != CallTable.Type.AD_HOC_CALL) {
|
||||
ChatUpdateMessage(
|
||||
individualCall = IndividualCall(
|
||||
callId = this.callId,
|
||||
type = if (this.type == CallTable.Type.VIDEO_CALL) IndividualCall.Type.VIDEO_CALL else IndividualCall.Type.AUDIO_CALL,
|
||||
direction = if (this.direction == CallTable.Direction.INCOMING) IndividualCall.Direction.INCOMING else IndividualCall.Direction.OUTGOING,
|
||||
state = when (this.event) {
|
||||
CallTable.Event.MISSED -> IndividualCall.State.MISSED
|
||||
CallTable.Event.MISSED_NOTIFICATION_PROFILE -> IndividualCall.State.MISSED_NOTIFICATION_PROFILE
|
||||
CallTable.Event.ACCEPTED -> IndividualCall.State.ACCEPTED
|
||||
CallTable.Event.NOT_ACCEPTED -> IndividualCall.State.NOT_ACCEPTED
|
||||
else -> IndividualCall.State.UNKNOWN_STATE
|
||||
},
|
||||
startedCallTimestamp = this.timestamp
|
||||
)
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun BackupMessageRecord.toPaymentNotificationUpdate(): PaymentNotification {
|
||||
val paymentUuid = UuidUtil.parseOrNull(this.body)
|
||||
val payment = if (paymentUuid != null) {
|
||||
SignalDatabase.payments.getPayment(paymentUuid)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
return if (payment == null) {
|
||||
PaymentNotification()
|
||||
} else {
|
||||
PaymentNotification(
|
||||
amountMob = payment.amount.serializeAmountString(),
|
||||
feeMob = payment.fee.serializeAmountString(),
|
||||
note = payment.note,
|
||||
transactionDetails = payment.getTransactionDetails()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun BackupMessageRecord.parseSharedContacts(attachments: List<DatabaseAttachment>?): List<Contact> {
|
||||
if (this.sharedContacts.isNullOrEmpty()) {
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
val attachmentIdMap: Map<AttachmentId, DatabaseAttachment> = attachments?.associateBy { it.attachmentId } ?: emptyMap()
|
||||
|
||||
try {
|
||||
val contacts: MutableList<Contact> = LinkedList()
|
||||
val jsonContacts = JSONArray(sharedContacts)
|
||||
|
||||
for (i in 0 until jsonContacts.length()) {
|
||||
val contact: Contact = Contact.deserialize(jsonContacts.getJSONObject(i).toString())
|
||||
|
||||
if (contact.avatar != null && contact.avatar!!.attachmentId != null) {
|
||||
val attachment = attachmentIdMap[contact.avatar!!.attachmentId]
|
||||
|
||||
val updatedAvatar = Contact.Avatar(
|
||||
contact.avatar!!.attachmentId,
|
||||
attachment,
|
||||
contact.avatar!!.isProfile
|
||||
)
|
||||
|
||||
contacts += Contact(contact, updatedAvatar)
|
||||
} else {
|
||||
contacts += contact
|
||||
}
|
||||
}
|
||||
|
||||
return contacts
|
||||
} catch (e: JSONException) {
|
||||
Log.w(TAG, "Failed to parse shared contacts.", e)
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "Failed to parse shared contacts.", e)
|
||||
}
|
||||
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
private fun BackupMessageRecord.parseLinkPreviews(attachments: List<DatabaseAttachment>?): List<LinkPreview> {
|
||||
if (linkPreview.isNullOrEmpty()) {
|
||||
return emptyList()
|
||||
}
|
||||
val attachmentIdMap: Map<AttachmentId, DatabaseAttachment> = attachments?.associateBy { it.attachmentId } ?: emptyMap()
|
||||
|
||||
try {
|
||||
val previews: MutableList<LinkPreview> = LinkedList()
|
||||
val jsonPreviews = JSONArray(linkPreview)
|
||||
|
||||
for (i in 0 until jsonPreviews.length()) {
|
||||
val preview = LinkPreview.deserialize(jsonPreviews.getJSONObject(i).toString())
|
||||
|
||||
if (preview.attachmentId != null) {
|
||||
val attachment = attachmentIdMap[preview.attachmentId]
|
||||
|
||||
if (attachment != null) {
|
||||
previews += LinkPreview(preview.url, preview.title, preview.description, preview.date, attachment)
|
||||
} else {
|
||||
previews += preview
|
||||
}
|
||||
} else {
|
||||
previews += preview
|
||||
}
|
||||
}
|
||||
|
||||
return previews
|
||||
} catch (e: JSONException) {
|
||||
Log.w(TAG, "Failed to parse link preview", e)
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "Failed to parse shared contacts.", e)
|
||||
}
|
||||
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
private fun LinkPreview.toBackupLinkPreview(): org.thoughtcrime.securesms.backup.v2.proto.LinkPreview {
|
||||
return org.thoughtcrime.securesms.backup.v2.proto.LinkPreview(
|
||||
url = url,
|
||||
title = title,
|
||||
image = (thumbnail.orNull() as? DatabaseAttachment)?.toBackupAttachment()?.pointer,
|
||||
description = description,
|
||||
date = date
|
||||
)
|
||||
}
|
||||
|
||||
private fun BackupMessageRecord.toContactMessage(reactionRecords: List<ReactionRecord>?, attachments: List<DatabaseAttachment>?): ContactMessage {
|
||||
val sharedContacts = parseSharedContacts(attachments)
|
||||
|
||||
val contacts = sharedContacts.map {
|
||||
ContactAttachment(
|
||||
name = it.name.toBackup(),
|
||||
avatar = (it.avatar?.attachment as? DatabaseAttachment)?.toBackupAttachment()?.pointer,
|
||||
organization = it.organization,
|
||||
number = it.phoneNumbers.map { phone ->
|
||||
ContactAttachment.Phone(
|
||||
value_ = phone.number,
|
||||
type = phone.type.toBackup(),
|
||||
label = phone.label
|
||||
)
|
||||
},
|
||||
email = it.emails.map { email ->
|
||||
ContactAttachment.Email(
|
||||
value_ = email.email,
|
||||
label = email.label,
|
||||
type = email.type.toBackup()
|
||||
)
|
||||
},
|
||||
address = it.postalAddresses.map { address ->
|
||||
ContactAttachment.PostalAddress(
|
||||
type = address.type.toBackup(),
|
||||
label = address.label,
|
||||
street = address.street,
|
||||
pobox = address.poBox,
|
||||
neighborhood = address.neighborhood,
|
||||
city = address.city,
|
||||
region = address.region,
|
||||
postcode = address.postalCode,
|
||||
country = address.country
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
return ContactMessage(
|
||||
contact = contacts,
|
||||
reactions = reactionRecords.toBackupReactions()
|
||||
)
|
||||
}
|
||||
|
||||
private fun Contact.Name.toBackup(): ContactAttachment.Name {
|
||||
return ContactAttachment.Name(
|
||||
givenName = givenName,
|
||||
familyName = familyName,
|
||||
prefix = prefix,
|
||||
suffix = suffix,
|
||||
middleName = middleName,
|
||||
displayName = displayName
|
||||
)
|
||||
}
|
||||
|
||||
private fun Contact.Phone.Type.toBackup(): ContactAttachment.Phone.Type {
|
||||
return when (this) {
|
||||
Contact.Phone.Type.HOME -> ContactAttachment.Phone.Type.HOME
|
||||
Contact.Phone.Type.MOBILE -> ContactAttachment.Phone.Type.MOBILE
|
||||
Contact.Phone.Type.WORK -> ContactAttachment.Phone.Type.WORK
|
||||
Contact.Phone.Type.CUSTOM -> ContactAttachment.Phone.Type.CUSTOM
|
||||
}
|
||||
}
|
||||
|
||||
private fun Contact.Email.Type.toBackup(): ContactAttachment.Email.Type {
|
||||
return when (this) {
|
||||
Contact.Email.Type.HOME -> ContactAttachment.Email.Type.HOME
|
||||
Contact.Email.Type.MOBILE -> ContactAttachment.Email.Type.MOBILE
|
||||
Contact.Email.Type.WORK -> ContactAttachment.Email.Type.WORK
|
||||
Contact.Email.Type.CUSTOM -> ContactAttachment.Email.Type.CUSTOM
|
||||
}
|
||||
}
|
||||
|
||||
private fun Contact.PostalAddress.Type.toBackup(): ContactAttachment.PostalAddress.Type {
|
||||
return when (this) {
|
||||
Contact.PostalAddress.Type.HOME -> ContactAttachment.PostalAddress.Type.HOME
|
||||
Contact.PostalAddress.Type.WORK -> ContactAttachment.PostalAddress.Type.WORK
|
||||
Contact.PostalAddress.Type.CUSTOM -> ContactAttachment.PostalAddress.Type.CUSTOM
|
||||
}
|
||||
}
|
||||
|
||||
private fun BackupMessageRecord.toStandardMessage(reactionRecords: List<ReactionRecord>?, mentions: List<Mention>?, attachments: List<DatabaseAttachment>?): StandardMessage {
|
||||
val text = if (body == null) {
|
||||
null
|
||||
@@ -424,14 +670,18 @@ class ChatItemExportIterator(private val cursor: Cursor, private val batchSize:
|
||||
bodyRanges = (this.bodyRanges?.toBackupBodyRanges() ?: emptyList()) + (mentions?.toBackupBodyRanges() ?: emptyList())
|
||||
)
|
||||
}
|
||||
val linkPreviews = parseLinkPreviews(attachments)
|
||||
val linkPreviewAttachments = linkPreviews.mapNotNull { it.thumbnail.orElse(null) }.toSet()
|
||||
val quotedAttachments = attachments?.filter { it.quote } ?: emptyList()
|
||||
val messageAttachments = attachments?.filter { !it.quote } ?: emptyList()
|
||||
val messageAttachments = attachments
|
||||
?.filterNot { it.quote }
|
||||
?.filterNot { linkPreviewAttachments.contains(it) }
|
||||
?: emptyList()
|
||||
return StandardMessage(
|
||||
quote = this.toQuote(quotedAttachments),
|
||||
text = text,
|
||||
attachments = messageAttachments.toBackupAttachments(),
|
||||
// TODO Link previews!
|
||||
linkPreview = emptyList(),
|
||||
linkPreview = linkPreviews.map { it.toBackupLinkPreview() },
|
||||
longText = null,
|
||||
reactions = reactionRecords.toBackupReactions()
|
||||
)
|
||||
@@ -456,6 +706,39 @@ class ChatItemExportIterator(private val cursor: Cursor, private val batchSize:
|
||||
}
|
||||
}
|
||||
|
||||
private fun BackupMessageRecord.toGiftBadgeUpdate(): BackupGiftBadge {
|
||||
val giftBadge = try {
|
||||
GiftBadge.ADAPTER.decode(Base64.decode(this.body ?: ""))
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "Failed to decode GiftBadge!")
|
||||
return BackupGiftBadge()
|
||||
}
|
||||
|
||||
return BackupGiftBadge(
|
||||
receiptCredentialPresentation = giftBadge.redemptionToken,
|
||||
state = when (giftBadge.redemptionState) {
|
||||
GiftBadge.RedemptionState.REDEEMED -> BackupGiftBadge.State.REDEEMED
|
||||
GiftBadge.RedemptionState.FAILED -> BackupGiftBadge.State.FAILED
|
||||
GiftBadge.RedemptionState.PENDING -> BackupGiftBadge.State.UNOPENED
|
||||
GiftBadge.RedemptionState.STARTED -> BackupGiftBadge.State.OPENED
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun DatabaseAttachment.toStickerMessage(reactions: List<ReactionRecord>?): StickerMessage {
|
||||
val stickerLocator = this.stickerLocator!!
|
||||
return StickerMessage(
|
||||
sticker = Sticker(
|
||||
packId = Hex.fromStringCondensed(stickerLocator.packId).toByteString(),
|
||||
packKey = Hex.fromStringCondensed(stickerLocator.packKey).toByteString(),
|
||||
stickerId = stickerLocator.stickerId,
|
||||
emoji = stickerLocator.emoji,
|
||||
data_ = this.toBackupAttachment().pointer
|
||||
),
|
||||
reactions = reactions.toBackupReactions()
|
||||
)
|
||||
}
|
||||
|
||||
private fun List<DatabaseAttachment>.toBackupQuoteAttachments(): List<Quote.QuotedAttachment> {
|
||||
return this.map { attachment ->
|
||||
Quote.QuotedAttachment(
|
||||
@@ -484,7 +767,7 @@ class ChatItemExportIterator(private val cursor: Cursor, private val batchSize:
|
||||
builder.backupLocator = FilePointer.BackupLocator(
|
||||
mediaName = archiveMediaName ?: this.getMediaName().toString(),
|
||||
cdnNumber = if (archiveMediaName != null) archiveCdn else Cdn.CDN_3.cdnNumber, // TODO (clark): Update when new proto with optional cdn is landed
|
||||
key = decode(remoteKey).toByteString(),
|
||||
key = Base64.decode(remoteKey).toByteString(),
|
||||
size = this.size.toInt(),
|
||||
digest = remoteDigest.toByteString()
|
||||
)
|
||||
@@ -496,7 +779,7 @@ class ChatItemExportIterator(private val cursor: Cursor, private val batchSize:
|
||||
cdnKey = this.remoteLocation,
|
||||
cdnNumber = this.cdn.cdnNumber,
|
||||
uploadTimestamp = this.uploadTimestamp,
|
||||
key = decode(remoteKey).toByteString(),
|
||||
key = Base64.decode(remoteKey).toByteString(),
|
||||
size = this.size.toInt(),
|
||||
digest = remoteDigest.toByteString()
|
||||
)
|
||||
@@ -514,7 +797,8 @@ class ChatItemExportIterator(private val cursor: Cursor, private val batchSize:
|
||||
MessageAttachment.Flag.BORDERLESS
|
||||
} else {
|
||||
MessageAttachment.Flag.NONE
|
||||
}
|
||||
},
|
||||
clientUuid = uuid?.let { UuidUtil.toByteString(uuid) }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -703,6 +987,28 @@ class ChatItemExportIterator(private val cursor: Cursor, private val batchSize:
|
||||
}
|
||||
}
|
||||
|
||||
private fun Long.isSmsType(): Boolean {
|
||||
if (MessageTypes.isSecureType(this)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (MessageTypes.isCallLog(this)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return MessageTypes.isOutgoingMessageType(this) || MessageTypes.isInboxType(this)
|
||||
}
|
||||
|
||||
private fun String.e164ToLong(): Long? {
|
||||
val fixed = if (this.startsWith("+")) {
|
||||
this.substring(1)
|
||||
} else {
|
||||
this
|
||||
}
|
||||
|
||||
return fixed.toLongOrNull()
|
||||
}
|
||||
|
||||
private fun Cursor.toBackupMessageRecord(): BackupMessageRecord {
|
||||
return BackupMessageRecord(
|
||||
id = this.requireLong(MessageTable.ID),
|
||||
@@ -719,6 +1025,8 @@ class ChatItemExportIterator(private val cursor: Cursor, private val batchSize:
|
||||
expireStarted = this.requireLong(MessageTable.EXPIRE_STARTED),
|
||||
remoteDeleted = this.requireBoolean(MessageTable.REMOTE_DELETED),
|
||||
sealedSender = this.requireBoolean(MessageTable.UNIDENTIFIED),
|
||||
linkPreview = this.requireString(MessageTable.LINK_PREVIEWS),
|
||||
sharedContacts = this.requireString(MessageTable.SHARED_CONTACTS),
|
||||
quoteTargetSentTimestamp = this.requireLong(MessageTable.QUOTE_ID),
|
||||
quoteAuthor = this.requireLong(MessageTable.QUOTE_AUTHOR),
|
||||
quoteBody = this.requireString(MessageTable.QUOTE_BODY),
|
||||
@@ -754,6 +1062,8 @@ class ChatItemExportIterator(private val cursor: Cursor, private val batchSize:
|
||||
val expireStarted: Long,
|
||||
val remoteDeleted: Boolean,
|
||||
val sealedSender: Boolean,
|
||||
val linkPreview: String?,
|
||||
val sharedContacts: String?,
|
||||
val quoteTargetSentTimestamp: Long,
|
||||
val quoteAuthor: Long,
|
||||
val quoteBody: String?,
|
||||
|
||||
@@ -7,7 +7,9 @@ package org.thoughtcrime.securesms.backup.v2.database
|
||||
|
||||
import android.content.ContentValues
|
||||
import androidx.core.content.contentValuesOf
|
||||
import okio.ByteString
|
||||
import org.signal.core.util.Base64
|
||||
import org.signal.core.util.Hex
|
||||
import org.signal.core.util.SqlUtil
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.core.util.orNull
|
||||
@@ -22,8 +24,11 @@ import org.thoughtcrime.securesms.backup.v2.BackupState
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.BodyRange
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.ChatItem
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.ChatUpdateMessage
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.ContactAttachment
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.FilePointer
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.GroupCall
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.IndividualCall
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.LinkPreview
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.MessageAttachment
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.PaymentNotification
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.Quote
|
||||
@@ -31,6 +36,8 @@ import org.thoughtcrime.securesms.backup.v2.proto.Reaction
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.SendStatus
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.SimpleChatUpdate
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.StandardMessage
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.Sticker
|
||||
import org.thoughtcrime.securesms.contactshare.Contact
|
||||
import org.thoughtcrime.securesms.database.AttachmentTable
|
||||
import org.thoughtcrime.securesms.database.CallTable
|
||||
import org.thoughtcrime.securesms.database.GroupReceiptTable
|
||||
@@ -49,6 +56,7 @@ import org.thoughtcrime.securesms.database.model.Mention
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.CryptoValue
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.GV2UpdateDescription
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.MessageExtras
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.PaymentTombstone
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.ProfileChangeDetails
|
||||
@@ -61,6 +69,7 @@ import org.thoughtcrime.securesms.payments.State
|
||||
import org.thoughtcrime.securesms.payments.proto.PaymentMetaData
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.stickers.StickerLocator
|
||||
import org.thoughtcrime.securesms.util.JsonUtils
|
||||
import org.whispersystems.signalservice.api.backup.MediaName
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentPointer
|
||||
@@ -72,6 +81,7 @@ import org.whispersystems.signalservice.internal.push.DataMessage
|
||||
import java.math.BigInteger
|
||||
import java.util.Optional
|
||||
import java.util.UUID
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.GiftBadge as BackupGiftBadge
|
||||
|
||||
/**
|
||||
* An object that will ingest all fo the [ChatItem]s you want to write, buffer them until hitting a specified batch size, and then batch insert them
|
||||
@@ -170,6 +180,8 @@ class ChatItemImportInserter(
|
||||
}
|
||||
val messageInsert = chatItem.toMessageInsert(fromLocalRecipientId, chatLocalRecipientId, localThreadId)
|
||||
if (chatItem.revisions.isNotEmpty()) {
|
||||
// Flush to avoid having revisions cross batch boundaries, which will cause a foreign key failure
|
||||
flush()
|
||||
val originalId = messageId
|
||||
val latestRevisionId = originalId + chatItem.revisions.size
|
||||
val sortedRevisions = chatItem.revisions.sortedBy { it.dateSent }.map { it.toMessageInsert(fromLocalRecipientId, chatLocalRecipientId, localThreadId) }
|
||||
@@ -308,6 +320,60 @@ class ChatItemImportInserter(
|
||||
}
|
||||
}
|
||||
}
|
||||
if (this.contactMessage != null) {
|
||||
val contacts = this.contactMessage.contact.map { backupContact ->
|
||||
Contact(
|
||||
backupContact.name.toLocal(),
|
||||
backupContact.organization,
|
||||
backupContact.number.map { phone ->
|
||||
Contact.Phone(
|
||||
phone.value_ ?: "",
|
||||
phone.type.toLocal(),
|
||||
phone.label
|
||||
)
|
||||
},
|
||||
backupContact.email.map { email ->
|
||||
Contact.Email(
|
||||
email.value_ ?: "",
|
||||
email.type.toLocal(),
|
||||
email.label
|
||||
)
|
||||
},
|
||||
backupContact.address.map { address ->
|
||||
Contact.PostalAddress(
|
||||
address.type.toLocal(),
|
||||
address.label,
|
||||
address.street,
|
||||
address.pobox,
|
||||
address.neighborhood,
|
||||
address.city,
|
||||
address.region,
|
||||
address.postcode,
|
||||
address.country
|
||||
)
|
||||
},
|
||||
Contact.Avatar(null, backupContact.avatar.toLocalAttachment(voiceNote = false, borderless = false, gif = false, wasDownloaded = true), true)
|
||||
)
|
||||
}
|
||||
val contactAttachments = contacts.mapNotNull { it.avatarAttachment }
|
||||
if (contacts.isNotEmpty()) {
|
||||
followUp = { messageRowId ->
|
||||
val attachmentMap = if (contactAttachments.isNotEmpty()) {
|
||||
SignalDatabase.attachments.insertAttachmentsForMessage(messageRowId, contactAttachments, emptyList())
|
||||
} else {
|
||||
emptyMap()
|
||||
}
|
||||
db.update(
|
||||
MessageTable.TABLE_NAME,
|
||||
contentValuesOf(
|
||||
MessageTable.SHARED_CONTACTS to SignalDatabase.messages.getSerializedSharedContacts(attachmentMap, contacts)
|
||||
),
|
||||
"${MessageTable.ID} = ?",
|
||||
SqlUtil.buildArgs(messageRowId)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (this.standardMessage != null) {
|
||||
val bodyRanges = this.standardMessage.text?.bodyRanges
|
||||
if (!bodyRanges.isNullOrEmpty()) {
|
||||
@@ -328,15 +394,36 @@ class ChatItemImportInserter(
|
||||
}
|
||||
}
|
||||
}
|
||||
val linkPreviews = this.standardMessage.linkPreview.map { it.toLocalLinkPreview() }
|
||||
val linkPreviewAttachments = linkPreviews.mapNotNull { it.thumbnail.orNull() }
|
||||
val attachments = this.standardMessage.attachments.mapNotNull { attachment ->
|
||||
attachment.toLocalAttachment()
|
||||
}
|
||||
val quoteAttachments = this.standardMessage.quote?.attachments?.mapNotNull {
|
||||
it.toLocalAttachment()
|
||||
} ?: emptyList()
|
||||
if (attachments.isNotEmpty()) {
|
||||
if (attachments.isNotEmpty() || linkPreviewAttachments.isNotEmpty() || quoteAttachments.isNotEmpty()) {
|
||||
followUp = { messageRowId ->
|
||||
SignalDatabase.attachments.insertAttachmentsForMessage(messageRowId, attachments, quoteAttachments)
|
||||
val attachmentMap = SignalDatabase.attachments.insertAttachmentsForMessage(messageRowId, attachments + linkPreviewAttachments, quoteAttachments)
|
||||
if (linkPreviews.isNotEmpty()) {
|
||||
db.update(
|
||||
MessageTable.TABLE_NAME,
|
||||
contentValuesOf(
|
||||
MessageTable.LINK_PREVIEWS to SignalDatabase.messages.getSerializedLinkPreviews(attachmentMap, linkPreviews)
|
||||
),
|
||||
"${MessageTable.ID} = ?",
|
||||
SqlUtil.buildArgs(messageRowId)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (this.stickerMessage != null) {
|
||||
val sticker = this.stickerMessage.sticker
|
||||
val attachment = sticker.toLocalAttachment()
|
||||
if (attachment != null) {
|
||||
followUp = { messageRowId ->
|
||||
SignalDatabase.attachments.insertAttachmentsForMessage(messageRowId, listOf(attachment), emptyList())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -396,6 +483,7 @@ class ChatItemImportInserter(
|
||||
this.remoteDeletedMessage != null -> contentValues.put(MessageTable.REMOTE_DELETED, 1)
|
||||
this.updateMessage != null -> contentValues.addUpdateMessage(this.updateMessage)
|
||||
this.paymentNotification != null -> contentValues.addPaymentNotification(this, chatRecipientId)
|
||||
this.giftBadge != null -> contentValues.addGiftBadge(this.giftBadge)
|
||||
}
|
||||
|
||||
return contentValues
|
||||
@@ -503,6 +591,10 @@ class ChatItemImportInserter(
|
||||
type = type or MessageTypes.SECURE_MESSAGE_BIT or MessageTypes.PUSH_MESSAGE_BIT
|
||||
}
|
||||
|
||||
if (this.giftBadge != null) {
|
||||
type = type or MessageTypes.SPECIAL_TYPE_GIFT_BADGE
|
||||
}
|
||||
|
||||
return type
|
||||
}
|
||||
|
||||
@@ -538,6 +630,7 @@ class ChatItemImportInserter(
|
||||
SimpleChatUpdate.Type.BAD_DECRYPT -> MessageTypes.BAD_DECRYPT_TYPE or typeWithoutBase
|
||||
SimpleChatUpdate.Type.PAYMENTS_ACTIVATED -> MessageTypes.SPECIAL_TYPE_PAYMENTS_ACTIVATED or typeWithoutBase
|
||||
SimpleChatUpdate.Type.PAYMENT_ACTIVATION_REQUEST -> MessageTypes.SPECIAL_TYPE_PAYMENTS_ACTIVATE_REQUEST or typeWithoutBase
|
||||
SimpleChatUpdate.Type.UNSUPPORTED_PROTOCOL_MESSAGE -> MessageTypes.UNSUPPORTED_MESSAGE_TYPE or typeWithoutBase
|
||||
}
|
||||
}
|
||||
updateMessage.expirationTimerChange != null -> {
|
||||
@@ -548,13 +641,13 @@ class ChatItemImportInserter(
|
||||
typeFlags = MessageTypes.PROFILE_CHANGE_TYPE
|
||||
val profileChangeDetails = ProfileChangeDetails(profileNameChange = ProfileChangeDetails.StringChange(previous = updateMessage.profileChange.previousName, newValue = updateMessage.profileChange.newName))
|
||||
val messageExtras = MessageExtras(profileChangeDetails = profileChangeDetails).encode()
|
||||
put(MessageTable.MESSAGE_EXTRAS, Base64.encodeWithPadding(messageExtras))
|
||||
put(MessageTable.MESSAGE_EXTRAS, messageExtras)
|
||||
}
|
||||
updateMessage.learnedProfileChange != null -> {
|
||||
typeFlags = MessageTypes.PROFILE_CHANGE_TYPE
|
||||
val profileChangeDetails = ProfileChangeDetails(learnedProfileName = ProfileChangeDetails.LearnedProfileName(e164 = updateMessage.learnedProfileChange.e164?.toString(), username = updateMessage.learnedProfileChange.username))
|
||||
val messageExtras = MessageExtras(profileChangeDetails = profileChangeDetails).encode()
|
||||
put(MessageTable.MESSAGE_EXTRAS, Base64.encodeWithPadding(messageExtras))
|
||||
put(MessageTable.MESSAGE_EXTRAS, messageExtras)
|
||||
}
|
||||
updateMessage.sessionSwitchover != null -> {
|
||||
typeFlags = MessageTypes.SESSION_SWITCHOVER_TYPE or (getAsLong(MessageTable.TYPE) and MessageTypes.BASE_TYPE_MASK.inv())
|
||||
@@ -680,6 +773,20 @@ class ChatItemImportInserter(
|
||||
)
|
||||
}
|
||||
|
||||
private fun ContentValues.addGiftBadge(giftBadge: BackupGiftBadge) {
|
||||
val dbGiftBadge = GiftBadge(
|
||||
redemptionToken = giftBadge.receiptCredentialPresentation,
|
||||
redemptionState = when (giftBadge.state) {
|
||||
BackupGiftBadge.State.UNOPENED -> GiftBadge.RedemptionState.PENDING
|
||||
BackupGiftBadge.State.OPENED -> GiftBadge.RedemptionState.STARTED
|
||||
BackupGiftBadge.State.REDEEMED -> GiftBadge.RedemptionState.REDEEMED
|
||||
BackupGiftBadge.State.FAILED -> GiftBadge.RedemptionState.FAILED
|
||||
}
|
||||
)
|
||||
|
||||
put(MessageTable.BODY, Base64.encodeWithPadding(GiftBadge.ADAPTER.encode(dbGiftBadge)))
|
||||
}
|
||||
|
||||
private fun String?.tryParseMoney(): Money? {
|
||||
if (this.isNullOrEmpty()) {
|
||||
return null
|
||||
@@ -803,74 +910,165 @@ class ChatItemImportInserter(
|
||||
}
|
||||
}
|
||||
|
||||
private fun MessageAttachment.toLocalAttachment(contentType: String? = pointer?.contentType, fileName: String? = pointer?.fileName): Attachment? {
|
||||
if (pointer == null) return null
|
||||
if (pointer.attachmentLocator != null) {
|
||||
private fun FilePointer?.toLocalAttachment(voiceNote: Boolean, borderless: Boolean, gif: Boolean, wasDownloaded: Boolean, stickerLocator: StickerLocator? = null, contentType: String? = this?.contentType, fileName: String? = this?.fileName, uuid: ByteString? = null): Attachment? {
|
||||
if (this == null) return null
|
||||
|
||||
if (attachmentLocator != null) {
|
||||
val signalAttachmentPointer = SignalServiceAttachmentPointer(
|
||||
pointer.attachmentLocator.cdnNumber,
|
||||
SignalServiceAttachmentRemoteId.from(pointer.attachmentLocator.cdnKey),
|
||||
attachmentLocator.cdnNumber,
|
||||
SignalServiceAttachmentRemoteId.from(attachmentLocator.cdnKey),
|
||||
contentType,
|
||||
pointer.attachmentLocator.key.toByteArray(),
|
||||
Optional.ofNullable(pointer.attachmentLocator.size),
|
||||
attachmentLocator.key.toByteArray(),
|
||||
Optional.ofNullable(attachmentLocator.size),
|
||||
Optional.empty(),
|
||||
pointer.width ?: 0,
|
||||
pointer.height ?: 0,
|
||||
Optional.ofNullable(pointer.attachmentLocator.digest.toByteArray()),
|
||||
Optional.ofNullable(pointer.incrementalMac?.toByteArray()),
|
||||
pointer.incrementalMacChunkSize ?: 0,
|
||||
width ?: 0,
|
||||
height ?: 0,
|
||||
Optional.ofNullable(attachmentLocator.digest.toByteArray()),
|
||||
Optional.ofNullable(incrementalMac?.toByteArray()),
|
||||
incrementalMacChunkSize ?: 0,
|
||||
Optional.ofNullable(fileName),
|
||||
flag == MessageAttachment.Flag.VOICE_MESSAGE,
|
||||
flag == MessageAttachment.Flag.BORDERLESS,
|
||||
flag == MessageAttachment.Flag.GIF,
|
||||
Optional.ofNullable(pointer.caption),
|
||||
Optional.ofNullable(pointer.blurHash),
|
||||
pointer.attachmentLocator.uploadTimestamp
|
||||
voiceNote,
|
||||
borderless,
|
||||
gif,
|
||||
Optional.ofNullable(caption),
|
||||
Optional.ofNullable(blurHash),
|
||||
attachmentLocator.uploadTimestamp,
|
||||
UuidUtil.fromByteStringOrNull(uuid)
|
||||
)
|
||||
return PointerAttachment.forPointer(
|
||||
pointer = Optional.of(signalAttachmentPointer),
|
||||
stickerLocator = stickerLocator,
|
||||
transferState = if (wasDownloaded) AttachmentTable.TRANSFER_NEEDS_RESTORE else AttachmentTable.TRANSFER_PROGRESS_PENDING
|
||||
).orNull()
|
||||
} else if (pointer.invalidAttachmentLocator != null) {
|
||||
} else if (invalidAttachmentLocator != null) {
|
||||
return TombstoneAttachment(
|
||||
contentType = contentType,
|
||||
incrementalMac = pointer.incrementalMac?.toByteArray(),
|
||||
incrementalMacChunkSize = pointer.incrementalMacChunkSize,
|
||||
width = pointer.width,
|
||||
height = pointer.height,
|
||||
caption = pointer.caption,
|
||||
blurHash = pointer.blurHash,
|
||||
voiceNote = flag == MessageAttachment.Flag.VOICE_MESSAGE,
|
||||
borderless = flag == MessageAttachment.Flag.BORDERLESS,
|
||||
gif = flag == MessageAttachment.Flag.GIF,
|
||||
quote = false
|
||||
incrementalMac = incrementalMac?.toByteArray(),
|
||||
incrementalMacChunkSize = incrementalMacChunkSize,
|
||||
width = width,
|
||||
height = height,
|
||||
caption = caption,
|
||||
blurHash = blurHash,
|
||||
voiceNote = voiceNote,
|
||||
borderless = borderless,
|
||||
gif = gif,
|
||||
quote = false,
|
||||
uuid = UuidUtil.fromByteStringOrNull(uuid)
|
||||
)
|
||||
} else if (pointer.backupLocator != null) {
|
||||
} else if (backupLocator != null) {
|
||||
return ArchivedAttachment(
|
||||
contentType = contentType,
|
||||
size = pointer.backupLocator.size.toLong(),
|
||||
cdn = pointer.backupLocator.transitCdnNumber ?: Cdn.CDN_0.cdnNumber,
|
||||
key = pointer.backupLocator.key.toByteArray(),
|
||||
cdnKey = pointer.backupLocator.transitCdnKey,
|
||||
archiveCdn = pointer.backupLocator.cdnNumber,
|
||||
archiveMediaName = pointer.backupLocator.mediaName,
|
||||
archiveMediaId = backupState.backupKey.deriveMediaId(MediaName(pointer.backupLocator.mediaName)).encode(),
|
||||
archiveThumbnailMediaId = backupState.backupKey.deriveMediaId(MediaName.forThumbnailFromMediaName(pointer.backupLocator.mediaName)).encode(),
|
||||
digest = pointer.backupLocator.digest.toByteArray(),
|
||||
incrementalMac = pointer.incrementalMac?.toByteArray(),
|
||||
incrementalMacChunkSize = pointer.incrementalMacChunkSize,
|
||||
width = pointer.width,
|
||||
height = pointer.height,
|
||||
caption = pointer.caption,
|
||||
blurHash = pointer.blurHash,
|
||||
voiceNote = flag == MessageAttachment.Flag.VOICE_MESSAGE,
|
||||
borderless = flag == MessageAttachment.Flag.BORDERLESS,
|
||||
gif = flag == MessageAttachment.Flag.GIF,
|
||||
quote = false
|
||||
size = backupLocator.size.toLong(),
|
||||
cdn = backupLocator.transitCdnNumber ?: Cdn.CDN_0.cdnNumber,
|
||||
key = backupLocator.key.toByteArray(),
|
||||
cdnKey = backupLocator.transitCdnKey,
|
||||
archiveCdn = backupLocator.cdnNumber,
|
||||
archiveMediaName = backupLocator.mediaName,
|
||||
archiveMediaId = backupState.backupKey.deriveMediaId(MediaName(backupLocator.mediaName)).encode(),
|
||||
archiveThumbnailMediaId = backupState.backupKey.deriveMediaId(MediaName.forThumbnailFromMediaName(backupLocator.mediaName)).encode(),
|
||||
digest = backupLocator.digest.toByteArray(),
|
||||
incrementalMac = incrementalMac?.toByteArray(),
|
||||
incrementalMacChunkSize = incrementalMacChunkSize,
|
||||
width = width,
|
||||
height = height,
|
||||
caption = caption,
|
||||
blurHash = blurHash,
|
||||
voiceNote = voiceNote,
|
||||
borderless = borderless,
|
||||
gif = gif,
|
||||
quote = false,
|
||||
stickerLocator = stickerLocator,
|
||||
uuid = UuidUtil.fromByteStringOrNull(uuid)
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun Sticker?.toLocalAttachment(): Attachment? {
|
||||
if (this == null) return null
|
||||
|
||||
return data_.toLocalAttachment(
|
||||
voiceNote = false,
|
||||
gif = false,
|
||||
borderless = false,
|
||||
wasDownloaded = true,
|
||||
stickerLocator = StickerLocator(
|
||||
packId = Hex.toStringCondensed(packId.toByteArray()),
|
||||
packKey = Hex.toStringCondensed(packKey.toByteArray()),
|
||||
stickerId = stickerId,
|
||||
emoji = emoji
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun LinkPreview.toLocalLinkPreview(): org.thoughtcrime.securesms.linkpreview.LinkPreview {
|
||||
return org.thoughtcrime.securesms.linkpreview.LinkPreview(
|
||||
this.url,
|
||||
this.title ?: "",
|
||||
this.description ?: "",
|
||||
this.date ?: 0,
|
||||
Optional.ofNullable(this.image?.toLocalAttachment(voiceNote = false, borderless = false, gif = false, wasDownloaded = true))
|
||||
)
|
||||
}
|
||||
|
||||
private fun MessageAttachment.toLocalAttachment(): Attachment? {
|
||||
return pointer?.toLocalAttachment(
|
||||
voiceNote = flag == MessageAttachment.Flag.VOICE_MESSAGE,
|
||||
gif = flag == MessageAttachment.Flag.GIF,
|
||||
borderless = flag == MessageAttachment.Flag.BORDERLESS,
|
||||
wasDownloaded = wasDownloaded,
|
||||
uuid = clientUuid
|
||||
)
|
||||
}
|
||||
|
||||
private fun ContactAttachment.Name?.toLocal(): Contact.Name {
|
||||
return Contact.Name(this?.displayName, this?.givenName, this?.familyName, this?.prefix, this?.suffix, this?.middleName)
|
||||
}
|
||||
|
||||
private fun ContactAttachment.Phone.Type?.toLocal(): Contact.Phone.Type {
|
||||
return when (this) {
|
||||
ContactAttachment.Phone.Type.HOME -> Contact.Phone.Type.HOME
|
||||
ContactAttachment.Phone.Type.MOBILE -> Contact.Phone.Type.MOBILE
|
||||
ContactAttachment.Phone.Type.WORK -> Contact.Phone.Type.WORK
|
||||
ContactAttachment.Phone.Type.CUSTOM,
|
||||
ContactAttachment.Phone.Type.UNKNOWN,
|
||||
null -> Contact.Phone.Type.CUSTOM
|
||||
}
|
||||
}
|
||||
|
||||
private fun ContactAttachment.Email.Type?.toLocal(): Contact.Email.Type {
|
||||
return when (this) {
|
||||
ContactAttachment.Email.Type.HOME -> Contact.Email.Type.HOME
|
||||
ContactAttachment.Email.Type.MOBILE -> Contact.Email.Type.MOBILE
|
||||
ContactAttachment.Email.Type.WORK -> Contact.Email.Type.WORK
|
||||
ContactAttachment.Email.Type.CUSTOM,
|
||||
ContactAttachment.Email.Type.UNKNOWN,
|
||||
null -> Contact.Email.Type.CUSTOM
|
||||
}
|
||||
}
|
||||
|
||||
private fun ContactAttachment.PostalAddress.Type?.toLocal(): Contact.PostalAddress.Type {
|
||||
return when (this) {
|
||||
ContactAttachment.PostalAddress.Type.HOME -> Contact.PostalAddress.Type.HOME
|
||||
ContactAttachment.PostalAddress.Type.WORK -> Contact.PostalAddress.Type.WORK
|
||||
ContactAttachment.PostalAddress.Type.CUSTOM,
|
||||
ContactAttachment.PostalAddress.Type.UNKNOWN,
|
||||
null -> Contact.PostalAddress.Type.CUSTOM
|
||||
}
|
||||
}
|
||||
|
||||
private fun MessageAttachment.toLocalAttachment(contentType: String?, fileName: String?): Attachment? {
|
||||
return pointer?.toLocalAttachment(
|
||||
voiceNote = flag == MessageAttachment.Flag.VOICE_MESSAGE,
|
||||
gif = flag == MessageAttachment.Flag.GIF,
|
||||
borderless = flag == MessageAttachment.Flag.BORDERLESS,
|
||||
wasDownloaded = wasDownloaded,
|
||||
contentType = contentType,
|
||||
fileName = fileName,
|
||||
uuid = clientUuid
|
||||
)
|
||||
}
|
||||
|
||||
private fun Quote.QuotedAttachment.toLocalAttachment(): Attachment? {
|
||||
return thumbnail?.toLocalAttachment(this.contentType, this.fileName)
|
||||
?: if (this.contentType == null) null else PointerAttachment.forPointer(quotedAttachment = DataMessage.Quote.QuotedAttachment(contentType = this.contentType, fileName = this.fileName, thumbnail = null)).orNull()
|
||||
|
||||
@@ -15,6 +15,8 @@ import org.signal.core.util.requireNonNullString
|
||||
import org.signal.core.util.requireObject
|
||||
import org.signal.core.util.select
|
||||
import org.thoughtcrime.securesms.backup.v2.BackupState
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.DistributionList
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.DistributionListItem
|
||||
import org.thoughtcrime.securesms.database.DistributionListTables
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.model.DistributionListId
|
||||
@@ -60,19 +62,28 @@ fun DistributionListTables.getAllForBackup(): List<BackupRecipient> {
|
||||
.map { recipient ->
|
||||
BackupRecipient(
|
||||
id = recipient.id.toLong(),
|
||||
distributionList = BackupDistributionList(
|
||||
name = recipient.record.name,
|
||||
distributionId = recipient.record.distributionId.asUuid().toByteArray().toByteString(),
|
||||
allowReplies = recipient.record.allowsReplies,
|
||||
deletionTimestamp = recipient.record.deletedAtTimestamp,
|
||||
privacyMode = recipient.record.privacyMode.toBackupPrivacyMode(),
|
||||
memberRecipientIds = recipient.record.members.map { it.toLong() }
|
||||
)
|
||||
distributionList = if (recipient.record.deletedAtTimestamp != 0L) {
|
||||
DistributionListItem(
|
||||
distributionId = recipient.record.distributionId.asUuid().toByteArray().toByteString(),
|
||||
deletionTimestamp = recipient.record.deletedAtTimestamp
|
||||
)
|
||||
} else {
|
||||
DistributionListItem(
|
||||
distributionId = recipient.record.distributionId.asUuid().toByteArray().toByteString(),
|
||||
distributionList = DistributionList(
|
||||
name = recipient.record.name,
|
||||
allowReplies = recipient.record.allowsReplies,
|
||||
privacyMode = recipient.record.privacyMode.toBackupPrivacyMode(),
|
||||
memberRecipientIds = recipient.record.members.map { it.toLong() }
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun DistributionListTables.restoreFromBackup(dlist: BackupDistributionList, backupState: BackupState): RecipientId {
|
||||
fun DistributionListTables.restoreFromBackup(dlistItem: DistributionListItem, backupState: BackupState): RecipientId? {
|
||||
val dlist = dlistItem.distributionList ?: return null
|
||||
val members: List<RecipientId> = dlist.memberRecipientIds
|
||||
.mapNotNull { backupState.backupToLocalRecipientId[it] }
|
||||
|
||||
@@ -80,15 +91,25 @@ fun DistributionListTables.restoreFromBackup(dlist: BackupDistributionList, back
|
||||
Log.w(TAG, "Couldn't find some member recipients! Missing backup recipientIds: ${dlist.memberRecipientIds.toSet() - members.toSet()}")
|
||||
}
|
||||
|
||||
val dlistId = this.createList(
|
||||
name = dlist.name,
|
||||
members = members,
|
||||
distributionId = DistributionId.from(UuidUtil.fromByteString(dlist.distributionId)),
|
||||
allowsReplies = dlist.allowReplies,
|
||||
deletionTimestamp = dlist.deletionTimestamp,
|
||||
storageId = null,
|
||||
privacyMode = dlist.privacyMode.toLocalPrivacyMode()
|
||||
)!!
|
||||
val distributionId = DistributionId.from(UuidUtil.fromByteString(dlistItem.distributionId))
|
||||
val privacyMode = dlist.privacyMode.toLocalPrivacyMode()
|
||||
|
||||
val dlistId = if (distributionId == DistributionId.MY_STORY) {
|
||||
setPrivacyMode(DistributionListId.MY_STORY, privacyMode)
|
||||
members.forEach { addMemberToList(DistributionListId.MY_STORY, privacyMode, it) }
|
||||
setAllowsReplies(DistributionListId.MY_STORY, dlist.allowReplies)
|
||||
DistributionListId.MY_STORY
|
||||
} else {
|
||||
createList(
|
||||
name = dlist.name,
|
||||
members = members,
|
||||
distributionId = distributionId,
|
||||
allowsReplies = dlist.allowReplies,
|
||||
deletionTimestamp = dlistItem.deletionTimestamp ?: 0,
|
||||
storageId = null,
|
||||
privacyMode = privacyMode
|
||||
)!!
|
||||
}
|
||||
|
||||
return SignalDatabase.distributionLists.getRecipientId(dlistId)!!
|
||||
}
|
||||
|
||||
@@ -33,6 +33,8 @@ fun MessageTable.getMessagesForBackup(backupTime: Long, archiveMedia: Boolean):
|
||||
MessageTable.EXPIRE_STARTED,
|
||||
MessageTable.REMOTE_DELETED,
|
||||
MessageTable.UNIDENTIFIED,
|
||||
MessageTable.LINK_PREVIEWS,
|
||||
MessageTable.SHARED_CONTACTS,
|
||||
MessageTable.QUOTE_ID,
|
||||
MessageTable.QUOTE_AUTHOR,
|
||||
MessageTable.QUOTE_BODY,
|
||||
|
||||
@@ -7,6 +7,7 @@ package org.thoughtcrime.securesms.backup.v2.database
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.database.Cursor
|
||||
import androidx.core.content.contentValuesOf
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import org.signal.core.util.Base64
|
||||
import org.signal.core.util.SqlUtil
|
||||
@@ -85,6 +86,8 @@ fun RecipientTable.getContactsForBackup(selfId: Long): BackupContactIterator {
|
||||
RecipientTable.PROFILE_FAMILY_NAME,
|
||||
RecipientTable.PROFILE_JOINED_NAME,
|
||||
RecipientTable.MUTE_UNTIL,
|
||||
RecipientTable.CHAT_COLORS,
|
||||
RecipientTable.CUSTOM_CHAT_COLORS_ID,
|
||||
RecipientTable.EXTRAS
|
||||
)
|
||||
.from(RecipientTable.TABLE_NAME)
|
||||
@@ -175,23 +178,30 @@ fun RecipientTable.restoreContactFromBackup(contact: Contact): RecipientId {
|
||||
)
|
||||
|
||||
val profileKey = contact.profileKey?.toByteArray()
|
||||
val values = contentValuesOf(
|
||||
RecipientTable.BLOCKED to contact.blocked,
|
||||
RecipientTable.HIDDEN to (contact.visibility == Contact.Visibility.HIDDEN),
|
||||
RecipientTable.TYPE to RecipientTable.RecipientType.INDIVIDUAL.id,
|
||||
RecipientTable.PROFILE_FAMILY_NAME to contact.profileFamilyName.nullIfBlank(),
|
||||
RecipientTable.PROFILE_GIVEN_NAME to contact.profileGivenName.nullIfBlank(),
|
||||
RecipientTable.PROFILE_JOINED_NAME to ProfileName.fromParts(contact.profileGivenName.nullIfBlank(), contact.profileFamilyName.nullIfBlank()).toString().nullIfBlank(),
|
||||
RecipientTable.PROFILE_KEY to if (profileKey == null) null else Base64.encodeWithPadding(profileKey),
|
||||
RecipientTable.PROFILE_SHARING to contact.profileSharing.toInt(),
|
||||
RecipientTable.USERNAME to contact.username,
|
||||
RecipientTable.EXTRAS to contact.toLocalExtras().encode()
|
||||
)
|
||||
|
||||
if (contact.registered != null) {
|
||||
values.put(RecipientTable.UNREGISTERED_TIMESTAMP, 0L)
|
||||
values.put(RecipientTable.REGISTERED, RecipientTable.RegisteredState.REGISTERED.id)
|
||||
} else if (contact.notRegistered != null) {
|
||||
values.put(RecipientTable.UNREGISTERED_TIMESTAMP, contact.notRegistered.unregisteredTimestamp)
|
||||
values.put(RecipientTable.REGISTERED, RecipientTable.RegisteredState.NOT_REGISTERED.id)
|
||||
}
|
||||
|
||||
writableDatabase
|
||||
.update(RecipientTable.TABLE_NAME)
|
||||
.values(
|
||||
RecipientTable.BLOCKED to contact.blocked,
|
||||
RecipientTable.HIDDEN to contact.hidden,
|
||||
RecipientTable.TYPE to RecipientTable.RecipientType.INDIVIDUAL.id,
|
||||
RecipientTable.PROFILE_FAMILY_NAME to contact.profileFamilyName.nullIfBlank(),
|
||||
RecipientTable.PROFILE_GIVEN_NAME to contact.profileGivenName.nullIfBlank(),
|
||||
RecipientTable.PROFILE_JOINED_NAME to ProfileName.fromParts(contact.profileGivenName.nullIfBlank(), contact.profileFamilyName.nullIfBlank()).toString().nullIfBlank(),
|
||||
RecipientTable.PROFILE_KEY to if (profileKey == null) null else Base64.encodeWithPadding(profileKey),
|
||||
RecipientTable.PROFILE_SHARING to contact.profileSharing.toInt(),
|
||||
RecipientTable.REGISTERED to contact.registered.toLocalRegisteredState().id,
|
||||
RecipientTable.USERNAME to contact.username,
|
||||
RecipientTable.UNREGISTERED_TIMESTAMP to contact.unregisteredTimestamp,
|
||||
RecipientTable.EXTRAS to contact.toLocalExtras().encode()
|
||||
)
|
||||
.values(values)
|
||||
.where("${RecipientTable.ID} = ?", id)
|
||||
.run()
|
||||
|
||||
@@ -200,7 +210,7 @@ fun RecipientTable.restoreContactFromBackup(contact: Contact): RecipientId {
|
||||
|
||||
fun RecipientTable.restoreReleaseNotes(): RecipientId {
|
||||
val releaseChannelId: RecipientId = insertReleaseChannelRecipient()
|
||||
SignalStore.releaseChannelValues().setReleaseChannelRecipientId(releaseChannelId)
|
||||
SignalStore.releaseChannel.setReleaseChannelRecipientId(releaseChannelId)
|
||||
|
||||
setProfileName(releaseChannelId, ProfileName.asGiven("Signal"))
|
||||
setMuted(releaseChannelId, Long.MAX_VALUE)
|
||||
@@ -231,7 +241,7 @@ fun RecipientTable.restoreGroupFromBackup(group: Group): RecipientId {
|
||||
}
|
||||
|
||||
val recipientId = writableDatabase.insert(RecipientTable.TABLE_NAME, null, values)
|
||||
val restoredId = SignalDatabase.groups.create(masterKey, decryptedState)
|
||||
val restoredId = SignalDatabase.groups.create(masterKey, decryptedState, groupSendEndorsements = null)
|
||||
if (restoredId != null) {
|
||||
SignalDatabase.groups.setShowAsStoryState(restoredId, group.storySendMode.toGroupShowAsStoryState())
|
||||
}
|
||||
@@ -418,23 +428,28 @@ class BackupContactIterator(private val cursor: Cursor, private val selfId: Long
|
||||
return null
|
||||
}
|
||||
|
||||
val contactBuilder = Contact.Builder()
|
||||
.aci(aci?.rawUuid?.toByteArray()?.toByteString())
|
||||
.pni(pni?.rawUuid?.toByteArray()?.toByteString())
|
||||
.username(cursor.requireString(RecipientTable.USERNAME))
|
||||
.e164(cursor.requireString(RecipientTable.E164)?.e164ToLong())
|
||||
.blocked(cursor.requireBoolean(RecipientTable.BLOCKED))
|
||||
.visibility(if (cursor.requireBoolean(RecipientTable.HIDDEN)) Contact.Visibility.HIDDEN else Contact.Visibility.VISIBLE)
|
||||
.profileKey(if (profileKey != null) Base64.decode(profileKey).toByteString() else null)
|
||||
.profileSharing(cursor.requireBoolean(RecipientTable.PROFILE_SHARING))
|
||||
.profileGivenName(cursor.requireString(RecipientTable.PROFILE_GIVEN_NAME).nullIfBlank())
|
||||
.profileFamilyName(cursor.requireString(RecipientTable.PROFILE_FAMILY_NAME).nullIfBlank())
|
||||
.hideStory(extras?.hideStory() ?: false)
|
||||
|
||||
if (registeredState == RecipientTable.RegisteredState.REGISTERED) {
|
||||
contactBuilder.registered = Contact.Registered()
|
||||
} else {
|
||||
contactBuilder.notRegistered = Contact.NotRegistered(unregisteredTimestamp = cursor.requireLong(RecipientTable.UNREGISTERED_TIMESTAMP))
|
||||
}
|
||||
|
||||
return BackupRecipient(
|
||||
id = id,
|
||||
contact = Contact(
|
||||
aci = aci?.rawUuid?.toByteArray()?.toByteString(),
|
||||
pni = pni?.rawUuid?.toByteArray()?.toByteString(),
|
||||
username = cursor.requireString(RecipientTable.USERNAME),
|
||||
e164 = cursor.requireString(RecipientTable.E164)?.e164ToLong(),
|
||||
blocked = cursor.requireBoolean(RecipientTable.BLOCKED),
|
||||
hidden = cursor.requireBoolean(RecipientTable.HIDDEN),
|
||||
registered = registeredState.toContactRegisteredState(),
|
||||
unregisteredTimestamp = cursor.requireLong(RecipientTable.UNREGISTERED_TIMESTAMP),
|
||||
profileKey = if (profileKey != null) Base64.decode(profileKey).toByteString() else null,
|
||||
profileSharing = cursor.requireBoolean(RecipientTable.PROFILE_SHARING),
|
||||
profileGivenName = cursor.requireString(RecipientTable.PROFILE_GIVEN_NAME).nullIfBlank(),
|
||||
profileFamilyName = cursor.requireString(RecipientTable.PROFILE_FAMILY_NAME).nullIfBlank(),
|
||||
hideStory = extras?.hideStory() ?: false
|
||||
)
|
||||
contact = contactBuilder.build()
|
||||
)
|
||||
}
|
||||
|
||||
@@ -489,22 +504,6 @@ private fun String.e164ToLong(): Long? {
|
||||
return fixed.toLongOrNull()
|
||||
}
|
||||
|
||||
private fun RecipientTable.RegisteredState.toContactRegisteredState(): Contact.Registered {
|
||||
return when (this) {
|
||||
RecipientTable.RegisteredState.REGISTERED -> Contact.Registered.REGISTERED
|
||||
RecipientTable.RegisteredState.NOT_REGISTERED -> Contact.Registered.NOT_REGISTERED
|
||||
RecipientTable.RegisteredState.UNKNOWN -> Contact.Registered.UNKNOWN
|
||||
}
|
||||
}
|
||||
|
||||
private fun Contact.Registered.toLocalRegisteredState(): RecipientTable.RegisteredState {
|
||||
return when (this) {
|
||||
Contact.Registered.REGISTERED -> RecipientTable.RegisteredState.REGISTERED
|
||||
Contact.Registered.NOT_REGISTERED -> RecipientTable.RegisteredState.NOT_REGISTERED
|
||||
Contact.Registered.UNKNOWN -> RecipientTable.RegisteredState.UNKNOWN
|
||||
}
|
||||
}
|
||||
|
||||
private fun GroupTable.ShowAsStoryState.toGroupStorySendMode(): Group.StorySendMode {
|
||||
return when (this) {
|
||||
GroupTable.ShowAsStoryState.ALWAYS -> Group.StorySendMode.ENABLED
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.backup.v2.database
|
||||
|
||||
import org.signal.core.util.deleteAll
|
||||
import org.thoughtcrime.securesms.database.StickerTable
|
||||
|
||||
fun StickerTable.clearAllDataForBackupRestore() {
|
||||
writableDatabase.deleteAll(StickerTable.TABLE_NAME)
|
||||
}
|
||||
@@ -7,16 +7,23 @@ package org.thoughtcrime.securesms.backup.v2.database
|
||||
|
||||
import android.database.Cursor
|
||||
import androidx.core.content.contentValuesOf
|
||||
import com.google.protobuf.InvalidProtocolBufferException
|
||||
import org.signal.core.util.SqlUtil
|
||||
import org.signal.core.util.insertInto
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.core.util.requireBlob
|
||||
import org.signal.core.util.requireBoolean
|
||||
import org.signal.core.util.requireInt
|
||||
import org.signal.core.util.requireLong
|
||||
import org.signal.core.util.toInt
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.Chat
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.ChatStyle
|
||||
import org.thoughtcrime.securesms.conversation.colors.ChatColors
|
||||
import org.thoughtcrime.securesms.conversation.colors.ChatColorsPalette
|
||||
import org.thoughtcrime.securesms.database.RecipientTable
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.ThreadTable
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.ChatColor
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import java.io.Closeable
|
||||
|
||||
@@ -33,7 +40,9 @@ fun ThreadTable.getThreadsForBackup(): ChatIterator {
|
||||
${ThreadTable.ARCHIVED},
|
||||
${RecipientTable.TABLE_NAME}.${RecipientTable.MESSAGE_EXPIRATION_TIME},
|
||||
${RecipientTable.TABLE_NAME}.${RecipientTable.MUTE_UNTIL},
|
||||
${RecipientTable.TABLE_NAME}.${RecipientTable.MENTION_SETTING}
|
||||
${RecipientTable.TABLE_NAME}.${RecipientTable.MENTION_SETTING},
|
||||
${RecipientTable.TABLE_NAME}.${RecipientTable.CHAT_COLORS},
|
||||
${RecipientTable.TABLE_NAME}.${RecipientTable.CUSTOM_CHAT_COLORS_ID}
|
||||
FROM ${ThreadTable.TABLE_NAME}
|
||||
LEFT OUTER JOIN ${RecipientTable.TABLE_NAME} ON ${ThreadTable.TABLE_NAME}.${ThreadTable.RECIPIENT_ID} = ${RecipientTable.TABLE_NAME}.${RecipientTable.ID}
|
||||
"""
|
||||
@@ -49,6 +58,15 @@ fun ThreadTable.clearAllDataForBackupRestore() {
|
||||
}
|
||||
|
||||
fun ThreadTable.restoreFromBackup(chat: Chat, recipientId: RecipientId): Long? {
|
||||
val chatColor = chat.style?.parseChatColor()
|
||||
val chatColorWithId = if (chatColor != null && chatColor.id is ChatColors.Id.NotSet) {
|
||||
val savedColors = SignalDatabase.chatColors.getSavedChatColors()
|
||||
val match = savedColors.find { it.matchesWithoutId(chatColor) }
|
||||
match ?: SignalDatabase.chatColors.saveChatColors(chatColor)
|
||||
} else {
|
||||
chatColor
|
||||
}
|
||||
|
||||
val threadId = writableDatabase
|
||||
.insertInto(ThreadTable.TABLE_NAME)
|
||||
.values(
|
||||
@@ -65,7 +83,9 @@ fun ThreadTable.restoreFromBackup(chat: Chat, recipientId: RecipientId): Long? {
|
||||
contentValuesOf(
|
||||
RecipientTable.MENTION_SETTING to (if (chat.dontNotifyForMentionsIfMuted) RecipientTable.MentionSetting.DO_NOT_NOTIFY.id else RecipientTable.MentionSetting.ALWAYS_NOTIFY.id),
|
||||
RecipientTable.MUTE_UNTIL to chat.muteUntilMs,
|
||||
RecipientTable.MESSAGE_EXPIRATION_TIME to chat.expirationTimerMs
|
||||
RecipientTable.MESSAGE_EXPIRATION_TIME to chat.expirationTimerMs,
|
||||
RecipientTable.CHAT_COLORS to chatColorWithId?.serialize()?.encode(),
|
||||
RecipientTable.CUSTOM_CHAT_COLORS_ID to (chatColorWithId?.id ?: ChatColors.Id.NotSet).longValue
|
||||
),
|
||||
"${RecipientTable.ID} = ?",
|
||||
SqlUtil.buildArgs(recipientId.toLong())
|
||||
@@ -84,6 +104,33 @@ class ChatIterator(private val cursor: Cursor) : Iterator<Chat>, Closeable {
|
||||
throw NoSuchElementException()
|
||||
}
|
||||
|
||||
val serializedChatColors = cursor.requireBlob(RecipientTable.CHAT_COLORS)
|
||||
val customChatColorsId = ChatColors.Id.forLongValue(cursor.requireLong(RecipientTable.CUSTOM_CHAT_COLORS_ID))
|
||||
val chatColors: ChatColors? = if (serializedChatColors != null) {
|
||||
try {
|
||||
ChatColors.forChatColor(customChatColorsId, ChatColor.ADAPTER.decode(serializedChatColors))
|
||||
} catch (e: InvalidProtocolBufferException) {
|
||||
null
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
var chatStyleBuilder: ChatStyle.Builder? = null
|
||||
if (chatColors != null) {
|
||||
chatStyleBuilder = ChatStyle.Builder()
|
||||
val presetBubbleColor = chatColors.tryToMapToBackupPreset()
|
||||
if (presetBubbleColor != null) {
|
||||
chatStyleBuilder.bubbleColorPreset = presetBubbleColor
|
||||
} else if (chatColors.isGradient()) {
|
||||
chatStyleBuilder.bubbleGradient = ChatStyle.Gradient(angle = chatColors.getDegrees().toInt(), colors = chatColors.getColors().toList())
|
||||
} else if (customChatColorsId is ChatColors.Id.Auto) {
|
||||
chatStyleBuilder.autoBubbleColor = ChatStyle.AutomaticBubbleColor()
|
||||
} else {
|
||||
chatStyleBuilder.bubbleSolidColor = chatColors.asSingleColor()
|
||||
}
|
||||
}
|
||||
|
||||
return Chat(
|
||||
id = cursor.requireLong(ThreadTable.ID),
|
||||
recipientId = cursor.requireLong(ThreadTable.RECIPIENT_ID),
|
||||
@@ -92,7 +139,8 @@ class ChatIterator(private val cursor: Cursor) : Iterator<Chat>, Closeable {
|
||||
expirationTimerMs = cursor.requireLong(RecipientTable.MESSAGE_EXPIRATION_TIME),
|
||||
muteUntilMs = cursor.requireLong(RecipientTable.MUTE_UNTIL),
|
||||
markedUnread = ThreadTable.ReadStatus.deserialize(cursor.requireInt(ThreadTable.READ)) == ThreadTable.ReadStatus.FORCED_UNREAD,
|
||||
dontNotifyForMentionsIfMuted = RecipientTable.MentionSetting.DO_NOT_NOTIFY.id == cursor.requireInt(RecipientTable.MENTION_SETTING)
|
||||
dontNotifyForMentionsIfMuted = RecipientTable.MentionSetting.DO_NOT_NOTIFY.id == cursor.requireInt(RecipientTable.MENTION_SETTING),
|
||||
style = chatStyleBuilder?.build()
|
||||
)
|
||||
}
|
||||
|
||||
@@ -100,3 +148,79 @@ class ChatIterator(private val cursor: Cursor) : Iterator<Chat>, Closeable {
|
||||
cursor.close()
|
||||
}
|
||||
}
|
||||
|
||||
private fun ChatStyle.parseChatColor(): ChatColors? {
|
||||
if (bubbleColorPreset != null) {
|
||||
return when (bubbleColorPreset) {
|
||||
ChatStyle.BubbleColorPreset.SOLID_CRIMSON -> ChatColorsPalette.Bubbles.CRIMSON
|
||||
ChatStyle.BubbleColorPreset.SOLID_VERMILION -> ChatColorsPalette.Bubbles.VERMILION
|
||||
ChatStyle.BubbleColorPreset.SOLID_BURLAP -> ChatColorsPalette.Bubbles.BURLAP
|
||||
ChatStyle.BubbleColorPreset.SOLID_FOREST -> ChatColorsPalette.Bubbles.FOREST
|
||||
ChatStyle.BubbleColorPreset.SOLID_WINTERGREEN -> ChatColorsPalette.Bubbles.WINTERGREEN
|
||||
ChatStyle.BubbleColorPreset.SOLID_TEAL -> ChatColorsPalette.Bubbles.TEAL
|
||||
ChatStyle.BubbleColorPreset.SOLID_BLUE -> ChatColorsPalette.Bubbles.BLUE
|
||||
ChatStyle.BubbleColorPreset.SOLID_INDIGO -> ChatColorsPalette.Bubbles.INDIGO
|
||||
ChatStyle.BubbleColorPreset.SOLID_VIOLET -> ChatColorsPalette.Bubbles.VIOLET
|
||||
ChatStyle.BubbleColorPreset.SOLID_PLUM -> ChatColorsPalette.Bubbles.PLUM
|
||||
ChatStyle.BubbleColorPreset.SOLID_TAUPE -> ChatColorsPalette.Bubbles.TAUPE
|
||||
ChatStyle.BubbleColorPreset.SOLID_STEEL -> ChatColorsPalette.Bubbles.STEEL
|
||||
ChatStyle.BubbleColorPreset.GRADIENT_EMBER -> ChatColorsPalette.Bubbles.EMBER
|
||||
ChatStyle.BubbleColorPreset.GRADIENT_MIDNIGHT -> ChatColorsPalette.Bubbles.MIDNIGHT
|
||||
ChatStyle.BubbleColorPreset.GRADIENT_INFRARED -> ChatColorsPalette.Bubbles.INFRARED
|
||||
ChatStyle.BubbleColorPreset.GRADIENT_LAGOON -> ChatColorsPalette.Bubbles.LAGOON
|
||||
ChatStyle.BubbleColorPreset.GRADIENT_FLUORESCENT -> ChatColorsPalette.Bubbles.FLUORESCENT
|
||||
ChatStyle.BubbleColorPreset.GRADIENT_BASIL -> ChatColorsPalette.Bubbles.BASIL
|
||||
ChatStyle.BubbleColorPreset.GRADIENT_SUBLIME -> ChatColorsPalette.Bubbles.SUBLIME
|
||||
ChatStyle.BubbleColorPreset.GRADIENT_SEA -> ChatColorsPalette.Bubbles.SEA
|
||||
ChatStyle.BubbleColorPreset.GRADIENT_TANGERINE -> ChatColorsPalette.Bubbles.TANGERINE
|
||||
ChatStyle.BubbleColorPreset.UNKNOWN_BUBBLE_COLOR_PRESET, ChatStyle.BubbleColorPreset.SOLID_ULTRAMARINE -> ChatColorsPalette.Bubbles.ULTRAMARINE
|
||||
}
|
||||
}
|
||||
if (autoBubbleColor != null) {
|
||||
return ChatColorsPalette.Bubbles.default.withId(ChatColors.Id.Auto)
|
||||
}
|
||||
if (bubbleSolidColor != null) {
|
||||
return ChatColors(id = ChatColors.Id.NotSet, singleColor = bubbleSolidColor, linearGradient = null)
|
||||
}
|
||||
if (bubbleGradient != null) {
|
||||
return ChatColors(
|
||||
id = ChatColors.Id.NotSet,
|
||||
singleColor = null,
|
||||
linearGradient = ChatColors.LinearGradient(
|
||||
degrees = bubbleGradient.angle.toFloat(),
|
||||
colors = bubbleGradient.colors.toIntArray(),
|
||||
positions = floatArrayOf(0f, 1f)
|
||||
)
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun ChatColors.tryToMapToBackupPreset(): ChatStyle.BubbleColorPreset? {
|
||||
when (this) {
|
||||
// Solids
|
||||
ChatColorsPalette.Bubbles.CRIMSON -> return ChatStyle.BubbleColorPreset.SOLID_CRIMSON
|
||||
ChatColorsPalette.Bubbles.VERMILION -> return ChatStyle.BubbleColorPreset.SOLID_VERMILION
|
||||
ChatColorsPalette.Bubbles.BURLAP -> return ChatStyle.BubbleColorPreset.SOLID_BURLAP
|
||||
ChatColorsPalette.Bubbles.FOREST -> return ChatStyle.BubbleColorPreset.SOLID_FOREST
|
||||
ChatColorsPalette.Bubbles.WINTERGREEN -> return ChatStyle.BubbleColorPreset.SOLID_WINTERGREEN
|
||||
ChatColorsPalette.Bubbles.TEAL -> return ChatStyle.BubbleColorPreset.SOLID_TEAL
|
||||
ChatColorsPalette.Bubbles.BLUE -> return ChatStyle.BubbleColorPreset.SOLID_BLUE
|
||||
ChatColorsPalette.Bubbles.INDIGO -> return ChatStyle.BubbleColorPreset.SOLID_INDIGO
|
||||
ChatColorsPalette.Bubbles.VIOLET -> return ChatStyle.BubbleColorPreset.SOLID_VIOLET
|
||||
ChatColorsPalette.Bubbles.PLUM -> return ChatStyle.BubbleColorPreset.SOLID_PLUM
|
||||
ChatColorsPalette.Bubbles.TAUPE -> return ChatStyle.BubbleColorPreset.SOLID_TAUPE
|
||||
ChatColorsPalette.Bubbles.STEEL -> return ChatStyle.BubbleColorPreset.SOLID_STEEL
|
||||
// Gradients
|
||||
ChatColorsPalette.Bubbles.EMBER -> return ChatStyle.BubbleColorPreset.GRADIENT_EMBER
|
||||
ChatColorsPalette.Bubbles.MIDNIGHT -> return ChatStyle.BubbleColorPreset.GRADIENT_MIDNIGHT
|
||||
ChatColorsPalette.Bubbles.INFRARED -> return ChatStyle.BubbleColorPreset.GRADIENT_INFRARED
|
||||
ChatColorsPalette.Bubbles.LAGOON -> return ChatStyle.BubbleColorPreset.GRADIENT_LAGOON
|
||||
ChatColorsPalette.Bubbles.FLUORESCENT -> return ChatStyle.BubbleColorPreset.GRADIENT_FLUORESCENT
|
||||
ChatColorsPalette.Bubbles.BASIL -> return ChatStyle.BubbleColorPreset.GRADIENT_BASIL
|
||||
ChatColorsPalette.Bubbles.SUBLIME -> return ChatStyle.BubbleColorPreset.GRADIENT_SUBLIME
|
||||
ChatColorsPalette.Bubbles.SEA -> return ChatStyle.BubbleColorPreset.GRADIENT_SEA
|
||||
ChatColorsPalette.Bubbles.TANGERINE -> return ChatStyle.BubbleColorPreset.GRADIENT_TANGERINE
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@ import org.thoughtcrime.securesms.backup.v2.stream.BackupFrameEmitter
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository
|
||||
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.UsernameQrCodeColorScheme
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase.Companion.recipients
|
||||
import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.jobs.RetrieveProfileAvatarJob
|
||||
@@ -29,47 +28,50 @@ import org.whispersystems.signalservice.api.push.UsernameLinkComponents
|
||||
import org.whispersystems.signalservice.api.storage.StorageRecordProtoUtil.defaultAccountRecord
|
||||
import org.whispersystems.signalservice.api.subscriptions.SubscriberId
|
||||
import org.whispersystems.signalservice.api.util.UuidUtil
|
||||
import kotlin.jvm.optionals.getOrNull
|
||||
import java.util.Currency
|
||||
|
||||
object AccountDataProcessor {
|
||||
|
||||
fun export(emitter: BackupFrameEmitter) {
|
||||
fun export(db: SignalDatabase, signalStore: SignalStore, emitter: BackupFrameEmitter) {
|
||||
val context = AppDependencies.application
|
||||
|
||||
val self = Recipient.self().fresh()
|
||||
val record = recipients.getRecordForSync(self.id)
|
||||
val selfId = db.recipientTable.getByAci(signalStore.accountValues.aci!!).get()
|
||||
val selfRecord = db.recipientTable.getRecordForSync(selfId)!!
|
||||
|
||||
val subscriber: InAppPaymentSubscriberRecord? = InAppPaymentsRepository.getSubscriber(InAppPaymentSubscriberRecord.Type.DONATION)
|
||||
val donationCurrency = signalStore.inAppPaymentValues.getSubscriptionCurrency(InAppPaymentSubscriberRecord.Type.DONATION)
|
||||
val donationSubscriber = db.inAppPaymentSubscriberTable.getByCurrencyCode(donationCurrency.currencyCode, InAppPaymentSubscriberRecord.Type.DONATION)
|
||||
|
||||
emitter.emit(
|
||||
Frame(
|
||||
account = AccountData(
|
||||
profileKey = self.profileKey?.toByteString() ?: EMPTY,
|
||||
givenName = self.profileName.givenName,
|
||||
familyName = self.profileName.familyName,
|
||||
avatarUrlPath = self.profileAvatar ?: "",
|
||||
subscriptionManuallyCancelled = InAppPaymentsRepository.isUserManuallyCancelled(InAppPaymentSubscriberRecord.Type.DONATION),
|
||||
username = self.username.getOrNull(),
|
||||
subscriberId = subscriber?.subscriberId?.bytes?.toByteString() ?: defaultAccountRecord.subscriberId,
|
||||
subscriberCurrencyCode = subscriber?.currencyCode ?: defaultAccountRecord.subscriberCurrencyCode,
|
||||
profileKey = selfRecord.profileKey?.toByteString() ?: EMPTY,
|
||||
givenName = selfRecord.signalProfileName.givenName,
|
||||
familyName = selfRecord.signalProfileName.familyName,
|
||||
avatarUrlPath = selfRecord.signalProfileAvatar ?: "",
|
||||
username = selfRecord.username,
|
||||
accountSettings = AccountData.AccountSettings(
|
||||
storyViewReceiptsEnabled = SignalStore.storyValues().viewedReceiptsEnabled,
|
||||
storyViewReceiptsEnabled = signalStore.storyValues.viewedReceiptsEnabled,
|
||||
typingIndicators = TextSecurePreferences.isTypingIndicatorsEnabled(context),
|
||||
readReceipts = TextSecurePreferences.isReadReceiptsEnabled(context),
|
||||
sealedSenderIndicators = TextSecurePreferences.isShowUnidentifiedDeliveryIndicatorsEnabled(context),
|
||||
linkPreviews = SignalStore.settings().isLinkPreviewsEnabled,
|
||||
notDiscoverableByPhoneNumber = SignalStore.phoneNumberPrivacy().phoneNumberDiscoverabilityMode == PhoneNumberDiscoverabilityMode.NOT_DISCOVERABLE,
|
||||
phoneNumberSharingMode = SignalStore.phoneNumberPrivacy().phoneNumberSharingMode.toBackupPhoneNumberSharingMode(),
|
||||
preferContactAvatars = SignalStore.settings().isPreferSystemContactPhotos,
|
||||
universalExpireTimer = SignalStore.settings().universalExpireTimer,
|
||||
preferredReactionEmoji = SignalStore.emojiValues().rawReactions,
|
||||
storiesDisabled = SignalStore.storyValues().isFeatureDisabled,
|
||||
hasViewedOnboardingStory = SignalStore.storyValues().userHasViewedOnboardingStory,
|
||||
hasSetMyStoriesPrivacy = SignalStore.storyValues().userHasBeenNotifiedAboutStories,
|
||||
keepMutedChatsArchived = SignalStore.settings().shouldKeepMutedChatsArchived(),
|
||||
displayBadgesOnProfile = SignalStore.donationsValues().getDisplayBadgesOnProfile(),
|
||||
hasSeenGroupStoryEducationSheet = SignalStore.storyValues().userHasSeenGroupStoryEducationSheet,
|
||||
hasCompletedUsernameOnboarding = SignalStore.uiHints().hasCompletedUsernameOnboarding()
|
||||
linkPreviews = signalStore.settingsValues.isLinkPreviewsEnabled,
|
||||
notDiscoverableByPhoneNumber = signalStore.phoneNumberPrivacyValues.phoneNumberDiscoverabilityMode == PhoneNumberDiscoverabilityMode.NOT_DISCOVERABLE,
|
||||
phoneNumberSharingMode = signalStore.phoneNumberPrivacyValues.phoneNumberSharingMode.toBackupPhoneNumberSharingMode(),
|
||||
preferContactAvatars = signalStore.settingsValues.isPreferSystemContactPhotos,
|
||||
universalExpireTimer = signalStore.settingsValues.universalExpireTimer,
|
||||
preferredReactionEmoji = signalStore.emojiValues.rawReactions,
|
||||
storiesDisabled = signalStore.storyValues.isFeatureDisabled,
|
||||
hasViewedOnboardingStory = signalStore.storyValues.userHasViewedOnboardingStory,
|
||||
hasSetMyStoriesPrivacy = signalStore.storyValues.userHasBeenNotifiedAboutStories,
|
||||
keepMutedChatsArchived = signalStore.settingsValues.shouldKeepMutedChatsArchived(),
|
||||
displayBadgesOnProfile = signalStore.inAppPaymentValues.getDisplayBadgesOnProfile(),
|
||||
hasSeenGroupStoryEducationSheet = signalStore.storyValues.userHasSeenGroupStoryEducationSheet,
|
||||
hasCompletedUsernameOnboarding = signalStore.uiHintValues.hasCompletedUsernameOnboarding()
|
||||
),
|
||||
donationSubscriberData = AccountData.SubscriberData(
|
||||
subscriberId = donationSubscriber?.subscriberId?.bytes?.toByteString() ?: defaultAccountRecord.subscriberId,
|
||||
currencyCode = donationSubscriber?.currency?.currencyCode ?: defaultAccountRecord.subscriberCurrencyCode,
|
||||
manuallyCancelled = signalStore.inAppPaymentValues.isDonationSubscriptionManuallyCancelled()
|
||||
)
|
||||
)
|
||||
)
|
||||
@@ -77,9 +79,9 @@ object AccountDataProcessor {
|
||||
}
|
||||
|
||||
fun import(accountData: AccountData, selfId: RecipientId) {
|
||||
recipients.restoreSelfFromBackup(accountData, selfId)
|
||||
SignalDatabase.recipients.restoreSelfFromBackup(accountData, selfId)
|
||||
|
||||
SignalStore.account().setRegistered(true)
|
||||
SignalStore.account.setRegistered(true)
|
||||
|
||||
val context = AppDependencies.application
|
||||
val settings = accountData.accountSettings
|
||||
@@ -88,37 +90,39 @@ object AccountDataProcessor {
|
||||
TextSecurePreferences.setReadReceiptsEnabled(context, settings.readReceipts)
|
||||
TextSecurePreferences.setTypingIndicatorsEnabled(context, settings.typingIndicators)
|
||||
TextSecurePreferences.setShowUnidentifiedDeliveryIndicatorsEnabled(context, settings.sealedSenderIndicators)
|
||||
SignalStore.settings().isLinkPreviewsEnabled = settings.linkPreviews
|
||||
SignalStore.phoneNumberPrivacy().phoneNumberDiscoverabilityMode = if (settings.notDiscoverableByPhoneNumber) PhoneNumberDiscoverabilityMode.NOT_DISCOVERABLE else PhoneNumberDiscoverabilityMode.DISCOVERABLE
|
||||
SignalStore.phoneNumberPrivacy().phoneNumberSharingMode = settings.phoneNumberSharingMode.toLocalPhoneNumberMode()
|
||||
SignalStore.settings().isPreferSystemContactPhotos = settings.preferContactAvatars
|
||||
SignalStore.settings().universalExpireTimer = settings.universalExpireTimer
|
||||
SignalStore.emojiValues().reactions = settings.preferredReactionEmoji
|
||||
SignalStore.donationsValues().setDisplayBadgesOnProfile(settings.displayBadgesOnProfile)
|
||||
SignalStore.settings().setKeepMutedChatsArchived(settings.keepMutedChatsArchived)
|
||||
SignalStore.storyValues().userHasBeenNotifiedAboutStories = settings.hasSetMyStoriesPrivacy
|
||||
SignalStore.storyValues().userHasViewedOnboardingStory = settings.hasViewedOnboardingStory
|
||||
SignalStore.storyValues().isFeatureDisabled = settings.storiesDisabled
|
||||
SignalStore.storyValues().userHasSeenGroupStoryEducationSheet = settings.hasSeenGroupStoryEducationSheet
|
||||
SignalStore.storyValues().viewedReceiptsEnabled = settings.storyViewReceiptsEnabled ?: settings.readReceipts
|
||||
SignalStore.settings.isLinkPreviewsEnabled = settings.linkPreviews
|
||||
SignalStore.phoneNumberPrivacy.phoneNumberDiscoverabilityMode = if (settings.notDiscoverableByPhoneNumber) PhoneNumberDiscoverabilityMode.NOT_DISCOVERABLE else PhoneNumberDiscoverabilityMode.DISCOVERABLE
|
||||
SignalStore.phoneNumberPrivacy.phoneNumberSharingMode = settings.phoneNumberSharingMode.toLocalPhoneNumberMode()
|
||||
SignalStore.settings.isPreferSystemContactPhotos = settings.preferContactAvatars
|
||||
SignalStore.settings.universalExpireTimer = settings.universalExpireTimer
|
||||
SignalStore.emoji.reactions = settings.preferredReactionEmoji
|
||||
SignalStore.inAppPayments.setDisplayBadgesOnProfile(settings.displayBadgesOnProfile)
|
||||
SignalStore.settings.setKeepMutedChatsArchived(settings.keepMutedChatsArchived)
|
||||
SignalStore.story.userHasBeenNotifiedAboutStories = settings.hasSetMyStoriesPrivacy
|
||||
SignalStore.story.userHasViewedOnboardingStory = settings.hasViewedOnboardingStory
|
||||
SignalStore.story.isFeatureDisabled = settings.storiesDisabled
|
||||
SignalStore.story.userHasSeenGroupStoryEducationSheet = settings.hasSeenGroupStoryEducationSheet
|
||||
SignalStore.story.viewedReceiptsEnabled = settings.storyViewReceiptsEnabled ?: settings.readReceipts
|
||||
|
||||
if (accountData.subscriberId.size > 0) {
|
||||
val remoteSubscriberId = SubscriberId.fromBytes(accountData.subscriberId.toByteArray())
|
||||
val localSubscriber = InAppPaymentsRepository.getSubscriber(InAppPaymentSubscriberRecord.Type.DONATION)
|
||||
if (accountData.donationSubscriberData != null) {
|
||||
if (accountData.donationSubscriberData.subscriberId.size > 0) {
|
||||
val remoteSubscriberId = SubscriberId.fromBytes(accountData.donationSubscriberData.subscriberId.toByteArray())
|
||||
val localSubscriber = InAppPaymentsRepository.getSubscriber(InAppPaymentSubscriberRecord.Type.DONATION)
|
||||
|
||||
val subscriber = InAppPaymentSubscriberRecord(
|
||||
remoteSubscriberId,
|
||||
accountData.subscriberCurrencyCode,
|
||||
InAppPaymentSubscriberRecord.Type.DONATION,
|
||||
localSubscriber?.requiresCancel ?: false,
|
||||
InAppPaymentsRepository.getLatestPaymentMethodType(InAppPaymentSubscriberRecord.Type.DONATION)
|
||||
)
|
||||
val subscriber = InAppPaymentSubscriberRecord(
|
||||
remoteSubscriberId,
|
||||
Currency.getInstance(accountData.donationSubscriberData.currencyCode),
|
||||
InAppPaymentSubscriberRecord.Type.DONATION,
|
||||
localSubscriber?.requiresCancel ?: accountData.donationSubscriberData.manuallyCancelled,
|
||||
InAppPaymentsRepository.getLatestPaymentMethodType(InAppPaymentSubscriberRecord.Type.DONATION)
|
||||
)
|
||||
|
||||
InAppPaymentsRepository.setSubscriber(subscriber)
|
||||
}
|
||||
InAppPaymentsRepository.setSubscriber(subscriber)
|
||||
}
|
||||
|
||||
if (accountData.subscriptionManuallyCancelled) {
|
||||
SignalStore.donationsValues().updateLocalStateForManualCancellation(InAppPaymentSubscriberRecord.Type.DONATION)
|
||||
if (accountData.donationSubscriberData.manuallyCancelled) {
|
||||
SignalStore.inAppPayments.updateLocalStateForManualCancellation(InAppPaymentSubscriberRecord.Type.DONATION)
|
||||
}
|
||||
}
|
||||
|
||||
if (accountData.avatarUrlPath.isNotEmpty()) {
|
||||
@@ -126,19 +130,19 @@ object AccountDataProcessor {
|
||||
}
|
||||
|
||||
if (accountData.usernameLink != null) {
|
||||
SignalStore.account().usernameLink = UsernameLinkComponents(
|
||||
SignalStore.account.usernameLink = UsernameLinkComponents(
|
||||
accountData.usernameLink.entropy.toByteArray(),
|
||||
UuidUtil.parseOrThrow(accountData.usernameLink.serverId.toByteArray())
|
||||
)
|
||||
SignalStore.misc().usernameQrCodeColorScheme = accountData.usernameLink.color.toLocalUsernameColor()
|
||||
SignalStore.misc.usernameQrCodeColorScheme = accountData.usernameLink.color.toLocalUsernameColor()
|
||||
}
|
||||
|
||||
if (settings.preferredReactionEmoji.isNotEmpty()) {
|
||||
SignalStore.emojiValues().reactions = settings.preferredReactionEmoji
|
||||
SignalStore.emoji.reactions = settings.preferredReactionEmoji
|
||||
}
|
||||
|
||||
if (settings.hasCompletedUsernameOnboarding) {
|
||||
SignalStore.uiHints().setHasCompletedUsernameOnboarding(true)
|
||||
SignalStore.uiHints.setHasCompletedUsernameOnboarding(true)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,8 +18,8 @@ object AdHocCallBackupProcessor {
|
||||
|
||||
val TAG = Log.tag(AdHocCallBackupProcessor::class.java)
|
||||
|
||||
fun export(emitter: BackupFrameEmitter) {
|
||||
SignalDatabase.calls.getAdhocCallsForBackup().use { reader ->
|
||||
fun export(db: SignalDatabase, emitter: BackupFrameEmitter) {
|
||||
db.callTable.getAdhocCallsForBackup().use { reader ->
|
||||
for (callLog in reader) {
|
||||
if (callLog != null) {
|
||||
emitter.emit(Frame(adHocCall = callLog))
|
||||
|
||||
@@ -19,8 +19,8 @@ import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
object ChatBackupProcessor {
|
||||
val TAG = Log.tag(ChatBackupProcessor::class.java)
|
||||
|
||||
fun export(exportState: ExportState, emitter: BackupFrameEmitter) {
|
||||
SignalDatabase.threads.getThreadsForBackup().use { reader ->
|
||||
fun export(db: SignalDatabase, exportState: ExportState, emitter: BackupFrameEmitter) {
|
||||
db.threadTable.getThreadsForBackup().use { reader ->
|
||||
for (chat in reader) {
|
||||
if (exportState.recipientIds.contains(chat.recipientId)) {
|
||||
exportState.threadIds.add(chat.id)
|
||||
|
||||
@@ -18,11 +18,14 @@ import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
object ChatItemBackupProcessor {
|
||||
val TAG = Log.tag(ChatItemBackupProcessor::class.java)
|
||||
|
||||
fun export(exportState: ExportState, emitter: BackupFrameEmitter) {
|
||||
SignalDatabase.messages.getMessagesForBackup(exportState.backupTime, exportState.allowMediaBackup).use { chatItems ->
|
||||
for (chatItem in chatItems) {
|
||||
if (exportState.threadIds.contains(chatItem.chatId)) {
|
||||
emitter.emit(Frame(chatItem = chatItem))
|
||||
fun export(db: SignalDatabase, exportState: ExportState, emitter: BackupFrameEmitter) {
|
||||
db.messageTable.getMessagesForBackup(exportState.backupTime, exportState.allowMediaBackup).use { chatItems ->
|
||||
while (chatItems.hasNext()) {
|
||||
val chatItem = chatItems.next()
|
||||
if (chatItem != null) {
|
||||
if (exportState.threadIds.contains(chatItem.chatId)) {
|
||||
emitter.emit(Frame(chatItem = chatItem))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,15 +24,13 @@ import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
|
||||
typealias BackupRecipient = org.thoughtcrime.securesms.backup.v2.proto.Recipient
|
||||
|
||||
object RecipientBackupProcessor {
|
||||
|
||||
val TAG = Log.tag(RecipientBackupProcessor::class.java)
|
||||
|
||||
fun export(state: ExportState, emitter: BackupFrameEmitter) {
|
||||
val selfId = Recipient.self().id.toLong()
|
||||
val releaseChannelId = SignalStore.releaseChannelValues().releaseChannelRecipientId
|
||||
fun export(db: SignalDatabase, signalStore: SignalStore, state: ExportState, emitter: BackupFrameEmitter) {
|
||||
val selfId = db.recipientTable.getByAci(signalStore.accountValues.aci!!).get().toLong()
|
||||
val releaseChannelId = signalStore.releaseChannelValues.releaseChannelRecipientId
|
||||
if (releaseChannelId != null) {
|
||||
emitter.emit(
|
||||
Frame(
|
||||
@@ -44,7 +42,7 @@ object RecipientBackupProcessor {
|
||||
)
|
||||
}
|
||||
|
||||
SignalDatabase.recipients.getContactsForBackup(selfId).use { reader ->
|
||||
db.recipientTable.getContactsForBackup(selfId).use { reader ->
|
||||
for (backupRecipient in reader) {
|
||||
if (backupRecipient != null) {
|
||||
state.recipientIds.add(backupRecipient.id)
|
||||
@@ -53,19 +51,19 @@ object RecipientBackupProcessor {
|
||||
}
|
||||
}
|
||||
|
||||
SignalDatabase.recipients.getGroupsForBackup().use { reader ->
|
||||
db.recipientTable.getGroupsForBackup().use { reader ->
|
||||
for (backupRecipient in reader) {
|
||||
state.recipientIds.add(backupRecipient.id)
|
||||
emitter.emit(Frame(recipient = backupRecipient))
|
||||
}
|
||||
}
|
||||
|
||||
SignalDatabase.distributionLists.getAllForBackup().forEach {
|
||||
db.distributionListTables.getAllForBackup().forEach {
|
||||
state.recipientIds.add(it.id)
|
||||
emitter.emit(Frame(recipient = it))
|
||||
}
|
||||
|
||||
SignalDatabase.callLinks.getCallLinksForBackup().forEach {
|
||||
db.callLinkTable.getCallLinksForBackup().forEach {
|
||||
state.recipientIds.add(it.id)
|
||||
emitter.emit(Frame(recipient = it))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.backup.v2.processor
|
||||
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import org.signal.core.util.Hex
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.Frame
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.StickerPack
|
||||
import org.thoughtcrime.securesms.backup.v2.stream.BackupFrameEmitter
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.StickerTable.StickerPackRecordReader
|
||||
import org.thoughtcrime.securesms.database.model.StickerPackRecord
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.jobs.StickerPackDownloadJob
|
||||
|
||||
object StickerBackupProcessor {
|
||||
fun export(db: SignalDatabase, emitter: BackupFrameEmitter) {
|
||||
StickerPackRecordReader(db.stickerTable.allStickerPacks).use { reader ->
|
||||
var record: StickerPackRecord? = reader.next
|
||||
while (record != null) {
|
||||
if (record.isInstalled) {
|
||||
val frame = record.toBackupFrame()
|
||||
emitter.emit(frame)
|
||||
}
|
||||
record = reader.next
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun import(stickerPack: StickerPack) {
|
||||
AppDependencies.jobManager.add(
|
||||
StickerPackDownloadJob.forInstall(Hex.toStringCondensed(stickerPack.packId.toByteArray()), Hex.toStringCondensed(stickerPack.packKey.toByteArray()), false)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun StickerPackRecord.toBackupFrame(): Frame {
|
||||
val packIdBytes = Hex.fromStringCondensed(packId)
|
||||
val packKey = Hex.fromStringCondensed(packKey)
|
||||
val pack = StickerPack(
|
||||
packId = packIdBytes.toByteString(),
|
||||
packKey = packKey.toByteString()
|
||||
)
|
||||
return Frame(stickerPack = pack)
|
||||
}
|
||||
@@ -10,4 +10,6 @@ import org.thoughtcrime.securesms.backup.v2.proto.Frame
|
||||
|
||||
interface BackupImportReader : Iterator<Frame>, AutoCloseable {
|
||||
fun getHeader(): BackupInfo?
|
||||
fun getBytesRead(): Long
|
||||
fun getStreamLength(): Long
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
package org.thoughtcrime.securesms.backup.v2.stream
|
||||
|
||||
import com.google.common.io.CountingInputStream
|
||||
import org.signal.core.util.readFully
|
||||
import org.signal.core.util.readNBytesOrThrow
|
||||
import org.signal.core.util.readVarInt32
|
||||
@@ -32,21 +33,22 @@ import javax.crypto.spec.SecretKeySpec
|
||||
class EncryptedBackupReader(
|
||||
key: BackupKey,
|
||||
aci: ACI,
|
||||
streamLength: Long,
|
||||
val length: Long,
|
||||
dataStream: () -> InputStream
|
||||
) : BackupImportReader {
|
||||
|
||||
val backupInfo: BackupInfo?
|
||||
var next: Frame? = null
|
||||
val stream: InputStream
|
||||
val countingStream: CountingInputStream
|
||||
|
||||
init {
|
||||
val keyMaterial = key.deriveBackupSecrets(aci)
|
||||
|
||||
validateMac(keyMaterial.macKey, streamLength, dataStream())
|
||||
validateMac(keyMaterial.macKey, length, dataStream())
|
||||
|
||||
val inputStream = dataStream()
|
||||
val iv = inputStream.readNBytesOrThrow(16)
|
||||
countingStream = CountingInputStream(dataStream())
|
||||
val iv = countingStream.readNBytesOrThrow(16)
|
||||
|
||||
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding").apply {
|
||||
init(Cipher.DECRYPT_MODE, SecretKeySpec(keyMaterial.cipherKey, "AES"), IvParameterSpec(iv))
|
||||
@@ -55,8 +57,8 @@ class EncryptedBackupReader(
|
||||
stream = GZIPInputStream(
|
||||
CipherInputStream(
|
||||
TruncatingInputStream(
|
||||
wrapped = inputStream,
|
||||
maxBytes = streamLength - MAC_SIZE
|
||||
wrapped = countingStream,
|
||||
maxBytes = length - MAC_SIZE
|
||||
),
|
||||
cipher
|
||||
)
|
||||
@@ -69,6 +71,10 @@ class EncryptedBackupReader(
|
||||
return backupInfo
|
||||
}
|
||||
|
||||
override fun getBytesRead() = countingStream.count
|
||||
|
||||
override fun getStreamLength() = length
|
||||
|
||||
override fun hasNext(): Boolean {
|
||||
return next != null
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
package org.thoughtcrime.securesms.backup.v2.stream
|
||||
|
||||
import com.google.common.io.CountingInputStream
|
||||
import org.signal.core.util.readNBytesOrThrow
|
||||
import org.signal.core.util.readVarInt32
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.BackupInfo
|
||||
@@ -15,12 +16,14 @@ import java.io.InputStream
|
||||
/**
|
||||
* Reads a plaintext backup import stream one frame at a time.
|
||||
*/
|
||||
class PlainTextBackupReader(val inputStream: InputStream) : BackupImportReader {
|
||||
class PlainTextBackupReader(val dataStream: InputStream, val length: Long) : BackupImportReader {
|
||||
|
||||
val backupInfo: BackupInfo?
|
||||
var next: Frame? = null
|
||||
val inputStream: CountingInputStream
|
||||
|
||||
init {
|
||||
inputStream = CountingInputStream(dataStream)
|
||||
backupInfo = readHeader()
|
||||
next = read()
|
||||
}
|
||||
@@ -29,6 +32,10 @@ class PlainTextBackupReader(val inputStream: InputStream) : BackupImportReader {
|
||||
return backupInfo
|
||||
}
|
||||
|
||||
override fun getBytesRead() = inputStream.count
|
||||
|
||||
override fun getStreamLength() = length
|
||||
|
||||
override fun hasNext(): Boolean {
|
||||
return next != null
|
||||
}
|
||||
|
||||
@@ -25,7 +25,9 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.dimensionResource
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.os.BundleCompat
|
||||
import androidx.core.os.bundleOf
|
||||
@@ -37,6 +39,7 @@ import org.signal.core.ui.Previews
|
||||
import org.signal.core.ui.SignalPreview
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.compose.ComposeBottomSheetDialogFragment
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
|
||||
|
||||
/**
|
||||
* Notifies the user of an issue with their backup.
|
||||
@@ -69,18 +72,17 @@ class BackupAlertBottomSheet : ComposeBottomSheetDialogFragment() {
|
||||
@Stable
|
||||
private fun performPrimaryAction() {
|
||||
when (backupAlert) {
|
||||
BackupAlert.GENERIC -> {
|
||||
BackupAlert.COULD_NOT_COMPLETE_BACKUP -> {
|
||||
// TODO [message-backups] -- Back up now
|
||||
}
|
||||
BackupAlert.PAYMENT_PROCESSING -> {
|
||||
// TODO [message-backups] -- Silence
|
||||
}
|
||||
BackupAlert.PAYMENT_PROCESSING -> Unit
|
||||
BackupAlert.MEDIA_BACKUPS_ARE_OFF -> {
|
||||
// TODO [message-backups] -- Download media now
|
||||
}
|
||||
BackupAlert.MEDIA_WILL_BE_DELETED_TODAY -> {
|
||||
// TODO [message-backups] -- Download media now
|
||||
}
|
||||
BackupAlert.DISK_FULL -> Unit
|
||||
}
|
||||
|
||||
dismissAllowingStateLoss()
|
||||
@@ -89,7 +91,7 @@ class BackupAlertBottomSheet : ComposeBottomSheetDialogFragment() {
|
||||
@Stable
|
||||
private fun performSecondaryAction() {
|
||||
when (backupAlert) {
|
||||
BackupAlert.GENERIC -> {
|
||||
BackupAlert.COULD_NOT_COMPLETE_BACKUP -> {
|
||||
// TODO [message-backups] - Dismiss and notify later
|
||||
}
|
||||
BackupAlert.PAYMENT_PROCESSING -> error("PAYMENT_PROCESSING state does not support a secondary action.")
|
||||
@@ -99,6 +101,7 @@ class BackupAlertBottomSheet : ComposeBottomSheetDialogFragment() {
|
||||
BackupAlert.MEDIA_WILL_BE_DELETED_TODAY -> {
|
||||
// TODO [message-backups] - Silence forever
|
||||
}
|
||||
BackupAlert.DISK_FULL -> Unit
|
||||
}
|
||||
|
||||
dismissAllowingStateLoss()
|
||||
@@ -139,10 +142,18 @@ private fun BackupAlertSheetContent(
|
||||
)
|
||||
|
||||
when (backupAlert) {
|
||||
BackupAlert.GENERIC -> GenericBody()
|
||||
BackupAlert.PAYMENT_PROCESSING -> PaymentProcessingBody()
|
||||
BackupAlert.MEDIA_BACKUPS_ARE_OFF -> MediaBackupsAreOffBody()
|
||||
BackupAlert.COULD_NOT_COMPLETE_BACKUP -> CouldNotCompleteBackup(
|
||||
daysSinceLastBackup = 7 // TODO [message-backups]
|
||||
)
|
||||
BackupAlert.PAYMENT_PROCESSING -> PaymentProcessingBody(
|
||||
paymentMethodType = InAppPaymentData.PaymentMethodType.GOOGLE_PAY // TODO [message-backups] -- Get this data from elsewhere... The active subscription object?
|
||||
)
|
||||
BackupAlert.MEDIA_BACKUPS_ARE_OFF -> MediaBackupsAreOffBody(30) // TODO [message-backups] -- Get this value from backend
|
||||
BackupAlert.MEDIA_WILL_BE_DELETED_TODAY -> MediaWillBeDeletedTodayBody()
|
||||
BackupAlert.DISK_FULL -> DiskFullBody(
|
||||
requiredSpace = "12 GB", // TODO [message-backups] Where does this value come from?
|
||||
daysUntilDeletion = 30 // TODO [message-backups] Where does this value come from?
|
||||
)
|
||||
}
|
||||
|
||||
val secondaryActionResource = rememberSecondaryActionResource(backupAlert = backupAlert)
|
||||
@@ -152,7 +163,7 @@ private fun BackupAlertSheetContent(
|
||||
onClick = onPrimaryActionClick,
|
||||
modifier = Modifier
|
||||
.defaultMinSize(minWidth = 220.dp)
|
||||
.padding(top = 60.dp, bottom = padBottom)
|
||||
.padding(bottom = padBottom)
|
||||
) {
|
||||
Text(text = stringResource(id = rememberPrimaryActionResource(backupAlert = backupAlert)))
|
||||
}
|
||||
@@ -166,30 +177,79 @@ private fun BackupAlertSheetContent(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GenericBody() {
|
||||
Text(text = "TODO")
|
||||
private fun CouldNotCompleteBackup(
|
||||
daysSinceLastBackup: Int
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.BackupAlertBottomSheet__your_device_hasnt, daysSinceLastBackup),
|
||||
modifier = Modifier.padding(bottom = 60.dp)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PaymentProcessingBody() {
|
||||
Text(text = "TODO")
|
||||
private fun PaymentProcessingBody(paymentMethodType: InAppPaymentData.PaymentMethodType) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.BackupAlertBottomSheet__were_having_trouble_collecting__google_pay),
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(bottom = 60.dp)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MediaBackupsAreOffBody() {
|
||||
Text(text = "TODO")
|
||||
private fun MediaBackupsAreOffBody(
|
||||
daysUntilDeletion: Long
|
||||
) {
|
||||
Text(
|
||||
text = pluralStringResource(id = R.plurals.BackupAlertBottomSheet__your_signal_media_backup_plan, daysUntilDeletion.toInt(), daysUntilDeletion),
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(bottom = 24.dp)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(id = R.string.BackupAlertBottomSheet__you_can_begin_paying_for_backups_again),
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(bottom = 36.dp)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MediaWillBeDeletedTodayBody() {
|
||||
Text(text = "TODO")
|
||||
Text(
|
||||
text = stringResource(id = R.string.BackupAlertBottomSheet__your_signal_media_backup_plan_has_been),
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(bottom = 24.dp)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(id = R.string.BackupAlertBottomSheet__you_can_begin_paying_for_backups_again),
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(bottom = 36.dp)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DiskFullBody(
|
||||
requiredSpace: String,
|
||||
daysUntilDeletion: Long
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.BackupAlertBottomSheet__your_device_does_not_have_enough_free_space, requiredSpace),
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(bottom = 24.dp)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = pluralStringResource(id = R.plurals.BackupAlertBottomSheet__if_you_choose_skip, daysUntilDeletion.toInt(), daysUntilDeletion), // TODO [message-backups] Learn More link
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(bottom = 36.dp)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun rememberBackupsIconColors(backupAlert: BackupAlert): BackupsIconColors {
|
||||
return remember(backupAlert) {
|
||||
when (backupAlert) {
|
||||
BackupAlert.GENERIC, BackupAlert.PAYMENT_PROCESSING -> BackupsIconColors.Warning
|
||||
BackupAlert.COULD_NOT_COMPLETE_BACKUP, BackupAlert.PAYMENT_PROCESSING, BackupAlert.DISK_FULL -> BackupsIconColors.Warning
|
||||
BackupAlert.MEDIA_BACKUPS_ARE_OFF, BackupAlert.MEDIA_WILL_BE_DELETED_TODAY -> BackupsIconColors.Error
|
||||
}
|
||||
}
|
||||
@@ -200,10 +260,11 @@ private fun rememberBackupsIconColors(backupAlert: BackupAlert): BackupsIconColo
|
||||
private fun rememberTitleResource(backupAlert: BackupAlert): Int {
|
||||
return remember(backupAlert) {
|
||||
when (backupAlert) {
|
||||
BackupAlert.GENERIC -> R.string.default_error_msg // TODO [message-backups] -- Finalized copy
|
||||
BackupAlert.PAYMENT_PROCESSING -> R.string.default_error_msg // TODO [message-backups] -- Finalized copy
|
||||
BackupAlert.MEDIA_BACKUPS_ARE_OFF -> R.string.default_error_msg // TODO [message-backups] -- Finalized copy
|
||||
BackupAlert.MEDIA_WILL_BE_DELETED_TODAY -> R.string.default_error_msg // TODO [message-backups] -- Finalized copy
|
||||
BackupAlert.COULD_NOT_COMPLETE_BACKUP -> R.string.BackupAlertBottomSheet__couldnt_complete_backup
|
||||
BackupAlert.PAYMENT_PROCESSING -> R.string.BackupAlertBottomSheet__cant_process_backup_payment
|
||||
BackupAlert.MEDIA_BACKUPS_ARE_OFF -> R.string.BackupAlertBottomSheet__media_backups_are_off
|
||||
BackupAlert.MEDIA_WILL_BE_DELETED_TODAY -> R.string.BackupAlertBottomSheet__your_media_will_be_deleted_today
|
||||
BackupAlert.DISK_FULL -> R.string.BackupAlertBottomSheet__cant_complete_download
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -212,10 +273,11 @@ private fun rememberTitleResource(backupAlert: BackupAlert): Int {
|
||||
private fun rememberPrimaryActionResource(backupAlert: BackupAlert): Int {
|
||||
return remember(backupAlert) {
|
||||
when (backupAlert) {
|
||||
BackupAlert.GENERIC -> android.R.string.ok // TODO [message-backups] -- Finalized copy
|
||||
BackupAlert.PAYMENT_PROCESSING -> android.R.string.ok // TODO [message-backups] -- Finalized copy
|
||||
BackupAlert.MEDIA_BACKUPS_ARE_OFF -> android.R.string.ok // TODO [message-backups] -- Finalized copy
|
||||
BackupAlert.MEDIA_WILL_BE_DELETED_TODAY -> android.R.string.ok // TODO [message-backups] -- Finalized copy
|
||||
BackupAlert.COULD_NOT_COMPLETE_BACKUP -> android.R.string.ok // TODO [message-backups] -- Finalized copy
|
||||
BackupAlert.PAYMENT_PROCESSING -> android.R.string.ok
|
||||
BackupAlert.MEDIA_BACKUPS_ARE_OFF -> R.string.BackupAlertBottomSheet__download_media_now
|
||||
BackupAlert.MEDIA_WILL_BE_DELETED_TODAY -> R.string.BackupAlertBottomSheet__download_media_now
|
||||
BackupAlert.DISK_FULL -> android.R.string.ok
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -224,10 +286,11 @@ private fun rememberPrimaryActionResource(backupAlert: BackupAlert): Int {
|
||||
private fun rememberSecondaryActionResource(backupAlert: BackupAlert): Int {
|
||||
return remember(backupAlert) {
|
||||
when (backupAlert) {
|
||||
BackupAlert.GENERIC -> android.R.string.cancel // TODO [message-backups] -- Finalized copy
|
||||
BackupAlert.COULD_NOT_COMPLETE_BACKUP -> android.R.string.cancel // TODO [message-backups] -- Finalized copy
|
||||
BackupAlert.PAYMENT_PROCESSING -> -1
|
||||
BackupAlert.MEDIA_BACKUPS_ARE_OFF -> android.R.string.cancel // TODO [message-backups] -- Finalized copy
|
||||
BackupAlert.MEDIA_WILL_BE_DELETED_TODAY -> android.R.string.cancel // TODO [message-backups] -- Finalized copy
|
||||
BackupAlert.MEDIA_BACKUPS_ARE_OFF -> R.string.BackupAlertBottomSheet__download_later
|
||||
BackupAlert.MEDIA_WILL_BE_DELETED_TODAY -> R.string.BackupAlertBottomSheet__dont_download_media
|
||||
BackupAlert.DISK_FULL -> R.string.BackupAlertBottomSheet__skip
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -237,7 +300,7 @@ private fun rememberSecondaryActionResource(backupAlert: BackupAlert): Int {
|
||||
private fun BackupAlertSheetContentPreviewGeneric() {
|
||||
Previews.BottomSheetPreview {
|
||||
BackupAlertSheetContent(
|
||||
backupAlert = BackupAlert.GENERIC,
|
||||
backupAlert = BackupAlert.COULD_NOT_COMPLETE_BACKUP,
|
||||
onPrimaryActionClick = {},
|
||||
onSecondaryActionClick = {}
|
||||
)
|
||||
@@ -280,10 +343,23 @@ private fun BackupAlertSheetContentPreviewDelete() {
|
||||
}
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@Composable
|
||||
private fun BackupAlertSheetContentPreviewDiskFull() {
|
||||
Previews.BottomSheetPreview {
|
||||
BackupAlertSheetContent(
|
||||
backupAlert = BackupAlert.DISK_FULL,
|
||||
onPrimaryActionClick = {},
|
||||
onSecondaryActionClick = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
enum class BackupAlert : Parcelable {
|
||||
GENERIC,
|
||||
COULD_NOT_COMPLETE_BACKUP,
|
||||
PAYMENT_PROCESSING,
|
||||
MEDIA_BACKUPS_ARE_OFF,
|
||||
MEDIA_WILL_BE_DELETED_TODAY
|
||||
MEDIA_WILL_BE_DELETED_TODAY,
|
||||
DISK_FULL
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.backup.v2.ui
|
||||
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.coroutineScope
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Delegate that controls whether and which backup alert sheet is displayed.
|
||||
*/
|
||||
object BackupAlertDelegate {
|
||||
@JvmStatic
|
||||
fun delegate(fragmentManager: FragmentManager, lifecycle: Lifecycle) {
|
||||
lifecycle.coroutineScope.launch {
|
||||
lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
|
||||
// TODO [message-backups]
|
||||
// 1. Get unnotified backup upload failures
|
||||
// 2. Get unnotified backup download failures
|
||||
// 3. Get unnotified backup payment failures
|
||||
|
||||
// Decide which do display
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,6 @@
|
||||
|
||||
package org.thoughtcrime.securesms.backup.v2.ui.status
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.background
|
||||
@@ -27,11 +26,11 @@ import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.signal.core.ui.Buttons
|
||||
import org.signal.core.ui.Icons
|
||||
import org.signal.core.ui.Previews
|
||||
import org.signal.core.ui.SignalPreview
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.backup.v2.ui.BackupsIconColors
|
||||
import kotlin.math.max
|
||||
@@ -71,13 +70,13 @@ fun BackupStatus(
|
||||
.weight(1f)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = data.titleRes),
|
||||
text = data.title,
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
|
||||
if (data.progress >= 0f) {
|
||||
LinearProgressIndicator(
|
||||
progress = data.progress,
|
||||
progress = { data.progress },
|
||||
strokeCap = StrokeCap.Round,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -105,8 +104,7 @@ fun BackupStatus(
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@SignalPreview
|
||||
@Composable
|
||||
fun BackupStatusPreview() {
|
||||
Previews.Preview {
|
||||
@@ -118,7 +116,7 @@ fun BackupStatusPreview() {
|
||||
)
|
||||
|
||||
BackupStatus(
|
||||
data = BackupStatusData.NotEnoughFreeSpace
|
||||
data = BackupStatusData.NotEnoughFreeSpace("12 GB")
|
||||
)
|
||||
|
||||
BackupStatus(
|
||||
@@ -138,8 +136,8 @@ sealed interface BackupStatusData {
|
||||
@get:DrawableRes
|
||||
val iconRes: Int
|
||||
|
||||
@get:StringRes
|
||||
val titleRes: Int
|
||||
@get:Composable
|
||||
val title: String
|
||||
|
||||
val iconColors: BackupsIconColors
|
||||
|
||||
@@ -154,18 +152,28 @@ sealed interface BackupStatusData {
|
||||
/**
|
||||
* Generic failure
|
||||
*/
|
||||
object CouldNotCompleteBackup : BackupStatusData {
|
||||
data object CouldNotCompleteBackup : BackupStatusData {
|
||||
override val iconRes: Int = R.drawable.symbol_backup_light
|
||||
override val titleRes: Int = R.string.default_error_msg
|
||||
|
||||
override val title: String
|
||||
@Composable
|
||||
get() = stringResource(R.string.default_error_msg)
|
||||
|
||||
override val iconColors: BackupsIconColors = BackupsIconColors.Warning
|
||||
}
|
||||
|
||||
/**
|
||||
* User does not have enough space on their device to complete backup restoration
|
||||
*/
|
||||
object NotEnoughFreeSpace : BackupStatusData {
|
||||
class NotEnoughFreeSpace(
|
||||
private val requiredSpace: String
|
||||
) : BackupStatusData {
|
||||
override val iconRes: Int = R.drawable.symbol_backup_light
|
||||
override val titleRes: Int = R.string.default_error_msg
|
||||
|
||||
override val title: String
|
||||
@Composable
|
||||
get() = stringResource(R.string.BackupStatus__free_up_s_of_space_to_download_your_media, requiredSpace)
|
||||
|
||||
override val iconColors: BackupsIconColors = BackupsIconColors.Warning
|
||||
override val actionRes: Int = R.string.registration_activity__skip
|
||||
}
|
||||
@@ -181,13 +189,16 @@ sealed interface BackupStatusData {
|
||||
override val iconRes: Int = R.drawable.symbol_backup_light
|
||||
override val iconColors: BackupsIconColors = BackupsIconColors.Normal
|
||||
|
||||
override val titleRes: Int = when (status) {
|
||||
Status.NONE -> R.string.default_error_msg
|
||||
Status.LOW_BATTERY -> R.string.default_error_msg
|
||||
Status.WAITING_FOR_INTERNET -> R.string.default_error_msg
|
||||
Status.WAITING_FOR_WIFI -> R.string.default_error_msg
|
||||
Status.FINISHED -> R.string.default_error_msg
|
||||
}
|
||||
override val title: String
|
||||
@Composable get() = stringResource(
|
||||
when (status) {
|
||||
Status.NONE -> R.string.default_error_msg
|
||||
Status.LOW_BATTERY -> R.string.default_error_msg
|
||||
Status.WAITING_FOR_INTERNET -> R.string.default_error_msg
|
||||
Status.WAITING_FOR_WIFI -> R.string.default_error_msg
|
||||
Status.FINISHED -> R.string.default_error_msg
|
||||
}
|
||||
)
|
||||
|
||||
override val statusRes: Int = when (status) {
|
||||
Status.NONE -> R.string.default_error_msg
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.backup.v2.ui.subscription
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.AlertDialogDefaults
|
||||
import androidx.compose.material3.BasicAlertDialog
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
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.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.signal.core.ui.Previews
|
||||
import org.signal.core.ui.SignalPreview
|
||||
import org.thoughtcrime.securesms.R
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ConfirmBackupCancellationDialog(
|
||||
onConfirmAndDownloadNow: () -> Unit,
|
||||
onConfirmAndDownloadLater: () -> Unit,
|
||||
onKeepSubscriptionClick: () -> Unit
|
||||
) {
|
||||
BasicAlertDialog(onDismissRequest = onKeepSubscriptionClick) {
|
||||
Surface(
|
||||
shape = AlertDialogDefaults.shape,
|
||||
color = AlertDialogDefaults.containerColor
|
||||
) {
|
||||
Column {
|
||||
Text(
|
||||
text = stringResource(id = R.string.ConfirmBackupCancellationDialog__confirm_cancellation),
|
||||
color = AlertDialogDefaults.titleContentColor,
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
modifier = Modifier
|
||||
.padding(top = 24.dp)
|
||||
.padding(horizontal = 24.dp)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(id = R.string.ConfirmBackupCancellationDialog__you_wont_be_charged_again),
|
||||
color = AlertDialogDefaults.textContentColor,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier
|
||||
.padding(top = 16.dp)
|
||||
.padding(horizontal = 24.dp)
|
||||
)
|
||||
|
||||
TextButton(
|
||||
onClick = onConfirmAndDownloadNow,
|
||||
modifier = Modifier
|
||||
.align(Alignment.End)
|
||||
.padding(end = 12.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.ConfirmBackupCancellationDialog__confirm_and_download_now)
|
||||
)
|
||||
}
|
||||
|
||||
TextButton(
|
||||
onClick = onConfirmAndDownloadLater,
|
||||
modifier = Modifier
|
||||
.align(Alignment.End)
|
||||
.padding(end = 12.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.ConfirmBackupCancellationDialog__confirm_and_download_later)
|
||||
)
|
||||
}
|
||||
|
||||
TextButton(
|
||||
onClick = onKeepSubscriptionClick,
|
||||
modifier = Modifier
|
||||
.align(Alignment.End)
|
||||
.padding(end = 12.dp, bottom = 12.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.ConfirmBackupCancellationDialog__keep_subscription)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@Composable
|
||||
private fun ConfirmCancellationDialogPreview() {
|
||||
Previews.Preview {
|
||||
ConfirmBackupCancellationDialog(
|
||||
onKeepSubscriptionClick = {},
|
||||
onConfirmAndDownloadNow = {},
|
||||
onConfirmAndDownloadLater = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -11,12 +11,14 @@ import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.SheetState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -30,49 +32,59 @@ import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.core.view.updateLayoutParams
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import org.signal.core.ui.BottomSheets
|
||||
import org.signal.core.ui.Buttons
|
||||
import org.signal.core.ui.Previews
|
||||
import org.signal.core.util.money.FiatMoney
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.models.GooglePayButton
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
|
||||
import org.thoughtcrime.securesms.databinding.PaypalButtonBinding
|
||||
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
|
||||
import java.math.BigDecimal
|
||||
import java.util.Currency
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun MessageBackupsCheckoutSheet(
|
||||
messageBackupTier: MessageBackupTier,
|
||||
messageBackupsType: MessageBackupsType,
|
||||
availablePaymentMethods: List<InAppPaymentData.PaymentMethodType>,
|
||||
sheetState: SheetState,
|
||||
onDismissRequest: () -> Unit,
|
||||
onPaymentMethodSelected: (InAppPaymentData.PaymentMethodType) -> Unit
|
||||
) {
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismissRequest,
|
||||
sheetState = sheetState,
|
||||
dragHandle = { BottomSheets.Handle() },
|
||||
modifier = Modifier.padding(horizontal = dimensionResource(id = R.dimen.core_ui__gutter))
|
||||
modifier = Modifier.padding()
|
||||
) {
|
||||
SheetContent(
|
||||
messageBackupTier = messageBackupTier,
|
||||
availablePaymentGateways = availablePaymentMethods,
|
||||
onPaymentGatewaySelected = onPaymentMethodSelected
|
||||
)
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = dimensionResource(id = R.dimen.core_ui__gutter))
|
||||
.navigationBarsPadding()
|
||||
) {
|
||||
SheetContent(
|
||||
messageBackupsType = messageBackupsType,
|
||||
availablePaymentGateways = availablePaymentMethods,
|
||||
onPaymentGatewaySelected = onPaymentMethodSelected
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SheetContent(
|
||||
messageBackupTier: MessageBackupTier,
|
||||
messageBackupsType: MessageBackupsType,
|
||||
availablePaymentGateways: List<InAppPaymentData.PaymentMethodType>,
|
||||
onPaymentGatewaySelected: (InAppPaymentData.PaymentMethodType) -> Unit
|
||||
) {
|
||||
val resources = LocalContext.current.resources
|
||||
val backupTypeDetails = remember(messageBackupTier) {
|
||||
getTierDetails(messageBackupTier)
|
||||
}
|
||||
val formattedPrice = remember(backupTypeDetails.pricePerMonth) {
|
||||
FiatMoneyUtil.format(resources, backupTypeDetails.pricePerMonth, FiatMoneyUtil.formatOptions().trimZerosAfterDecimal())
|
||||
val formattedPrice = remember(messageBackupsType.pricePerMonth) {
|
||||
FiatMoneyUtil.format(resources, messageBackupsType.pricePerMonth, FiatMoneyUtil.formatOptions().trimZerosAfterDecimal())
|
||||
}
|
||||
|
||||
Text(
|
||||
@@ -88,7 +100,8 @@ private fun SheetContent(
|
||||
)
|
||||
|
||||
MessageBackupsTypeBlock(
|
||||
messageBackupsType = backupTypeDetails,
|
||||
messageBackupsType = messageBackupsType,
|
||||
isCurrent = false,
|
||||
isSelected = false,
|
||||
onSelected = {},
|
||||
enabled = false,
|
||||
@@ -231,7 +244,12 @@ private fun MessageBackupsCheckoutSheetPreview() {
|
||||
modifier = Modifier.padding(horizontal = dimensionResource(id = R.dimen.core_ui__gutter))
|
||||
) {
|
||||
SheetContent(
|
||||
messageBackupTier = MessageBackupTier.PAID,
|
||||
messageBackupsType = MessageBackupsType(
|
||||
tier = MessageBackupTier.FREE,
|
||||
title = "Free",
|
||||
pricePerMonth = FiatMoney(BigDecimal.ZERO, Currency.getInstance("USD")),
|
||||
features = persistentListOf()
|
||||
),
|
||||
availablePaymentGateways = availablePaymentGateways,
|
||||
onPaymentGatewaySelected = {}
|
||||
)
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.backup.v2.ui.subscription
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.dialog
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import org.signal.core.ui.theme.SignalTheme
|
||||
import org.thoughtcrime.securesms.PassphraseRequiredActivity
|
||||
import org.thoughtcrime.securesms.util.viewModel
|
||||
|
||||
class MessageBackupsFlowActivity : PassphraseRequiredActivity() {
|
||||
|
||||
private val viewModel: MessageBackupsFlowViewModel by viewModel { MessageBackupsFlowViewModel() }
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) {
|
||||
setContent {
|
||||
SignalTheme {
|
||||
val state by viewModel.state
|
||||
val navController = rememberNavController()
|
||||
|
||||
fun MessageBackupsScreen.next() {
|
||||
val nextScreen = viewModel.goToNextScreen(this)
|
||||
if (nextScreen == MessageBackupsScreen.COMPLETED) {
|
||||
finishAfterTransition()
|
||||
return
|
||||
}
|
||||
if (nextScreen != this) {
|
||||
navController.navigate(nextScreen.name)
|
||||
}
|
||||
}
|
||||
|
||||
fun NavController.popOrFinish() {
|
||||
if (popBackStack()) {
|
||||
return
|
||||
}
|
||||
|
||||
finishAfterTransition()
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
navController.setLifecycleOwner(this@MessageBackupsFlowActivity)
|
||||
navController.setOnBackPressedDispatcher(this@MessageBackupsFlowActivity.onBackPressedDispatcher)
|
||||
navController.enableOnBackPressed(true)
|
||||
}
|
||||
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = if (state.currentMessageBackupTier == null) MessageBackupsScreen.EDUCATION.name else MessageBackupsScreen.TYPE_SELECTION.name,
|
||||
enterTransition = { slideInHorizontally(initialOffsetX = { it }) },
|
||||
exitTransition = { slideOutHorizontally(targetOffsetX = { -it }) },
|
||||
popEnterTransition = { slideInHorizontally(initialOffsetX = { -it }) },
|
||||
popExitTransition = { slideOutHorizontally(targetOffsetX = { it }) }
|
||||
) {
|
||||
composable(route = MessageBackupsScreen.EDUCATION.name) {
|
||||
MessageBackupsEducationScreen(
|
||||
onNavigationClick = navController::popOrFinish,
|
||||
onEnableBackups = { MessageBackupsScreen.EDUCATION.next() },
|
||||
onLearnMore = {}
|
||||
)
|
||||
}
|
||||
|
||||
composable(route = MessageBackupsScreen.PIN_EDUCATION.name) {
|
||||
MessageBackupsPinEducationScreen(
|
||||
onNavigationClick = navController::popOrFinish,
|
||||
onGeneratePinClick = {},
|
||||
onUseCurrentPinClick = { MessageBackupsScreen.PIN_EDUCATION.next() },
|
||||
recommendedPinSize = 16 // TODO [message-backups] This value should come from some kind of config
|
||||
)
|
||||
}
|
||||
|
||||
composable(route = MessageBackupsScreen.PIN_CONFIRMATION.name) {
|
||||
MessageBackupsPinConfirmationScreen(
|
||||
pin = state.pin,
|
||||
onPinChanged = viewModel::onPinEntryUpdated,
|
||||
pinKeyboardType = state.pinKeyboardType,
|
||||
onPinKeyboardTypeSelected = viewModel::onPinKeyboardTypeUpdated,
|
||||
onNextClick = { MessageBackupsScreen.PIN_CONFIRMATION.next() }
|
||||
)
|
||||
}
|
||||
|
||||
composable(route = MessageBackupsScreen.TYPE_SELECTION.name) {
|
||||
MessageBackupsTypeSelectionScreen(
|
||||
selectedBackupTier = state.selectedMessageBackupTier,
|
||||
availableBackupTiers = state.availableBackupTiers,
|
||||
onMessageBackupsTierSelected = viewModel::onMessageBackupTierUpdated,
|
||||
onNavigationClick = navController::popOrFinish,
|
||||
onReadMoreClicked = {},
|
||||
onNextClicked = { MessageBackupsScreen.TYPE_SELECTION.next() }
|
||||
)
|
||||
}
|
||||
|
||||
dialog(route = MessageBackupsScreen.CHECKOUT_SHEET.name) {
|
||||
MessageBackupsCheckoutSheet(
|
||||
messageBackupTier = state.selectedMessageBackupTier!!,
|
||||
availablePaymentMethods = state.availablePaymentMethods,
|
||||
onDismissRequest = navController::popOrFinish,
|
||||
onPaymentMethodSelected = {
|
||||
viewModel.onPaymentMethodUpdated(it)
|
||||
MessageBackupsScreen.CHECKOUT_SHEET.next()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.backup.v2.ui.subscription
|
||||
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import io.reactivex.rxjava3.processors.PublishProcessor
|
||||
import org.signal.donations.InAppPaymentType
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.InAppPaymentCheckoutDelegate
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.InAppPaymentProcessorAction
|
||||
import org.thoughtcrime.securesms.compose.ComposeFragment
|
||||
import org.thoughtcrime.securesms.compose.Nav
|
||||
import org.thoughtcrime.securesms.database.InAppPaymentTable
|
||||
import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
||||
import org.thoughtcrime.securesms.util.viewModel
|
||||
|
||||
/**
|
||||
* Handles the selection, payment, and changing of a user's backup tier.
|
||||
*/
|
||||
class MessageBackupsFlowFragment : ComposeFragment(), InAppPaymentCheckoutDelegate.Callback {
|
||||
|
||||
private val viewModel: MessageBackupsFlowViewModel by viewModel { MessageBackupsFlowViewModel() }
|
||||
|
||||
private val inAppPaymentIdProcessor = PublishProcessor.create<InAppPaymentTable.InAppPaymentId>()
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
override fun FragmentContent() {
|
||||
val state by viewModel.stateFlow.collectAsState()
|
||||
val pin by viewModel.pinState
|
||||
val navController = rememberNavController()
|
||||
|
||||
val checkoutDelegate = remember {
|
||||
InAppPaymentCheckoutDelegate(this, this, inAppPaymentIdProcessor)
|
||||
}
|
||||
|
||||
LaunchedEffect(state.inAppPayment?.id) {
|
||||
val inAppPaymentId = state.inAppPayment?.id
|
||||
if (inAppPaymentId != null) {
|
||||
inAppPaymentIdProcessor.onNext(inAppPaymentId)
|
||||
}
|
||||
}
|
||||
|
||||
val checkoutSheetState = rememberModalBottomSheetState(
|
||||
skipPartiallyExpanded = true
|
||||
)
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
navController.setLifecycleOwner(this@MessageBackupsFlowFragment)
|
||||
|
||||
requireActivity().onBackPressedDispatcher.addCallback(object : OnBackPressedCallback(true) {
|
||||
override fun handleOnBackPressed() {
|
||||
viewModel.goToPreviousScreen()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Nav.Host(
|
||||
navController = navController,
|
||||
startDestination = state.startScreen.name
|
||||
) {
|
||||
composable(route = MessageBackupsScreen.EDUCATION.name) {
|
||||
MessageBackupsEducationScreen(
|
||||
onNavigationClick = viewModel::goToPreviousScreen,
|
||||
onEnableBackups = viewModel::goToNextScreen,
|
||||
onLearnMore = {}
|
||||
)
|
||||
}
|
||||
|
||||
composable(route = MessageBackupsScreen.PIN_EDUCATION.name) {
|
||||
MessageBackupsPinEducationScreen(
|
||||
onNavigationClick = viewModel::goToPreviousScreen,
|
||||
onGeneratePinClick = {},
|
||||
onUseCurrentPinClick = viewModel::goToNextScreen,
|
||||
recommendedPinSize = 16 // TODO [message-backups] This value should come from some kind of config
|
||||
)
|
||||
}
|
||||
|
||||
composable(route = MessageBackupsScreen.PIN_CONFIRMATION.name) {
|
||||
MessageBackupsPinConfirmationScreen(
|
||||
pin = pin,
|
||||
onPinChanged = viewModel::onPinEntryUpdated,
|
||||
pinKeyboardType = state.pinKeyboardType,
|
||||
onPinKeyboardTypeSelected = viewModel::onPinKeyboardTypeUpdated,
|
||||
onNextClick = viewModel::goToNextScreen
|
||||
)
|
||||
}
|
||||
|
||||
composable(route = MessageBackupsScreen.TYPE_SELECTION.name) {
|
||||
MessageBackupsTypeSelectionScreen(
|
||||
currentBackupTier = state.currentMessageBackupTier,
|
||||
selectedBackupTier = state.selectedMessageBackupTier,
|
||||
availableBackupTypes = state.availableBackupTypes,
|
||||
onMessageBackupsTierSelected = viewModel::onMessageBackupTierUpdated,
|
||||
onNavigationClick = viewModel::goToPreviousScreen,
|
||||
onReadMoreClicked = {},
|
||||
onCancelSubscriptionClicked = viewModel::displayCancellationDialog,
|
||||
onNextClicked = viewModel::goToNextScreen
|
||||
)
|
||||
|
||||
if (state.screen == MessageBackupsScreen.CHECKOUT_SHEET) {
|
||||
MessageBackupsCheckoutSheet(
|
||||
messageBackupsType = state.availableBackupTypes.first { it.tier == state.selectedMessageBackupTier!! },
|
||||
availablePaymentMethods = state.availablePaymentMethods,
|
||||
sheetState = checkoutSheetState,
|
||||
onDismissRequest = {
|
||||
viewModel.goToPreviousScreen()
|
||||
},
|
||||
onPaymentMethodSelected = {
|
||||
viewModel.onPaymentMethodUpdated(it)
|
||||
viewModel.goToNextScreen()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (state.screen == MessageBackupsScreen.CANCELLATION_DIALOG) {
|
||||
ConfirmBackupCancellationDialog(
|
||||
onConfirmAndDownloadNow = {
|
||||
// TODO [message-backups] Set appropriate state to handle post-cancellation action.
|
||||
viewModel.goToNextScreen()
|
||||
},
|
||||
onConfirmAndDownloadLater = {
|
||||
// TODO [message-backups] Set appropriate state to handle post-cancellation action.
|
||||
viewModel.goToNextScreen()
|
||||
},
|
||||
onKeepSubscriptionClick = viewModel::goToPreviousScreen
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(state.screen) {
|
||||
val route = navController.currentDestination?.route ?: return@LaunchedEffect
|
||||
if (route == state.screen.name) {
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
if (state.screen == MessageBackupsScreen.COMPLETED) {
|
||||
if (!findNavController().popBackStack()) {
|
||||
requireActivity().finishAfterTransition()
|
||||
}
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
if (state.screen == MessageBackupsScreen.CREATING_IN_APP_PAYMENT) {
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
if (state.screen == MessageBackupsScreen.PROCESS_PAYMENT) {
|
||||
checkoutDelegate.handleGatewaySelectionResponse(state.inAppPayment!!)
|
||||
viewModel.goToPreviousScreen()
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
if (state.screen == MessageBackupsScreen.PROCESS_CANCELLATION) {
|
||||
cancelSubscription()
|
||||
viewModel.goToPreviousScreen()
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
if (state.screen == MessageBackupsScreen.CHECKOUT_SHEET) {
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
if (state.screen == MessageBackupsScreen.CANCELLATION_DIALOG) {
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
val routeScreen = MessageBackupsScreen.valueOf(route)
|
||||
if (routeScreen.isAfter(state.screen)) {
|
||||
navController.popBackStack()
|
||||
} else {
|
||||
navController.navigate(state.screen.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun cancelSubscription() {
|
||||
findNavController().safeNavigate(
|
||||
MessageBackupsFlowFragmentDirections.actionDonateToSignalFragmentToStripePaymentInProgressFragment(
|
||||
InAppPaymentProcessorAction.CANCEL_SUBSCRIPTION,
|
||||
null,
|
||||
InAppPaymentType.RECURRING_BACKUP
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override fun navigateToStripePaymentInProgress(inAppPayment: InAppPaymentTable.InAppPayment) {
|
||||
findNavController().safeNavigate(
|
||||
MessageBackupsFlowFragmentDirections.actionDonateToSignalFragmentToStripePaymentInProgressFragment(
|
||||
InAppPaymentProcessorAction.PROCESS_NEW_IN_APP_PAYMENT,
|
||||
inAppPayment,
|
||||
inAppPayment.type
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override fun navigateToPayPalPaymentInProgress(inAppPayment: InAppPaymentTable.InAppPayment) {
|
||||
findNavController().safeNavigate(
|
||||
MessageBackupsFlowFragmentDirections.actionDonateToSignalFragmentToPaypalPaymentInProgressFragment(
|
||||
InAppPaymentProcessorAction.PROCESS_NEW_IN_APP_PAYMENT,
|
||||
inAppPayment,
|
||||
inAppPayment.type
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override fun navigateToCreditCardForm(inAppPayment: InAppPaymentTable.InAppPayment) {
|
||||
findNavController().safeNavigate(
|
||||
MessageBackupsFlowFragmentDirections.actionDonateToSignalFragmentToCreditCardFragment(inAppPayment)
|
||||
)
|
||||
}
|
||||
|
||||
override fun navigateToIdealDetailsFragment(inAppPayment: InAppPaymentTable.InAppPayment) {
|
||||
findNavController().safeNavigate(
|
||||
MessageBackupsFlowFragmentDirections.actionDonateToSignalFragmentToIdealTransferDetailsFragment(inAppPayment)
|
||||
)
|
||||
}
|
||||
|
||||
override fun navigateToBankTransferMandate(inAppPayment: InAppPaymentTable.InAppPayment) {
|
||||
findNavController().safeNavigate(
|
||||
MessageBackupsFlowFragmentDirections.actionDonateToSignalFragmentToBankTransferMandateFragment(inAppPayment)
|
||||
)
|
||||
}
|
||||
|
||||
override fun onPaymentComplete(inAppPayment: InAppPaymentTable.InAppPayment) {
|
||||
// TODO [message-backups] What do? probably some kind of success thing?
|
||||
if (!findNavController().popBackStack()) {
|
||||
requireActivity().finishAfterTransition()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSubscriptionCancelled(inAppPaymentType: InAppPaymentType) {
|
||||
if (!findNavController().popBackStack()) {
|
||||
requireActivity().finishAfterTransition()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onProcessorActionProcessed() = Unit
|
||||
|
||||
override fun onUserLaunchedAnExternalApplication() {
|
||||
// TODO [message-backups] What do? Are we even supporting bank transfers?
|
||||
}
|
||||
|
||||
override fun navigateToDonationPending(inAppPayment: InAppPaymentTable.InAppPayment) {
|
||||
// TODO [message-backups] What do? Are we even supporting bank transfers?
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user