mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-04 17:28:53 +01:00
Compare commits
209 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
69e1146e2c | ||
|
|
a0c7b56ab4 | ||
|
|
6b7ea28e8f | ||
|
|
6f1949db98 | ||
|
|
551d873a1a | ||
|
|
760d5ab2ce | ||
|
|
ff4364586b | ||
|
|
12b78336c6 | ||
|
|
70d6a8f1fe | ||
|
|
2e81d717d0 | ||
|
|
6ddd780e0e | ||
|
|
2449b5f4a4 | ||
|
|
fde78cf5b8 | ||
|
|
eab1f5944d | ||
|
|
ecd16dbe9c | ||
|
|
a76f5e600e | ||
|
|
054b517a04 | ||
|
|
40ca94a7dd | ||
|
|
ba1e8b6c14 | ||
|
|
f3a9f7f91d | ||
|
|
3c0e9c9e4e | ||
|
|
9888b1a5f8 | ||
|
|
ec49352635 | ||
|
|
5b69d98579 | ||
|
|
90998a4076 | ||
|
|
a10958ee13 | ||
|
|
7935d12675 | ||
|
|
cafa5c9e28 | ||
|
|
a7bdfb6d76 | ||
|
|
e14078d2ec | ||
|
|
12e25b0f40 | ||
|
|
d23ef647d8 | ||
|
|
d88265ede6 | ||
|
|
0e83e25e6e | ||
|
|
1597ee70ba | ||
|
|
01ee98af91 | ||
|
|
9a1d5f4dce | ||
|
|
60bf121974 | ||
|
|
46844ced7c | ||
|
|
1ac19e84c2 | ||
|
|
48bd57c56a | ||
|
|
b340097f9c | ||
|
|
a1bf4d62ab | ||
|
|
b74f04495e | ||
|
|
ba06efe35a | ||
|
|
24133c6dac | ||
|
|
64ada79e8f | ||
|
|
8933d89b56 | ||
|
|
88d1c0cf87 | ||
|
|
703c00b9af | ||
|
|
c0d115325a | ||
|
|
6f3f204cbe | ||
|
|
cd846f2b6d | ||
|
|
5bd3eda17d | ||
|
|
c36c6e62e2 | ||
|
|
6b9e921888 | ||
|
|
f57b1a8f5e | ||
|
|
7727deef9f | ||
|
|
789aea3a3a | ||
|
|
81b4339bea | ||
|
|
76175c7a6b | ||
|
|
e81fc2900d | ||
|
|
db9a2f04f3 | ||
|
|
1d719333a3 | ||
|
|
71f6c77b42 | ||
|
|
7a66533e70 | ||
|
|
9106812b74 | ||
|
|
fcb2e3cc74 | ||
|
|
1f638db959 | ||
|
|
832d15ff47 | ||
|
|
f8846e3593 | ||
|
|
59e0afde14 | ||
|
|
00058f7762 | ||
|
|
56159043e3 | ||
|
|
2180b78466 | ||
|
|
dc77226995 | ||
|
|
0a346eda5b | ||
|
|
6188502cb1 | ||
|
|
b425920144 | ||
|
|
db60a3cb2c | ||
|
|
6b2ff05adb | ||
|
|
0108a1d3e3 | ||
|
|
64e61ccce3 | ||
|
|
efef179124 | ||
|
|
6789715556 | ||
|
|
463fabcbc4 | ||
|
|
23d82a3a01 | ||
|
|
d1475228f7 | ||
|
|
636b5a4ba6 | ||
|
|
850515b363 | ||
|
|
5c6644d1a1 | ||
|
|
0d37013481 | ||
|
|
5647215659 | ||
|
|
e80ebd87fe | ||
|
|
816006c67e | ||
|
|
baa6032770 | ||
|
|
7735ca9dab | ||
|
|
36a8a399d9 | ||
|
|
9912a5fdfe | ||
|
|
c3be92d365 | ||
|
|
0fe9df3023 | ||
|
|
cb126a2f08 | ||
|
|
7835b1d1fc | ||
|
|
e247d311d8 | ||
|
|
1f2b5e90a3 | ||
|
|
ee033b49fe | ||
|
|
a7b958d811 | ||
|
|
c4bcb7dc93 | ||
|
|
1e8626647e | ||
|
|
a50f316659 | ||
|
|
1f09f48e6b | ||
|
|
514f7cc767 | ||
|
|
b858161f92 | ||
|
|
85d90aa121 | ||
|
|
a8fb4eb21a | ||
|
|
a6767e4f8a | ||
|
|
b00855b097 | ||
|
|
929942de9d | ||
|
|
6112ee9bd3 | ||
|
|
9261c34213 | ||
|
|
f29d4f980a | ||
|
|
bf46e5bc24 | ||
|
|
c9746b59ed | ||
|
|
2123c642a5 | ||
|
|
118085f692 | ||
|
|
2701b570bb | ||
|
|
390ea341ca | ||
|
|
b7abd85992 | ||
|
|
982b90d423 | ||
|
|
36bfd19bcf | ||
|
|
7eac9ce1f4 | ||
|
|
ba2d5bce41 | ||
|
|
93c8cd133d | ||
|
|
d59985c7b1 | ||
|
|
a8bf03af89 | ||
|
|
00d20a1917 | ||
|
|
4e35906680 | ||
|
|
4d23f11f6e | ||
|
|
e5b482c7ad | ||
|
|
6c09b59d1b | ||
|
|
8070f26207 | ||
|
|
623312d8f6 | ||
|
|
ac9e5505ae | ||
|
|
4b47d38d78 | ||
|
|
07289b417b | ||
|
|
6827955c41 | ||
|
|
269d3c43f6 | ||
|
|
ac10ff4cbe | ||
|
|
b681b4169f | ||
|
|
7472166628 | ||
|
|
04f9468cc6 | ||
|
|
c592a5b39d | ||
|
|
a992da9a7b | ||
|
|
1aed8eefcd | ||
|
|
6682815663 | ||
|
|
676be03ffc | ||
|
|
527096cc0e | ||
|
|
83c3cc6a6d | ||
|
|
0c4725dfa7 | ||
|
|
2c7668253e | ||
|
|
ab7bdc3c03 | ||
|
|
bb1b548c27 | ||
|
|
216073f4c2 | ||
|
|
84ae8db549 | ||
|
|
09bd460875 | ||
|
|
97ea5dc45e | ||
|
|
85449802d1 | ||
|
|
d683b8a321 | ||
|
|
2b1bbdda15 | ||
|
|
011a36c8f3 | ||
|
|
dd1976d431 | ||
|
|
643f64e181 | ||
|
|
659e36673b | ||
|
|
907918d3fa | ||
|
|
243c86cec3 | ||
|
|
dca10634e6 | ||
|
|
5dfc4c422e | ||
|
|
46753fc617 | ||
|
|
e263d7da73 | ||
|
|
c4ba579310 | ||
|
|
d6d9e5ca64 | ||
|
|
90a8d90e40 | ||
|
|
b61ca37523 | ||
|
|
b7af1e09e2 | ||
|
|
ff47f784a3 | ||
|
|
1f196f74ff | ||
|
|
4152294b57 | ||
|
|
1aaa833127 | ||
|
|
2cfd19add6 | ||
|
|
8e3000d852 | ||
|
|
4e48a445bf | ||
|
|
45833ef24a | ||
|
|
4354a9ff5e | ||
|
|
f1bf6105ea | ||
|
|
282ec6918b | ||
|
|
69d62d385e | ||
|
|
0f7f866562 | ||
|
|
5f66e2eb15 | ||
|
|
3f71f90234 | ||
|
|
204fcc28c7 | ||
|
|
f53cb19943 | ||
|
|
cea8546ce5 | ||
|
|
bb7ee5915c | ||
|
|
cf8e05fa39 | ||
|
|
01cf0b69e0 | ||
|
|
0aa764586e | ||
|
|
532441db24 | ||
|
|
2bc07e87d8 | ||
|
|
60ad879cac |
@@ -21,8 +21,8 @@ plugins {
|
||||
|
||||
apply(from = "static-ips.gradle.kts")
|
||||
|
||||
val canonicalVersionCode = 1449
|
||||
val canonicalVersionName = "7.15.2"
|
||||
val canonicalVersionCode = 1465
|
||||
val canonicalVersionName = "7.18.2"
|
||||
val currentHotfixVersion = 0
|
||||
val maxHotfixVersions = 100
|
||||
|
||||
@@ -54,6 +54,7 @@ val signalBuildToolsVersion: String by rootProject.extra
|
||||
val signalCompileSdkVersion: String by rootProject.extra
|
||||
val signalTargetSdkVersion: Int by rootProject.extra
|
||||
val signalMinSdkVersion: Int by rootProject.extra
|
||||
val signalNdkVersion: String by rootProject.extra
|
||||
val signalJavaVersion: JavaVersion by rootProject.extra
|
||||
val signalKotlinJvmTarget: String by rootProject.extra
|
||||
|
||||
@@ -80,6 +81,7 @@ android {
|
||||
|
||||
buildToolsVersion = signalBuildToolsVersion
|
||||
compileSdkVersion = signalCompileSdkVersion
|
||||
ndkVersion = signalNdkVersion
|
||||
|
||||
flavorDimensions += listOf("distribution", "environment")
|
||||
useLibrary("org.apache.http.legacy")
|
||||
@@ -89,6 +91,7 @@ android {
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = signalKotlinJvmTarget
|
||||
freeCompilerArgs = listOf("-Xjvm-default=all")
|
||||
}
|
||||
|
||||
keystores["debug"]?.let { properties ->
|
||||
@@ -175,8 +178,6 @@ android {
|
||||
minSdk = signalMinSdkVersion
|
||||
targetSdk = signalTargetSdkVersion
|
||||
|
||||
multiDexEnabled = true
|
||||
|
||||
vectorDrawables.useSupportLibrary = true
|
||||
project.ext.set("archivesBaseName", "Signal")
|
||||
|
||||
@@ -501,7 +502,6 @@ dependencies {
|
||||
implementation(libs.androidx.compose.runtime.livedata)
|
||||
implementation(libs.androidx.activity.compose)
|
||||
implementation(libs.androidx.constraintlayout)
|
||||
implementation(libs.androidx.multidex)
|
||||
implementation(libs.androidx.navigation.fragment.ktx)
|
||||
implementation(libs.androidx.navigation.ui.ktx)
|
||||
implementation(libs.androidx.navigation.compose)
|
||||
@@ -595,7 +595,6 @@ dependencies {
|
||||
testImplementation(testLibs.robolectric.robolectric) {
|
||||
exclude(group = "com.google.protobuf", module = "protobuf-java")
|
||||
}
|
||||
testImplementation(testLibs.robolectric.shadows.multidex)
|
||||
testImplementation(testLibs.bouncycastle.bcprov.jdk15on) {
|
||||
version {
|
||||
strictly("1.70")
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -10,7 +10,6 @@ import androidx.test.platform.app.InstrumentationRegistry
|
||||
import com.github.difflib.DiffUtils
|
||||
import com.github.difflib.UnifiedDiffUtils
|
||||
import junit.framework.Assert.assertTrue
|
||||
import org.junit.Ignore
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.signal.core.util.Base64
|
||||
@@ -40,84 +39,175 @@ class ArchiveImportExportTests {
|
||||
const val TAG = "ImportExport"
|
||||
const val TESTS_FOLDER = "backupTests"
|
||||
|
||||
val SELF_ACI = ServiceId.ACI.from(UUID(100, 100))
|
||||
val SELF_PNI = ServiceId.PNI.from(UUID(101, 101))
|
||||
val SELF_ACI = ServiceId.ACI.from(UUID.fromString("00000000-0000-4000-8000-000000000001"))
|
||||
val SELF_PNI = ServiceId.PNI.from(UUID.fromString("00000000-0000-4000-8000-000000000002"))
|
||||
val SELF_E164 = "+10000000000"
|
||||
val SELF_PROFILE_KEY: ByteArray = Base64.decode("YQKRq+3DQklInaOaMcmlzZnN0m/1hzLiaONX7gB12dg=")
|
||||
val MASTER_KEY = Base64.decode("sHuBMP4ToZk4tcNU+S8eBUeCt8Am5EZnvuqTBJIR4Do")
|
||||
}
|
||||
|
||||
@Test
|
||||
// @Test
|
||||
fun all() {
|
||||
runTests()
|
||||
}
|
||||
|
||||
@Ignore("Just for debugging")
|
||||
@Test
|
||||
fun temp() {
|
||||
runTests { it == "chat_item_standard_message_formatted_text_03.binproto" }
|
||||
}
|
||||
|
||||
// Passing
|
||||
// @Test
|
||||
fun accountData() {
|
||||
runTests { it.startsWith("account_data_") }
|
||||
}
|
||||
|
||||
@Ignore("Just for debugging")
|
||||
@Test
|
||||
fun adHocCall() {
|
||||
runTests { it.startsWith("ad_hoc_call") }
|
||||
}
|
||||
|
||||
// Passing
|
||||
// @Test
|
||||
fun chat() {
|
||||
runTests { it.startsWith("chat_") && !it.contains("_item") }
|
||||
}
|
||||
|
||||
// Passing
|
||||
// @Test
|
||||
fun chatItemContactMessage() {
|
||||
runTests { it.startsWith("chat_item_contact_message_") }
|
||||
}
|
||||
|
||||
// Passing
|
||||
// @Test
|
||||
fun chatItemExpirationTimerUpdate() {
|
||||
runTests { it.startsWith("chat_item_expiration_timer_") }
|
||||
}
|
||||
|
||||
// Passing
|
||||
// @Test
|
||||
fun chatItemGiftBadge() {
|
||||
runTests { it.startsWith("chat_item_gift_badge_") }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun chatItemGroupCallUpdate() {
|
||||
runTests { it.startsWith("chat_item_group_call_update_") }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun chatItemIndividualCallUpdate() {
|
||||
runTests { it.startsWith("chat_item_individual_call_update_") }
|
||||
}
|
||||
|
||||
// Passing
|
||||
// @Test
|
||||
fun chatItemLearnedProfileUpdate() {
|
||||
runTests { it.startsWith("chat_item_learned_profile_update_") }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun chatItemPaymentNotification() {
|
||||
runTests { it.startsWith("chat_item_payment_notification_") }
|
||||
}
|
||||
|
||||
// Passing
|
||||
// @Test
|
||||
fun chatItemProfileChangeUpdate() {
|
||||
runTests { it.startsWith("chat_item_profile_change_update_") }
|
||||
}
|
||||
|
||||
// Passing
|
||||
// @Test
|
||||
fun chatItemRemoteDelete() {
|
||||
runTests { it.startsWith("chat_item_remote_delete_") }
|
||||
}
|
||||
|
||||
// Passing
|
||||
// @Test
|
||||
fun chatItemSessionSwitchoverUpdate() {
|
||||
runTests { it.startsWith("chat_item_session_switchover_update_") }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun chatItemSimpleUpdates() {
|
||||
runTests { it.startsWith("chat_item_simple_updates_") }
|
||||
}
|
||||
|
||||
// Passing
|
||||
// @Test
|
||||
fun chatItemStandardMessageFormattedText() {
|
||||
runTests { it.startsWith("chat_item_standard_message_formatted_text_") }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun chatItemStandardMessageLongText() {
|
||||
runTests { it.startsWith("chat_item_standard_message_long_text_") }
|
||||
}
|
||||
|
||||
// Passing
|
||||
// @Test
|
||||
fun chatItemStandardMessageSpecialAttachments() {
|
||||
runTests { it.startsWith("chat_item_standard_message_special_attachments_") }
|
||||
}
|
||||
|
||||
// Passing
|
||||
// @Test
|
||||
fun chatItemStandardMessageStandardAttachments() {
|
||||
runTests { it.startsWith("chat_item_standard_message_standard_attachments_") }
|
||||
}
|
||||
|
||||
// Passing
|
||||
// @Test
|
||||
fun chatItemStandardMessageTextOnly() {
|
||||
runTests { it.startsWith("chat_item_standard_message_text_only_") }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun chatItemStandardMessageWithEdits() {
|
||||
runTests { it.startsWith("chat_item_standard_message_with_edits_") }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun chatItemStandardMessageWithQuote() {
|
||||
runTests { it.startsWith("chat_item_standard_message_with_quote_") }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun chatItemStickerMessage() {
|
||||
runTests { it.startsWith("chat_item_sticker_message_") }
|
||||
}
|
||||
|
||||
// Passing
|
||||
// @Test
|
||||
fun chatItemThreadMergeUpdate() {
|
||||
runTests { it.startsWith("chat_item_thread_merge_update_") }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun recipientCallLink() {
|
||||
runTests { it.startsWith("recipient_call_link_") }
|
||||
}
|
||||
|
||||
// Passing
|
||||
// @Test
|
||||
fun recipientContacts() {
|
||||
runTests { it.startsWith("recipient_contacts_") }
|
||||
}
|
||||
|
||||
@Ignore("Just for debugging")
|
||||
@Test
|
||||
// Passing
|
||||
// @Test
|
||||
fun recipientDistributionLists() {
|
||||
runTests { it.startsWith("recipient_distribution_list_") }
|
||||
}
|
||||
|
||||
@Ignore("Just for debugging")
|
||||
@Test
|
||||
// Passing
|
||||
// @Test
|
||||
fun recipientGroups() {
|
||||
runTests { it.startsWith("recipient_groups_") }
|
||||
}
|
||||
|
||||
@Ignore("Just for debugging")
|
||||
@Test
|
||||
fun chatStandardMessageTextOnly() {
|
||||
runTests { it.startsWith("chat_standard_message_text_only_") }
|
||||
}
|
||||
|
||||
@Ignore("Just for debugging")
|
||||
@Test
|
||||
fun chatStandardMessageFormattedText() {
|
||||
runTests { it.startsWith("chat_standard_message_formatted_text_") }
|
||||
}
|
||||
|
||||
@Ignore("Just for debugging")
|
||||
@Test
|
||||
fun chatStandardMessageLongText() {
|
||||
runTests { it.startsWith("chat_standard_message_long_text_") }
|
||||
}
|
||||
|
||||
@Ignore("Just for debugging")
|
||||
@Test
|
||||
fun chatStandardMessageStandardAttachments() {
|
||||
runTests { it.startsWith("chat_standard_message_standard_attachments_") }
|
||||
}
|
||||
|
||||
@Ignore("Just for debugging")
|
||||
@Test
|
||||
fun chatStandardMessageSpecialAttachments() {
|
||||
runTests { it.startsWith("chat_standard_message_special_attachments_") }
|
||||
}
|
||||
|
||||
@Ignore("Just for debugging")
|
||||
@Test
|
||||
fun chatSimpleUpdates() {
|
||||
runTests { it.startsWith("chat_simple_updates_") }
|
||||
}
|
||||
|
||||
@Ignore("Just for debugging")
|
||||
@Test
|
||||
fun chatContactMessage() {
|
||||
runTests { it.startsWith("chat_contact_message_") }
|
||||
}
|
||||
|
||||
private fun runTests(predicate: (String) -> Boolean = { true }) {
|
||||
val testFiles = InstrumentationRegistry.getInstrumentation().context.resources.assets.list(TESTS_FOLDER)!!.filter(predicate)
|
||||
val results: MutableList<TestResult> = mutableListOf()
|
||||
@@ -152,7 +242,7 @@ class ArchiveImportExportTests {
|
||||
val message = "Some tests failed! Only $successCount/${results.size} passed. Failure details are above. Failing tests:\n$failingTestNames"
|
||||
|
||||
Log.d(TAG, message)
|
||||
throw AssertionError(message)
|
||||
throw AssertionError("Some tests failed!")
|
||||
} else {
|
||||
Log.d(TAG, "All ${results.size} tests passed!")
|
||||
}
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
package org.thoughtcrime.securesms.database
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.filters.FlakyTest
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Before
|
||||
import org.junit.Ignore
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.signal.core.util.copyTo
|
||||
import org.signal.core.util.readFully
|
||||
import org.signal.core.util.stream.NullOutputStream
|
||||
import org.thoughtcrime.securesms.attachments.Attachment
|
||||
import org.thoughtcrime.securesms.attachments.AttachmentId
|
||||
import org.thoughtcrime.securesms.attachments.PointerAttachment
|
||||
import org.thoughtcrime.securesms.attachments.UriAttachment
|
||||
import org.thoughtcrime.securesms.mms.MediaStream
|
||||
import org.thoughtcrime.securesms.mms.SentMediaQuality
|
||||
@@ -17,6 +25,15 @@ import org.thoughtcrime.securesms.providers.BlobProvider
|
||||
import org.thoughtcrime.securesms.testing.assertIs
|
||||
import org.thoughtcrime.securesms.testing.assertIsNot
|
||||
import org.thoughtcrime.securesms.util.MediaUtil
|
||||
import org.thoughtcrime.securesms.util.Util
|
||||
import org.whispersystems.signalservice.api.crypto.AttachmentCipherInputStream
|
||||
import org.whispersystems.signalservice.api.crypto.AttachmentCipherOutputStream
|
||||
import org.whispersystems.signalservice.api.crypto.NoCipherOutputStream
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentPointer
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentRemoteId
|
||||
import org.whispersystems.signalservice.internal.crypto.PaddingInputStream
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.File
|
||||
import java.util.Optional
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
@@ -163,6 +180,91 @@ class AttachmentTableTest {
|
||||
highInfo.file.exists() assertIs true
|
||||
}
|
||||
|
||||
@Test
|
||||
fun finalizeAttachmentAfterDownload_fixDigestOnNonZeroPadding() {
|
||||
// Insert attachment metadata for badly-padded attachment
|
||||
val plaintext = byteArrayOf(1, 2, 3, 4)
|
||||
val key = Util.getSecretBytes(64)
|
||||
val iv = Util.getSecretBytes(16)
|
||||
|
||||
val badlyPaddedPlaintext = PaddingInputStream(plaintext.inputStream(), plaintext.size.toLong()).readFully().also { it[it.size - 1] = 0x42 }
|
||||
val badlyPaddedCiphertext = encryptPrePaddedBytes(badlyPaddedPlaintext, key, iv)
|
||||
val badlyPaddedDigest = getDigest(badlyPaddedCiphertext)
|
||||
|
||||
val cipherFile = getTempFile()
|
||||
cipherFile.writeBytes(badlyPaddedCiphertext)
|
||||
|
||||
val mmsId = -1L
|
||||
val attachmentId = SignalDatabase.attachments.insertAttachmentsForMessage(mmsId, listOf(createAttachmentPointer(key, badlyPaddedDigest, plaintext.size)), emptyList()).values.first()
|
||||
|
||||
// Give data to attachment table
|
||||
val cipherInputStream = AttachmentCipherInputStream.createForAttachment(cipherFile, plaintext.size.toLong(), key, badlyPaddedDigest, null, 4, false)
|
||||
SignalDatabase.attachments.finalizeAttachmentAfterDownload(mmsId, attachmentId, cipherInputStream, iv)
|
||||
|
||||
// Verify the digest has been updated to the properly padded one
|
||||
val properlyPaddedPlaintext = PaddingInputStream(plaintext.inputStream(), plaintext.size.toLong()).readFully()
|
||||
val properlyPaddedCiphertext = encryptPrePaddedBytes(properlyPaddedPlaintext, key, iv)
|
||||
val properlyPaddedDigest = getDigest(properlyPaddedCiphertext)
|
||||
|
||||
val newDigest = SignalDatabase.attachments.getAttachment(attachmentId)!!.remoteDigest!!
|
||||
|
||||
assertArrayEquals(properlyPaddedDigest, newDigest)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun finalizeAttachmentAfterDownload_leaveDigestAloneForAllZeroPadding() {
|
||||
// Insert attachment metadata for properly-padded attachment
|
||||
val plaintext = byteArrayOf(1, 2, 3, 4)
|
||||
val key = Util.getSecretBytes(64)
|
||||
val iv = Util.getSecretBytes(16)
|
||||
|
||||
val paddedPlaintext = PaddingInputStream(plaintext.inputStream(), plaintext.size.toLong()).readFully()
|
||||
val ciphertext = encryptPrePaddedBytes(paddedPlaintext, key, iv)
|
||||
val digest = getDigest(ciphertext)
|
||||
|
||||
val cipherFile = getTempFile()
|
||||
cipherFile.writeBytes(ciphertext)
|
||||
|
||||
val mmsId = -1L
|
||||
val attachmentId = SignalDatabase.attachments.insertAttachmentsForMessage(mmsId, listOf(createAttachmentPointer(key, digest, plaintext.size)), emptyList()).values.first()
|
||||
|
||||
// Give data to attachment table
|
||||
val cipherInputStream = AttachmentCipherInputStream.createForAttachment(cipherFile, plaintext.size.toLong(), key, digest, null, 4, false)
|
||||
SignalDatabase.attachments.finalizeAttachmentAfterDownload(mmsId, attachmentId, cipherInputStream, iv)
|
||||
|
||||
// Verify the digest hasn't changed
|
||||
val newDigest = SignalDatabase.attachments.getAttachment(attachmentId)!!.remoteDigest!!
|
||||
assertArrayEquals(digest, newDigest)
|
||||
}
|
||||
|
||||
private fun createAttachmentPointer(key: ByteArray, digest: ByteArray, size: Int): Attachment {
|
||||
return PointerAttachment.forPointer(
|
||||
pointer = Optional.of(
|
||||
SignalServiceAttachmentPointer(
|
||||
cdnNumber = 3,
|
||||
remoteId = SignalServiceAttachmentRemoteId.V4("asdf"),
|
||||
contentType = MediaUtil.IMAGE_JPEG,
|
||||
key = key,
|
||||
size = Optional.of(size),
|
||||
preview = Optional.empty(),
|
||||
width = 2,
|
||||
height = 2,
|
||||
digest = Optional.of(digest),
|
||||
incrementalDigest = Optional.empty(),
|
||||
incrementalMacChunkSize = 0,
|
||||
fileName = Optional.of("file.jpg"),
|
||||
voiceNote = false,
|
||||
isBorderless = false,
|
||||
isGif = false,
|
||||
caption = Optional.empty(),
|
||||
blurHash = Optional.empty(),
|
||||
uploadTimestamp = 0,
|
||||
uuid = null
|
||||
)
|
||||
)
|
||||
).get()
|
||||
}
|
||||
|
||||
private fun createAttachment(id: Long, uri: Uri, transformProperties: AttachmentTable.TransformProperties): UriAttachment {
|
||||
return UriAttachmentBuilder.build(
|
||||
id,
|
||||
@@ -179,4 +281,24 @@ class AttachmentTableTest {
|
||||
private fun createMediaStream(byteArray: ByteArray): MediaStream {
|
||||
return MediaStream(byteArray.inputStream(), MediaUtil.IMAGE_JPEG, 2, 2)
|
||||
}
|
||||
|
||||
private fun getDigest(ciphertext: ByteArray): ByteArray {
|
||||
val digestStream = NoCipherOutputStream(NullOutputStream)
|
||||
ciphertext.inputStream().copyTo(digestStream)
|
||||
return digestStream.transmittedDigest
|
||||
}
|
||||
|
||||
private fun encryptPrePaddedBytes(plaintext: ByteArray, key: ByteArray, iv: ByteArray): ByteArray {
|
||||
val outputStream = ByteArrayOutputStream()
|
||||
val cipherStream = AttachmentCipherOutputStream(key, iv, outputStream)
|
||||
plaintext.inputStream().copyTo(cipherStream)
|
||||
|
||||
return outputStream.toByteArray()
|
||||
}
|
||||
|
||||
private fun getTempFile(): File {
|
||||
val dir = InstrumentationRegistry.getInstrumentation().targetContext.getDir("temp", Context.MODE_PRIVATE)
|
||||
dir.mkdir()
|
||||
return File.createTempFile("transfer", ".mms", dir)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@ import org.signal.core.util.Base64
|
||||
import org.signal.core.util.update
|
||||
import org.thoughtcrime.securesms.attachments.AttachmentId
|
||||
import org.thoughtcrime.securesms.attachments.Cdn
|
||||
import org.thoughtcrime.securesms.attachments.PointerAttachment
|
||||
import org.thoughtcrime.securesms.backup.v2.BackupRepository.getMediaName
|
||||
import org.thoughtcrime.securesms.database.AttachmentTable.TransformProperties
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
@@ -27,7 +26,9 @@ import org.thoughtcrime.securesms.providers.BlobProvider
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.util.MediaUtil
|
||||
import org.thoughtcrime.securesms.util.Util
|
||||
import org.whispersystems.signalservice.api.attachment.AttachmentUploadResult
|
||||
import org.whispersystems.signalservice.api.backup.MediaId
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentRemoteId
|
||||
import org.whispersystems.signalservice.api.push.ServiceId
|
||||
import java.io.File
|
||||
import java.util.UUID
|
||||
@@ -110,15 +111,6 @@ class AttachmentTableTest_deduping {
|
||||
assertDataFilesAreDifferent(id1, id2)
|
||||
assertDataHashStartMatches(id1, id2)
|
||||
}
|
||||
|
||||
// Non-matching mp4 fast start
|
||||
test {
|
||||
val id1 = insertWithData(DATA_A, TransformProperties(mp4FastStart = true))
|
||||
val id2 = insertWithData(DATA_A, TransformProperties(mp4FastStart = false))
|
||||
|
||||
assertDataFilesAreDifferent(id1, id2)
|
||||
assertDataHashStartMatches(id1, id2)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -261,6 +253,15 @@ class AttachmentTableTest_deduping {
|
||||
|
||||
assertDoesNotHaveRemoteFields(id2)
|
||||
assertArchiveFieldsMatch(id1, id2)
|
||||
|
||||
upload(id2)
|
||||
|
||||
assertDataFilesAreTheSame(id1, id2)
|
||||
assertDataHashStartMatches(id1, id2)
|
||||
assertDataHashEndMatches(id1, id2)
|
||||
assertSkipTransform(id1, true)
|
||||
assertSkipTransform(id2, true)
|
||||
assertRemoteFieldsMatch(id1, id2)
|
||||
}
|
||||
|
||||
// This isn't so much "desirable behavior" as it is documenting how things work.
|
||||
@@ -661,7 +662,8 @@ class AttachmentTableTest_deduping {
|
||||
}
|
||||
|
||||
fun upload(attachmentId: AttachmentId, uploadTimestamp: Long = System.currentTimeMillis()) {
|
||||
SignalDatabase.attachments.finalizeAttachmentAfterUpload(attachmentId, createPointerAttachment(attachmentId, uploadTimestamp), uploadTimestamp)
|
||||
SignalDatabase.attachments.createKeyIvIfNecessary(attachmentId)
|
||||
SignalDatabase.attachments.finalizeAttachmentAfterUpload(attachmentId, createUploadResult(attachmentId, uploadTimestamp))
|
||||
|
||||
val attachment = SignalDatabase.attachments.getAttachment(attachmentId)!!
|
||||
SignalDatabase.attachments.setArchiveData(
|
||||
@@ -763,6 +765,7 @@ class AttachmentTableTest_deduping {
|
||||
|
||||
assertEquals(lhsAttachment.remoteLocation, rhsAttachment.remoteLocation)
|
||||
assertEquals(lhsAttachment.remoteKey, rhsAttachment.remoteKey)
|
||||
assertArrayEquals(lhsAttachment.remoteIv, rhsAttachment.remoteIv)
|
||||
assertArrayEquals(lhsAttachment.remoteDigest, rhsAttachment.remoteDigest)
|
||||
assertArrayEquals(lhsAttachment.incrementalDigest, rhsAttachment.incrementalDigest)
|
||||
assertEquals(lhsAttachment.incrementalMacChunkSize, rhsAttachment.incrementalMacChunkSize)
|
||||
@@ -796,36 +799,19 @@ class AttachmentTableTest_deduping {
|
||||
return MediaStream(this.inputStream(), MediaUtil.IMAGE_JPEG, 2, 2)
|
||||
}
|
||||
|
||||
private fun createPointerAttachment(attachmentId: AttachmentId, uploadTimestamp: Long = System.currentTimeMillis()): PointerAttachment {
|
||||
val location = "somewhere-${Random.nextLong()}"
|
||||
val key = "somekey-${Random.nextLong()}"
|
||||
val digest = Random.nextBytes(32)
|
||||
val incrementalDigest = Random.nextBytes(16)
|
||||
|
||||
private fun createUploadResult(attachmentId: AttachmentId, uploadTimestamp: Long = System.currentTimeMillis()): AttachmentUploadResult {
|
||||
val databaseAttachment = SignalDatabase.attachments.getAttachment(attachmentId)!!
|
||||
|
||||
return PointerAttachment(
|
||||
"image/jpeg",
|
||||
AttachmentTable.TRANSFER_PROGRESS_DONE,
|
||||
databaseAttachment.size, // size
|
||||
null,
|
||||
Cdn.CDN_3, // cdnNumber
|
||||
location,
|
||||
key,
|
||||
digest,
|
||||
incrementalDigest,
|
||||
5, // incrementalMacChunkSize
|
||||
null,
|
||||
databaseAttachment.voiceNote,
|
||||
databaseAttachment.borderless,
|
||||
databaseAttachment.videoGif,
|
||||
databaseAttachment.width,
|
||||
databaseAttachment.height,
|
||||
uploadTimestamp,
|
||||
databaseAttachment.caption,
|
||||
databaseAttachment.stickerLocator,
|
||||
databaseAttachment.blurHash,
|
||||
databaseAttachment.uuid
|
||||
return AttachmentUploadResult(
|
||||
remoteId = SignalServiceAttachmentRemoteId.V4("somewhere-${Random.nextLong()}"),
|
||||
cdnNumber = Cdn.CDN_3.cdnNumber,
|
||||
key = databaseAttachment.remoteKey?.let { Base64.decode(it) } ?: Util.getSecretBytes(64),
|
||||
iv = databaseAttachment.remoteIv ?: Util.getSecretBytes(16),
|
||||
digest = Random.nextBytes(32),
|
||||
incrementalDigest = Random.nextBytes(16),
|
||||
incrementalDigestChunkSize = 5,
|
||||
uploadTimestamp = uploadTimestamp,
|
||||
dataSize = databaseAttachment.size
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,7 +86,8 @@ class CallLinkTableTest {
|
||||
linkKeyBytes = roomId,
|
||||
adminPassBytes = null
|
||||
),
|
||||
state = SignalCallLinkState()
|
||||
state = SignalCallLinkState(),
|
||||
deletionTimestamp = 0L
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.signal.core.util.concurrent.SignalExecutors
|
||||
import org.thoughtcrime.securesms.testing.SignalFlakyTest
|
||||
import org.thoughtcrime.securesms.testing.SignalFlakyTestRule
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
@@ -187,8 +186,8 @@ class SQLiteDatabaseTest {
|
||||
assertTrue(hasRun2.get())
|
||||
}
|
||||
|
||||
@SignalFlakyTest
|
||||
@Test
|
||||
// @SignalFlakyTest
|
||||
// @Test
|
||||
fun runPostSuccessfulTransaction_runsAfterMainTransactionInNestedTransaction() {
|
||||
val hasRun1 = AtomicBoolean(false)
|
||||
val hasRun2 = AtomicBoolean(false)
|
||||
|
||||
@@ -34,6 +34,7 @@ import org.whispersystems.signalservice.internal.configuration.SignalServiceConf
|
||||
import org.whispersystems.signalservice.internal.configuration.SignalServiceUrl
|
||||
import org.whispersystems.signalservice.internal.configuration.SignalStorageUrl
|
||||
import org.whispersystems.signalservice.internal.configuration.SignalSvr2Url
|
||||
import org.whispersystems.signalservice.internal.push.PushServiceSocket
|
||||
import java.util.Optional
|
||||
|
||||
/**
|
||||
@@ -112,10 +113,10 @@ class InstrumentationApplicationDependencyProvider(val application: Application,
|
||||
override fun provideSignalServiceMessageSender(
|
||||
signalWebSocket: SignalWebSocket,
|
||||
protocolStore: SignalServiceDataStore,
|
||||
signalServiceConfiguration: SignalServiceConfiguration
|
||||
pushServiceSocket: PushServiceSocket
|
||||
): SignalServiceMessageSender {
|
||||
if (signalServiceMessageSender == null) {
|
||||
signalServiceMessageSender = spyk(objToCopy = default.provideSignalServiceMessageSender(signalWebSocket, protocolStore, signalServiceConfiguration))
|
||||
signalServiceMessageSender = spyk(objToCopy = default.provideSignalServiceMessageSender(signalWebSocket, protocolStore, pushServiceSocket))
|
||||
}
|
||||
return signalServiceMessageSender!!
|
||||
}
|
||||
|
||||
@@ -77,7 +77,7 @@ class EditMessageSyncProcessorTest {
|
||||
.build()
|
||||
).build()
|
||||
).build()
|
||||
SignalDatabase.recipients.setExpireMessages(toRecipient.id, content.dataMessage?.expireTimer ?: 0)
|
||||
SignalDatabase.recipients.setExpireMessages(toRecipient.id, content.dataMessage?.expireTimer ?: 0, content.dataMessage?.expireTimerVersion ?: 1)
|
||||
val syncTextMessage = TestMessage(
|
||||
envelope = MessageContentFuzzer.envelope(originalTimestamp),
|
||||
content = syncContent,
|
||||
@@ -112,7 +112,7 @@ class EditMessageSyncProcessorTest {
|
||||
|
||||
testResult.runSync(listOf(syncTextMessage, syncEditMessage))
|
||||
|
||||
SignalDatabase.recipients.setExpireMessages(toRecipient.id, (content.dataMessage?.expireTimer ?: 0) / 1000)
|
||||
SignalDatabase.recipients.setExpireMessages(toRecipient.id, (content.dataMessage?.expireTimer ?: 0) / 1000, content.dataMessage?.expireTimerVersion ?: 1)
|
||||
val originalTextMessage = OutgoingMessage(
|
||||
threadRecipient = toRecipient,
|
||||
sentTimeMillis = originalTimestamp,
|
||||
|
||||
@@ -13,6 +13,7 @@ import org.junit.Ignore
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.signal.core.util.Base64
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.core.util.update
|
||||
import org.signal.core.util.withinTransaction
|
||||
@@ -33,6 +34,9 @@ import org.thoughtcrime.securesms.testing.assertIsNot
|
||||
import org.thoughtcrime.securesms.testing.assertIsNotNull
|
||||
import org.thoughtcrime.securesms.testing.assertIsSize
|
||||
import org.thoughtcrime.securesms.util.IdentityUtil
|
||||
import org.thoughtcrime.securesms.util.Util
|
||||
import org.whispersystems.signalservice.api.attachment.AttachmentUploadResult
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentRemoteId
|
||||
import java.util.UUID
|
||||
|
||||
@Suppress("ClassName")
|
||||
@@ -574,30 +578,35 @@ class SyncMessageProcessorTest_synchronizeDeleteForMe {
|
||||
// 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
|
||||
uploadResult = attachments[0].toUploadResult(
|
||||
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
|
||||
uploadResult = attachments[1].toUploadResult(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
|
||||
uploadResult = attachments[2].toUploadResult(
|
||||
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
|
||||
uploadResult = attachments[3].toUploadResult(
|
||||
digest = byteArrayOf(attachments[3].attachmentId.id.toByte()),
|
||||
uploadTimestamp = message1.timestamp + 1
|
||||
)
|
||||
)
|
||||
|
||||
attachments = SignalDatabase.attachments.getAttachmentsForMessage(message1.messageId)
|
||||
@@ -674,6 +683,7 @@ class SyncMessageProcessorTest_synchronizeDeleteForMe {
|
||||
cdn = this.cdn,
|
||||
location = this.remoteLocation,
|
||||
key = this.remoteKey,
|
||||
iv = this.remoteIv,
|
||||
digest = digest,
|
||||
incrementalDigest = this.incrementalDigest,
|
||||
incrementalMacChunkSize = this.incrementalMacChunkSize,
|
||||
@@ -693,11 +703,28 @@ class SyncMessageProcessorTest_synchronizeDeleteForMe {
|
||||
uploadTimestamp = this.uploadTimestamp,
|
||||
dataHash = this.dataHash,
|
||||
archiveCdn = this.archiveCdn,
|
||||
archiveThumbnailCdn = this.archiveThumbnailCdn,
|
||||
archiveMediaName = this.archiveMediaName,
|
||||
archiveMediaId = this.archiveMediaId,
|
||||
thumbnailRestoreState = this.thumbnailRestoreState,
|
||||
archiveTransferState = this.archiveTransferState,
|
||||
uuid = uuid
|
||||
)
|
||||
}
|
||||
|
||||
private fun Attachment.toUploadResult(
|
||||
digest: ByteArray = this.remoteDigest ?: byteArrayOf(),
|
||||
uploadTimestamp: Long = this.uploadTimestamp
|
||||
): AttachmentUploadResult {
|
||||
return AttachmentUploadResult(
|
||||
remoteId = SignalServiceAttachmentRemoteId.V4(this.remoteLocation ?: "some-location"),
|
||||
cdnNumber = this.cdn.cdnNumber,
|
||||
key = this.remoteKey?.let { Base64.decode(it) } ?: Util.getSecretBytes(64),
|
||||
iv = this.remoteIv ?: Util.getSecretBytes(16),
|
||||
digest = digest,
|
||||
incrementalDigest = this.incrementalDigest,
|
||||
incrementalDigestChunkSize = this.incrementalMacChunkSize,
|
||||
dataSize = this.size,
|
||||
uploadTimestamp = uploadTimestamp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import android.content.SharedPreferences
|
||||
import android.preference.PreferenceManager
|
||||
import androidx.test.core.app.ActivityScenario
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import org.junit.rules.ExternalResource
|
||||
import org.signal.libsignal.protocol.IdentityKey
|
||||
@@ -25,18 +26,15 @@ import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.profiles.ProfileName
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.registration.RegistrationData
|
||||
import org.thoughtcrime.securesms.registration.RegistrationRepository
|
||||
import org.thoughtcrime.securesms.registration.RegistrationUtil
|
||||
import org.thoughtcrime.securesms.registration.VerifyResponse
|
||||
import org.thoughtcrime.securesms.registration.data.LocalRegistrationMetadataUtil
|
||||
import org.thoughtcrime.securesms.registration.data.RegistrationData
|
||||
import org.thoughtcrime.securesms.registration.data.RegistrationRepository
|
||||
import org.thoughtcrime.securesms.registration.util.RegistrationUtil
|
||||
import org.thoughtcrime.securesms.testing.GroupTestingUtils.asMember
|
||||
import org.thoughtcrime.securesms.util.Util
|
||||
import org.whispersystems.signalservice.api.profiles.SignalServiceProfile
|
||||
import org.whispersystems.signalservice.api.push.ServiceId.ACI
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress
|
||||
import org.whispersystems.signalservice.internal.ServiceResponse
|
||||
import org.whispersystems.signalservice.internal.ServiceResponseProcessor
|
||||
import org.whispersystems.signalservice.internal.push.VerifyAccountResponse
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
@@ -48,6 +46,7 @@ import java.util.UUID
|
||||
class SignalActivityRule(private val othersCount: Int = 4, private val createGroup: Boolean = false) : ExternalResource() {
|
||||
|
||||
val application: Application = AppDependencies.application
|
||||
private val TEST_E164 = "+15555550101"
|
||||
|
||||
lateinit var context: Context
|
||||
private set
|
||||
@@ -93,31 +92,31 @@ class SignalActivityRule(private val othersCount: Int = 4, private val createGro
|
||||
SignalStore.account.generateAciIdentityKeyIfNecessary()
|
||||
SignalStore.account.generatePniIdentityKeyIfNecessary()
|
||||
|
||||
val registrationRepository = RegistrationRepository(application)
|
||||
|
||||
InstrumentationApplicationDependencyProvider.addMockWebRequestHandlers(Put("/v2/keys") { MockResponse().success() })
|
||||
val response: ServiceResponse<VerifyResponse> = registrationRepository.registerAccount(
|
||||
RegistrationData(
|
||||
runBlocking {
|
||||
val registrationData = RegistrationData(
|
||||
code = "123123",
|
||||
e164 = "+15555550101",
|
||||
e164 = TEST_E164,
|
||||
password = Util.getSecret(18),
|
||||
registrationId = registrationRepository.registrationId,
|
||||
profileKey = registrationRepository.getProfileKey("+15555550101"),
|
||||
registrationId = RegistrationRepository.getRegistrationId(),
|
||||
profileKey = RegistrationRepository.getProfileKey(TEST_E164),
|
||||
fcmToken = null,
|
||||
pniRegistrationId = registrationRepository.pniRegistrationId,
|
||||
pniRegistrationId = RegistrationRepository.getPniRegistrationId(),
|
||||
recoveryPassword = "asdfasdfasdfasdf"
|
||||
),
|
||||
VerifyResponse(
|
||||
verifyAccountResponse = VerifyAccountResponse(UUID.randomUUID().toString(), UUID.randomUUID().toString(), false),
|
||||
)
|
||||
val remoteResult = RegistrationRepository.AccountRegistrationResult(
|
||||
uuid = UUID.randomUUID().toString(),
|
||||
pni = UUID.randomUUID().toString(),
|
||||
storageCapable = false,
|
||||
number = TEST_E164,
|
||||
masterKey = null,
|
||||
pin = null,
|
||||
aciPreKeyCollection = RegistrationRepository.generateSignedAndLastResortPreKeys(SignalStore.account.aciIdentityKey, SignalStore.account.aciPreKeys),
|
||||
pniPreKeyCollection = RegistrationRepository.generateSignedAndLastResortPreKeys(SignalStore.account.aciIdentityKey, SignalStore.account.pniPreKeys)
|
||||
),
|
||||
false
|
||||
).blockingGet()
|
||||
|
||||
ServiceResponseProcessor.DefaultProcessor(response).resultOrThrow
|
||||
)
|
||||
val localRegistrationData = LocalRegistrationMetadataUtil.createLocalRegistrationMetadata(SignalStore.account.aciIdentityKey, SignalStore.account.pniIdentityKey, registrationData, remoteResult, false)
|
||||
RegistrationRepository.registerAccountLocally(application, localRegistrationData)
|
||||
}
|
||||
|
||||
SignalStore.svr.optOut()
|
||||
RegistrationUtil.maybeMarkRegistrationComplete()
|
||||
@@ -141,7 +140,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, false))
|
||||
SignalDatabase.recipients.setCapabilities(recipientId, SignalServiceProfile.Capabilities(true, false, true))
|
||||
SignalDatabase.recipients.setProfileSharing(recipientId, true)
|
||||
SignalDatabase.recipients.markRegistered(recipientId, aci)
|
||||
val otherIdentity = IdentityKeyUtil.generateIdentityKeyPair()
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
package org.signal.benchmark
|
||||
|
||||
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.RemoteConfig
|
||||
import org.whispersystems.signalservice.api.SignalServiceAccountManager
|
||||
import org.whispersystems.signalservice.api.account.PreKeyUpload
|
||||
import org.whispersystems.signalservice.api.push.ServiceId.ACI
|
||||
import org.whispersystems.signalservice.api.push.ServiceId.PNI
|
||||
import org.whispersystems.signalservice.internal.configuration.SignalServiceConfiguration
|
||||
import java.io.IOException
|
||||
import java.util.Optional
|
||||
|
||||
class DummyAccountManagerFactory : AccountManagerFactory() {
|
||||
override fun createAuthenticated(context: Context, aci: ACI, pni: PNI, number: String, deviceId: Int, password: String): SignalServiceAccountManager {
|
||||
return DummyAccountManager(
|
||||
AppDependencies.signalServiceNetworkAccess.getConfiguration(number),
|
||||
aci,
|
||||
pni,
|
||||
number,
|
||||
deviceId,
|
||||
password,
|
||||
BuildConfig.SIGNAL_AGENT,
|
||||
RemoteConfig.okHttpAutomaticRetry,
|
||||
RemoteConfig.groupLimits.hardLimit
|
||||
)
|
||||
}
|
||||
|
||||
private class DummyAccountManager(configuration: SignalServiceConfiguration?, aci: ACI?, pni: PNI?, e164: String?, deviceId: Int, password: String?, signalAgent: String?, automaticNetworkRetry: Boolean, maxGroupSize: Int) : SignalServiceAccountManager(configuration, aci, pni, e164, deviceId, password, signalAgent, automaticNetworkRetry, maxGroupSize) {
|
||||
@Throws(IOException::class)
|
||||
override fun setGcmId(gcmRegistrationId: Optional<String>) {
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
override fun setPreKeys(preKeyUpload: PreKeyUpload) {
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,7 @@ package org.signal.benchmark.setup
|
||||
import android.app.Application
|
||||
import android.content.SharedPreferences
|
||||
import android.preference.PreferenceManager
|
||||
import org.signal.benchmark.DummyAccountManagerFactory
|
||||
import org.signal.core.util.concurrent.safeBlockingGet
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.signal.libsignal.protocol.SignalProtocolAddress
|
||||
import org.thoughtcrime.securesms.crypto.IdentityKeyUtil
|
||||
import org.thoughtcrime.securesms.crypto.MasterSecretUtil
|
||||
@@ -14,25 +13,22 @@ import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.net.DeviceTransferBlockingInterceptor
|
||||
import org.thoughtcrime.securesms.profiles.ProfileName
|
||||
import org.thoughtcrime.securesms.push.AccountManagerFactory
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.registration.RegistrationData
|
||||
import org.thoughtcrime.securesms.registration.RegistrationRepository
|
||||
import org.thoughtcrime.securesms.registration.RegistrationUtil
|
||||
import org.thoughtcrime.securesms.registration.VerifyResponse
|
||||
import org.thoughtcrime.securesms.registration.data.LocalRegistrationMetadataUtil
|
||||
import org.thoughtcrime.securesms.registration.data.RegistrationData
|
||||
import org.thoughtcrime.securesms.registration.data.RegistrationRepository
|
||||
import org.thoughtcrime.securesms.registration.util.RegistrationUtil
|
||||
import org.thoughtcrime.securesms.util.Util
|
||||
import org.whispersystems.signalservice.api.profiles.SignalServiceProfile
|
||||
import org.whispersystems.signalservice.api.push.ServiceId.ACI
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress
|
||||
import org.whispersystems.signalservice.internal.ServiceResponse
|
||||
import org.whispersystems.signalservice.internal.ServiceResponseProcessor
|
||||
import org.whispersystems.signalservice.internal.push.VerifyAccountResponse
|
||||
import java.util.UUID
|
||||
|
||||
object TestUsers {
|
||||
|
||||
private var generatedOthers: Int = 0
|
||||
private val TEST_E164 = "+15555550101"
|
||||
|
||||
fun setupSelf(): Recipient {
|
||||
val application: Application = AppDependencies.application
|
||||
@@ -47,35 +43,30 @@ object TestUsers {
|
||||
SignalStore.account.generateAciIdentityKeyIfNecessary()
|
||||
SignalStore.account.generatePniIdentityKeyIfNecessary()
|
||||
|
||||
val registrationRepository = RegistrationRepository(application)
|
||||
val registrationData = RegistrationData(
|
||||
code = "123123",
|
||||
e164 = "+15555550101",
|
||||
password = Util.getSecret(18),
|
||||
registrationId = registrationRepository.registrationId,
|
||||
profileKey = registrationRepository.getProfileKey("+15555550101"),
|
||||
fcmToken = "fcm-token",
|
||||
pniRegistrationId = registrationRepository.pniRegistrationId,
|
||||
recoveryPassword = "asdfasdfasdfasdf"
|
||||
)
|
||||
|
||||
val verifyResponse = VerifyResponse(
|
||||
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)
|
||||
)
|
||||
|
||||
AccountManagerFactory.setInstance(DummyAccountManagerFactory())
|
||||
|
||||
val response: ServiceResponse<VerifyResponse> = registrationRepository.registerAccount(
|
||||
registrationData,
|
||||
verifyResponse,
|
||||
false
|
||||
).safeBlockingGet()
|
||||
|
||||
ServiceResponseProcessor.DefaultProcessor(response).resultOrThrow
|
||||
runBlocking {
|
||||
val registrationData = RegistrationData(
|
||||
code = "123123",
|
||||
e164 = TEST_E164,
|
||||
password = Util.getSecret(18),
|
||||
registrationId = RegistrationRepository.getRegistrationId(),
|
||||
profileKey = RegistrationRepository.getProfileKey(TEST_E164),
|
||||
fcmToken = null,
|
||||
pniRegistrationId = RegistrationRepository.getPniRegistrationId(),
|
||||
recoveryPassword = "asdfasdfasdfasdf"
|
||||
)
|
||||
val remoteResult = RegistrationRepository.AccountRegistrationResult(
|
||||
uuid = UUID.randomUUID().toString(),
|
||||
pni = UUID.randomUUID().toString(),
|
||||
storageCapable = false,
|
||||
number = TEST_E164,
|
||||
masterKey = null,
|
||||
pin = null,
|
||||
aciPreKeyCollection = RegistrationRepository.generateSignedAndLastResortPreKeys(SignalStore.account.aciIdentityKey, SignalStore.account.aciPreKeys),
|
||||
pniPreKeyCollection = RegistrationRepository.generateSignedAndLastResortPreKeys(SignalStore.account.aciIdentityKey, SignalStore.account.pniPreKeys)
|
||||
)
|
||||
val localRegistrationData = LocalRegistrationMetadataUtil.createLocalRegistrationMetadata(SignalStore.account.aciIdentityKey, SignalStore.account.pniIdentityKey, registrationData, remoteResult, false)
|
||||
RegistrationRepository.registerAccountLocally(application, localRegistrationData)
|
||||
}
|
||||
|
||||
SignalStore.svr.optOut()
|
||||
RegistrationUtil.maybeMarkRegistrationComplete()
|
||||
@@ -100,7 +91,7 @@ object TestUsers {
|
||||
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, true, true))
|
||||
SignalDatabase.recipients.setProfileSharing(recipientId, true)
|
||||
SignalDatabase.recipients.markRegistered(recipientId, aci)
|
||||
val otherIdentity = IdentityKeyUtil.generateIdentityKeyPair()
|
||||
|
||||
@@ -96,6 +96,7 @@ class ConversationElementGenerator {
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
false,
|
||||
true,
|
||||
null,
|
||||
|
||||
@@ -1121,6 +1121,12 @@
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
|
||||
android:exported="false"/>
|
||||
|
||||
<activity android:name=".backup.v2.ui.subscription.MessageBackupsCheckoutActivity"
|
||||
android:theme="@style/Theme.Signal.DayNight.NoActionBar"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
|
||||
android:exported="false"/>
|
||||
|
||||
<service
|
||||
android:enabled="true"
|
||||
android:name=".service.webrtc.WebRtcCallService"
|
||||
|
||||
BIN
app/src/main/assets/fonts/SignalSymbols-Regular.otf
Normal file
BIN
app/src/main/assets/fonts/SignalSymbols-Regular.otf
Normal file
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -11,7 +11,8 @@ object AppCapabilities {
|
||||
fun getCapabilities(storageCapable: Boolean): AccountAttributes.Capabilities {
|
||||
return AccountAttributes.Capabilities(
|
||||
storage = storageCapable,
|
||||
deleteSync = true
|
||||
deleteSync = true,
|
||||
versionedExpirationTimer = true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,13 +16,13 @@
|
||||
*/
|
||||
package org.thoughtcrime.securesms;
|
||||
|
||||
import android.app.Application;
|
||||
import android.content.Context;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.VisibleForTesting;
|
||||
import androidx.annotation.WorkerThread;
|
||||
import androidx.multidex.MultiDexApplication;
|
||||
|
||||
import com.bumptech.glide.Glide;
|
||||
import com.google.android.gms.security.ProviderInstaller;
|
||||
@@ -84,7 +84,7 @@ import org.thoughtcrime.securesms.mms.SignalGlideModule;
|
||||
import org.thoughtcrime.securesms.providers.BlobProvider;
|
||||
import org.thoughtcrime.securesms.ratelimit.RateLimitUtil;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.registration.RegistrationUtil;
|
||||
import org.thoughtcrime.securesms.registration.util.RegistrationUtil;
|
||||
import org.thoughtcrime.securesms.ringrtc.RingRtcLogger;
|
||||
import org.thoughtcrime.securesms.service.AnalyzeDatabaseAlarmListener;
|
||||
import org.thoughtcrime.securesms.service.DirectoryRefreshListener;
|
||||
@@ -129,7 +129,7 @@ import rxdogtag2.RxDogTag;
|
||||
*
|
||||
* @author Moxie Marlinspike
|
||||
*/
|
||||
public class ApplicationContext extends MultiDexApplication implements AppForegroundObserver.Listener {
|
||||
public class ApplicationContext extends Application implements AppForegroundObserver.Listener {
|
||||
|
||||
private static final String TAG = Log.tag(ApplicationContext.class);
|
||||
|
||||
@@ -167,7 +167,7 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
|
||||
.addBlocking("scrubber", () -> Scrubber.setIdentifierHmacKeyProvider(() -> SignalStore.svr().getOrCreateMasterKey().deriveLoggingKey()))
|
||||
.addBlocking("first-launch", this::initializeFirstEverAppLaunch)
|
||||
.addBlocking("app-migrations", this::initializeApplicationMigrations)
|
||||
.addBlocking("lifecycle-observer", () -> AppDependencies.getAppForegroundObserver().addListener(this))
|
||||
.addBlocking("lifecycle-observer", () -> AppForegroundObserver.addListener(this))
|
||||
.addBlocking("message-retriever", this::initializeMessageRetrieval)
|
||||
.addBlocking("dynamic-theme", () -> DynamicTheme.setDefaultDayNightMode(this))
|
||||
.addBlocking("proxy-init", () -> {
|
||||
@@ -366,7 +366,11 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
|
||||
|
||||
@VisibleForTesting
|
||||
void initializeAppDependencies() {
|
||||
AppDependencies.init(this, new ApplicationDependencyProvider(this));
|
||||
if (!AppDependencies.isInitialized()) {
|
||||
Log.i(TAG, "Initializing AppDependencies.");
|
||||
AppDependencies.init(this, new ApplicationDependencyProvider(this));
|
||||
}
|
||||
AppForegroundObserver.begin();
|
||||
}
|
||||
|
||||
private void initializeFirstEverAppLaunch() {
|
||||
|
||||
@@ -35,8 +35,9 @@ class BiometricDeviceAuthentication(
|
||||
private val DISALLOWED_BIOMETRIC_VERSIONS = setOf(28, 29)
|
||||
}
|
||||
|
||||
fun canAuthenticate(): Boolean {
|
||||
return biometricManager.canAuthenticate(ALLOWED_AUTHENTICATORS) == BiometricManager.BIOMETRIC_SUCCESS
|
||||
fun canAuthenticate(context: Context): Boolean {
|
||||
val isKeyGuardSecure = ServiceUtil.getKeyguardManager(context).isKeyguardSecure
|
||||
return isKeyGuardSecure && biometricManager.canAuthenticate(ALLOWED_AUTHENTICATORS) == BiometricManager.BIOMETRIC_SUCCESS
|
||||
}
|
||||
|
||||
fun authenticate(context: Context, force: Boolean, showConfirmDeviceCredentialIntent: () -> Unit): Boolean {
|
||||
|
||||
@@ -53,6 +53,7 @@ import org.signal.core.util.concurrent.LifecycleDisposable;
|
||||
import org.signal.core.util.concurrent.RxExtensions;
|
||||
import org.signal.core.util.concurrent.SimpleTask;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.calls.YouAreAlreadyInACallSnackbar;
|
||||
import org.thoughtcrime.securesms.components.RecyclerViewFastScroller;
|
||||
import org.thoughtcrime.securesms.contacts.ContactChipViewModel;
|
||||
import org.thoughtcrime.securesms.contacts.ContactSelectionDisplayMode;
|
||||
@@ -1009,12 +1010,16 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
private class CallButtonClickCallbacks implements ContactSearchAdapter.CallButtonClickCallbacks {
|
||||
@Override
|
||||
public void onVideoCallButtonClicked(@NonNull Recipient recipient) {
|
||||
CommunicationActions.startVideoCall(ContactSelectionListFragment.this, recipient);
|
||||
CommunicationActions.startVideoCall(ContactSelectionListFragment.this, recipient, () -> {
|
||||
YouAreAlreadyInACallSnackbar.show(requireView());
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onAudioCallButtonClicked(@NonNull Recipient recipient) {
|
||||
CommunicationActions.startVoiceCall(ContactSelectionListFragment.this, recipient);
|
||||
CommunicationActions.startVoiceCall(ContactSelectionListFragment.this, recipient, () -> {
|
||||
YouAreAlreadyInACallSnackbar.show(requireView());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder;
|
||||
|
||||
import org.signal.core.util.concurrent.LifecycleDisposable;
|
||||
import org.signal.donations.StripeApi;
|
||||
import org.thoughtcrime.securesms.calls.YouAreAlreadyInACallSnackbar;
|
||||
import org.thoughtcrime.securesms.components.DebugLogsPromptDialogFragment;
|
||||
import org.thoughtcrime.securesms.components.DeviceSpecificNotificationBottomSheet;
|
||||
import org.thoughtcrime.securesms.components.PromptBatterySaverDialogFragment;
|
||||
@@ -243,7 +244,9 @@ public class MainActivity extends PassphraseRequiredActivity implements VoiceNot
|
||||
private void handleCallLinkInIntent(Intent intent) {
|
||||
Uri data = intent.getData();
|
||||
if (data != null) {
|
||||
CommunicationActions.handlePotentialCallLinkUrl(this, data.toString());
|
||||
CommunicationActions.handlePotentialCallLinkUrl(this, data.toString(), () -> {
|
||||
YouAreAlreadyInACallSnackbar.show(findViewById(android.R.id.content));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ import org.signal.core.util.DimensionUnit;
|
||||
import org.signal.core.util.concurrent.LifecycleDisposable;
|
||||
import org.signal.core.util.concurrent.SimpleTask;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.calls.YouAreAlreadyInACallSnackbar;
|
||||
import org.thoughtcrime.securesms.components.menu.ActionItem;
|
||||
import org.thoughtcrime.securesms.components.menu.SignalContextMenu;
|
||||
import org.thoughtcrime.securesms.contacts.management.ContactsManagementRepository;
|
||||
@@ -305,7 +306,9 @@ public class NewConversationActivity extends ContactSelectionActivity
|
||||
R.drawable.ic_phone_right_24,
|
||||
getString(R.string.NewConversationActivity__audio_call),
|
||||
R.color.signal_colorOnSurface,
|
||||
() -> CommunicationActions.startVoiceCall(this, recipient)
|
||||
() -> CommunicationActions.startVoiceCall(this, recipient, () -> {
|
||||
YouAreAlreadyInACallSnackbar.show(findViewById(android.R.id.content));
|
||||
})
|
||||
);
|
||||
} else {
|
||||
return null;
|
||||
@@ -321,7 +324,9 @@ public class NewConversationActivity extends ContactSelectionActivity
|
||||
R.drawable.ic_video_call_24,
|
||||
getString(R.string.NewConversationActivity__video_call),
|
||||
R.color.signal_colorOnSurface,
|
||||
() -> CommunicationActions.startVideoCall(this, recipient)
|
||||
() -> CommunicationActions.startVideoCall(this, recipient, () -> {
|
||||
YouAreAlreadyInACallSnackbar.show(findViewById(android.R.id.content));
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.registration.ui.RegistrationActivity;
|
||||
import org.thoughtcrime.securesms.restore.RestoreActivity;
|
||||
import org.thoughtcrime.securesms.service.KeyCachingService;
|
||||
import org.thoughtcrime.securesms.util.AppForegroundObserver;
|
||||
import org.thoughtcrime.securesms.util.AppStartup;
|
||||
import org.thoughtcrime.securesms.util.RemoteConfig;
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
@@ -92,8 +93,8 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
|
||||
@Override
|
||||
public void onMasterSecretCleared() {
|
||||
Log.d(TAG, "onMasterSecretCleared()");
|
||||
if (AppDependencies.getAppForegroundObserver().isForegrounded()) routeApplicationState(true);
|
||||
else finish();
|
||||
if (AppForegroundObserver.isForegrounded()) routeApplicationState(true);
|
||||
else finish();
|
||||
}
|
||||
|
||||
protected <T extends Fragment> T initFragment(@IdRes int target,
|
||||
@@ -168,8 +169,6 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
|
||||
return STATE_WELCOME_PUSH_SCREEN;
|
||||
} 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()) {
|
||||
@@ -209,7 +208,7 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
|
||||
|
||||
private Intent getPromptPassphraseIntent() {
|
||||
Intent intent = getRoutedIntent(PassphrasePromptActivity.class, getIntent());
|
||||
intent.putExtra(PassphrasePromptActivity.FROM_FOREGROUND, AppDependencies.getAppForegroundObserver().isForegrounded());
|
||||
intent.putExtra(PassphrasePromptActivity.FROM_FOREGROUND, AppForegroundObserver.isForegrounded());
|
||||
return intent;
|
||||
}
|
||||
|
||||
|
||||
@@ -84,9 +84,10 @@ import org.thoughtcrime.securesms.components.webrtc.controls.ControlsAndInfoCont
|
||||
import org.thoughtcrime.securesms.components.webrtc.controls.ControlsAndInfoViewModel;
|
||||
import org.thoughtcrime.securesms.components.webrtc.participantslist.CallParticipantsListDialog;
|
||||
import org.thoughtcrime.securesms.components.webrtc.requests.CallLinkIncomingRequestSheet;
|
||||
import org.thoughtcrime.securesms.components.webrtc.v2.CallEvent;
|
||||
import org.thoughtcrime.securesms.components.webrtc.v2.CallPermissionsDialogController;
|
||||
import org.thoughtcrime.securesms.components.webrtc.v2.CallControlsChange;
|
||||
import org.thoughtcrime.securesms.components.webrtc.v2.CallEvent;
|
||||
import org.thoughtcrime.securesms.components.webrtc.v2.CallIntent;
|
||||
import org.thoughtcrime.securesms.components.webrtc.v2.CallPermissionsDialogController;
|
||||
import org.thoughtcrime.securesms.conversation.ui.error.SafetyNumberChangeDialog;
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies;
|
||||
import org.thoughtcrime.securesms.events.WebRtcViewModel;
|
||||
@@ -115,6 +116,7 @@ import org.whispersystems.signalservice.api.messages.calls.HangupMessage;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.stream.Collectors;
|
||||
@@ -132,23 +134,6 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
||||
private static final int STANDARD_DELAY_FINISH = 1000;
|
||||
private static final int VIBRATE_DURATION = 50;
|
||||
|
||||
/**
|
||||
* ANSWER the call via voice-only.
|
||||
*/
|
||||
public static final String ANSWER_ACTION = WebRtcCallActivity.class.getCanonicalName() + ".ANSWER_ACTION";
|
||||
|
||||
/**
|
||||
* ANSWER the call via video.
|
||||
*/
|
||||
public static final String ANSWER_VIDEO_ACTION = WebRtcCallActivity.class.getCanonicalName() + ".ANSWER_VIDEO_ACTION";
|
||||
public static final String DENY_ACTION = WebRtcCallActivity.class.getCanonicalName() + ".DENY_ACTION";
|
||||
public static final String END_CALL_ACTION = WebRtcCallActivity.class.getCanonicalName() + ".END_CALL_ACTION";
|
||||
|
||||
public static final String EXTRA_ENABLE_VIDEO_IF_AVAILABLE = WebRtcCallActivity.class.getCanonicalName() + ".ENABLE_VIDEO_IF_AVAILABLE";
|
||||
public static final String EXTRA_STARTED_FROM_FULLSCREEN = WebRtcCallActivity.class.getCanonicalName() + ".STARTED_FROM_FULLSCREEN";
|
||||
public static final String EXTRA_STARTED_FROM_CALL_LINK = WebRtcCallActivity.class.getCanonicalName() + ".STARTED_FROM_CALL_LINK";
|
||||
public static final String EXTRA_LAUNCH_IN_PIP = WebRtcCallActivity.class.getCanonicalName() + ".STARTED_FROM_CALL_LINK";
|
||||
|
||||
private CallParticipantsListUpdatePopupWindow participantUpdateWindow;
|
||||
private CallStateUpdatePopupWindow callStateUpdatePopupWindow;
|
||||
private CallOverflowPopupWindow callOverflowPopupWindow;
|
||||
@@ -184,7 +169,8 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
||||
@SuppressLint({ "MissingInflatedId" })
|
||||
@Override
|
||||
public void onCreate(Bundle savedInstanceState) {
|
||||
Log.i(TAG, "onCreate(" + getIntent().getBooleanExtra(EXTRA_STARTED_FROM_FULLSCREEN, false) + ")");
|
||||
CallIntent callIntent = getCallIntent();
|
||||
Log.i(TAG, "onCreate(" + callIntent.isStartedFromFullScreen() + ")");
|
||||
|
||||
lifecycleDisposable = new LifecycleDisposable();
|
||||
lifecycleDisposable.bindTo(this);
|
||||
@@ -214,18 +200,18 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
||||
|
||||
lifecycleDisposable.add(controlsAndInfo);
|
||||
|
||||
logIntent(getIntent());
|
||||
logIntent(callIntent);
|
||||
|
||||
if (ANSWER_VIDEO_ACTION.equals(getIntent().getAction())) {
|
||||
if (callIntent.getAction() == CallIntent.Action.ANSWER_VIDEO) {
|
||||
enableVideoIfAvailable = true;
|
||||
} else if (ANSWER_ACTION.equals(getIntent().getAction()) || getIntent().getBooleanExtra(EXTRA_STARTED_FROM_FULLSCREEN, false)) {
|
||||
} else if (callIntent.getAction() == CallIntent.Action.ANSWER_AUDIO || callIntent.isStartedFromFullScreen()) {
|
||||
enableVideoIfAvailable = false;
|
||||
} else {
|
||||
enableVideoIfAvailable = getIntent().getBooleanExtra(EXTRA_ENABLE_VIDEO_IF_AVAILABLE, false);
|
||||
getIntent().removeExtra(EXTRA_ENABLE_VIDEO_IF_AVAILABLE);
|
||||
enableVideoIfAvailable = callIntent.shouldEnableVideoIfAvailable();
|
||||
callIntent.setShouldEnableVideoIfAvailable(false);
|
||||
}
|
||||
|
||||
processIntent(getIntent());
|
||||
processIntent(callIntent);
|
||||
|
||||
registerSystemPipChangeListeners();
|
||||
|
||||
@@ -241,9 +227,12 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
||||
WindowUtil.setNavigationBarColor(this, ContextCompat.getColor(this, R.color.signal_dark_colorSurface));
|
||||
|
||||
if (!hasCameraPermission() & !hasAudioPermission()) {
|
||||
askCameraAudioPermissions(() -> handleSetMuteVideo(false));
|
||||
askCameraAudioPermissions(() -> {
|
||||
callScreen.setMicEnabled(viewModel.getMicrophoneEnabled().getValue());
|
||||
handleSetMuteVideo(false);
|
||||
});
|
||||
} else if (!hasAudioPermission()) {
|
||||
askAudioPermissions(() -> {});
|
||||
askAudioPermissions(() -> callScreen.setMicEnabled(viewModel.getMicrophoneEnabled().getValue()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -302,10 +291,11 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
||||
|
||||
@Override
|
||||
public void onNewIntent(Intent intent) {
|
||||
Log.i(TAG, "onNewIntent(" + intent.getBooleanExtra(EXTRA_STARTED_FROM_FULLSCREEN, false) + ")");
|
||||
CallIntent callIntent = getCallIntent();
|
||||
Log.i(TAG, "onNewIntent(" + callIntent.isStartedFromFullScreen() + ")");
|
||||
super.onNewIntent(intent);
|
||||
logIntent(intent);
|
||||
processIntent(intent);
|
||||
logIntent(callIntent);
|
||||
processIntent(callIntent);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -363,6 +353,7 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
||||
|
||||
@Override
|
||||
protected void onUserLeaveHint() {
|
||||
super.onUserLeaveHint();
|
||||
enterPipModeIfPossible();
|
||||
}
|
||||
|
||||
@@ -373,6 +364,10 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
||||
}
|
||||
}
|
||||
|
||||
private @NonNull CallIntent getCallIntent() {
|
||||
return new CallIntent(getIntent());
|
||||
}
|
||||
|
||||
private boolean enterPipModeIfPossible() {
|
||||
if (isSystemPipEnabledAndAvailable()) {
|
||||
if (viewModel.canEnterPipMode()) {
|
||||
@@ -396,26 +391,20 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
||||
return isSystemPipEnabledAndAvailable() && isInPictureInPictureMode();
|
||||
}
|
||||
|
||||
private void logIntent(@NonNull Intent intent) {
|
||||
Log.d(TAG, "Intent: Action: " + intent.getAction());
|
||||
Log.d(TAG, "Intent: EXTRA_STARTED_FROM_FULLSCREEN: " + intent.getBooleanExtra(EXTRA_STARTED_FROM_FULLSCREEN, false));
|
||||
Log.d(TAG, "Intent: EXTRA_ENABLE_VIDEO_IF_AVAILABLE: " + intent.getBooleanExtra(EXTRA_ENABLE_VIDEO_IF_AVAILABLE, false));
|
||||
Log.d(TAG, "Intent: EXTRA_LAUNCH_IN_PIP: " + intent.getBooleanExtra(EXTRA_LAUNCH_IN_PIP, false));
|
||||
private void logIntent(@NonNull CallIntent intent) {
|
||||
Log.d(TAG, intent.toString());
|
||||
}
|
||||
|
||||
private void processIntent(@NonNull Intent intent) {
|
||||
if (ANSWER_ACTION.equals(intent.getAction())) {
|
||||
handleAnswerWithAudio();
|
||||
} else if (ANSWER_VIDEO_ACTION.equals(intent.getAction())) {
|
||||
handleAnswerWithVideo();
|
||||
} else if (DENY_ACTION.equals(intent.getAction())) {
|
||||
handleDenyCall();
|
||||
} else if (END_CALL_ACTION.equals(intent.getAction())) {
|
||||
handleEndCall();
|
||||
private void processIntent(@NonNull CallIntent intent) {
|
||||
switch (intent.getAction()) {
|
||||
case ANSWER_AUDIO -> handleAnswerWithAudio();
|
||||
case ANSWER_VIDEO -> handleAnswerWithVideo();
|
||||
case DENY -> handleDenyCall();
|
||||
case END_CALL -> handleEndCall();
|
||||
}
|
||||
|
||||
if (System.currentTimeMillis() - lastProcessedIntentTimestamp > TimeUnit.SECONDS.toMillis(1)) {
|
||||
enterPipOnResume = intent.getBooleanExtra(EXTRA_LAUNCH_IN_PIP, false);
|
||||
enterPipOnResume = intent.shouldLaunchInPip();
|
||||
}
|
||||
|
||||
lastProcessedIntentTimestamp = System.currentTimeMillis();
|
||||
@@ -529,7 +518,7 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
||||
|
||||
lifecycleDisposable.add(viewModel.getInCallstatus().subscribe(this::handleInCallStatus));
|
||||
|
||||
boolean isStartedFromCallLink = getIntent().getBooleanExtra(WebRtcCallActivity.EXTRA_STARTED_FROM_CALL_LINK, false);
|
||||
boolean isStartedFromCallLink = getCallIntent().isStartedFromCallLink();
|
||||
LiveDataUtil.combineLatest(LiveDataReactiveStreams.fromPublisher(viewModel.getCallParticipantsState().toFlowable(BackpressureStrategy.LATEST)),
|
||||
orientationAndLandscapeEnabled,
|
||||
viewModel.getEphemeralState(),
|
||||
|
||||
@@ -17,6 +17,7 @@ import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.jobs.ApkUpdateJob
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.util.AppForegroundObserver
|
||||
import org.thoughtcrime.securesms.util.Environment
|
||||
import org.thoughtcrime.securesms.util.FileUtils
|
||||
import java.io.FileInputStream
|
||||
@@ -61,7 +62,7 @@ object ApkUpdateInstaller {
|
||||
}
|
||||
|
||||
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=${AppForegroundObserver.isForegrounded()}, AutoUpdate=${SignalStore.apkUpdate.autoUpdate})")
|
||||
ApkUpdateNotifications.showInstallPrompt(context, downloadId)
|
||||
return
|
||||
}
|
||||
@@ -145,6 +146,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 && !AppForegroundObserver.isForegrounded()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,7 +83,7 @@ object ApkUpdateNotifications {
|
||||
ServiceUtil.getNotificationManager(context).notify(NotificationIds.APK_UPDATE_FAILED_INSTALL, notification)
|
||||
}
|
||||
|
||||
fun showAutoUpdateSuccess(context: Context) {
|
||||
fun showUpdateSuccess(context: Context, userInitiated: Boolean) {
|
||||
val pendingIntent = PendingIntent.getActivity(
|
||||
context,
|
||||
0,
|
||||
@@ -93,9 +93,15 @@ object ApkUpdateNotifications {
|
||||
|
||||
val appVersionName = context.packageManager.getPackageInfo(context.packageName, 0).versionName
|
||||
|
||||
val body = if (userInitiated) {
|
||||
context.getString(R.string.ApkUpdateNotifications_manual_update_success_body, appVersionName)
|
||||
} else {
|
||||
context.getString(R.string.ApkUpdateNotifications_auto_update_success_body, appVersionName)
|
||||
}
|
||||
|
||||
val notification = NotificationCompat.Builder(context, NotificationChannels.getInstance().APP_UPDATES)
|
||||
.setContentTitle(context.getString(R.string.ApkUpdateNotifications_auto_update_success_title))
|
||||
.setContentText(context.getString(R.string.ApkUpdateNotifications_auto_update_success_body, appVersionName))
|
||||
.setContentText(body)
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setColor(ContextCompat.getColor(context, R.color.core_ultramarine))
|
||||
.setContentIntent(pendingIntent)
|
||||
|
||||
@@ -39,7 +39,7 @@ class ApkUpdatePackageInstallerReceiver : BroadcastReceiver() {
|
||||
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)
|
||||
ApkUpdateNotifications.showUpdateSuccess(context, userInitiated)
|
||||
} else {
|
||||
Log.i(TAG, "Spurious 'success' notification?")
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ class ArchivedAttachment : Attachment {
|
||||
size: Long,
|
||||
cdn: Int,
|
||||
key: ByteArray,
|
||||
iv: ByteArray?,
|
||||
cdnKey: String?,
|
||||
archiveCdn: Int?,
|
||||
archiveMediaName: String,
|
||||
@@ -60,6 +61,7 @@ class ArchivedAttachment : Attachment {
|
||||
cdn = Cdn.fromCdnNumber(cdn),
|
||||
remoteLocation = cdnKey,
|
||||
remoteKey = Base64.encodeWithoutPadding(key),
|
||||
remoteIv = iv,
|
||||
remoteDigest = digest,
|
||||
incrementalDigest = incrementalMac,
|
||||
fastPreflightId = null,
|
||||
|
||||
@@ -37,6 +37,8 @@ abstract class Attachment(
|
||||
@JvmField
|
||||
val remoteKey: String?,
|
||||
@JvmField
|
||||
val remoteIv: ByteArray?,
|
||||
@JvmField
|
||||
val remoteDigest: ByteArray?,
|
||||
@JvmField
|
||||
val incrementalDigest: ByteArray?,
|
||||
@@ -86,6 +88,7 @@ abstract class Attachment(
|
||||
cdn = Cdn.deserialize(parcel.readInt()),
|
||||
remoteLocation = parcel.readString(),
|
||||
remoteKey = parcel.readString(),
|
||||
remoteIv = ParcelUtil.readByteArray(parcel),
|
||||
remoteDigest = ParcelUtil.readByteArray(parcel),
|
||||
incrementalDigest = ParcelUtil.readByteArray(parcel),
|
||||
fastPreflightId = parcel.readString(),
|
||||
|
||||
@@ -29,9 +29,6 @@ class DatabaseAttachment : Attachment {
|
||||
@JvmField
|
||||
val archiveCdn: Int
|
||||
|
||||
@JvmField
|
||||
val archiveThumbnailCdn: Int
|
||||
|
||||
@JvmField
|
||||
val archiveMediaName: String?
|
||||
|
||||
@@ -41,6 +38,9 @@ class DatabaseAttachment : Attachment {
|
||||
@JvmField
|
||||
val thumbnailRestoreState: AttachmentTable.ThumbnailRestoreState
|
||||
|
||||
@JvmField
|
||||
val archiveTransferState: AttachmentTable.ArchiveTransferState
|
||||
|
||||
private val hasArchiveThumbnail: Boolean
|
||||
private val hasThumbnail: Boolean
|
||||
val displayOrder: Int
|
||||
@@ -58,6 +58,7 @@ class DatabaseAttachment : Attachment {
|
||||
cdn: Cdn,
|
||||
location: String?,
|
||||
key: String?,
|
||||
iv: ByteArray?,
|
||||
digest: ByteArray?,
|
||||
incrementalDigest: ByteArray?,
|
||||
incrementalMacChunkSize: Int,
|
||||
@@ -77,10 +78,10 @@ class DatabaseAttachment : Attachment {
|
||||
uploadTimestamp: Long,
|
||||
dataHash: String?,
|
||||
archiveCdn: Int,
|
||||
archiveThumbnailCdn: Int,
|
||||
archiveMediaName: String?,
|
||||
archiveMediaId: String?,
|
||||
thumbnailRestoreState: AttachmentTable.ThumbnailRestoreState,
|
||||
archiveTransferState: AttachmentTable.ArchiveTransferState,
|
||||
uuid: UUID?
|
||||
) : super(
|
||||
contentType = contentType,
|
||||
@@ -90,6 +91,7 @@ class DatabaseAttachment : Attachment {
|
||||
cdn = cdn,
|
||||
remoteLocation = location,
|
||||
remoteKey = key,
|
||||
remoteIv = iv,
|
||||
remoteDigest = digest,
|
||||
incrementalDigest = incrementalDigest,
|
||||
fastPreflightId = fastPreflightId,
|
||||
@@ -115,10 +117,10 @@ class DatabaseAttachment : Attachment {
|
||||
this.hasArchiveThumbnail = hasArchiveThumbnail
|
||||
this.displayOrder = displayOrder
|
||||
this.archiveCdn = archiveCdn
|
||||
this.archiveThumbnailCdn = archiveThumbnailCdn
|
||||
this.archiveMediaName = archiveMediaName
|
||||
this.archiveMediaId = archiveMediaId
|
||||
this.thumbnailRestoreState = thumbnailRestoreState
|
||||
this.archiveTransferState = archiveTransferState
|
||||
}
|
||||
|
||||
constructor(parcel: Parcel) : super(parcel) {
|
||||
@@ -129,11 +131,11 @@ class DatabaseAttachment : Attachment {
|
||||
mmsId = parcel.readLong()
|
||||
displayOrder = parcel.readInt()
|
||||
archiveCdn = parcel.readInt()
|
||||
archiveThumbnailCdn = parcel.readInt()
|
||||
archiveMediaName = parcel.readString()
|
||||
archiveMediaId = parcel.readString()
|
||||
hasArchiveThumbnail = ParcelUtil.readBoolean(parcel)
|
||||
thumbnailRestoreState = AttachmentTable.ThumbnailRestoreState.deserialize(parcel.readInt())
|
||||
archiveTransferState = AttachmentTable.ArchiveTransferState.deserialize(parcel.readInt())
|
||||
}
|
||||
|
||||
override fun writeToParcel(dest: Parcel, flags: Int) {
|
||||
@@ -145,11 +147,11 @@ class DatabaseAttachment : Attachment {
|
||||
dest.writeLong(mmsId)
|
||||
dest.writeInt(displayOrder)
|
||||
dest.writeInt(archiveCdn)
|
||||
dest.writeInt(archiveThumbnailCdn)
|
||||
dest.writeString(archiveMediaName)
|
||||
dest.writeString(archiveMediaId)
|
||||
ParcelUtil.writeBoolean(dest, hasArchiveThumbnail)
|
||||
dest.writeInt(thumbnailRestoreState.value)
|
||||
dest.writeInt(archiveTransferState.value)
|
||||
}
|
||||
|
||||
override val uri: Uri?
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.attachments
|
||||
|
||||
/**
|
||||
* Thrown by jobs unable to rehydrate enough attachment information to download it.
|
||||
*/
|
||||
class InvalidAttachmentException : Exception {
|
||||
constructor(s: String?) : super(s)
|
||||
constructor(e: Exception?) : super(e)
|
||||
}
|
||||
@@ -3,7 +3,7 @@ package org.thoughtcrime.securesms.attachments
|
||||
import android.net.Uri
|
||||
import android.os.Parcel
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import org.signal.core.util.Base64.encodeWithPadding
|
||||
import org.signal.core.util.Base64
|
||||
import org.thoughtcrime.securesms.blurhash.BlurHash
|
||||
import org.thoughtcrime.securesms.database.AttachmentTable
|
||||
import org.thoughtcrime.securesms.stickers.StickerLocator
|
||||
@@ -24,6 +24,7 @@ class PointerAttachment : Attachment {
|
||||
cdn: Cdn,
|
||||
location: String,
|
||||
key: String?,
|
||||
iv: ByteArray?,
|
||||
digest: ByteArray?,
|
||||
incrementalDigest: ByteArray?,
|
||||
incrementalMacChunkSize: Int,
|
||||
@@ -46,6 +47,7 @@ class PointerAttachment : Attachment {
|
||||
cdn = cdn,
|
||||
remoteLocation = location,
|
||||
remoteKey = key,
|
||||
remoteIv = iv,
|
||||
remoteDigest = digest,
|
||||
incrementalDigest = incrementalDigest,
|
||||
fastPreflightId = fastPreflightId,
|
||||
@@ -86,12 +88,17 @@ class PointerAttachment : Attachment {
|
||||
|
||||
@JvmStatic
|
||||
@JvmOverloads
|
||||
fun forPointer(pointer: Optional<SignalServiceAttachment>, stickerLocator: StickerLocator? = null, fastPreflightId: String? = null, transferState: Int = AttachmentTable.TRANSFER_PROGRESS_PENDING): Optional<Attachment> {
|
||||
fun forPointer(
|
||||
pointer: Optional<SignalServiceAttachment>,
|
||||
stickerLocator: StickerLocator? = null,
|
||||
fastPreflightId: String? = null,
|
||||
transferState: Int = AttachmentTable.TRANSFER_PROGRESS_PENDING
|
||||
): Optional<Attachment> {
|
||||
if (!pointer.isPresent || !pointer.get().isPointer()) {
|
||||
return Optional.empty()
|
||||
}
|
||||
|
||||
val encodedKey: String? = pointer.get().asPointer().key?.let { encodeWithPadding(it) }
|
||||
val encodedKey: String? = pointer.get().asPointer().key?.let { Base64.encodeWithPadding(it) }
|
||||
|
||||
return Optional.of(
|
||||
PointerAttachment(
|
||||
@@ -102,6 +109,7 @@ class PointerAttachment : Attachment {
|
||||
cdn = Cdn.fromCdnNumber(pointer.get().asPointer().cdnNumber),
|
||||
location = pointer.get().asPointer().remoteId.toString(),
|
||||
key = encodedKey,
|
||||
iv = null,
|
||||
digest = pointer.get().asPointer().digest.orElse(null),
|
||||
incrementalDigest = pointer.get().asPointer().incrementalDigest.orElse(null),
|
||||
incrementalMacChunkSize = pointer.get().asPointer().incrementalMacChunkSize,
|
||||
@@ -139,7 +147,8 @@ class PointerAttachment : Attachment {
|
||||
fileName = quotedAttachment.fileName,
|
||||
cdn = Cdn.fromCdnNumber(thumbnail?.asPointer()?.cdnNumber ?: 0),
|
||||
location = thumbnail?.asPointer()?.remoteId?.toString() ?: "0",
|
||||
key = thumbnail?.asPointer()?.key?.let { encodeWithPadding(it) },
|
||||
key = thumbnail?.asPointer()?.key?.let { Base64.encodeWithPadding(it) },
|
||||
iv = null,
|
||||
digest = thumbnail?.asPointer()?.digest?.orElse(null),
|
||||
incrementalDigest = thumbnail?.asPointer()?.incrementalDigest?.orElse(null),
|
||||
incrementalMacChunkSize = thumbnail?.asPointer()?.incrementalMacChunkSize ?: 0,
|
||||
|
||||
@@ -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 org.thoughtcrime.securesms.stickers.StickerLocator
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
@@ -22,6 +23,7 @@ class TombstoneAttachment : Attachment {
|
||||
cdn = Cdn.CDN_0,
|
||||
remoteLocation = null,
|
||||
remoteKey = null,
|
||||
remoteIv = null,
|
||||
remoteDigest = null,
|
||||
incrementalDigest = null,
|
||||
fastPreflightId = null,
|
||||
@@ -47,10 +49,12 @@ class TombstoneAttachment : Attachment {
|
||||
width: Int?,
|
||||
height: Int?,
|
||||
caption: String?,
|
||||
fileName: String? = null,
|
||||
blurHash: String?,
|
||||
voiceNote: Boolean = false,
|
||||
borderless: Boolean = false,
|
||||
gif: Boolean = false,
|
||||
stickerLocator: StickerLocator? = null,
|
||||
quote: Boolean,
|
||||
uuid: UUID?
|
||||
) : super(
|
||||
@@ -58,10 +62,11 @@ class TombstoneAttachment : Attachment {
|
||||
quote = quote,
|
||||
transferState = AttachmentTable.TRANSFER_PROGRESS_PERMANENT_FAILURE,
|
||||
size = 0,
|
||||
fileName = null,
|
||||
fileName = fileName,
|
||||
cdn = Cdn.CDN_0,
|
||||
remoteLocation = null,
|
||||
remoteKey = null,
|
||||
remoteIv = null,
|
||||
remoteDigest = null,
|
||||
incrementalDigest = incrementalMac,
|
||||
fastPreflightId = null,
|
||||
@@ -73,7 +78,7 @@ class TombstoneAttachment : Attachment {
|
||||
incrementalMacChunkSize = incrementalMacChunkSize ?: 0,
|
||||
uploadTimestamp = 0,
|
||||
caption = caption,
|
||||
stickerLocator = null,
|
||||
stickerLocator = stickerLocator,
|
||||
blurHash = BlurHash.parseOrNull(blurHash),
|
||||
audioHash = null,
|
||||
transformProperties = null,
|
||||
|
||||
@@ -75,6 +75,7 @@ class UriAttachment : Attachment {
|
||||
cdn = Cdn.CDN_0,
|
||||
remoteLocation = null,
|
||||
remoteKey = null,
|
||||
remoteIv = null,
|
||||
remoteDigest = null,
|
||||
incrementalDigest = null,
|
||||
fastPreflightId = fastPreflightId,
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.attachments
|
||||
|
||||
import org.thoughtcrime.securesms.database.AttachmentTable
|
||||
import org.thoughtcrime.securesms.database.AttachmentTable.TransformProperties
|
||||
import org.thoughtcrime.securesms.util.MediaUtil
|
||||
|
||||
/**
|
||||
* A basically-empty [Attachment] that is solely used for inserting an attachment into the [AttachmentTable].
|
||||
*/
|
||||
class WallpaperAttachment() : Attachment(
|
||||
contentType = MediaUtil.IMAGE_WEBP,
|
||||
transferState = AttachmentTable.TRANSFER_PROGRESS_DONE,
|
||||
size = 0,
|
||||
fileName = null,
|
||||
cdn = Cdn.CDN_0,
|
||||
remoteLocation = null,
|
||||
remoteKey = null,
|
||||
remoteIv = null,
|
||||
remoteDigest = null,
|
||||
incrementalDigest = null,
|
||||
fastPreflightId = null,
|
||||
voiceNote = false,
|
||||
borderless = false,
|
||||
videoGif = false,
|
||||
width = 0,
|
||||
height = 0,
|
||||
incrementalMacChunkSize = 0,
|
||||
quote = false,
|
||||
uploadTimestamp = 0,
|
||||
caption = null,
|
||||
stickerLocator = null,
|
||||
blurHash = null,
|
||||
audioHash = null,
|
||||
transformProperties = TransformProperties.empty(),
|
||||
uuid = null
|
||||
) {
|
||||
override val uri = null
|
||||
override val publicUri = null
|
||||
override val thumbnailUri = null
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.backup
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.shareIn
|
||||
import org.signal.core.util.throttleLatest
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.keyvalue.protos.ArchiveUploadProgressState
|
||||
import kotlin.math.max
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
/**
|
||||
* Tracks the progress of uploading your message archive and provides an observable stream of results.
|
||||
*/
|
||||
object ArchiveUploadProgress {
|
||||
|
||||
private val PROGRESS_NONE = ArchiveUploadProgressState(
|
||||
state = ArchiveUploadProgressState.State.None
|
||||
)
|
||||
|
||||
private val _progress: MutableSharedFlow<Unit> = MutableSharedFlow(replay = 1)
|
||||
|
||||
private var uploadProgress: ArchiveUploadProgressState = SignalStore.backup.archiveUploadState ?: PROGRESS_NONE
|
||||
|
||||
/**
|
||||
* Observe this to get updates on the current upload progress.
|
||||
*/
|
||||
val progress: Flow<ArchiveUploadProgressState> = _progress
|
||||
.throttleLatest(500.milliseconds)
|
||||
.map {
|
||||
if (uploadProgress.state != ArchiveUploadProgressState.State.UploadingAttachments) {
|
||||
return@map uploadProgress
|
||||
}
|
||||
|
||||
val pendingCount = SignalDatabase.attachments.getPendingArchiveUploadCount()
|
||||
if (pendingCount == uploadProgress.totalAttachments) {
|
||||
return@map PROGRESS_NONE
|
||||
}
|
||||
|
||||
// It's possible that new attachments may be pending upload after we start a backup.
|
||||
// If we wanted the most accurate progress possible, we could maintain a new database flag that indicates whether an attachment has been flagged as part
|
||||
// of the current upload batch. However, this gets us pretty close while keeping things simple and not having to juggle extra flags, with the caveat that
|
||||
// the progress bar may occasionally be including media that is not actually referenced in the active backup file.
|
||||
val totalCount = max(uploadProgress.totalAttachments, pendingCount)
|
||||
|
||||
ArchiveUploadProgressState(
|
||||
state = ArchiveUploadProgressState.State.UploadingAttachments,
|
||||
completedAttachments = totalCount - pendingCount,
|
||||
totalAttachments = totalCount
|
||||
)
|
||||
}
|
||||
.onEach {
|
||||
updateState(it, notify = false)
|
||||
}
|
||||
.flowOn(Dispatchers.IO)
|
||||
.shareIn(scope = CoroutineScope(Dispatchers.IO), started = SharingStarted.WhileSubscribed(), replay = 1)
|
||||
|
||||
val inProgress
|
||||
get() = uploadProgress.state != ArchiveUploadProgressState.State.None
|
||||
|
||||
fun begin() {
|
||||
updateState(
|
||||
ArchiveUploadProgressState(
|
||||
state = ArchiveUploadProgressState.State.BackingUpMessages
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun onMessageBackupCreated() {
|
||||
updateState(
|
||||
ArchiveUploadProgressState(
|
||||
state = ArchiveUploadProgressState.State.UploadingMessages
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun onAttachmentsStarted(attachmentCount: Long) {
|
||||
updateState(
|
||||
ArchiveUploadProgressState(
|
||||
state = ArchiveUploadProgressState.State.UploadingAttachments,
|
||||
completedAttachments = 0,
|
||||
totalAttachments = attachmentCount
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun onAttachmentFinished() {
|
||||
_progress.tryEmit(Unit)
|
||||
}
|
||||
|
||||
fun onMessageBackupFinishedEarly() {
|
||||
updateState(PROGRESS_NONE)
|
||||
}
|
||||
|
||||
private fun updateState(state: ArchiveUploadProgressState, notify: Boolean = true) {
|
||||
uploadProgress = state
|
||||
SignalStore.backup.archiveUploadState = state
|
||||
|
||||
if (notify) {
|
||||
_progress.tryEmit(Unit)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,13 +2,13 @@ package org.thoughtcrime.securesms.backup
|
||||
|
||||
import org.greenrobot.eventbus.EventBus
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.core.util.stream.NullOutputStream
|
||||
import org.thoughtcrime.securesms.backup.proto.Attachment
|
||||
import org.thoughtcrime.securesms.backup.proto.Avatar
|
||||
import org.thoughtcrime.securesms.backup.proto.BackupFrame
|
||||
import org.thoughtcrime.securesms.backup.proto.Sticker
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
|
||||
/**
|
||||
* Given a backup file, run over it and verify it will decrypt properly when attempting to import it.
|
||||
@@ -89,10 +89,4 @@ object BackupVerifier {
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private object NullOutputStream : OutputStream() {
|
||||
override fun write(b: Int) = Unit
|
||||
override fun write(b: ByteArray?) = Unit
|
||||
override fun write(b: ByteArray?, off: Int, len: Int) = Unit
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@ import org.signal.core.util.concurrent.LimitedWorker
|
||||
import org.signal.core.util.concurrent.SignalExecutors
|
||||
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.stream.NonClosingOutputStream
|
||||
import org.signal.core.util.withinTransaction
|
||||
import org.signal.libsignal.messagebackup.MessageBackup
|
||||
@@ -56,6 +55,7 @@ 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.net.SignalNetwork
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.util.toMillis
|
||||
import org.whispersystems.signalservice.api.NetworkResult
|
||||
@@ -73,15 +73,14 @@ 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.AttachmentUploadForm
|
||||
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.time.ZonedDateTime
|
||||
import java.util.Currency
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
@@ -98,13 +97,15 @@ object BackupRepository {
|
||||
private val resetInitializedStateErrorAction: StatusCodeErrorAction = { error ->
|
||||
when (error.code) {
|
||||
401 -> {
|
||||
Log.i(TAG, "Resetting initialized state due to 401.")
|
||||
Log.w(TAG, "Received status 401. Resetting initialized state + auth credentials.", error.exception)
|
||||
SignalStore.backup.backupsInitialized = false
|
||||
SignalStore.backup.clearAllCredentials()
|
||||
}
|
||||
|
||||
403 -> {
|
||||
Log.i(TAG, "Bad auth credential. Clearing stored credentials.")
|
||||
SignalStore.backup.clearAllCredentials()
|
||||
Log.w(TAG, "Received status 403. The user is not in the media tier. Updating local state.", error.exception)
|
||||
SignalStore.backup.backupTier = MessageBackupTier.FREE
|
||||
// TODO [backup] If the user thought they were in media tier but aren't, feels like we should have a special UX flow for this?
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -164,6 +165,8 @@ object BackupRepository {
|
||||
private fun createSignalStoreSnapshot(baseName: String): SignalStore {
|
||||
val context = AppDependencies.application
|
||||
|
||||
SignalStore.blockUntilAllWritesFinished()
|
||||
|
||||
// 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.")
|
||||
@@ -330,12 +333,17 @@ object BackupRepository {
|
||||
fun localImport(mainStreamFactory: () -> InputStream, mainStreamLength: Long, selfData: SelfData): ImportResult {
|
||||
val backupKey = SignalStore.svr.getOrCreateMasterKey().deriveBackupKey()
|
||||
|
||||
val frameReader = EncryptedBackupReader(
|
||||
key = backupKey,
|
||||
aci = selfData.aci,
|
||||
length = mainStreamLength,
|
||||
dataStream = mainStreamFactory
|
||||
)
|
||||
val frameReader = try {
|
||||
EncryptedBackupReader(
|
||||
key = backupKey,
|
||||
aci = selfData.aci,
|
||||
length = mainStreamLength,
|
||||
dataStream = mainStreamFactory
|
||||
)
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "Unable to import local archive", e)
|
||||
return ImportResult.Failure
|
||||
}
|
||||
|
||||
return frameReader.use { reader ->
|
||||
import(backupKey, reader, selfData)
|
||||
@@ -364,8 +372,7 @@ object BackupRepository {
|
||||
private fun import(
|
||||
backupKey: BackupKey,
|
||||
frameReader: BackupImportReader,
|
||||
selfData: SelfData,
|
||||
importExtras: ((EventTimer) -> Unit)? = null
|
||||
selfData: SelfData
|
||||
): ImportResult {
|
||||
val eventTimer = EventTimer()
|
||||
|
||||
@@ -443,23 +450,27 @@ object BackupRepository {
|
||||
eventTimer.emit("chatItem")
|
||||
}
|
||||
|
||||
importExtras?.invoke(eventTimer)
|
||||
|
||||
importState.chatIdToLocalThreadId.values.forEach {
|
||||
SignalDatabase.threads.update(it, unarchive = false, allowDeletion = false)
|
||||
}
|
||||
}
|
||||
|
||||
SignalDatabase.groups.getGroups().use { groups ->
|
||||
while (groups.hasNext()) {
|
||||
val group = groups.next()
|
||||
if (group.id.isV2) {
|
||||
AppDependencies.jobManager.add(RequestGroupV2InfoJob(group.id as GroupId.V2))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Log.d(TAG, "import() ${eventTimer.stop().summary}")
|
||||
|
||||
val groupJobs = SignalDatabase.groups.getGroups().use { groups ->
|
||||
groups
|
||||
.asSequence()
|
||||
.mapNotNull { group ->
|
||||
if (group.id.isV2) {
|
||||
RequestGroupV2InfoJob(group.id as GroupId.V2)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
.toList()
|
||||
}
|
||||
AppDependencies.jobManager.addAll(groupJobs)
|
||||
|
||||
return ImportResult.Success(backupTime = header.backupTimeMs)
|
||||
}
|
||||
|
||||
@@ -471,33 +482,30 @@ object BackupRepository {
|
||||
}
|
||||
|
||||
fun listRemoteMediaObjects(limit: Int, cursor: String? = null): NetworkResult<ArchiveGetMediaItemsResponse> {
|
||||
val api = AppDependencies.signalServiceAccountManager.archiveApi
|
||||
val backupKey = SignalStore.svr.getOrCreateMasterKey().deriveBackupKey()
|
||||
|
||||
return initBackupAndFetchAuth(backupKey)
|
||||
.then { credential ->
|
||||
api.getArchiveMediaItemsPage(backupKey, SignalStore.account.requireAci(), credential, limit, cursor)
|
||||
SignalNetwork.archive.getArchiveMediaItemsPage(backupKey, SignalStore.account.requireAci(), credential, limit, cursor)
|
||||
}
|
||||
}
|
||||
|
||||
fun getRemoteBackupUsedSpace(): NetworkResult<Long?> {
|
||||
val api = AppDependencies.signalServiceAccountManager.archiveApi
|
||||
val backupKey = SignalStore.svr.getOrCreateMasterKey().deriveBackupKey()
|
||||
|
||||
return initBackupAndFetchAuth(backupKey)
|
||||
.then { credential ->
|
||||
api.getBackupInfo(backupKey, SignalStore.account.requireAci(), credential)
|
||||
SignalNetwork.archive.getBackupInfo(backupKey, SignalStore.account.requireAci(), credential)
|
||||
.map { it.usedSpace }
|
||||
}
|
||||
}
|
||||
|
||||
private fun getBackupTier(): NetworkResult<MessageBackupTier> {
|
||||
val api = AppDependencies.signalServiceAccountManager.archiveApi
|
||||
private fun getBackupTier(aci: ACI): NetworkResult<MessageBackupTier> {
|
||||
val backupKey = SignalStore.svr.getOrCreateMasterKey().deriveBackupKey()
|
||||
|
||||
return initBackupAndFetchAuth(backupKey)
|
||||
.map { credential ->
|
||||
val zkCredential = api.getZkCredential(backupKey, credential)
|
||||
val zkCredential = SignalNetwork.archive.getZkCredential(backupKey, aci, credential)
|
||||
if (zkCredential.backupLevel == BackupLevel.MEDIA) {
|
||||
MessageBackupTier.PAID
|
||||
} else {
|
||||
@@ -510,17 +518,16 @@ object BackupRepository {
|
||||
* 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()
|
||||
|
||||
return initBackupAndFetchAuth(backupKey)
|
||||
.then { credential ->
|
||||
api.getBackupInfo(backupKey, SignalStore.account.requireAci(), credential)
|
||||
SignalNetwork.archive.getBackupInfo(backupKey, SignalStore.account.requireAci(), credential)
|
||||
.map { it to credential }
|
||||
}
|
||||
.then { pair ->
|
||||
val (info, credential) = pair
|
||||
api.debugGetUploadedMediaItemMetadata(backupKey, SignalStore.account.requireAci(), credential)
|
||||
SignalNetwork.archive.debugGetUploadedMediaItemMetadata(backupKey, SignalStore.account.requireAci(), credential)
|
||||
.also { Log.i(TAG, "MediaItemMetadataResult: $it") }
|
||||
.map { mediaObjects ->
|
||||
BackupMetadata(
|
||||
@@ -536,35 +543,32 @@ object BackupRepository {
|
||||
*
|
||||
* @return True if successful, otherwise false.
|
||||
*/
|
||||
fun uploadBackupFile(backupStream: InputStream, backupStreamLength: Long): Boolean {
|
||||
val api = AppDependencies.signalServiceAccountManager.archiveApi
|
||||
fun uploadBackupFile(backupStream: InputStream, backupStreamLength: Long): NetworkResult<Unit> {
|
||||
val backupKey = SignalStore.svr.getOrCreateMasterKey().deriveBackupKey()
|
||||
|
||||
return initBackupAndFetchAuth(backupKey)
|
||||
.then { credential ->
|
||||
api.getMessageBackupUploadForm(backupKey, SignalStore.account.requireAci(), credential)
|
||||
SignalNetwork.archive.getMessageBackupUploadForm(backupKey, SignalStore.account.requireAci(), credential)
|
||||
.also { Log.i(TAG, "UploadFormResult: $it") }
|
||||
}
|
||||
.then { form ->
|
||||
api.getBackupResumableUploadUrl(form)
|
||||
SignalNetwork.archive.getBackupResumableUploadUrl(form)
|
||||
.also { Log.i(TAG, "ResumableUploadUrlResult: $it") }
|
||||
.map { form to it }
|
||||
}
|
||||
.then { formAndUploadUrl ->
|
||||
val (form, resumableUploadUrl) = formAndUploadUrl
|
||||
api.uploadBackupFile(form, resumableUploadUrl, backupStream, backupStreamLength)
|
||||
SignalNetwork.archive.uploadBackupFile(form, resumableUploadUrl, backupStream, backupStreamLength)
|
||||
.also { Log.i(TAG, "UploadBackupFileResult: $it") }
|
||||
}
|
||||
.also { Log.i(TAG, "OverallResult: $it") } is NetworkResult.Success
|
||||
}
|
||||
|
||||
fun downloadBackupFile(destination: File, listener: ProgressListener? = null): Boolean {
|
||||
val api = AppDependencies.signalServiceAccountManager.archiveApi
|
||||
val backupKey = SignalStore.svr.getOrCreateMasterKey().deriveBackupKey()
|
||||
|
||||
return initBackupAndFetchAuth(backupKey)
|
||||
.then { credential ->
|
||||
api.getBackupInfo(backupKey, SignalStore.account.requireAci(), credential)
|
||||
SignalNetwork.archive.getBackupInfo(backupKey, SignalStore.account.requireAci(), credential)
|
||||
}
|
||||
.then { info -> getCdnReadCredentials(info.cdn ?: Cdn.CDN_3.cdnNumber).map { it.headers to info } }
|
||||
.map { pair ->
|
||||
@@ -575,12 +579,11 @@ object BackupRepository {
|
||||
}
|
||||
|
||||
fun getBackupFileLastModified(): NetworkResult<ZonedDateTime?> {
|
||||
val api = AppDependencies.signalServiceAccountManager.archiveApi
|
||||
val backupKey = SignalStore.svr.getOrCreateMasterKey().deriveBackupKey()
|
||||
|
||||
return initBackupAndFetchAuth(backupKey)
|
||||
.then { credential ->
|
||||
api.getBackupInfo(backupKey, SignalStore.account.requireAci(), credential)
|
||||
SignalNetwork.archive.getBackupInfo(backupKey, SignalStore.account.requireAci(), credential)
|
||||
}
|
||||
.then { info -> getCdnReadCredentials(info.cdn ?: Cdn.CDN_3.cdnNumber).map { it.headers to info } }
|
||||
.then { pair ->
|
||||
@@ -596,39 +599,39 @@ object BackupRepository {
|
||||
* 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()
|
||||
|
||||
return initBackupAndFetchAuth(backupKey)
|
||||
.then { credential ->
|
||||
api.debugGetUploadedMediaItemMetadata(backupKey, SignalStore.account.requireAci(), credential)
|
||||
SignalNetwork.archive.debugGetUploadedMediaItemMetadata(backupKey, SignalStore.account.requireAci(), credential)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves an upload spec that can be used to upload attachment media.
|
||||
* Retrieves an [AttachmentUploadForm] that can be used to upload an attachment to the transit cdn.
|
||||
* To continue the upload, use [org.whispersystems.signalservice.api.attachment.AttachmentApi.getResumableUploadSpec].
|
||||
*
|
||||
* It's important to note that in order to get this to the archive cdn, you still need to use [copyAttachmentToArchive].
|
||||
*/
|
||||
fun getMediaUploadSpec(secretKey: ByteArray? = null): NetworkResult<ResumableUploadSpec> {
|
||||
val api = AppDependencies.signalServiceAccountManager.archiveApi
|
||||
fun getAttachmentUploadForm(): NetworkResult<AttachmentUploadForm> {
|
||||
val backupKey = SignalStore.svr.getOrCreateMasterKey().deriveBackupKey()
|
||||
|
||||
return initBackupAndFetchAuth(backupKey)
|
||||
.then { credential ->
|
||||
api.getMediaUploadForm(backupKey, SignalStore.account.requireAci(), credential)
|
||||
}
|
||||
.then { form ->
|
||||
api.getResumableUploadSpec(form, secretKey)
|
||||
SignalNetwork.archive.getMediaUploadForm(backupKey, SignalStore.account.requireAci(), credential)
|
||||
}
|
||||
}
|
||||
|
||||
fun archiveThumbnail(thumbnailAttachment: Attachment, parentAttachment: DatabaseAttachment): NetworkResult<ArchiveMediaResponse> {
|
||||
val api = AppDependencies.signalServiceAccountManager.archiveApi
|
||||
/**
|
||||
* Copies a thumbnail that has been uploaded to the transit cdn to the archive cdn.
|
||||
*/
|
||||
fun copyThumbnailToArchive(thumbnailAttachment: Attachment, parentAttachment: DatabaseAttachment): NetworkResult<ArchiveMediaResponse> {
|
||||
val backupKey = SignalStore.svr.getOrCreateMasterKey().deriveBackupKey()
|
||||
val request = thumbnailAttachment.toArchiveMediaRequest(parentAttachment.getThumbnailMediaName(), backupKey)
|
||||
|
||||
return initBackupAndFetchAuth(backupKey)
|
||||
.then { credential ->
|
||||
api.archiveAttachmentMedia(
|
||||
SignalNetwork.archive.copyAttachmentToArchive(
|
||||
backupKey = backupKey,
|
||||
aci = SignalStore.account.requireAci(),
|
||||
serviceCredential = credential,
|
||||
@@ -637,16 +640,18 @@ object BackupRepository {
|
||||
}
|
||||
}
|
||||
|
||||
fun archiveMedia(attachment: DatabaseAttachment): NetworkResult<Unit> {
|
||||
val api = AppDependencies.signalServiceAccountManager.archiveApi
|
||||
/**
|
||||
* Copies an attachment that has been uploaded to the transit cdn to the archive cdn.
|
||||
*/
|
||||
fun copyAttachmentToArchive(attachment: DatabaseAttachment): NetworkResult<Unit> {
|
||||
val backupKey = SignalStore.svr.getOrCreateMasterKey().deriveBackupKey()
|
||||
|
||||
return initBackupAndFetchAuth(backupKey)
|
||||
.then { credential ->
|
||||
val mediaName = attachment.getMediaName()
|
||||
val request = attachment.toArchiveMediaRequest(mediaName, backupKey)
|
||||
api
|
||||
.archiveAttachmentMedia(
|
||||
SignalNetwork.archive
|
||||
.copyAttachmentToArchive(
|
||||
backupKey = backupKey,
|
||||
aci = SignalStore.account.requireAci(),
|
||||
serviceCredential = credential,
|
||||
@@ -661,8 +666,7 @@ object BackupRepository {
|
||||
.also { Log.i(TAG, "archiveMediaResult: $it") }
|
||||
}
|
||||
|
||||
fun archiveMedia(databaseAttachments: List<DatabaseAttachment>): NetworkResult<BatchArchiveMediaResult> {
|
||||
val api = AppDependencies.signalServiceAccountManager.archiveApi
|
||||
fun copyAttachmentToArchive(databaseAttachments: List<DatabaseAttachment>): NetworkResult<BatchArchiveMediaResult> {
|
||||
val backupKey = SignalStore.svr.getOrCreateMasterKey().deriveBackupKey()
|
||||
|
||||
return initBackupAndFetchAuth(backupKey)
|
||||
@@ -679,8 +683,8 @@ object BackupRepository {
|
||||
attachmentIdToMediaName[it.attachmentId] = mediaName.name
|
||||
}
|
||||
|
||||
api
|
||||
.archiveAttachmentMedia(
|
||||
SignalNetwork.archive
|
||||
.copyAttachmentToArchive(
|
||||
backupKey = backupKey,
|
||||
aci = SignalStore.account.requireAci(),
|
||||
serviceCredential = credential,
|
||||
@@ -703,7 +707,6 @@ object BackupRepository {
|
||||
}
|
||||
|
||||
fun deleteArchivedMedia(attachments: List<DatabaseAttachment>): NetworkResult<Unit> {
|
||||
val api = AppDependencies.signalServiceAccountManager.archiveApi
|
||||
val backupKey = SignalStore.svr.getOrCreateMasterKey().deriveBackupKey()
|
||||
|
||||
val mediaToDelete = attachments
|
||||
@@ -722,7 +725,7 @@ object BackupRepository {
|
||||
|
||||
return initBackupAndFetchAuth(backupKey)
|
||||
.then { credential ->
|
||||
api.deleteArchivedMedia(
|
||||
SignalNetwork.archive.deleteArchivedMedia(
|
||||
backupKey = backupKey,
|
||||
aci = SignalStore.account.requireAci(),
|
||||
serviceCredential = credential,
|
||||
@@ -736,7 +739,6 @@ object BackupRepository {
|
||||
}
|
||||
|
||||
fun deleteAbandonedMediaObjects(mediaObjects: Collection<ArchivedMediaObject>): NetworkResult<Unit> {
|
||||
val api = AppDependencies.signalServiceAccountManager.archiveApi
|
||||
val backupKey = SignalStore.svr.getOrCreateMasterKey().deriveBackupKey()
|
||||
|
||||
val mediaToDelete = mediaObjects
|
||||
@@ -754,7 +756,7 @@ object BackupRepository {
|
||||
|
||||
return initBackupAndFetchAuth(backupKey)
|
||||
.then { credential ->
|
||||
api.deleteArchivedMedia(
|
||||
SignalNetwork.archive.deleteArchivedMedia(
|
||||
backupKey = backupKey,
|
||||
aci = SignalStore.account.requireAci(),
|
||||
serviceCredential = credential,
|
||||
@@ -765,7 +767,6 @@ object BackupRepository {
|
||||
}
|
||||
|
||||
fun debugDeleteAllArchivedMedia(): NetworkResult<Unit> {
|
||||
val api = AppDependencies.signalServiceAccountManager.archiveApi
|
||||
val backupKey = SignalStore.svr.getOrCreateMasterKey().deriveBackupKey()
|
||||
|
||||
return debugGetArchivedMediaState()
|
||||
@@ -784,7 +785,7 @@ object BackupRepository {
|
||||
} else {
|
||||
getAuthCredential()
|
||||
.then { credential ->
|
||||
api.deleteArchivedMedia(
|
||||
SignalNetwork.archive.deleteArchivedMedia(
|
||||
backupKey = backupKey,
|
||||
aci = SignalStore.account.requireAci(),
|
||||
serviceCredential = credential,
|
||||
@@ -808,12 +809,11 @@ object BackupRepository {
|
||||
return NetworkResult.Success(cached)
|
||||
}
|
||||
|
||||
val api = AppDependencies.signalServiceAccountManager.archiveApi
|
||||
val backupKey = SignalStore.svr.getOrCreateMasterKey().deriveBackupKey()
|
||||
|
||||
return initBackupAndFetchAuth(backupKey)
|
||||
.then { credential ->
|
||||
api.getCdnReadCredentials(
|
||||
SignalNetwork.archive.getCdnReadCredentials(
|
||||
cdnNumber = cdnNumber,
|
||||
backupKey = backupKey,
|
||||
aci = SignalStore.account.requireAci(),
|
||||
@@ -828,7 +828,7 @@ object BackupRepository {
|
||||
.also { Log.i(TAG, "getCdnReadCredentialsResult: $it") }
|
||||
}
|
||||
|
||||
fun restoreBackupTier(): MessageBackupTier? {
|
||||
fun restoreBackupTier(aci: ACI): MessageBackupTier? {
|
||||
// TODO: more complete error handling
|
||||
try {
|
||||
val lastModified = getBackupFileLastModified().successOrThrow()
|
||||
@@ -841,7 +841,7 @@ object BackupRepository {
|
||||
return null
|
||||
}
|
||||
SignalStore.backup.backupTier = try {
|
||||
getBackupTier().successOrThrow()
|
||||
getBackupTier(aci).successOrThrow()
|
||||
} catch (e: Exception) {
|
||||
Log.i(TAG, "Could not retrieve backup tier.", e)
|
||||
null
|
||||
@@ -867,12 +867,11 @@ object BackupRepository {
|
||||
)
|
||||
}
|
||||
|
||||
val api = AppDependencies.signalServiceAccountManager.archiveApi
|
||||
val backupKey = SignalStore.svr.getOrCreateMasterKey().deriveBackupKey()
|
||||
|
||||
return initBackupAndFetchAuth(backupKey)
|
||||
.then { credential ->
|
||||
api.getBackupInfo(backupKey, SignalStore.account.requireAci(), credential).map {
|
||||
SignalNetwork.archive.getBackupInfo(backupKey, SignalStore.account.requireAci(), credential).map {
|
||||
SignalStore.backup.usedBackupMediaSpace = it.usedSpace ?: 0L
|
||||
BackupDirectories(it.backupDir!!, it.mediaDir!!)
|
||||
}
|
||||
@@ -886,14 +885,13 @@ object BackupRepository {
|
||||
}
|
||||
|
||||
suspend fun getAvailableBackupsTypes(availableBackupTiers: List<MessageBackupTier>): List<MessageBackupsType> {
|
||||
return availableBackupTiers.map { getBackupsType(it) }
|
||||
return availableBackupTiers.mapNotNull { getBackupsType(it) }
|
||||
}
|
||||
|
||||
suspend fun getBackupsType(tier: MessageBackupTier): MessageBackupsType {
|
||||
val backupCurrency = SignalStore.inAppPayments.getSubscriptionCurrency(InAppPaymentSubscriberRecord.Type.BACKUP)
|
||||
suspend fun getBackupsType(tier: MessageBackupTier): MessageBackupsType? {
|
||||
return when (tier) {
|
||||
MessageBackupTier.FREE -> getFreeType()
|
||||
MessageBackupTier.PAID -> getPaidType(backupCurrency)
|
||||
MessageBackupTier.PAID -> getPaidType()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -905,11 +903,12 @@ object BackupRepository {
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun getPaidType(currency: Currency): MessageBackupsType {
|
||||
private suspend fun getPaidType(): MessageBackupsType? {
|
||||
val config = getSubscriptionsConfiguration()
|
||||
val product = AppDependencies.billingApi.queryProduct() ?: return null
|
||||
|
||||
return MessageBackupsType.Paid(
|
||||
pricePerMonth = FiatMoney(config.currencies[currency.currencyCode.lowercase()]!!.backupSubscription[SubscriptionsConfiguration.BACKUPS_LEVEL]!!, currency),
|
||||
pricePerMonth = product.price,
|
||||
storageAllowanceBytes = config.backupConfiguration.backupLevelConfigurationMap[SubscriptionsConfiguration.BACKUPS_LEVEL]!!.storageAllowanceBytes
|
||||
)
|
||||
}
|
||||
@@ -941,15 +940,13 @@ object BackupRepository {
|
||||
* Should be the basis of all backup operations.
|
||||
*/
|
||||
private fun initBackupAndFetchAuth(backupKey: BackupKey): NetworkResult<ArchiveServiceCredential> {
|
||||
val api = AppDependencies.signalServiceAccountManager.archiveApi
|
||||
|
||||
return if (SignalStore.backup.backupsInitialized) {
|
||||
getAuthCredential().runOnStatusCodeError(resetInitializedStateErrorAction)
|
||||
} else {
|
||||
return api
|
||||
.triggerBackupIdReservation(backupKey)
|
||||
return SignalNetwork.archive
|
||||
.triggerBackupIdReservation(backupKey, SignalStore.account.requireAci())
|
||||
.then { getAuthCredential() }
|
||||
.then { credential -> api.setPublicKey(backupKey, SignalStore.account.requireAci(), credential).map { credential } }
|
||||
.then { credential -> SignalNetwork.archive.setPublicKey(backupKey, SignalStore.account.requireAci(), credential).map { credential } }
|
||||
.runIfSuccessful { SignalStore.backup.backupsInitialized = true }
|
||||
.runOnStatusCodeError(resetInitializedStateErrorAction)
|
||||
}
|
||||
@@ -969,7 +966,7 @@ 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 ->
|
||||
return SignalNetwork.archive.getServiceCredentials(currentTime).map { result ->
|
||||
SignalStore.backup.addCredentials(result.credentials.toList())
|
||||
SignalStore.backup.clearCredentialsOlderThan(currentTime)
|
||||
SignalStore.backup.credentialsByDay.getForCurrentTime(currentTime.milliseconds)!!
|
||||
|
||||
@@ -30,6 +30,7 @@ object BackupRestoreManager {
|
||||
SignalExecutors.BOUNDED.execute {
|
||||
synchronized(this) {
|
||||
val restoringAttachments = messageRecords
|
||||
.asSequence()
|
||||
.mapNotNull { (it as? MmsMessageRecord?)?.slideDeck?.slides }
|
||||
.flatten()
|
||||
.mapNotNull { it.asAttachment() as? DatabaseAttachment }
|
||||
@@ -41,10 +42,11 @@ object BackupRestoreManager {
|
||||
.toSet()
|
||||
|
||||
reprioritizedAttachments += restoringAttachments.map { it.first }
|
||||
val thumbnailJobs = restoringAttachments.map {
|
||||
val (attachmentId, mmsId) = it
|
||||
|
||||
val thumbnailJobs = restoringAttachments.map { (attachmentId, mmsId) ->
|
||||
RestoreAttachmentThumbnailJob(attachmentId = attachmentId, messageId = mmsId, highPriority = true)
|
||||
}
|
||||
|
||||
if (thumbnailJobs.isNotEmpty()) {
|
||||
AppDependencies.jobManager.addAll(thumbnailJobs)
|
||||
}
|
||||
|
||||
@@ -5,14 +5,6 @@
|
||||
|
||||
package org.thoughtcrime.securesms.backup.v2
|
||||
|
||||
class BackupV2Event(val type: Type, val count: Long, val estimatedTotalCount: Long) {
|
||||
enum class Type {
|
||||
PROGRESS_MESSAGES,
|
||||
PROGRESS_ATTACHMENTS,
|
||||
FINISHED
|
||||
}
|
||||
}
|
||||
|
||||
class LocalBackupV2Event(val type: Type, val count: Long = 0, val estimatedTotalCount: Long = 0) {
|
||||
enum class Type {
|
||||
PROGRESS_ACCOUNT,
|
||||
@@ -6,8 +6,14 @@
|
||||
package org.thoughtcrime.securesms.backup.v2.database
|
||||
|
||||
import org.signal.core.util.deleteAll
|
||||
import org.thoughtcrime.securesms.attachments.Attachment
|
||||
import org.thoughtcrime.securesms.attachments.AttachmentId
|
||||
import org.thoughtcrime.securesms.database.AttachmentTable
|
||||
|
||||
fun AttachmentTable.clearAllDataForBackupRestore() {
|
||||
writableDatabase.deleteAll(AttachmentTable.TABLE_NAME)
|
||||
}
|
||||
|
||||
fun AttachmentTable.restoreWallpaperAttachment(attachment: Attachment): AttachmentId? {
|
||||
return insertAttachmentsForMessage(AttachmentTable.WALLPAPER_MESSAGE_ID, listOf(attachment), emptyList()).values.firstOrNull()
|
||||
}
|
||||
|
||||
@@ -47,7 +47,8 @@ fun CallLinkTable.restoreFromBackup(callLink: CallLink): RecipientId? {
|
||||
name = callLink.name,
|
||||
restrictions = callLink.restrictions.toLocal(),
|
||||
expiration = Instant.ofEpochMilli(callLink.expirationMs)
|
||||
)
|
||||
),
|
||||
deletionTimestamp = 0L
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -20,15 +20,12 @@ 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
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.IndividualCall
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.LearnedProfileChatUpdate
|
||||
@@ -46,6 +43,7 @@ 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.backup.v2.util.toRemoteFilePointer
|
||||
import org.thoughtcrime.securesms.contactshare.Contact
|
||||
import org.thoughtcrime.securesms.database.AttachmentTable
|
||||
import org.thoughtcrime.securesms.database.CallTable
|
||||
@@ -95,7 +93,7 @@ import org.thoughtcrime.securesms.backup.v2.proto.GiftBadge as BackupGiftBadge
|
||||
*
|
||||
* 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 mediaArchiveEnabled: Boolean) : Iterator<ChatItem?>, Closeable {
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(ChatItemExportIterator::class.java)
|
||||
@@ -182,6 +180,15 @@ class ChatItemExportIterator(private val cursor: Cursor, private val batchSize:
|
||||
MessageTypes.isReportedSpam(record.type) -> {
|
||||
builder.updateMessage = simpleUpdate(SimpleChatUpdate.Type.REPORTED_SPAM)
|
||||
}
|
||||
MessageTypes.isMessageRequestAccepted(record.type) -> {
|
||||
builder.updateMessage = simpleUpdate(SimpleChatUpdate.Type.MESSAGE_REQUEST_ACCEPTED)
|
||||
}
|
||||
MessageTypes.isBlocked(record.type) -> {
|
||||
builder.updateMessage = simpleUpdate(SimpleChatUpdate.Type.BLOCKED)
|
||||
}
|
||||
MessageTypes.isUnblocked(record.type) -> {
|
||||
builder.updateMessage = simpleUpdate(SimpleChatUpdate.Type.UNBLOCKED)
|
||||
}
|
||||
MessageTypes.isExpirationTimerUpdate(record.type) -> {
|
||||
builder.updateMessage = ChatUpdateMessage(expirationTimerChange = ExpirationTimerChatUpdate(record.expiresIn))
|
||||
builder.expiresInMs = 0
|
||||
@@ -275,7 +282,7 @@ class ChatItemExportIterator(private val cursor: Cursor, private val batchSize:
|
||||
directionless = ChatItem.DirectionlessMessageDetails()
|
||||
} else if (MessageTypes.isOutgoingMessageType(record.type)) {
|
||||
outgoing = ChatItem.OutgoingMessageDetails(
|
||||
sendStatus = record.toBackupSendStatus(groupReceipts)
|
||||
sendStatus = record.toRemoteSendStatus(groupReceipts)
|
||||
)
|
||||
} else {
|
||||
incoming = ChatItem.IncomingMessageDetails(
|
||||
@@ -496,7 +503,7 @@ class ChatItemExportIterator(private val cursor: Cursor, private val batchSize:
|
||||
PaymentNotification(
|
||||
amountMob = payment.amount.serializeAmountString(),
|
||||
feeMob = payment.fee.serializeAmountString(),
|
||||
note = payment.note,
|
||||
note = payment.note.takeUnless { it.isEmpty() },
|
||||
transactionDetails = payment.getTransactionDetails()
|
||||
)
|
||||
}
|
||||
@@ -581,7 +588,7 @@ class ChatItemExportIterator(private val cursor: Cursor, private val batchSize:
|
||||
return org.thoughtcrime.securesms.backup.v2.proto.LinkPreview(
|
||||
url = url,
|
||||
title = title,
|
||||
image = (thumbnail.orNull() as? DatabaseAttachment)?.toBackupAttachment()?.pointer,
|
||||
image = (thumbnail.orNull() as? DatabaseAttachment)?.toRemoteMessageAttachment()?.pointer,
|
||||
description = description,
|
||||
date = date
|
||||
)
|
||||
@@ -593,7 +600,7 @@ class ChatItemExportIterator(private val cursor: Cursor, private val batchSize:
|
||||
val contacts = sharedContacts.map {
|
||||
ContactAttachment(
|
||||
name = it.name.toBackup(),
|
||||
avatar = (it.avatar?.attachment as? DatabaseAttachment)?.toBackupAttachment()?.pointer,
|
||||
avatar = (it.avatar?.attachment as? DatabaseAttachment)?.toRemoteMessageAttachment()?.pointer,
|
||||
organization = it.organization,
|
||||
number = it.phoneNumbers.map { phone ->
|
||||
ContactAttachment.Phone(
|
||||
@@ -636,8 +643,7 @@ class ChatItemExportIterator(private val cursor: Cursor, private val batchSize:
|
||||
familyName = familyName,
|
||||
prefix = prefix,
|
||||
suffix = suffix,
|
||||
middleName = middleName,
|
||||
displayName = displayName
|
||||
middleName = middleName
|
||||
)
|
||||
}
|
||||
|
||||
@@ -679,16 +685,18 @@ class ChatItemExportIterator(private val cursor: Cursor, private val batchSize:
|
||||
val linkPreviews = parseLinkPreviews(attachments)
|
||||
val linkPreviewAttachments = linkPreviews.mapNotNull { it.thumbnail.orElse(null) }.toSet()
|
||||
val quotedAttachments = attachments?.filter { it.quote } ?: emptyList()
|
||||
val longTextAttachment = attachments?.firstOrNull { it.contentType == "text/x-signal-plain" }
|
||||
val messageAttachments = attachments
|
||||
?.filterNot { it.quote }
|
||||
?.filterNot { linkPreviewAttachments.contains(it) }
|
||||
?.filterNot { it == longTextAttachment }
|
||||
?: emptyList()
|
||||
return StandardMessage(
|
||||
quote = this.toQuote(quotedAttachments),
|
||||
text = text,
|
||||
attachments = messageAttachments.toBackupAttachments(),
|
||||
linkPreview = linkPreviews.map { it.toBackupLinkPreview() },
|
||||
longText = null,
|
||||
longText = longTextAttachment?.toRemoteFilePointer(mediaArchiveEnabled),
|
||||
reactions = reactionRecords.toBackupReactions()
|
||||
)
|
||||
}
|
||||
@@ -699,9 +707,13 @@ class ChatItemExportIterator(private val cursor: Cursor, private val batchSize:
|
||||
Quote(
|
||||
targetSentTimestamp = this.quoteTargetSentTimestamp.takeIf { !this.quoteMissing && it != MessageTable.QUOTE_TARGET_MISSING_ID },
|
||||
authorId = this.quoteAuthor,
|
||||
text = this.quoteBody,
|
||||
text = this.quoteBody?.let { body ->
|
||||
Text(
|
||||
body = body,
|
||||
bodyRanges = this.quoteBodyRanges?.toBackupBodyRanges() ?: emptyList()
|
||||
)
|
||||
},
|
||||
attachments = attachments?.toBackupQuoteAttachments() ?: emptyList(),
|
||||
bodyRanges = this.quoteBodyRanges?.toBackupBodyRanges() ?: emptyList(),
|
||||
type = when (type) {
|
||||
QuoteModel.Type.NORMAL -> Quote.Type.NORMAL
|
||||
QuoteModel.Type.GIFT_BADGE -> Quote.Type.GIFTBADGE
|
||||
@@ -739,7 +751,7 @@ class ChatItemExportIterator(private val cursor: Cursor, private val batchSize:
|
||||
packKey = Hex.fromStringCondensed(stickerLocator.packKey).toByteString(),
|
||||
stickerId = stickerLocator.stickerId,
|
||||
emoji = stickerLocator.emoji,
|
||||
data_ = this.toBackupAttachment().pointer
|
||||
data_ = this.toRemoteMessageAttachment().pointer
|
||||
),
|
||||
reactions = reactions.toBackupReactions()
|
||||
)
|
||||
@@ -750,50 +762,14 @@ class ChatItemExportIterator(private val cursor: Cursor, private val batchSize:
|
||||
Quote.QuotedAttachment(
|
||||
contentType = attachment.contentType,
|
||||
fileName = attachment.fileName,
|
||||
thumbnail = attachment.toBackupAttachment()
|
||||
thumbnail = attachment.toRemoteMessageAttachment().takeUnless { it.pointer?.invalidAttachmentLocator != null }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun DatabaseAttachment.toBackupAttachment(): MessageAttachment {
|
||||
val builder = FilePointer.Builder()
|
||||
builder.contentType = this.contentType?.takeUnless { it.isBlank() }
|
||||
builder.incrementalMac = this.incrementalDigest?.toByteString()
|
||||
builder.incrementalMacChunkSize = this.incrementalMacChunkSize.takeIf { it > 0 }
|
||||
builder.fileName = this.fileName
|
||||
builder.width = this.width.takeUnless { it == 0 }
|
||||
builder.height = this.height.takeUnless { it == 0 }
|
||||
builder.caption = this.caption
|
||||
builder.blurHash = this.blurHash?.hash
|
||||
|
||||
if (this.remoteKey.isNullOrBlank() || this.remoteDigest == null || this.size == 0L) {
|
||||
builder.invalidAttachmentLocator = FilePointer.InvalidAttachmentLocator()
|
||||
} else {
|
||||
if (archiveMedia) {
|
||||
builder.backupLocator = FilePointer.BackupLocator(
|
||||
mediaName = this.archiveMediaName ?: this.getMediaName().toString(),
|
||||
cdnNumber = if (this.archiveMediaName != null) this.archiveCdn else Cdn.CDN_3.cdnNumber, // TODO (clark): Update when new proto with optional cdn is landed
|
||||
key = Base64.decode(remoteKey).toByteString(),
|
||||
size = this.size.toInt(),
|
||||
digest = this.remoteDigest.toByteString()
|
||||
)
|
||||
} else {
|
||||
if (this.remoteLocation.isNullOrBlank()) {
|
||||
builder.invalidAttachmentLocator = FilePointer.InvalidAttachmentLocator()
|
||||
} else {
|
||||
builder.attachmentLocator = FilePointer.AttachmentLocator(
|
||||
cdnKey = this.remoteLocation,
|
||||
cdnNumber = this.cdn.cdnNumber,
|
||||
uploadTimestamp = this.uploadTimestamp,
|
||||
key = Base64.decode(remoteKey).toByteString(),
|
||||
size = this.size.toInt(),
|
||||
digest = this.remoteDigest.toByteString()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
private fun DatabaseAttachment.toRemoteMessageAttachment(): MessageAttachment {
|
||||
return MessageAttachment(
|
||||
pointer = builder.build(),
|
||||
pointer = this.toRemoteFilePointer(mediaArchiveEnabled),
|
||||
wasDownloaded = this.transferState == AttachmentTable.TRANSFER_PROGRESS_DONE || this.transferState == AttachmentTable.TRANSFER_NEEDS_RESTORE,
|
||||
flag = if (this.voiceNote) {
|
||||
MessageAttachment.Flag.VOICE_MESSAGE
|
||||
@@ -810,21 +786,23 @@ class ChatItemExportIterator(private val cursor: Cursor, private val batchSize:
|
||||
|
||||
private fun List<DatabaseAttachment>.toBackupAttachments(): List<MessageAttachment> {
|
||||
return this.map { attachment ->
|
||||
attachment.toBackupAttachment()
|
||||
attachment.toRemoteMessageAttachment()
|
||||
}
|
||||
}
|
||||
|
||||
private fun PaymentTable.PaymentTransaction.getTransactionDetails(): PaymentNotification.TransactionDetails? {
|
||||
if (failureReason != null || state == State.FAILED) {
|
||||
return PaymentNotification.TransactionDetails(failedTransaction = PaymentNotification.TransactionDetails.FailedTransaction(reason = failureReason.toBackupFailureReason()))
|
||||
if (this.failureReason != null || this.state == State.FAILED) {
|
||||
return PaymentNotification.TransactionDetails(failedTransaction = PaymentNotification.TransactionDetails.FailedTransaction(reason = this.failureReason.toBackupFailureReason()))
|
||||
}
|
||||
return PaymentNotification.TransactionDetails(
|
||||
transaction = PaymentNotification.TransactionDetails.Transaction(
|
||||
status = this.state.toBackupState(),
|
||||
timestamp = timestamp,
|
||||
blockIndex = blockIndex,
|
||||
blockTimestamp = blockTimestamp,
|
||||
mobileCoinIdentification = paymentMetaData.mobileCoinTxoIdentification?.toBackup()
|
||||
timestamp = this.timestamp,
|
||||
blockIndex = this.blockIndex,
|
||||
blockTimestamp = this.blockTimestamp,
|
||||
mobileCoinIdentification = this.paymentMetaData.mobileCoinTxoIdentification?.toBackup(),
|
||||
transaction = this.transaction?.toByteString(),
|
||||
receipt = this.receipt?.toByteString()
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -906,19 +884,18 @@ class ChatItemExportIterator(private val cursor: Cursor, private val batchSize:
|
||||
emoji = it.emoji,
|
||||
authorId = it.author.toLong(),
|
||||
sentTimestamp = it.dateSent,
|
||||
receivedTimestamp = it.dateReceived,
|
||||
sortOrder = 0 // TODO [backup] make this it.dateReceived once comparator support is added
|
||||
sortOrder = it.dateReceived
|
||||
)
|
||||
} ?: emptyList()
|
||||
}
|
||||
|
||||
private fun BackupMessageRecord.toBackupSendStatus(groupReceipts: List<GroupReceiptTable.GroupReceiptInfo>?): List<SendStatus> {
|
||||
private fun BackupMessageRecord.toRemoteSendStatus(groupReceipts: List<GroupReceiptTable.GroupReceiptInfo>?): List<SendStatus> {
|
||||
if (!MessageTypes.isOutgoingMessageType(this.type)) {
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
if (!groupReceipts.isNullOrEmpty()) {
|
||||
return groupReceipts.toBackupSendStatus(this.networkFailureRecipientIds, this.identityMismatchRecipientIds)
|
||||
return groupReceipts.toRemoteSendStatus(this, this.networkFailureRecipientIds, this.identityMismatchRecipientIds)
|
||||
}
|
||||
|
||||
val statusBuilder = SendStatus.Builder()
|
||||
@@ -928,21 +905,16 @@ class ChatItemExportIterator(private val cursor: Cursor, private val batchSize:
|
||||
when {
|
||||
this.identityMismatchRecipientIds.contains(this.toRecipientId) -> {
|
||||
statusBuilder.failed = SendStatus.Failed(
|
||||
identityKeyMismatch = true
|
||||
reason = SendStatus.Failed.FailureReason.IDENTITY_KEY_MISMATCH
|
||||
)
|
||||
}
|
||||
this.networkFailureRecipientIds.contains(this.toRecipientId) -> {
|
||||
statusBuilder.failed = SendStatus.Failed(
|
||||
network = true
|
||||
reason = SendStatus.Failed.FailureReason.NETWORK
|
||||
)
|
||||
}
|
||||
this.baseType == MessageTypes.BASE_SENT_TYPE -> {
|
||||
statusBuilder.sent = SendStatus.Sent(
|
||||
sealedSender = this.sealedSender
|
||||
)
|
||||
}
|
||||
this.hasDeliveryReceipt -> {
|
||||
statusBuilder.delivered = SendStatus.Delivered(
|
||||
this.viewed -> {
|
||||
statusBuilder.viewed = SendStatus.Viewed(
|
||||
sealedSender = this.sealedSender
|
||||
)
|
||||
}
|
||||
@@ -951,8 +923,21 @@ class ChatItemExportIterator(private val cursor: Cursor, private val batchSize:
|
||||
sealedSender = this.sealedSender
|
||||
)
|
||||
}
|
||||
this.viewed -> {
|
||||
statusBuilder.viewed = SendStatus.Viewed(
|
||||
this.hasDeliveryReceipt -> {
|
||||
statusBuilder.delivered = SendStatus.Delivered(
|
||||
sealedSender = this.sealedSender
|
||||
)
|
||||
}
|
||||
this.baseType == MessageTypes.BASE_SENT_FAILED_TYPE -> {
|
||||
statusBuilder.failed = SendStatus.Failed(
|
||||
reason = SendStatus.Failed.FailureReason.UNKNOWN
|
||||
)
|
||||
}
|
||||
this.baseType == MessageTypes.BASE_SENDING_SKIPPED_TYPE -> {
|
||||
statusBuilder.skipped = SendStatus.Skipped()
|
||||
}
|
||||
this.baseType == MessageTypes.BASE_SENT_TYPE -> {
|
||||
statusBuilder.sent = SendStatus.Sent(
|
||||
sealedSender = this.sealedSender
|
||||
)
|
||||
}
|
||||
@@ -964,7 +949,7 @@ class ChatItemExportIterator(private val cursor: Cursor, private val batchSize:
|
||||
return listOf(statusBuilder.build())
|
||||
}
|
||||
|
||||
private fun List<GroupReceiptTable.GroupReceiptInfo>.toBackupSendStatus(networkFailureRecipientIds: Set<Long>, identityMismatchRecipientIds: Set<Long>): List<SendStatus> {
|
||||
private fun List<GroupReceiptTable.GroupReceiptInfo>.toRemoteSendStatus(messageRecord: BackupMessageRecord, networkFailureRecipientIds: Set<Long>, identityMismatchRecipientIds: Set<Long>): List<SendStatus> {
|
||||
return this.map {
|
||||
val statusBuilder = SendStatus.Builder()
|
||||
.recipientId(it.recipientId.toLong())
|
||||
@@ -973,12 +958,17 @@ class ChatItemExportIterator(private val cursor: Cursor, private val batchSize:
|
||||
when {
|
||||
identityMismatchRecipientIds.contains(it.recipientId.toLong()) -> {
|
||||
statusBuilder.failed = SendStatus.Failed(
|
||||
identityKeyMismatch = true
|
||||
reason = SendStatus.Failed.FailureReason.IDENTITY_KEY_MISMATCH
|
||||
)
|
||||
}
|
||||
networkFailureRecipientIds.contains(it.recipientId.toLong()) -> {
|
||||
statusBuilder.failed = SendStatus.Failed(
|
||||
network = true
|
||||
reason = SendStatus.Failed.FailureReason.NETWORK
|
||||
)
|
||||
}
|
||||
messageRecord.baseType == MessageTypes.BASE_SENT_FAILED_TYPE -> {
|
||||
statusBuilder.failed = SendStatus.Failed(
|
||||
reason = SendStatus.Failed.FailureReason.UNKNOWN
|
||||
)
|
||||
}
|
||||
it.status == GroupReceiptTable.STATUS_UNKNOWN -> {
|
||||
@@ -1081,7 +1071,10 @@ class ChatItemExportIterator(private val cursor: Cursor, private val batchSize:
|
||||
MessageTypes.isPaymentsActivated(this) ||
|
||||
MessageTypes.isPaymentsRequestToActivate(this) ||
|
||||
MessageTypes.isUnsupportedMessageType(this) ||
|
||||
MessageTypes.isReportedSpam(this)
|
||||
MessageTypes.isReportedSpam(this) ||
|
||||
MessageTypes.isMessageRequestAccepted(this) ||
|
||||
MessageTypes.isBlocked(this) ||
|
||||
MessageTypes.isUnblocked(this)
|
||||
}
|
||||
|
||||
private fun String.e164ToLong(): Long? {
|
||||
|
||||
@@ -7,7 +7,6 @@ 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
|
||||
@@ -17,17 +16,13 @@ import org.signal.core.util.orNull
|
||||
import org.signal.core.util.requireLong
|
||||
import org.signal.core.util.toInt
|
||||
import org.signal.core.util.update
|
||||
import org.thoughtcrime.securesms.attachments.ArchivedAttachment
|
||||
import org.thoughtcrime.securesms.attachments.Attachment
|
||||
import org.thoughtcrime.securesms.attachments.Cdn
|
||||
import org.thoughtcrime.securesms.attachments.PointerAttachment
|
||||
import org.thoughtcrime.securesms.attachments.TombstoneAttachment
|
||||
import org.thoughtcrime.securesms.backup.v2.ImportState
|
||||
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
|
||||
@@ -39,8 +34,8 @@ 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.backup.v2.util.toLocalAttachment
|
||||
import org.thoughtcrime.securesms.contactshare.Contact
|
||||
import org.thoughtcrime.securesms.database.AttachmentTable
|
||||
import org.thoughtcrime.securesms.database.CallTable
|
||||
import org.thoughtcrime.securesms.database.GroupReceiptTable
|
||||
import org.thoughtcrime.securesms.database.MessageTable
|
||||
@@ -67,15 +62,14 @@ import org.thoughtcrime.securesms.database.model.databaseprotos.ThreadMergeEvent
|
||||
import org.thoughtcrime.securesms.mms.QuoteModel
|
||||
import org.thoughtcrime.securesms.payments.CryptoValueUtil
|
||||
import org.thoughtcrime.securesms.payments.Direction
|
||||
import org.thoughtcrime.securesms.payments.FailureReason
|
||||
import org.thoughtcrime.securesms.payments.State
|
||||
import org.thoughtcrime.securesms.payments.proto.PaymentMetaData
|
||||
import org.thoughtcrime.securesms.profiles.ProfileName
|
||||
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
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentRemoteId
|
||||
import org.whispersystems.signalservice.api.payments.Money
|
||||
import org.whispersystems.signalservice.api.push.ServiceId
|
||||
import org.whispersystems.signalservice.api.util.UuidUtil
|
||||
@@ -101,6 +95,7 @@ class ChatItemImportInserter(
|
||||
MessageTable.DATE_SENT,
|
||||
MessageTable.DATE_RECEIVED,
|
||||
MessageTable.DATE_SERVER,
|
||||
MessageTable.RECEIPT_TIMESTAMP,
|
||||
MessageTable.TYPE,
|
||||
MessageTable.THREAD_ID,
|
||||
MessageTable.READ,
|
||||
@@ -341,7 +336,7 @@ class ChatItemImportInserter(
|
||||
address.country
|
||||
)
|
||||
},
|
||||
Contact.Avatar(null, backupContact.avatar.toLocalAttachment(), true)
|
||||
Contact.Avatar(null, backupContact.avatar.toLocalAttachment(importState = importState, voiceNote = false, borderless = false, gif = false, wasDownloaded = true), true)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -386,18 +381,25 @@ class ChatItemImportInserter(
|
||||
}
|
||||
}
|
||||
val linkPreviews = this.standardMessage.linkPreview.map { it.toLocalLinkPreview() }
|
||||
val linkPreviewAttachments = linkPreviews.mapNotNull { it.thumbnail.orNull() }
|
||||
val attachments = this.standardMessage.attachments.mapNotNull { attachment ->
|
||||
val linkPreviewAttachments: List<Attachment> = linkPreviews.mapNotNull { it.thumbnail.orNull() }
|
||||
val attachments: List<Attachment> = this.standardMessage.attachments.mapNotNull { attachment ->
|
||||
attachment.toLocalAttachment()
|
||||
}
|
||||
|
||||
val quoteAttachments = this.standardMessage.quote?.attachments?.mapNotNull {
|
||||
val longTextAttachments: List<Attachment> = this.standardMessage.longText?.let { longTextPointer ->
|
||||
longTextPointer.toLocalAttachment(
|
||||
importState = importState,
|
||||
contentType = "text/x-signal-plain"
|
||||
)
|
||||
}?.let { listOf(it) } ?: emptyList()
|
||||
|
||||
val quoteAttachments: List<Attachment> = this.standardMessage.quote?.attachments?.mapNotNull {
|
||||
it.toLocalAttachment()
|
||||
} ?: emptyList()
|
||||
|
||||
if (attachments.isNotEmpty() || linkPreviewAttachments.isNotEmpty() || quoteAttachments.isNotEmpty()) {
|
||||
if (attachments.isNotEmpty() || linkPreviewAttachments.isNotEmpty() || quoteAttachments.isNotEmpty() || longTextAttachments.isNotEmpty()) {
|
||||
followUp = { messageRowId ->
|
||||
val attachmentMap = SignalDatabase.attachments.insertAttachmentsForMessage(messageRowId, attachments + linkPreviewAttachments, quoteAttachments)
|
||||
val attachmentMap = SignalDatabase.attachments.insertAttachmentsForMessage(messageRowId, attachments + linkPreviewAttachments + longTextAttachments, quoteAttachments)
|
||||
if (linkPreviews.isNotEmpty()) {
|
||||
db.update(
|
||||
MessageTable.TABLE_NAME,
|
||||
@@ -425,8 +427,6 @@ class ChatItemImportInserter(
|
||||
return MessageInsert(contentValues, followUp)
|
||||
}
|
||||
|
||||
private class BatchInsert(val inserts: List<MessageInsert>, val query: SqlUtil.Query)
|
||||
|
||||
private fun ChatItem.toMessageContentValues(fromRecipientId: RecipientId, chatRecipientId: RecipientId, threadId: Long): ContentValues {
|
||||
val contentValues = ContentValues()
|
||||
|
||||
@@ -441,8 +441,8 @@ class ChatItemImportInserter(
|
||||
contentValues.putNull(MessageTable.LATEST_REVISION_ID)
|
||||
contentValues.putNull(MessageTable.ORIGINAL_MESSAGE_ID)
|
||||
contentValues.put(MessageTable.REVISION_NUMBER, 0)
|
||||
contentValues.put(MessageTable.EXPIRES_IN, this.expiresInMs ?: 0)
|
||||
contentValues.put(MessageTable.EXPIRE_STARTED, this.expireStartDate ?: 0)
|
||||
contentValues.put(MessageTable.EXPIRES_IN, this.expiresInMs)
|
||||
contentValues.put(MessageTable.EXPIRE_STARTED, this.expireStartDate)
|
||||
|
||||
if (this.outgoing != null) {
|
||||
val viewed = this.outgoing.sendStatus.any { it.viewed != null }
|
||||
@@ -502,6 +502,7 @@ class ChatItemImportInserter(
|
||||
chatRecipientId,
|
||||
transaction.timestamp ?: 0,
|
||||
transaction.blockIndex ?: 0,
|
||||
transaction.blockTimestamp ?: 0,
|
||||
paymentNotification.note ?: "",
|
||||
if (chatItem.outgoing != null) Direction.SENT else Direction.RECEIVED,
|
||||
transaction.status.toLocalStatus(),
|
||||
@@ -510,7 +511,8 @@ class ChatItemImportInserter(
|
||||
transaction.transaction?.toByteArray(),
|
||||
transaction.receipt?.toByteArray(),
|
||||
mobileCoinIdentification,
|
||||
chatItem.incoming?.read ?: true
|
||||
chatItem.incoming?.read ?: true,
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
@@ -531,7 +533,7 @@ class ChatItemImportInserter(
|
||||
ReactionTable.MESSAGE_ID to messageId,
|
||||
ReactionTable.AUTHOR_ID to authorId,
|
||||
ReactionTable.DATE_SENT to it.sentTimestamp,
|
||||
ReactionTable.DATE_RECEIVED to (it.receivedTimestamp ?: it.sortOrder),
|
||||
ReactionTable.DATE_RECEIVED to it.sortOrder,
|
||||
ReactionTable.EMOJI to it.emoji
|
||||
)
|
||||
} else {
|
||||
@@ -560,7 +562,7 @@ class ChatItemImportInserter(
|
||||
GroupReceiptTable.RECIPIENT_ID to recipientId.serialize(),
|
||||
GroupReceiptTable.STATUS to sendStatus.toLocalSendStatus(),
|
||||
GroupReceiptTable.TIMESTAMP to sendStatus.timestamp,
|
||||
GroupReceiptTable.UNIDENTIFIED to sendStatus.sealedSender
|
||||
GroupReceiptTable.UNIDENTIFIED to sendStatus.sealedSender.toInt()
|
||||
)
|
||||
} else {
|
||||
Log.w(TAG, "[GroupReceipts] Could not find a local recipient for backup recipient ID ${sendStatus.recipientId}! Skipping.")
|
||||
@@ -571,10 +573,21 @@ class ChatItemImportInserter(
|
||||
|
||||
private fun ChatItem.getMessageType(): Long {
|
||||
var type: Long = if (this.outgoing != null) {
|
||||
if (this.outgoing.sendStatus.count { it.failed?.identityKeyMismatch == true } > 0) {
|
||||
if (this.outgoing.sendStatus.count { it.failed?.reason == SendStatus.Failed.FailureReason.IDENTITY_KEY_MISMATCH } > 0) {
|
||||
MessageTypes.BASE_SENT_FAILED_TYPE
|
||||
} else if (this.outgoing.sendStatus.count { it.failed?.network == true } > 0) {
|
||||
} else if (this.outgoing.sendStatus.count { it.failed?.reason == SendStatus.Failed.FailureReason.UNKNOWN } > 0) {
|
||||
MessageTypes.BASE_SENT_FAILED_TYPE
|
||||
} else if (this.outgoing.sendStatus.count { it.failed?.reason == SendStatus.Failed.FailureReason.NETWORK } > 0) {
|
||||
MessageTypes.BASE_SENDING_TYPE
|
||||
} else if (this.outgoing.sendStatus.count { it.pending != null } > 0) {
|
||||
MessageTypes.BASE_SENDING_TYPE
|
||||
} else if (this.outgoing.sendStatus.count { it.skipped != null } > 0) {
|
||||
val count = this.outgoing.sendStatus.count { it.skipped != null }
|
||||
if (count == this.outgoing.sendStatus.size) {
|
||||
MessageTypes.BASE_SENDING_SKIPPED_TYPE
|
||||
} else {
|
||||
MessageTypes.BASE_SENDING_TYPE
|
||||
}
|
||||
} else {
|
||||
MessageTypes.BASE_SENT_TYPE
|
||||
}
|
||||
@@ -627,7 +640,9 @@ class ChatItemImportInserter(
|
||||
SimpleChatUpdate.Type.PAYMENT_ACTIVATION_REQUEST -> MessageTypes.SPECIAL_TYPE_PAYMENTS_ACTIVATE_REQUEST or typeWithoutBase
|
||||
SimpleChatUpdate.Type.UNSUPPORTED_PROTOCOL_MESSAGE -> MessageTypes.UNSUPPORTED_MESSAGE_TYPE or typeWithoutBase
|
||||
SimpleChatUpdate.Type.REPORTED_SPAM -> MessageTypes.SPECIAL_TYPE_REPORTED_SPAM or typeWithoutBase
|
||||
else -> throw NotImplementedError()
|
||||
SimpleChatUpdate.Type.BLOCKED -> MessageTypes.SPECIAL_TYPE_BLOCKED or typeWithoutBase
|
||||
SimpleChatUpdate.Type.UNBLOCKED -> MessageTypes.SPECIAL_TYPE_UNBLOCKED or typeWithoutBase
|
||||
SimpleChatUpdate.Type.MESSAGE_REQUEST_ACCEPTED -> MessageTypes.SPECIAL_TYPE_MESSAGE_REQUEST_ACCEPTED or typeWithoutBase
|
||||
}
|
||||
}
|
||||
updateMessage.expirationTimerChange != null -> {
|
||||
@@ -705,17 +720,17 @@ class ChatItemImportInserter(
|
||||
private fun ContentValues.addPaymentNotification(chatItem: ChatItem, chatRecipientId: RecipientId) {
|
||||
val paymentNotification = chatItem.paymentNotification!!
|
||||
if (chatItem.paymentNotification.amountMob.isNullOrEmpty()) {
|
||||
addPaymentTombstoneNoAmount()
|
||||
this.addPaymentTombstoneNoAmount()
|
||||
return
|
||||
}
|
||||
val amount = paymentNotification.amountMob?.tryParseMoney() ?: return addPaymentTombstoneNoAmount()
|
||||
val fee = paymentNotification.feeMob?.tryParseMoney() ?: return addPaymentTombstoneNoAmount()
|
||||
val amount = paymentNotification.amountMob?.tryParseMoney() ?: return this.addPaymentTombstoneNoAmount()
|
||||
val fee = paymentNotification.feeMob?.tryParseMoney() ?: return this.addPaymentTombstoneNoAmount()
|
||||
|
||||
if (chatItem.paymentNotification.transactionDetails?.failedTransaction != null) {
|
||||
addFailedPaymentNotification(chatItem, amount, fee, chatRecipientId)
|
||||
this.addFailedPaymentNotification(chatItem, amount, fee, chatRecipientId)
|
||||
return
|
||||
}
|
||||
addPaymentTombstoneNoMetadata(chatItem.paymentNotification)
|
||||
this.addPaymentTombstoneNoMetadata(chatItem.paymentNotification)
|
||||
}
|
||||
|
||||
private fun PaymentNotification.TransactionDetails.MobileCoinTxoIdentification.toLocal(): PaymentMetaData {
|
||||
@@ -732,6 +747,7 @@ class ChatItemImportInserter(
|
||||
chatRecipientId,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
chatItem.paymentNotification?.note ?: "",
|
||||
if (chatItem.outgoing != null) Direction.SENT else Direction.RECEIVED,
|
||||
State.FAILED,
|
||||
@@ -740,7 +756,8 @@ class ChatItemImportInserter(
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
chatItem.incoming?.read ?: true
|
||||
chatItem.incoming?.read ?: true,
|
||||
chatItem.paymentNotification?.transactionDetails?.failedTransaction?.reason?.toLocalPaymentFailureReason()
|
||||
)
|
||||
if (uuid != null) {
|
||||
put(MessageTable.BODY, uuid.toString())
|
||||
@@ -750,6 +767,14 @@ class ChatItemImportInserter(
|
||||
}
|
||||
}
|
||||
|
||||
private fun PaymentNotification.TransactionDetails.FailedTransaction.FailureReason.toLocalPaymentFailureReason(): FailureReason {
|
||||
return when (this) {
|
||||
PaymentNotification.TransactionDetails.FailedTransaction.FailureReason.GENERIC -> FailureReason.UNKNOWN
|
||||
PaymentNotification.TransactionDetails.FailedTransaction.FailureReason.NETWORK -> FailureReason.NETWORK
|
||||
PaymentNotification.TransactionDetails.FailedTransaction.FailureReason.INSUFFICIENT_FUNDS -> FailureReason.INSUFFICIENT_FUNDS
|
||||
}
|
||||
}
|
||||
|
||||
private fun ContentValues.addPaymentTombstoneNoAmount() {
|
||||
put(MessageTable.TYPE, getAsLong(MessageTable.TYPE) or MessageTypes.SPECIAL_TYPE_PAYMENTS_TOMBSTONE)
|
||||
}
|
||||
@@ -812,10 +837,10 @@ class ChatItemImportInserter(
|
||||
private fun ContentValues.addQuote(quote: Quote) {
|
||||
this.put(MessageTable.QUOTE_ID, quote.targetSentTimestamp ?: MessageTable.QUOTE_TARGET_MISSING_ID)
|
||||
this.put(MessageTable.QUOTE_AUTHOR, importState.remoteToLocalRecipientId[quote.authorId]!!.serialize())
|
||||
this.put(MessageTable.QUOTE_BODY, quote.text)
|
||||
this.put(MessageTable.QUOTE_BODY, quote.text?.body)
|
||||
this.put(MessageTable.QUOTE_TYPE, quote.type.toLocalQuoteType())
|
||||
this.put(MessageTable.QUOTE_BODY_RANGES, quote.bodyRanges.toLocalBodyRanges()?.encode())
|
||||
// TODO quote attachments
|
||||
this.put(MessageTable.QUOTE_BODY_RANGES, quote.text?.bodyRanges?.toLocalBodyRanges()?.encode())
|
||||
// TODO [backup] quote attachments
|
||||
this.put(MessageTable.QUOTE_MISSING, (quote.targetSentTimestamp == null).toInt())
|
||||
}
|
||||
|
||||
@@ -842,7 +867,7 @@ class ChatItemImportInserter(
|
||||
}
|
||||
|
||||
val networkFailures = chatItem.outgoing.sendStatus
|
||||
.filter { status -> status.failed?.network ?: false }
|
||||
.filter { status -> status.failed?.reason == SendStatus.Failed.FailureReason.NETWORK }
|
||||
.mapNotNull { status -> importState.remoteToLocalRecipientId[status.recipientId] }
|
||||
.map { recipientId -> NetworkFailure(recipientId) }
|
||||
.toSet()
|
||||
@@ -858,7 +883,7 @@ class ChatItemImportInserter(
|
||||
}
|
||||
|
||||
val mismatches = chatItem.outgoing.sendStatus
|
||||
.filter { status -> status.failed?.identityKeyMismatch ?: false }
|
||||
.filter { status -> status.failed?.reason == SendStatus.Failed.FailureReason.IDENTITY_KEY_MISMATCH }
|
||||
.mapNotNull { status -> importState.remoteToLocalRecipientId[status.recipientId] }
|
||||
.map { recipientId -> IdentityKeyMismatch(recipientId, null) } // TODO We probably want the actual identity key in this status situation?
|
||||
.toSet()
|
||||
@@ -916,29 +941,6 @@ class ChatItemImportInserter(
|
||||
?: false
|
||||
}
|
||||
|
||||
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())
|
||||
)
|
||||
}
|
||||
|
||||
private fun MessageAttachment.toLocalAttachment(contentType: String? = this.pointer?.contentType, fileName: String? = this.pointer?.fileName): Attachment? {
|
||||
return this.pointer?.toLocalAttachment(
|
||||
voiceNote = this.flag == MessageAttachment.Flag.VOICE_MESSAGE,
|
||||
borderless = this.flag == MessageAttachment.Flag.BORDERLESS,
|
||||
gif = this.flag == MessageAttachment.Flag.GIF,
|
||||
wasDownloaded = this.wasDownloaded,
|
||||
stickerLocator = null,
|
||||
contentType = contentType,
|
||||
fileName = fileName,
|
||||
uuid = this.clientUuid
|
||||
)
|
||||
}
|
||||
|
||||
private fun Quote.QuotedAttachment.toLocalAttachment(): Attachment? {
|
||||
val thumbnail = this.thumbnail?.toLocalAttachment(this.contentType, this.fileName)
|
||||
|
||||
@@ -961,6 +963,11 @@ class ChatItemImportInserter(
|
||||
if (this == null) return null
|
||||
|
||||
return data_.toLocalAttachment(
|
||||
importState = importState,
|
||||
voiceNote = false,
|
||||
gif = false,
|
||||
borderless = false,
|
||||
wasDownloaded = true,
|
||||
stickerLocator = StickerLocator(
|
||||
packId = Hex.toStringCondensed(packId.toByteArray()),
|
||||
packKey = Hex.toStringCondensed(packKey.toByteArray()),
|
||||
@@ -970,93 +977,43 @@ class ChatItemImportInserter(
|
||||
)
|
||||
}
|
||||
|
||||
private fun FilePointer?.toLocalAttachment(
|
||||
borderless: Boolean = false,
|
||||
gif: Boolean = false,
|
||||
voiceNote: Boolean = false,
|
||||
wasDownloaded: Boolean = true,
|
||||
stickerLocator: StickerLocator? = null,
|
||||
contentType: String? = this?.contentType,
|
||||
fileName: String? = this?.fileName,
|
||||
uuid: ByteString? = null
|
||||
): Attachment? {
|
||||
return if (this == null) {
|
||||
null
|
||||
} else if (this.attachmentLocator != null) {
|
||||
val signalAttachmentPointer = SignalServiceAttachmentPointer(
|
||||
cdnNumber = this.attachmentLocator.cdnNumber,
|
||||
remoteId = SignalServiceAttachmentRemoteId.from(this.attachmentLocator.cdnKey),
|
||||
contentType = contentType,
|
||||
key = this.attachmentLocator.key.toByteArray(),
|
||||
size = Optional.ofNullable(this.attachmentLocator.size),
|
||||
preview = Optional.empty(),
|
||||
width = this.width ?: 0,
|
||||
height = this.height ?: 0,
|
||||
digest = Optional.ofNullable(this.attachmentLocator.digest.toByteArray()),
|
||||
incrementalDigest = Optional.ofNullable(this.incrementalMac?.toByteArray()),
|
||||
incrementalMacChunkSize = this.incrementalMacChunkSize ?: 0,
|
||||
fileName = Optional.ofNullable(fileName),
|
||||
voiceNote = voiceNote,
|
||||
isBorderless = borderless,
|
||||
isGif = gif,
|
||||
caption = Optional.ofNullable(this.caption),
|
||||
blurHash = Optional.ofNullable(this.blurHash),
|
||||
uploadTimestamp = this.attachmentLocator.uploadTimestamp,
|
||||
uuid = UuidUtil.fromByteStringOrNull(uuid)
|
||||
)
|
||||
PointerAttachment.forPointer(
|
||||
pointer = Optional.of(signalAttachmentPointer),
|
||||
stickerLocator = stickerLocator,
|
||||
transferState = if (wasDownloaded) AttachmentTable.TRANSFER_NEEDS_RESTORE else AttachmentTable.TRANSFER_PROGRESS_PENDING
|
||||
).orNull()
|
||||
} else if (this.invalidAttachmentLocator != null) {
|
||||
TombstoneAttachment(
|
||||
contentType = contentType,
|
||||
incrementalMac = this.incrementalMac?.toByteArray(),
|
||||
incrementalMacChunkSize = this.incrementalMacChunkSize,
|
||||
width = this.width,
|
||||
height = this.height,
|
||||
caption = this.caption,
|
||||
blurHash = this.blurHash,
|
||||
voiceNote = voiceNote,
|
||||
borderless = borderless,
|
||||
gif = gif,
|
||||
quote = false,
|
||||
uuid = UuidUtil.fromByteStringOrNull(uuid)
|
||||
)
|
||||
} else if (this.backupLocator != null) {
|
||||
ArchivedAttachment(
|
||||
contentType = contentType,
|
||||
size = this.backupLocator.size.toLong(),
|
||||
cdn = this.backupLocator.transitCdnNumber ?: Cdn.CDN_0.cdnNumber,
|
||||
key = this.backupLocator.key.toByteArray(),
|
||||
cdnKey = this.backupLocator.transitCdnKey,
|
||||
archiveCdn = this.backupLocator.cdnNumber,
|
||||
archiveMediaName = this.backupLocator.mediaName,
|
||||
archiveMediaId = importState.backupKey.deriveMediaId(MediaName(this.backupLocator.mediaName)).encode(),
|
||||
archiveThumbnailMediaId = importState.backupKey.deriveMediaId(MediaName.forThumbnailFromMediaName(this.backupLocator.mediaName)).encode(),
|
||||
digest = this.backupLocator.digest.toByteArray(),
|
||||
incrementalMac = this.incrementalMac?.toByteArray(),
|
||||
incrementalMacChunkSize = this.incrementalMacChunkSize,
|
||||
width = this.width,
|
||||
height = this.height,
|
||||
caption = this.caption,
|
||||
blurHash = this.blurHash,
|
||||
voiceNote = voiceNote,
|
||||
borderless = borderless,
|
||||
gif = gif,
|
||||
quote = false,
|
||||
stickerLocator = stickerLocator,
|
||||
uuid = UuidUtil.fromByteStringOrNull(uuid),
|
||||
fileName = fileName
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
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(importState = importState, voiceNote = false, borderless = false, gif = false, wasDownloaded = true))
|
||||
)
|
||||
}
|
||||
|
||||
private fun MessageAttachment.toLocalAttachment(): Attachment? {
|
||||
return this.pointer?.toLocalAttachment(
|
||||
importState = importState,
|
||||
voiceNote = this.flag == MessageAttachment.Flag.VOICE_MESSAGE,
|
||||
gif = this.flag == MessageAttachment.Flag.GIF,
|
||||
borderless = this.flag == MessageAttachment.Flag.BORDERLESS,
|
||||
wasDownloaded = this.wasDownloaded,
|
||||
uuid = this.clientUuid
|
||||
)
|
||||
}
|
||||
|
||||
private fun MessageAttachment.toLocalAttachment(contentType: String?, fileName: String?): Attachment? {
|
||||
return pointer?.toLocalAttachment(
|
||||
importState = importState,
|
||||
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 ContactAttachment.Name?.toLocal(): Contact.Name {
|
||||
return Contact.Name(this?.displayName, this?.givenName, this?.familyName, this?.prefix, this?.suffix, this?.middleName)
|
||||
val displayName = ProfileName.fromParts(this?.givenName, this?.familyName).toString()
|
||||
return Contact.Name(displayName, this?.givenName, this?.familyName, this?.prefix, this?.suffix, this?.middleName)
|
||||
}
|
||||
|
||||
private fun ContactAttachment.Phone.Type?.toLocal(): Contact.Phone.Type {
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.backup.v2.database
|
||||
|
||||
import android.text.TextUtils
|
||||
import org.signal.core.util.Base64
|
||||
import org.thoughtcrime.securesms.attachments.DatabaseAttachment
|
||||
import org.thoughtcrime.securesms.attachments.InvalidAttachmentException
|
||||
import org.thoughtcrime.securesms.backup.v2.BackupRepository
|
||||
import org.thoughtcrime.securesms.backup.v2.BackupRepository.getThumbnailMediaName
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.util.Util
|
||||
import org.whispersystems.signalservice.api.backup.MediaName
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentPointer
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentRemoteId
|
||||
import java.io.IOException
|
||||
import java.util.Optional
|
||||
|
||||
/**
|
||||
* Creates a [SignalServiceAttachmentPointer] for the archived attachment of the given [DatabaseAttachment].
|
||||
*/
|
||||
@Throws(InvalidAttachmentException::class)
|
||||
fun DatabaseAttachment.createArchiveAttachmentPointer(useArchiveCdn: Boolean): SignalServiceAttachmentPointer {
|
||||
if (remoteKey.isNullOrBlank()) {
|
||||
throw InvalidAttachmentException("empty encrypted key")
|
||||
}
|
||||
|
||||
if (remoteDigest == null) {
|
||||
throw InvalidAttachmentException("no digest")
|
||||
}
|
||||
|
||||
return try {
|
||||
val (remoteId, cdnNumber) = if (useArchiveCdn) {
|
||||
val backupKey = SignalStore.svr.getOrCreateMasterKey().deriveBackupKey()
|
||||
val backupDirectories = BackupRepository.getCdnBackupDirectories().successOrThrow()
|
||||
|
||||
val id = SignalServiceAttachmentRemoteId.Backup(
|
||||
backupDir = backupDirectories.backupDir,
|
||||
mediaDir = backupDirectories.mediaDir,
|
||||
mediaId = backupKey.deriveMediaId(MediaName(archiveMediaName!!)).encode()
|
||||
)
|
||||
|
||||
id to archiveCdn
|
||||
} else {
|
||||
if (remoteLocation.isNullOrEmpty()) {
|
||||
throw InvalidAttachmentException("empty content id")
|
||||
}
|
||||
|
||||
SignalServiceAttachmentRemoteId.from(remoteLocation) to cdn.cdnNumber
|
||||
}
|
||||
|
||||
val key = Base64.decode(remoteKey)
|
||||
|
||||
SignalServiceAttachmentPointer(
|
||||
cdnNumber = cdnNumber,
|
||||
remoteId = remoteId,
|
||||
contentType = null,
|
||||
key = key,
|
||||
size = Optional.of(Util.toIntExact(size)),
|
||||
preview = Optional.empty(),
|
||||
width = 0,
|
||||
height = 0,
|
||||
digest = Optional.ofNullable(remoteDigest),
|
||||
incrementalDigest = Optional.ofNullable(getIncrementalDigest()),
|
||||
incrementalMacChunkSize = incrementalMacChunkSize,
|
||||
fileName = Optional.ofNullable(fileName),
|
||||
voiceNote = voiceNote,
|
||||
isBorderless = borderless,
|
||||
isGif = videoGif,
|
||||
caption = Optional.empty(),
|
||||
blurHash = Optional.ofNullable(blurHash).map { it.hash },
|
||||
uploadTimestamp = uploadTimestamp,
|
||||
uuid = uuid
|
||||
)
|
||||
} catch (e: IOException) {
|
||||
throw InvalidAttachmentException(e)
|
||||
} catch (e: ArithmeticException) {
|
||||
throw InvalidAttachmentException(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a [SignalServiceAttachmentPointer] for an archived thumbnail of the given [DatabaseAttachment].
|
||||
*/
|
||||
@Throws(InvalidAttachmentException::class)
|
||||
fun DatabaseAttachment.createArchiveThumbnailPointer(): SignalServiceAttachmentPointer {
|
||||
if (TextUtils.isEmpty(remoteKey)) {
|
||||
throw InvalidAttachmentException("empty encrypted key")
|
||||
}
|
||||
|
||||
val backupKey = SignalStore.svr.getOrCreateMasterKey().deriveBackupKey()
|
||||
val backupDirectories = BackupRepository.getCdnBackupDirectories().successOrThrow()
|
||||
return try {
|
||||
val key = backupKey.deriveThumbnailTransitKey(getThumbnailMediaName())
|
||||
val mediaId = backupKey.deriveMediaId(getThumbnailMediaName()).encode()
|
||||
SignalServiceAttachmentPointer(
|
||||
cdnNumber = archiveCdn,
|
||||
remoteId = SignalServiceAttachmentRemoteId.Backup(
|
||||
backupDir = backupDirectories.backupDir,
|
||||
mediaDir = backupDirectories.mediaDir,
|
||||
mediaId = mediaId
|
||||
),
|
||||
contentType = null,
|
||||
key = key,
|
||||
size = Optional.empty(),
|
||||
preview = Optional.empty(),
|
||||
width = 0,
|
||||
height = 0,
|
||||
digest = Optional.empty(),
|
||||
incrementalDigest = Optional.empty(),
|
||||
incrementalMacChunkSize = incrementalMacChunkSize,
|
||||
fileName = Optional.empty(),
|
||||
voiceNote = voiceNote,
|
||||
isBorderless = borderless,
|
||||
isGif = videoGif,
|
||||
caption = Optional.empty(),
|
||||
blurHash = Optional.ofNullable(blurHash).map { it.hash },
|
||||
uploadTimestamp = uploadTimestamp,
|
||||
uuid = uuid
|
||||
)
|
||||
} catch (e: IOException) {
|
||||
throw InvalidAttachmentException(e)
|
||||
} catch (e: ArithmeticException) {
|
||||
throw InvalidAttachmentException(e)
|
||||
}
|
||||
}
|
||||
@@ -310,7 +310,7 @@ private fun DecryptedGroup.toSnapshot(): Group.GroupSnapshot? {
|
||||
return Group.GroupSnapshot(
|
||||
title = Group.GroupAttributeBlob(title = this.title),
|
||||
avatarUrl = this.avatar,
|
||||
disappearingMessagesTimer = Group.GroupAttributeBlob(disappearingMessagesDuration = this.disappearingMessagesTimer?.duration ?: 0),
|
||||
disappearingMessagesTimer = this.disappearingMessagesTimer?.takeIf { it.duration > 0 }?.let { Group.GroupAttributeBlob(disappearingMessagesDuration = it.duration) },
|
||||
accessControl = this.accessControl?.toSnapshot(),
|
||||
version = this.revision,
|
||||
members = this.members.map { it.toSnapshot() },
|
||||
@@ -324,11 +324,11 @@ private fun DecryptedGroup.toSnapshot(): Group.GroupSnapshot? {
|
||||
}
|
||||
|
||||
private fun Group.Member.toLocal(): DecryptedMember {
|
||||
return DecryptedMember(aciBytes = userId, role = role.toLocal(), profileKey = profileKey, joinedAtRevision = joinedAtVersion)
|
||||
return DecryptedMember(aciBytes = userId, role = role.toLocal(), joinedAtRevision = joinedAtVersion)
|
||||
}
|
||||
|
||||
private fun DecryptedMember.toSnapshot(): Group.Member {
|
||||
return Group.Member(userId = aciBytes, role = role.toSnapshot(), profileKey = profileKey, joinedAtVersion = joinedAtRevision)
|
||||
return Group.Member(userId = aciBytes, role = role.toSnapshot(), joinedAtVersion = joinedAtRevision)
|
||||
}
|
||||
|
||||
private fun Group.MemberPendingProfileKey.toLocal(operations: GroupsV2Operations.GroupOperations): DecryptedPendingMember {
|
||||
@@ -355,7 +355,6 @@ private fun DecryptedPendingMember.toSnapshot(): Group.MemberPendingProfileKey {
|
||||
private fun Group.MemberPendingAdminApproval.toLocal(): DecryptedRequestingMember {
|
||||
return DecryptedRequestingMember(
|
||||
aciBytes = this.userId,
|
||||
profileKey = this.profileKey,
|
||||
timestamp = this.timestamp
|
||||
)
|
||||
}
|
||||
@@ -363,7 +362,6 @@ private fun Group.MemberPendingAdminApproval.toLocal(): DecryptedRequestingMembe
|
||||
private fun DecryptedRequestingMember.toSnapshot(): Group.MemberPendingAdminApproval {
|
||||
return Group.MemberPendingAdminApproval(
|
||||
userId = this.aciBytes,
|
||||
profileKey = this.profileKey,
|
||||
timestamp = this.timestamp
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,8 +7,8 @@ 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.decodeOrNull
|
||||
import org.signal.core.util.insertInto
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.core.util.requireBlob
|
||||
@@ -16,20 +16,26 @@ 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.attachments.AttachmentId
|
||||
import org.thoughtcrime.securesms.backup.v2.ImportState
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.Chat
|
||||
import org.thoughtcrime.securesms.backup.v2.util.BackupConverters
|
||||
import org.thoughtcrime.securesms.backup.v2.util.ChatStyleConverter
|
||||
import org.thoughtcrime.securesms.backup.v2.util.parseChatWallpaper
|
||||
import org.thoughtcrime.securesms.backup.v2.util.toLocal
|
||||
import org.thoughtcrime.securesms.backup.v2.util.toLocalAttachment
|
||||
import org.thoughtcrime.securesms.conversation.colors.ChatColors
|
||||
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.database.model.databaseprotos.Wallpaper
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.wallpaper.UriChatWallpaper
|
||||
import java.io.Closeable
|
||||
|
||||
private val TAG = Log.tag(ThreadTable::class.java)
|
||||
|
||||
fun ThreadTable.getThreadsForBackup(): ChatExportIterator {
|
||||
fun ThreadTable.getThreadsForBackup(db: SignalDatabase): ChatExportIterator {
|
||||
//language=sql
|
||||
val query = """
|
||||
SELECT
|
||||
@@ -39,17 +45,19 @@ fun ThreadTable.getThreadsForBackup(): ChatExportIterator {
|
||||
${ThreadTable.READ},
|
||||
${ThreadTable.ARCHIVED},
|
||||
${RecipientTable.TABLE_NAME}.${RecipientTable.MESSAGE_EXPIRATION_TIME},
|
||||
${RecipientTable.TABLE_NAME}.${RecipientTable.MESSAGE_EXPIRATION_TIME_VERSION},
|
||||
${RecipientTable.TABLE_NAME}.${RecipientTable.MUTE_UNTIL},
|
||||
${RecipientTable.TABLE_NAME}.${RecipientTable.MENTION_SETTING},
|
||||
${RecipientTable.TABLE_NAME}.${RecipientTable.CHAT_COLORS},
|
||||
${RecipientTable.TABLE_NAME}.${RecipientTable.CUSTOM_CHAT_COLORS_ID}
|
||||
${RecipientTable.TABLE_NAME}.${RecipientTable.CUSTOM_CHAT_COLORS_ID},
|
||||
${RecipientTable.TABLE_NAME}.${RecipientTable.WALLPAPER}
|
||||
FROM ${ThreadTable.TABLE_NAME}
|
||||
LEFT OUTER JOIN ${RecipientTable.TABLE_NAME} ON ${ThreadTable.TABLE_NAME}.${ThreadTable.RECIPIENT_ID} = ${RecipientTable.TABLE_NAME}.${RecipientTable.ID}
|
||||
WHERE ${ThreadTable.ACTIVE} = 1
|
||||
"""
|
||||
val cursor = readableDatabase.query(query)
|
||||
|
||||
return ChatExportIterator(cursor)
|
||||
return ChatExportIterator(cursor, db)
|
||||
}
|
||||
|
||||
fun ThreadTable.clearAllDataForBackupRestore() {
|
||||
@@ -58,10 +66,16 @@ fun ThreadTable.clearAllDataForBackupRestore() {
|
||||
clearCache()
|
||||
}
|
||||
|
||||
fun ThreadTable.restoreFromBackup(chat: Chat, recipientId: RecipientId, importState: ImportState): Long? {
|
||||
fun ThreadTable.restoreFromBackup(chat: Chat, recipientId: RecipientId, importState: ImportState): Long {
|
||||
val chatColor = chat.style?.toLocal(importState)
|
||||
|
||||
// TODO [backup] Wallpaper
|
||||
val wallpaperAttachmentId: AttachmentId? = chat.style?.wallpaperPhoto?.let { filePointer ->
|
||||
filePointer.toLocalAttachment(importState)?.let {
|
||||
SignalDatabase.attachments.restoreWallpaperAttachment(it)
|
||||
}
|
||||
}
|
||||
|
||||
val chatWallpaper = chat.style?.parseChatWallpaper(wallpaperAttachmentId)
|
||||
|
||||
val threadId = writableDatabase
|
||||
.insertInto(ThreadTable.TABLE_NAME)
|
||||
@@ -73,6 +87,7 @@ fun ThreadTable.restoreFromBackup(chat: Chat, recipientId: RecipientId, importSt
|
||||
ThreadTable.ACTIVE to 1
|
||||
)
|
||||
.run()
|
||||
|
||||
writableDatabase
|
||||
.update(
|
||||
RecipientTable.TABLE_NAME,
|
||||
@@ -80,8 +95,11 @@ fun ThreadTable.restoreFromBackup(chat: Chat, recipientId: RecipientId, importSt
|
||||
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_VERSION to chat.expireTimerVersion,
|
||||
RecipientTable.CHAT_COLORS to chatColor?.serialize()?.encode(),
|
||||
RecipientTable.CUSTOM_CHAT_COLORS_ID to (chatColor?.id ?: ChatColors.Id.NotSet).longValue
|
||||
RecipientTable.CUSTOM_CHAT_COLORS_ID to (chatColor?.id ?: ChatColors.Id.NotSet).longValue,
|
||||
RecipientTable.WALLPAPER_URI to if (chatWallpaper is UriChatWallpaper) chatWallpaper.uri.toString() else null,
|
||||
RecipientTable.WALLPAPER to chatWallpaper?.serialize()?.encode()
|
||||
),
|
||||
"${RecipientTable.ID} = ?",
|
||||
SqlUtil.buildArgs(recipientId.toLong())
|
||||
@@ -90,7 +108,7 @@ fun ThreadTable.restoreFromBackup(chat: Chat, recipientId: RecipientId, importSt
|
||||
return threadId
|
||||
}
|
||||
|
||||
class ChatExportIterator(private val cursor: Cursor) : Iterator<Chat>, Closeable {
|
||||
class ChatExportIterator(private val cursor: Cursor, private val db: SignalDatabase) : Iterator<Chat>, Closeable {
|
||||
override fun hasNext(): Boolean {
|
||||
return cursor.count > 0 && !cursor.isLast
|
||||
}
|
||||
@@ -100,14 +118,15 @@ class ChatExportIterator(private val cursor: Cursor) : Iterator<Chat>, Closeable
|
||||
throw NoSuchElementException()
|
||||
}
|
||||
|
||||
val serializedChatColors = cursor.requireBlob(RecipientTable.CHAT_COLORS)
|
||||
val chatColorId = ChatColors.Id.forLongValue(cursor.requireLong(RecipientTable.CUSTOM_CHAT_COLORS_ID))
|
||||
val chatColors: ChatColors? = serializedChatColors?.let { serialized ->
|
||||
try {
|
||||
ChatColors.forChatColor(chatColorId, ChatColor.ADAPTER.decode(serialized))
|
||||
} catch (e: InvalidProtocolBufferException) {
|
||||
null
|
||||
}
|
||||
val customChatColorsId = ChatColors.Id.forLongValue(cursor.requireLong(RecipientTable.CUSTOM_CHAT_COLORS_ID))
|
||||
|
||||
val chatColors: ChatColors? = cursor.requireBlob(RecipientTable.CHAT_COLORS)?.let { serializedChatColors ->
|
||||
val chatColor = ChatColor.ADAPTER.decodeOrNull(serializedChatColors)
|
||||
chatColor?.let { ChatColors.forChatColor(customChatColorsId, it) }
|
||||
}
|
||||
|
||||
val chatWallpaper: Wallpaper? = cursor.requireBlob(RecipientTable.WALLPAPER)?.let { serializedWallpaper ->
|
||||
Wallpaper.ADAPTER.decodeOrNull(serializedWallpaper)
|
||||
}
|
||||
|
||||
return Chat(
|
||||
@@ -116,10 +135,16 @@ class ChatExportIterator(private val cursor: Cursor) : Iterator<Chat>, Closeable
|
||||
archived = cursor.requireBoolean(ThreadTable.ARCHIVED),
|
||||
pinnedOrder = cursor.requireInt(ThreadTable.PINNED),
|
||||
expirationTimerMs = cursor.requireLong(RecipientTable.MESSAGE_EXPIRATION_TIME),
|
||||
expireTimerVersion = cursor.requireInt(RecipientTable.MESSAGE_EXPIRATION_TIME_VERSION),
|
||||
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),
|
||||
style = BackupConverters.constructRemoteChatStyle(chatColors, chatColorId)
|
||||
style = ChatStyleConverter.constructRemoteChatStyle(
|
||||
db = db,
|
||||
chatColors = chatColors,
|
||||
chatColorId = customChatColorsId,
|
||||
chatWallpaper = chatWallpaper
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -63,6 +63,10 @@ class ArchiveFileSystem private constructor(private val context: Context, root:
|
||||
fun fromFile(context: Context, backupDirectory: File): ArchiveFileSystem {
|
||||
return ArchiveFileSystem(context, DocumentFile.fromFile(backupDirectory))
|
||||
}
|
||||
|
||||
fun openInputStream(context: Context, uri: Uri): InputStream? {
|
||||
return context.contentResolver.openInputStream(uri)
|
||||
}
|
||||
}
|
||||
|
||||
private val signalBackups: DocumentFile
|
||||
@@ -284,29 +288,22 @@ class FilesFileSystem(private val context: Context, private val root: DocumentFi
|
||||
* undefined and should be avoided.
|
||||
*/
|
||||
fun fileOutputStream(mediaName: MediaName): OutputStream? {
|
||||
val subFileDirectoryName = mediaName.name.substring(0..1)
|
||||
val subFileDirectory = subFolders[subFileDirectoryName]!!
|
||||
val subFileDirectory = subFileDirectoryFor(mediaName)
|
||||
val file = subFileDirectory.createFile("application/octet-stream", mediaName.name)
|
||||
return file?.outputStream(context)
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a [file], open and return an [InputStream].
|
||||
*/
|
||||
fun fileInputStream(file: DocumentFileInfo): InputStream? {
|
||||
return file.documentFile.inputStream(context)
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a file for the given [mediaName] if it exists.
|
||||
*
|
||||
* @return true if deleted, false if not, null if not found
|
||||
*/
|
||||
fun delete(mediaName: MediaName): Boolean? {
|
||||
val subFileDirectoryName = mediaName.name.substring(0..1)
|
||||
val subFileDirectory = subFolders[subFileDirectoryName]!!
|
||||
return subFileDirectoryFor(mediaName).delete(context, mediaName.name)
|
||||
}
|
||||
|
||||
return subFileDirectory.delete(context, mediaName.name)
|
||||
private fun subFileDirectoryFor(mediaName: MediaName): DocumentFile {
|
||||
return subFolders[mediaName.name.substring(0..1)]!!
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,6 @@ import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
import java.util.Collections
|
||||
import kotlin.random.Random
|
||||
|
||||
typealias ArchiveResult = org.signal.core.util.Result<Unit, LocalArchiver.FailureCause>
|
||||
|
||||
@@ -70,10 +69,9 @@ object LocalArchiver {
|
||||
}
|
||||
|
||||
source()?.use { sourceStream ->
|
||||
val iv = Random.nextBytes(16) // todo [local-backup] but really do an iv from table
|
||||
val iv = attachment.remoteIv
|
||||
val combinedKey = Base64.decode(attachment.remoteKey)
|
||||
|
||||
var destination: OutputStream? = filesFileSystem.fileOutputStream(mediaName)
|
||||
val destination: OutputStream? = filesFileSystem.fileOutputStream(mediaName)
|
||||
|
||||
if (destination == null) {
|
||||
Log.w(TAG, "Unable to create output file for attachment")
|
||||
|
||||
@@ -8,14 +8,18 @@ package org.thoughtcrime.securesms.backup.v2.processor
|
||||
import okio.ByteString.Companion.EMPTY
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.attachments.AttachmentId
|
||||
import org.thoughtcrime.securesms.backup.v2.ImportState
|
||||
import org.thoughtcrime.securesms.backup.v2.database.restoreSelfFromBackup
|
||||
import org.thoughtcrime.securesms.backup.v2.database.restoreWallpaperAttachment
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.AccountData
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.ChatStyle
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.Frame
|
||||
import org.thoughtcrime.securesms.backup.v2.stream.BackupFrameEmitter
|
||||
import org.thoughtcrime.securesms.backup.v2.util.BackupConverters
|
||||
import org.thoughtcrime.securesms.backup.v2.util.ChatStyleConverter
|
||||
import org.thoughtcrime.securesms.backup.v2.util.parseChatWallpaper
|
||||
import org.thoughtcrime.securesms.backup.v2.util.toLocal
|
||||
import org.thoughtcrime.securesms.backup.v2.util.toLocalAttachment
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository
|
||||
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.UsernameQrCodeColorScheme
|
||||
import org.thoughtcrime.securesms.conversation.colors.ChatColors
|
||||
@@ -50,6 +54,7 @@ object AccountDataProcessor {
|
||||
val donationSubscriber = db.inAppPaymentSubscriberTable.getByCurrencyCode(donationCurrency.currencyCode, InAppPaymentSubscriberRecord.Type.DONATION)
|
||||
|
||||
val chatColors = SignalStore.chatColors.chatColors
|
||||
val chatWallpaper = SignalStore.wallpaper.currentRawWallpaper
|
||||
|
||||
emitter.emit(
|
||||
Frame(
|
||||
@@ -87,12 +92,12 @@ object AccountDataProcessor {
|
||||
hasSeenGroupStoryEducationSheet = signalStore.storyValues.userHasSeenGroupStoryEducationSheet,
|
||||
hasCompletedUsernameOnboarding = signalStore.uiHintValues.hasCompletedUsernameOnboarding(),
|
||||
customChatColors = db.chatColorsTable.getSavedChatColors().toRemoteChatColors(),
|
||||
defaultChatStyle = BackupConverters.constructRemoteChatStyle(chatColors, chatColors?.id ?: ChatColors.Id.NotSet)?.also {
|
||||
it.newBuilder().apply {
|
||||
// TODO [backup] We should do this elsewhere once we handle wallpaper better
|
||||
dimWallpaperInDarkMode = (SignalStore.wallpaper.wallpaper?.dimLevelForDarkTheme ?: 0f) > 0f
|
||||
}.build()
|
||||
}
|
||||
defaultChatStyle = ChatStyleConverter.constructRemoteChatStyle(
|
||||
db = db,
|
||||
chatColors = chatColors,
|
||||
chatColorId = chatColors?.id ?: ChatColors.Id.NotSet,
|
||||
chatWallpaper = chatWallpaper
|
||||
)
|
||||
),
|
||||
donationSubscriberData = donationSubscriber?.toSubscriberData(signalStore.inAppPaymentValues.isDonationSubscriptionManuallyCancelled())
|
||||
)
|
||||
@@ -155,11 +160,17 @@ object AccountDataProcessor {
|
||||
if (settings.defaultChatStyle != null) {
|
||||
val chatColors = settings.defaultChatStyle.toLocal(importState)
|
||||
SignalStore.chatColors.chatColors = chatColors
|
||||
if (SignalStore.wallpaper.wallpaper != null) {
|
||||
SignalStore.wallpaper.setDimInDarkTheme(settings.defaultChatStyle.dimWallpaperInDarkMode)
|
||||
|
||||
val wallpaperAttachmentId: AttachmentId? = settings.defaultChatStyle.wallpaperPhoto?.let { filePointer ->
|
||||
filePointer.toLocalAttachment(importState)?.let {
|
||||
SignalDatabase.attachments.restoreWallpaperAttachment(it)
|
||||
}
|
||||
}
|
||||
|
||||
// TODO [backup] wallpaper
|
||||
SignalStore.wallpaper.wallpaper = settings.defaultChatStyle.parseChatWallpaper(wallpaperAttachmentId)
|
||||
} else {
|
||||
SignalStore.chatColors.chatColors = null
|
||||
SignalStore.wallpaper.wallpaper = null
|
||||
}
|
||||
|
||||
if (accountData.donationSubscriberData != null) {
|
||||
|
||||
@@ -20,7 +20,7 @@ object ChatBackupProcessor {
|
||||
val TAG = Log.tag(ChatBackupProcessor::class.java)
|
||||
|
||||
fun export(db: SignalDatabase, exportState: ExportState, emitter: BackupFrameEmitter) {
|
||||
db.threadTable.getThreadsForBackup().use { reader ->
|
||||
db.threadTable.getThreadsForBackup(db).use { reader ->
|
||||
for (chat in reader) {
|
||||
if (exportState.recipientIds.contains(chat.recipientId)) {
|
||||
exportState.threadIds.add(chat.id)
|
||||
@@ -39,7 +39,7 @@ object ChatBackupProcessor {
|
||||
return
|
||||
}
|
||||
|
||||
SignalDatabase.threads.restoreFromBackup(chat, recipientId, importState)?.let { threadId ->
|
||||
SignalDatabase.threads.restoreFromBackup(chat, recipientId, importState).let { threadId ->
|
||||
importState.chatIdToLocalRecipientId[chat.id] = recipientId
|
||||
importState.chatIdToLocalThreadId[chat.id] = threadId
|
||||
importState.chatIdToBackupRecipientId[chat.id] = chat.recipientId
|
||||
|
||||
@@ -41,6 +41,8 @@ object RecipientBackupProcessor {
|
||||
)
|
||||
)
|
||||
)
|
||||
} else {
|
||||
Log.w(TAG, "Missing release channel id on export!")
|
||||
}
|
||||
|
||||
db.recipientTable.getContactsForBackup(selfId).use { reader ->
|
||||
|
||||
@@ -7,10 +7,13 @@ package org.thoughtcrime.securesms.backup.v2.processor
|
||||
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import org.signal.core.util.Hex
|
||||
import org.signal.core.util.insertInto
|
||||
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.SQLiteDatabase
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.StickerTable
|
||||
import org.thoughtcrime.securesms.database.StickerTable.StickerPackRecordReader
|
||||
import org.thoughtcrime.securesms.database.model.StickerPackRecord
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
@@ -31,8 +34,27 @@ object StickerBackupProcessor {
|
||||
}
|
||||
|
||||
fun import(stickerPack: StickerPack) {
|
||||
SignalDatabase.rawDatabase
|
||||
.insertInto(StickerTable.TABLE_NAME)
|
||||
.values(
|
||||
StickerTable.PACK_ID to Hex.toStringCondensed(stickerPack.packId.toByteArray()),
|
||||
StickerTable.PACK_KEY to Hex.toStringCondensed(stickerPack.packKey.toByteArray()),
|
||||
StickerTable.PACK_TITLE to "",
|
||||
StickerTable.PACK_AUTHOR to "",
|
||||
StickerTable.INSTALLED to 1,
|
||||
StickerTable.COVER to 1,
|
||||
StickerTable.EMOJI to "",
|
||||
StickerTable.CONTENT_TYPE to "",
|
||||
StickerTable.FILE_PATH to ""
|
||||
)
|
||||
.run(SQLiteDatabase.CONFLICT_IGNORE)
|
||||
|
||||
AppDependencies.jobManager.add(
|
||||
StickerPackDownloadJob.forInstall(Hex.toStringCondensed(stickerPack.packId.toByteArray()), Hex.toStringCondensed(stickerPack.packKey.toByteArray()), false)
|
||||
StickerPackDownloadJob.forInstall(
|
||||
Hex.toStringCondensed(stickerPack.packId.toByteArray()),
|
||||
Hex.toStringCondensed(stickerPack.packKey.toByteArray()),
|
||||
false
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,8 +9,8 @@ import com.google.common.io.CountingInputStream
|
||||
import org.signal.core.util.readFully
|
||||
import org.signal.core.util.readNBytesOrThrow
|
||||
import org.signal.core.util.readVarInt32
|
||||
import org.signal.core.util.stream.LimitedInputStream
|
||||
import org.signal.core.util.stream.MacInputStream
|
||||
import org.signal.core.util.stream.TruncatingInputStream
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.BackupInfo
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.Frame
|
||||
import org.whispersystems.signalservice.api.backup.BackupKey
|
||||
@@ -56,7 +56,7 @@ class EncryptedBackupReader(
|
||||
|
||||
stream = GZIPInputStream(
|
||||
CipherInputStream(
|
||||
TruncatingInputStream(
|
||||
LimitedInputStream(
|
||||
wrapped = countingStream,
|
||||
maxBytes = length - MAC_SIZE
|
||||
),
|
||||
@@ -121,7 +121,7 @@ class EncryptedBackupReader(
|
||||
}
|
||||
|
||||
val macStream = MacInputStream(
|
||||
wrapped = TruncatingInputStream(dataStream, maxBytes = streamLength - MAC_SIZE),
|
||||
wrapped = LimitedInputStream(dataStream, maxBytes = streamLength - MAC_SIZE),
|
||||
mac = mac
|
||||
)
|
||||
|
||||
|
||||
@@ -9,13 +9,14 @@ import androidx.annotation.DrawableRes
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
@@ -45,11 +46,13 @@ private const val NONE = -1
|
||||
@Composable
|
||||
fun BackupStatus(
|
||||
data: BackupStatusData,
|
||||
onActionClick: () -> Unit = {}
|
||||
onActionClick: () -> Unit = {},
|
||||
contentPadding: PaddingValues = PaddingValues(horizontal = 12.dp, vertical = 8.dp)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.padding(contentPadding)
|
||||
.border(1.dp, color = MaterialTheme.colorScheme.outline.copy(alpha = 0.38f), shape = RoundedCornerShape(12.dp))
|
||||
.fillMaxWidth()
|
||||
.padding(14.dp)
|
||||
@@ -71,7 +74,8 @@ fun BackupStatus(
|
||||
) {
|
||||
Text(
|
||||
text = data.title,
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
|
||||
if (data.progress >= 0f) {
|
||||
@@ -108,17 +112,19 @@ fun BackupStatus(
|
||||
@Composable
|
||||
fun BackupStatusPreview() {
|
||||
Previews.Preview {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Column {
|
||||
BackupStatus(
|
||||
data = BackupStatusData.CouldNotCompleteBackup
|
||||
)
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
BackupStatus(
|
||||
data = BackupStatusData.NotEnoughFreeSpace("12 GB")
|
||||
)
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
BackupStatus(
|
||||
data = BackupStatusData.RestoringMedia(50, 100)
|
||||
)
|
||||
@@ -201,7 +207,7 @@ sealed interface BackupStatusData {
|
||||
)
|
||||
|
||||
override val statusRes: Int = when (status) {
|
||||
Status.NONE -> R.string.default_error_msg
|
||||
Status.NONE -> NONE
|
||||
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
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.backup.v2.ui.subscription
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.activity.result.contract.ActivityResultContract
|
||||
import androidx.fragment.app.Fragment
|
||||
import org.signal.core.util.getParcelableExtraCompat
|
||||
import org.thoughtcrime.securesms.components.FragmentWrapperActivity
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.CheckoutFlowActivity.Result
|
||||
|
||||
/**
|
||||
* Self-contained activity for message backups checkout, which utilizes Google Play Billing
|
||||
* instead of the normal donations routes.
|
||||
*/
|
||||
class MessageBackupsCheckoutActivity : FragmentWrapperActivity() {
|
||||
|
||||
companion object {
|
||||
private const val RESULT_DATA = "result_data"
|
||||
}
|
||||
|
||||
override fun getFragment(): Fragment = MessageBackupsFlowFragment()
|
||||
|
||||
class Contract : ActivityResultContract<Unit, Result?>() {
|
||||
|
||||
override fun createIntent(context: Context, input: Unit): Intent {
|
||||
return Intent(context, MessageBackupsCheckoutActivity::class.java)
|
||||
}
|
||||
|
||||
override fun parseResult(resultCode: Int, intent: Intent?): Result? {
|
||||
return intent?.getParcelableExtraCompat(RESULT_DATA, Result::class.java)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,254 +0,0 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.backup.v2.ui.subscription
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
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
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.dimensionResource
|
||||
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 androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.core.view.updateLayoutParams
|
||||
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.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(
|
||||
messageBackupsType: MessageBackupsType.Paid,
|
||||
availablePaymentMethods: List<InAppPaymentData.PaymentMethodType>,
|
||||
sheetState: SheetState,
|
||||
onDismissRequest: () -> Unit,
|
||||
onPaymentMethodSelected: (InAppPaymentData.PaymentMethodType) -> Unit
|
||||
) {
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismissRequest,
|
||||
sheetState = sheetState,
|
||||
dragHandle = { BottomSheets.Handle() },
|
||||
modifier = Modifier.padding()
|
||||
) {
|
||||
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(
|
||||
messageBackupsType: MessageBackupsType.Paid,
|
||||
availablePaymentGateways: List<InAppPaymentData.PaymentMethodType>,
|
||||
onPaymentGatewaySelected: (InAppPaymentData.PaymentMethodType) -> Unit
|
||||
) {
|
||||
val resources = LocalContext.current.resources
|
||||
val formattedPrice = remember(messageBackupsType.pricePerMonth) {
|
||||
FiatMoneyUtil.format(resources, messageBackupsType.pricePerMonth, FiatMoneyUtil.formatOptions().trimZerosAfterDecimal())
|
||||
}
|
||||
|
||||
Text(
|
||||
text = stringResource(id = R.string.MessageBackupsCheckoutSheet__pay_s_per_month, formattedPrice),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
modifier = Modifier.padding(top = 48.dp)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(id = R.string.MessageBackupsCheckoutSheet__youll_get),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 5.dp)
|
||||
)
|
||||
|
||||
MessageBackupsTypeBlock(
|
||||
messageBackupsType = messageBackupsType,
|
||||
isCurrent = false,
|
||||
isSelected = false,
|
||||
onSelected = {},
|
||||
enabled = false,
|
||||
modifier = Modifier.padding(top = 24.dp)
|
||||
)
|
||||
|
||||
Column(
|
||||
verticalArrangement = spacedBy(12.dp),
|
||||
modifier = Modifier.padding(top = 48.dp, bottom = 24.dp)
|
||||
) {
|
||||
availablePaymentGateways.forEach {
|
||||
when (it) {
|
||||
InAppPaymentData.PaymentMethodType.GOOGLE_PAY -> GooglePayButton {
|
||||
onPaymentGatewaySelected(InAppPaymentData.PaymentMethodType.GOOGLE_PAY)
|
||||
}
|
||||
|
||||
InAppPaymentData.PaymentMethodType.PAYPAL -> PayPalButton {
|
||||
onPaymentGatewaySelected(InAppPaymentData.PaymentMethodType.PAYPAL)
|
||||
}
|
||||
|
||||
InAppPaymentData.PaymentMethodType.CARD -> CreditOrDebitCardButton {
|
||||
onPaymentGatewaySelected(InAppPaymentData.PaymentMethodType.CARD)
|
||||
}
|
||||
|
||||
InAppPaymentData.PaymentMethodType.SEPA_DEBIT -> SepaButton {
|
||||
onPaymentGatewaySelected(InAppPaymentData.PaymentMethodType.SEPA_DEBIT)
|
||||
}
|
||||
|
||||
InAppPaymentData.PaymentMethodType.IDEAL -> IdealButton {
|
||||
onPaymentGatewaySelected(InAppPaymentData.PaymentMethodType.IDEAL)
|
||||
}
|
||||
|
||||
InAppPaymentData.PaymentMethodType.UNKNOWN -> error("Unsupported payment method type $it")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PayPalButton(
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
AndroidView(factory = {
|
||||
val view = LayoutInflater.from(it).inflate(R.layout.paypal_button, null)
|
||||
view.layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
|
||||
view
|
||||
}) {
|
||||
val binding = PaypalButtonBinding.bind(it)
|
||||
binding.paypalButton.updateLayoutParams<ViewGroup.MarginLayoutParams> {
|
||||
marginStart = 0
|
||||
marginEnd = 0
|
||||
}
|
||||
|
||||
binding.paypalButton.setOnClickListener {
|
||||
onClick()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GooglePayButton(
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
val model = GooglePayButton.Model(onClick, true)
|
||||
|
||||
AndroidView(factory = {
|
||||
LayoutInflater.from(it).inflate(R.layout.google_pay_button_pref, null)
|
||||
}) {
|
||||
val holder = GooglePayButton.ViewHolder(it)
|
||||
holder.bind(model)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SepaButton(
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
Buttons.LargeTonal(
|
||||
onClick = onClick,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.bank_transfer),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.padding(end = 8.dp)
|
||||
)
|
||||
|
||||
Text(text = stringResource(id = R.string.GatewaySelectorBottomSheet__bank_transfer))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun IdealButton(
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
Buttons.LargeTonal(
|
||||
onClick = onClick,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.logo_ideal),
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.size(32.dp)
|
||||
.padding(end = 8.dp)
|
||||
)
|
||||
|
||||
Text(text = stringResource(id = R.string.GatewaySelectorBottomSheet__ideal))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CreditOrDebitCardButton(
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
Buttons.LargePrimary(
|
||||
onClick = onClick,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.credit_card),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.padding(end = 8.dp)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(id = R.string.GatewaySelectorBottomSheet__credit_or_debit_card)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun MessageBackupsCheckoutSheetPreview() {
|
||||
val availablePaymentGateways = InAppPaymentData.PaymentMethodType.values().toList() - InAppPaymentData.PaymentMethodType.UNKNOWN
|
||||
|
||||
Previews.Preview {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.padding(horizontal = dimensionResource(id = R.dimen.core_ui__gutter))
|
||||
) {
|
||||
SheetContent(
|
||||
messageBackupsType = MessageBackupsType.Paid(
|
||||
pricePerMonth = FiatMoney(BigDecimal.ZERO, Currency.getInstance("USD")),
|
||||
storageAllowanceBytes = 107374182400
|
||||
),
|
||||
availablePaymentGateways = availablePaymentGateways,
|
||||
onPaymentGatewaySelected = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -49,7 +49,7 @@ fun MessageBackupsEducationScreen(
|
||||
Scaffolds.Settings(
|
||||
onNavigationClick = onNavigationClick,
|
||||
navigationIconPainter = painterResource(id = R.drawable.symbol_x_24),
|
||||
title = stringResource(id = R.string.RemoteBackupsSettingsFragment__signal_backups)
|
||||
title = ""
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
|
||||
@@ -5,63 +5,37 @@
|
||||
|
||||
package org.thoughtcrime.securesms.backup.v2.ui.subscription
|
||||
|
||||
import android.app.Activity
|
||||
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.compose.ui.platform.LocalContext
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
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.R
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.InAppPaymentCheckoutDelegate
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.InAppPaymentProcessorAction
|
||||
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
|
||||
import org.thoughtcrime.securesms.compose.ComposeFragment
|
||||
import org.thoughtcrime.securesms.compose.Nav
|
||||
import org.thoughtcrime.securesms.database.InAppPaymentTable
|
||||
import org.thoughtcrime.securesms.lock.v2.CreateSvrPinActivity
|
||||
import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.util.Util
|
||||
import org.thoughtcrime.securesms.util.viewModel
|
||||
|
||||
/**
|
||||
* Handles the selection, payment, and changing of a user's backup tier.
|
||||
*/
|
||||
class MessageBackupsFlowFragment : ComposeFragment(), InAppPaymentCheckoutDelegate.Callback {
|
||||
class MessageBackupsFlowFragment : ComposeFragment() {
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
navController.setLifecycleOwner(this@MessageBackupsFlowFragment)
|
||||
|
||||
@@ -69,7 +43,7 @@ class MessageBackupsFlowFragment : ComposeFragment(), InAppPaymentCheckoutDelega
|
||||
lifecycleOwner,
|
||||
object : OnBackPressedCallback(true) {
|
||||
override fun handleOnBackPressed() {
|
||||
viewModel.goToPreviousScreen()
|
||||
viewModel.goToPreviousStage()
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -79,40 +53,39 @@ class MessageBackupsFlowFragment : ComposeFragment(), InAppPaymentCheckoutDelega
|
||||
navController = navController,
|
||||
startDestination = state.startScreen.name
|
||||
) {
|
||||
composable(route = MessageBackupsScreen.EDUCATION.name) {
|
||||
composable(route = MessageBackupsStage.Route.EDUCATION.name) {
|
||||
MessageBackupsEducationScreen(
|
||||
onNavigationClick = viewModel::goToPreviousScreen,
|
||||
onEnableBackups = viewModel::goToNextScreen,
|
||||
onNavigationClick = viewModel::goToPreviousStage,
|
||||
onEnableBackups = viewModel::goToNextStage,
|
||||
onLearnMore = {}
|
||||
)
|
||||
}
|
||||
|
||||
composable(route = MessageBackupsScreen.PIN_EDUCATION.name) {
|
||||
MessageBackupsPinEducationScreen(
|
||||
onNavigationClick = viewModel::goToPreviousScreen,
|
||||
onCreatePinClick = {},
|
||||
onUseCurrentPinClick = viewModel::goToNextScreen,
|
||||
recommendedPinSize = 16 // TODO [message-backups] This value should come from some kind of config
|
||||
composable(route = MessageBackupsStage.Route.BACKUP_KEY_EDUCATION.name) {
|
||||
MessageBackupsKeyEducationScreen(
|
||||
onNavigationClick = viewModel::goToPreviousStage,
|
||||
onNextClick = viewModel::goToNextStage
|
||||
)
|
||||
}
|
||||
|
||||
composable(route = MessageBackupsScreen.PIN_CONFIRMATION.name) {
|
||||
MessageBackupsPinConfirmationScreen(
|
||||
pin = pin,
|
||||
isPinIncorrect = state.displayIncorrectPinError,
|
||||
onPinChanged = viewModel::onPinEntryUpdated,
|
||||
pinKeyboardType = state.pinKeyboardType,
|
||||
onPinKeyboardTypeSelected = viewModel::onPinKeyboardTypeUpdated,
|
||||
onNextClick = viewModel::goToNextScreen,
|
||||
onCreateNewPinClick = this@MessageBackupsFlowFragment::createANewPin
|
||||
composable(route = MessageBackupsStage.Route.BACKUP_KEY_RECORD.name) {
|
||||
val context = LocalContext.current
|
||||
|
||||
MessageBackupsKeyRecordScreen(
|
||||
backupKey = state.backupKey,
|
||||
onNavigationClick = viewModel::goToPreviousStage,
|
||||
onNextClick = viewModel::goToNextStage,
|
||||
onCopyToClipboardClick = {
|
||||
Util.copyToClipboard(context, it)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
composable(route = MessageBackupsScreen.TYPE_SELECTION.name) {
|
||||
composable(route = MessageBackupsStage.Route.TYPE_SELECTION.name) {
|
||||
MessageBackupsTypeSelectionScreen(
|
||||
currentBackupTier = state.currentMessageBackupTier,
|
||||
selectedBackupTier = state.selectedMessageBackupTier,
|
||||
availableBackupTypes = state.availableBackupTypes,
|
||||
availableBackupTypes = state.availableBackupTypes.filter { it.tier == MessageBackupTier.FREE || state.hasBackupSubscriberAvailable },
|
||||
onMessageBackupsTierSelected = { tier ->
|
||||
val type = state.availableBackupTypes.first { it.tier == tier }
|
||||
val label = when (type) {
|
||||
@@ -122,174 +95,32 @@ class MessageBackupsFlowFragment : ComposeFragment(), InAppPaymentCheckoutDelega
|
||||
|
||||
viewModel.onMessageBackupTierUpdated(tier, label)
|
||||
},
|
||||
onNavigationClick = viewModel::goToPreviousScreen,
|
||||
onNavigationClick = viewModel::goToPreviousStage,
|
||||
onReadMoreClicked = {},
|
||||
onCancelSubscriptionClicked = viewModel::displayCancellationDialog,
|
||||
onNextClicked = viewModel::goToNextScreen
|
||||
onNextClicked = viewModel::goToNextStage
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (state.screen == MessageBackupsScreen.CHECKOUT_SHEET) {
|
||||
MessageBackupsCheckoutSheet(
|
||||
messageBackupsType = state.availableBackupTypes.filterIsInstance<MessageBackupsType.Paid>().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.stage) {
|
||||
val newRoute = state.stage.route.name
|
||||
val currentRoute = navController.currentDestination?.route
|
||||
if (currentRoute != newRoute) {
|
||||
if (currentRoute != null && MessageBackupsStage.Route.valueOf(currentRoute).isAfter(state.stage.route)) {
|
||||
navController.popBackStack()
|
||||
} else {
|
||||
navController.navigate(newRoute)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(state.screen) {
|
||||
val route = navController.currentDestination?.route ?: return@LaunchedEffect
|
||||
if (route == state.screen.name) {
|
||||
return@LaunchedEffect
|
||||
if (state.stage == MessageBackupsStage.CHECKOUT_SHEET) {
|
||||
AppDependencies.billingApi.launchBillingFlow(requireActivity())
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
if (state.screen == MessageBackupsScreen.PROCESS_FREE) {
|
||||
checkoutDelegate.setActivityResult(InAppPaymentProcessorAction.UPDATE_SUBSCRIPTION, InAppPaymentType.RECURRING_BACKUP)
|
||||
viewModel.goToNextScreen()
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
val routeScreen = MessageBackupsScreen.valueOf(route)
|
||||
if (routeScreen.isAfter(state.screen)) {
|
||||
navController.popBackStack()
|
||||
} else {
|
||||
navController.navigate(state.screen.name)
|
||||
if (state.stage == MessageBackupsStage.COMPLETED) {
|
||||
requireActivity().setResult(Activity.RESULT_OK)
|
||||
requireActivity().finishAfterTransition()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun createANewPin() {
|
||||
viewModel.onPinEntryUpdated("")
|
||||
startActivity(CreateSvrPinActivity.getIntentForPinChangeFromSettings(requireContext()))
|
||||
}
|
||||
|
||||
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) {
|
||||
viewModel.onCancellationComplete()
|
||||
|
||||
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?
|
||||
}
|
||||
|
||||
override fun exitCheckoutFlow() {
|
||||
requireActivity().finishAfterTransition()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,20 +7,18 @@ package org.thoughtcrime.securesms.backup.v2.ui.subscription
|
||||
|
||||
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
|
||||
import org.thoughtcrime.securesms.database.InAppPaymentTable
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.lock.v2.PinKeyboardType
|
||||
import org.whispersystems.signalservice.api.backup.BackupKey
|
||||
|
||||
data class MessageBackupsFlowState(
|
||||
val hasBackupSubscriberAvailable: Boolean = false,
|
||||
val selectedMessageBackupTierLabel: String? = null,
|
||||
val selectedMessageBackupTier: MessageBackupTier? = SignalStore.backup.backupTier,
|
||||
val currentMessageBackupTier: MessageBackupTier? = SignalStore.backup.backupTier,
|
||||
val availableBackupTypes: List<MessageBackupsType> = emptyList(),
|
||||
val selectedPaymentMethod: InAppPaymentData.PaymentMethodType? = null,
|
||||
val availablePaymentMethods: List<InAppPaymentData.PaymentMethodType> = emptyList(),
|
||||
val pinKeyboardType: PinKeyboardType = SignalStore.pin.keyboardType,
|
||||
val inAppPayment: InAppPaymentTable.InAppPayment? = null,
|
||||
val startScreen: MessageBackupsScreen,
|
||||
val screen: MessageBackupsScreen = startScreen,
|
||||
val displayIncorrectPinError: Boolean = false
|
||||
val startScreen: MessageBackupsStage,
|
||||
val stage: MessageBackupsStage = startScreen,
|
||||
val backupKey: BackupKey = SignalStore.svr.getOrCreateMasterKey().deriveBackupKey(),
|
||||
val failure: Throwable? = null
|
||||
)
|
||||
|
||||
@@ -5,56 +5,72 @@
|
||||
|
||||
package org.thoughtcrime.securesms.backup.v2.ui.subscription
|
||||
|
||||
import android.text.TextUtils
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.take
|
||||
import kotlinx.coroutines.flow.timeout
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.reactive.asFlow
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.signal.core.util.money.FiatMoney
|
||||
import org.signal.core.util.billing.BillingPurchaseResult
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.donations.InAppPaymentType
|
||||
import org.thoughtcrime.securesms.backup.v2.BackupRepository
|
||||
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationSerializationHelper.toFiatValue
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppDonations
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository.toPaymentSourceType
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayOrderStrategy
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.RecurringInAppPaymentRepository
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.InAppPaymentError
|
||||
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.InAppPaymentData
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.jobs.InAppPaymentPurchaseTokenJob
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.lock.v2.PinKeyboardType
|
||||
import org.thoughtcrime.securesms.lock.v2.SvrConstants
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.util.RemoteConfig
|
||||
import org.whispersystems.signalservice.api.kbs.PinHashUtil.verifyLocalPinHash
|
||||
import org.whispersystems.signalservice.internal.push.SubscriptionsConfiguration
|
||||
import java.math.BigDecimal
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
class MessageBackupsFlowViewModel : ViewModel() {
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(MessageBackupsFlowViewModel::class)
|
||||
}
|
||||
|
||||
private val internalStateFlow = MutableStateFlow(
|
||||
MessageBackupsFlowState(
|
||||
availableBackupTypes = emptyList(),
|
||||
selectedMessageBackupTier = SignalStore.backup.backupTier,
|
||||
availablePaymentMethods = GatewayOrderStrategy.getStrategy().orderedGateways.filter { InAppDonations.isPaymentSourceAvailable(it.toPaymentSourceType(), InAppPaymentType.RECURRING_BACKUP) },
|
||||
startScreen = if (SignalStore.backup.backupTier == null) MessageBackupsScreen.EDUCATION else MessageBackupsScreen.TYPE_SELECTION
|
||||
startScreen = if (SignalStore.backup.backupTier == null) MessageBackupsStage.EDUCATION else MessageBackupsStage.TYPE_SELECTION
|
||||
)
|
||||
)
|
||||
|
||||
private val internalPinState = mutableStateOf("")
|
||||
private var isDowngrading = false
|
||||
|
||||
val stateFlow: StateFlow<MessageBackupsFlowState> = internalStateFlow
|
||||
val pinState: State<String> = internalPinState
|
||||
|
||||
init {
|
||||
check(SignalStore.backup.backupTier != MessageBackupTier.PAID) { "This screen does not support cancellation or downgrades." }
|
||||
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
ensureSubscriberIdForBackups()
|
||||
internalStateFlow.update {
|
||||
it.copy(
|
||||
hasBackupSubscriberAvailable = true
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to ensure a subscriber id exists.", e)
|
||||
}
|
||||
|
||||
internalStateFlow.update {
|
||||
it.copy(
|
||||
availableBackupTypes = BackupRepository.getAvailableBackupsTypes(
|
||||
@@ -63,71 +79,83 @@ class MessageBackupsFlowViewModel : ViewModel() {
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun goToNextScreen() {
|
||||
val pinSnapshot = pinState.value
|
||||
viewModelScope.launch {
|
||||
AppDependencies.billingApi.getBillingPurchaseResults().collect { result ->
|
||||
when (result) {
|
||||
is BillingPurchaseResult.Success -> {
|
||||
internalStateFlow.update { it.copy(stage = MessageBackupsStage.PROCESS_PAYMENT) }
|
||||
|
||||
internalStateFlow.update {
|
||||
when (it.screen) {
|
||||
MessageBackupsScreen.EDUCATION -> it.copy(screen = MessageBackupsScreen.PIN_EDUCATION)
|
||||
MessageBackupsScreen.PIN_EDUCATION -> it.copy(screen = MessageBackupsScreen.PIN_CONFIRMATION)
|
||||
MessageBackupsScreen.PIN_CONFIRMATION -> validatePinAndUpdateState(it, pinSnapshot)
|
||||
MessageBackupsScreen.TYPE_SELECTION -> validateTypeAndUpdateState(it)
|
||||
MessageBackupsScreen.CHECKOUT_SHEET -> validateGatewayAndUpdateState(it)
|
||||
MessageBackupsScreen.CREATING_IN_APP_PAYMENT -> error("This is driven by an async coroutine.")
|
||||
MessageBackupsScreen.CANCELLATION_DIALOG -> it.copy(screen = MessageBackupsScreen.PROCESS_CANCELLATION)
|
||||
MessageBackupsScreen.PROCESS_PAYMENT -> it.copy(screen = MessageBackupsScreen.COMPLETED)
|
||||
MessageBackupsScreen.PROCESS_CANCELLATION -> it.copy(screen = MessageBackupsScreen.COMPLETED)
|
||||
MessageBackupsScreen.PROCESS_FREE -> it.copy(screen = MessageBackupsScreen.COMPLETED)
|
||||
MessageBackupsScreen.COMPLETED -> error("Unsupported state transition from terminal state COMPLETED")
|
||||
try {
|
||||
handleSuccess(
|
||||
result,
|
||||
internalStateFlow.value.inAppPayment!!.id
|
||||
)
|
||||
|
||||
internalStateFlow.update {
|
||||
it.copy(
|
||||
stage = MessageBackupsStage.COMPLETED
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
internalStateFlow.update {
|
||||
it.copy(
|
||||
stage = MessageBackupsStage.FAILURE,
|
||||
failure = e
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
else -> goToPreviousStage()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun goToPreviousScreen() {
|
||||
/**
|
||||
* Go to the next stage of the pipeline, based off of the current stage and state data.
|
||||
*/
|
||||
fun goToNextStage() {
|
||||
internalStateFlow.update {
|
||||
if (it.screen == it.startScreen) {
|
||||
it.copy(screen = MessageBackupsScreen.COMPLETED)
|
||||
when (it.stage) {
|
||||
MessageBackupsStage.EDUCATION -> it.copy(stage = MessageBackupsStage.BACKUP_KEY_EDUCATION)
|
||||
MessageBackupsStage.BACKUP_KEY_EDUCATION -> it.copy(stage = MessageBackupsStage.BACKUP_KEY_RECORD)
|
||||
MessageBackupsStage.BACKUP_KEY_RECORD -> it.copy(stage = MessageBackupsStage.TYPE_SELECTION)
|
||||
MessageBackupsStage.TYPE_SELECTION -> validateTypeAndUpdateState(it)
|
||||
MessageBackupsStage.CHECKOUT_SHEET -> validateGatewayAndUpdateState(it)
|
||||
MessageBackupsStage.CREATING_IN_APP_PAYMENT -> error("This is driven by an async coroutine.")
|
||||
MessageBackupsStage.PROCESS_PAYMENT -> error("This is driven by an async coroutine.")
|
||||
MessageBackupsStage.PROCESS_FREE -> error("This is driven by an async coroutine.")
|
||||
MessageBackupsStage.COMPLETED -> error("Unsupported state transition from terminal state COMPLETED")
|
||||
MessageBackupsStage.FAILURE -> error("Unsupported state transition from terminal state FAILURE")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun goToPreviousStage() {
|
||||
internalStateFlow.update {
|
||||
if (it.stage == it.startScreen) {
|
||||
it.copy(stage = MessageBackupsStage.COMPLETED)
|
||||
} else {
|
||||
val previousScreen = when (it.screen) {
|
||||
MessageBackupsScreen.EDUCATION -> MessageBackupsScreen.COMPLETED
|
||||
MessageBackupsScreen.PIN_EDUCATION -> MessageBackupsScreen.EDUCATION
|
||||
MessageBackupsScreen.PIN_CONFIRMATION -> MessageBackupsScreen.PIN_EDUCATION
|
||||
MessageBackupsScreen.TYPE_SELECTION -> MessageBackupsScreen.PIN_CONFIRMATION
|
||||
MessageBackupsScreen.CHECKOUT_SHEET -> MessageBackupsScreen.TYPE_SELECTION
|
||||
MessageBackupsScreen.CREATING_IN_APP_PAYMENT -> MessageBackupsScreen.TYPE_SELECTION
|
||||
MessageBackupsScreen.PROCESS_PAYMENT -> MessageBackupsScreen.TYPE_SELECTION
|
||||
MessageBackupsScreen.PROCESS_CANCELLATION -> MessageBackupsScreen.TYPE_SELECTION
|
||||
MessageBackupsScreen.PROCESS_FREE -> MessageBackupsScreen.TYPE_SELECTION
|
||||
MessageBackupsScreen.CANCELLATION_DIALOG -> MessageBackupsScreen.TYPE_SELECTION
|
||||
MessageBackupsScreen.COMPLETED -> error("Unsupported state transition from terminal state COMPLETED")
|
||||
val previousScreen = when (it.stage) {
|
||||
MessageBackupsStage.EDUCATION -> MessageBackupsStage.COMPLETED
|
||||
MessageBackupsStage.BACKUP_KEY_EDUCATION -> MessageBackupsStage.EDUCATION
|
||||
MessageBackupsStage.BACKUP_KEY_RECORD -> MessageBackupsStage.BACKUP_KEY_EDUCATION
|
||||
MessageBackupsStage.TYPE_SELECTION -> MessageBackupsStage.BACKUP_KEY_RECORD
|
||||
MessageBackupsStage.CHECKOUT_SHEET -> MessageBackupsStage.TYPE_SELECTION
|
||||
MessageBackupsStage.CREATING_IN_APP_PAYMENT -> MessageBackupsStage.CREATING_IN_APP_PAYMENT
|
||||
MessageBackupsStage.PROCESS_PAYMENT -> MessageBackupsStage.PROCESS_PAYMENT
|
||||
MessageBackupsStage.PROCESS_FREE -> MessageBackupsStage.PROCESS_FREE
|
||||
MessageBackupsStage.COMPLETED -> error("Unsupported state transition from terminal state COMPLETED")
|
||||
MessageBackupsStage.FAILURE -> error("Unsupported state transition from terminal state FAILURE")
|
||||
}
|
||||
|
||||
it.copy(screen = previousScreen)
|
||||
it.copy(stage = previousScreen)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun displayCancellationDialog() {
|
||||
internalStateFlow.update {
|
||||
check(it.screen == MessageBackupsScreen.TYPE_SELECTION)
|
||||
it.copy(screen = MessageBackupsScreen.CANCELLATION_DIALOG)
|
||||
}
|
||||
}
|
||||
|
||||
fun onPinEntryUpdated(pin: String) {
|
||||
internalPinState.value = pin
|
||||
}
|
||||
|
||||
fun onPinKeyboardTypeUpdated(pinKeyboardType: PinKeyboardType) {
|
||||
internalStateFlow.update { it.copy(pinKeyboardType = pinKeyboardType) }
|
||||
}
|
||||
|
||||
fun onPaymentMethodUpdated(paymentMethod: InAppPaymentData.PaymentMethodType) {
|
||||
internalStateFlow.update { it.copy(selectedPaymentMethod = paymentMethod) }
|
||||
}
|
||||
|
||||
fun onMessageBackupTierUpdated(messageBackupTier: MessageBackupTier, messageBackupTierLabel: String) {
|
||||
internalStateFlow.update {
|
||||
it.copy(
|
||||
@@ -137,79 +165,44 @@ class MessageBackupsFlowViewModel : ViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
fun onCancellationComplete() {
|
||||
if (isDowngrading) {
|
||||
SignalStore.backup.areBackupsEnabled = true
|
||||
SignalStore.backup.backupTier = MessageBackupTier.FREE
|
||||
|
||||
// TODO [message-backups] -- Trigger backup now?
|
||||
}
|
||||
}
|
||||
|
||||
private fun validatePinAndUpdateState(state: MessageBackupsFlowState, pin: String): MessageBackupsFlowState {
|
||||
val pinHash = SignalStore.svr.localPinHash
|
||||
|
||||
if (pinHash == null || TextUtils.isEmpty(pin) || pin.length < SvrConstants.MINIMUM_PIN_LENGTH) {
|
||||
return state.copy(
|
||||
screen = MessageBackupsScreen.PIN_CONFIRMATION,
|
||||
displayIncorrectPinError = true
|
||||
)
|
||||
}
|
||||
|
||||
if (!verifyLocalPinHash(pinHash, pin)) {
|
||||
return state.copy(
|
||||
screen = MessageBackupsScreen.PIN_CONFIRMATION,
|
||||
displayIncorrectPinError = true
|
||||
)
|
||||
}
|
||||
|
||||
internalPinState.value = ""
|
||||
return state.copy(
|
||||
screen = MessageBackupsScreen.TYPE_SELECTION,
|
||||
displayIncorrectPinError = false
|
||||
)
|
||||
}
|
||||
|
||||
private fun validateTypeAndUpdateState(state: MessageBackupsFlowState): MessageBackupsFlowState {
|
||||
return when (state.selectedMessageBackupTier!!) {
|
||||
MessageBackupTier.FREE -> {
|
||||
if (SignalStore.backup.backupTier == MessageBackupTier.PAID) {
|
||||
isDowngrading = true
|
||||
state.copy(screen = MessageBackupsScreen.PROCESS_CANCELLATION)
|
||||
} else {
|
||||
SignalStore.backup.areBackupsEnabled = true
|
||||
SignalStore.backup.backupTier = MessageBackupTier.FREE
|
||||
SignalStore.backup.areBackupsEnabled = true
|
||||
SignalStore.backup.backupTier = MessageBackupTier.FREE
|
||||
|
||||
state.copy(screen = MessageBackupsScreen.PROCESS_FREE)
|
||||
}
|
||||
state.copy(stage = MessageBackupsStage.COMPLETED)
|
||||
}
|
||||
MessageBackupTier.PAID -> state.copy(screen = MessageBackupsScreen.CHECKOUT_SHEET)
|
||||
|
||||
MessageBackupTier.PAID -> state.copy(stage = MessageBackupsStage.CHECKOUT_SHEET)
|
||||
}
|
||||
}
|
||||
|
||||
private fun validateGatewayAndUpdateState(state: MessageBackupsFlowState): MessageBackupsFlowState {
|
||||
val backupsType = state.availableBackupTypes.first { it.tier == state.selectedMessageBackupTier }
|
||||
check(state.selectedMessageBackupTier == MessageBackupTier.PAID)
|
||||
check(state.availableBackupTypes.any { it.tier == state.selectedMessageBackupTier })
|
||||
check(state.hasBackupSubscriberAvailable)
|
||||
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
withContext(Dispatchers.Main) {
|
||||
internalStateFlow.update { it.copy(inAppPayment = null) }
|
||||
}
|
||||
|
||||
val currency = SignalStore.inAppPayments.getSubscriptionCurrency(InAppPaymentSubscriberRecord.Type.BACKUP)
|
||||
val paidFiat = AppDependencies.billingApi.queryProduct()!!.price
|
||||
|
||||
SignalDatabase.inAppPayments.clearCreated()
|
||||
val id = SignalDatabase.inAppPayments.insert(
|
||||
type = InAppPaymentType.RECURRING_BACKUP,
|
||||
state = InAppPaymentTable.State.CREATED,
|
||||
subscriberId = null,
|
||||
subscriberId = InAppPaymentsRepository.requireSubscriber(InAppPaymentSubscriberRecord.Type.BACKUP).subscriberId,
|
||||
endOfPeriod = null,
|
||||
inAppPaymentData = InAppPaymentData(
|
||||
badge = null,
|
||||
label = state.selectedMessageBackupTierLabel!!,
|
||||
amount = if (backupsType is MessageBackupsType.Paid) backupsType.pricePerMonth.toFiatValue() else FiatMoney(BigDecimal.ZERO, currency).toFiatValue(),
|
||||
amount = paidFiat.toFiatValue(),
|
||||
level = SubscriptionsConfiguration.BACKUPS_LEVEL.toLong(),
|
||||
recipientId = Recipient.self().id.serialize(),
|
||||
paymentMethodType = state.selectedPaymentMethod!!,
|
||||
paymentMethodType = InAppPaymentData.PaymentMethodType.GOOGLE_PLAY_BILLING,
|
||||
redemption = InAppPaymentData.RedemptionState(
|
||||
stage = InAppPaymentData.RedemptionState.Stage.INIT
|
||||
)
|
||||
@@ -219,10 +212,57 @@ class MessageBackupsFlowViewModel : ViewModel() {
|
||||
val inAppPayment = SignalDatabase.inAppPayments.getById(id)!!
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
internalStateFlow.update { it.copy(inAppPayment = inAppPayment, screen = MessageBackupsScreen.PROCESS_PAYMENT) }
|
||||
internalStateFlow.update { it.copy(inAppPayment = inAppPayment, stage = MessageBackupsStage.PROCESS_PAYMENT) }
|
||||
}
|
||||
}
|
||||
|
||||
return state.copy(screen = MessageBackupsScreen.CREATING_IN_APP_PAYMENT)
|
||||
return state.copy(stage = MessageBackupsStage.CREATING_IN_APP_PAYMENT)
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures we have a SubscriberId created and available for use. This is considered safe because
|
||||
* the screen this is called in is assumed to only be accessible if the user does not currently have
|
||||
* a subscription.
|
||||
*/
|
||||
private suspend fun ensureSubscriberIdForBackups() {
|
||||
val product = AppDependencies.billingApi.queryProduct() ?: error("No product available.")
|
||||
SignalStore.inAppPayments.setSubscriberCurrency(product.price.currency, InAppPaymentSubscriberRecord.Type.BACKUP)
|
||||
RecurringInAppPaymentRepository.ensureSubscriberId(InAppPaymentSubscriberRecord.Type.BACKUP).blockingAwait()
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles a successful BillingPurchaseResult. Updates the in app payment, enqueues the appropriate job chain,
|
||||
* and handles any resulting error. Like donations, we will wait up to 10s for the completion of the job chain.
|
||||
*/
|
||||
@OptIn(FlowPreview::class)
|
||||
private suspend fun handleSuccess(result: BillingPurchaseResult.Success, inAppPaymentId: InAppPaymentTable.InAppPaymentId) {
|
||||
withContext(Dispatchers.IO) {
|
||||
val inAppPayment = SignalDatabase.inAppPayments.getById(inAppPaymentId)!!
|
||||
SignalDatabase.inAppPayments.update(
|
||||
inAppPayment.copy(
|
||||
data = inAppPayment.data.copy(
|
||||
redemption = inAppPayment.data.redemption!!.copy(
|
||||
googlePlayBillingPurchaseToken = result.purchaseToken
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
InAppPaymentPurchaseTokenJob.createJobChain(inAppPayment).enqueue()
|
||||
}
|
||||
|
||||
val terminalInAppPayment = withContext(Dispatchers.IO) {
|
||||
InAppPaymentsRepository.observeUpdates(inAppPaymentId).asFlow()
|
||||
.filter { it.state == InAppPaymentTable.State.END }
|
||||
.take(1)
|
||||
.timeout(10.seconds)
|
||||
.first()
|
||||
}
|
||||
|
||||
if (terminalInAppPayment.data.error != null) {
|
||||
throw InAppPaymentError(terminalInAppPayment.data.error)
|
||||
} else {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.backup.v2.ui.subscription
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
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.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.signal.core.ui.Buttons
|
||||
import org.signal.core.ui.Previews
|
||||
import org.signal.core.ui.Scaffolds
|
||||
import org.signal.core.ui.SignalPreview
|
||||
import org.thoughtcrime.securesms.R
|
||||
|
||||
/**
|
||||
* Screen detailing how a backups key is used to restore a backup
|
||||
*/
|
||||
@Composable
|
||||
fun MessageBackupsKeyEducationScreen(
|
||||
onNavigationClick: () -> Unit = {},
|
||||
onNextClick: () -> Unit = {}
|
||||
) {
|
||||
Scaffolds.Settings(
|
||||
title = "",
|
||||
navigationIconPainter = painterResource(R.drawable.symbol_arrow_left_24),
|
||||
onNavigationClick = onNavigationClick
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(it)
|
||||
.padding(horizontal = dimensionResource(R.dimen.core_ui__gutter))
|
||||
.fillMaxSize(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.symbol_key_24),
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier
|
||||
.padding(top = 24.dp)
|
||||
.size(80.dp)
|
||||
.background(
|
||||
color = MaterialTheme.colorScheme.primaryContainer,
|
||||
shape = CircleShape
|
||||
)
|
||||
.padding(16.dp)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.MessageBackupsKeyEducationScreen__your_backup_key),
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
modifier = Modifier.padding(top = 16.dp)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.MessageBackupsKeyEducationScreen__your_backup_key_is_a),
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 12.dp)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.MessageBackupsKeyEducationScreen__if_you_forget_your_key),
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 16.dp)
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f)
|
||||
.padding(bottom = 24.dp)
|
||||
) {
|
||||
Buttons.LargeTonal(
|
||||
onClick = onNextClick,
|
||||
modifier = Modifier.align(Alignment.BottomEnd)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.MessageBackupsKeyEducationScreen__next)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@Composable
|
||||
private fun MessageBackupsKeyEducationScreenPreview() {
|
||||
Previews.Preview {
|
||||
MessageBackupsKeyEducationScreen()
|
||||
}
|
||||
}
|
||||
@@ -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.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
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.stringResource
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import kotlinx.coroutines.launch
|
||||
import org.signal.core.ui.BottomSheets
|
||||
import org.signal.core.ui.Buttons
|
||||
import org.signal.core.ui.Previews
|
||||
import org.signal.core.ui.Scaffolds
|
||||
import org.signal.core.ui.SignalPreview
|
||||
import org.signal.core.ui.theme.SignalTheme
|
||||
import org.signal.core.util.Hex
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.whispersystems.signalservice.api.backup.BackupKey
|
||||
import kotlin.random.Random
|
||||
|
||||
/**
|
||||
* Screen displaying the backup key allowing the user to write it down
|
||||
* or copy it.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun MessageBackupsKeyRecordScreen(
|
||||
backupKey: BackupKey,
|
||||
onNavigationClick: () -> Unit = {},
|
||||
onCopyToClipboardClick: (String) -> Unit = {},
|
||||
onNextClick: () -> Unit = {}
|
||||
) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val sheetState = rememberModalBottomSheetState(
|
||||
skipPartiallyExpanded = true
|
||||
)
|
||||
|
||||
Scaffolds.Settings(
|
||||
title = "",
|
||||
navigationIconPainter = painterResource(R.drawable.symbol_arrow_left_24),
|
||||
onNavigationClick = onNavigationClick
|
||||
) { paddingValues ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(paddingValues)
|
||||
.padding(horizontal = dimensionResource(R.dimen.core_ui__gutter))
|
||||
.fillMaxSize(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.symbol_lock_24),
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier
|
||||
.padding(top = 24.dp)
|
||||
.size(80.dp)
|
||||
.background(
|
||||
color = MaterialTheme.colorScheme.primaryContainer,
|
||||
shape = CircleShape
|
||||
)
|
||||
.padding(16.dp)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.MessageBackupsKeyRecordScreen__record_your_backup_key),
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
modifier = Modifier.padding(top = 16.dp)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.MessageBackupsKeyRecordScreen__this_key_is_required_to_recover),
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 12.dp)
|
||||
)
|
||||
|
||||
val backupKeyString = remember(backupKey) {
|
||||
backupKey.value.toList().chunked(2).map { Hex.toStringCondensed(it.toByteArray()) }.joinToString(" ")
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(top = 24.dp, bottom = 16.dp)
|
||||
.background(
|
||||
color = SignalTheme.colors.colorSurface1,
|
||||
shape = RoundedCornerShape(10.dp)
|
||||
)
|
||||
.padding(24.dp)
|
||||
) {
|
||||
Text(
|
||||
text = backupKeyString,
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
.copy(
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight(400),
|
||||
letterSpacing = 1.44.sp,
|
||||
lineHeight = 36.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
fontFamily = FontFamily.Monospace
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
Buttons.Small(
|
||||
onClick = { onCopyToClipboardClick(backupKeyString) }
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.MessageBackupsKeyRecordScreen__copy_to_clipboard)
|
||||
)
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f)
|
||||
.padding(bottom = 24.dp)
|
||||
) {
|
||||
Buttons.LargeTonal(
|
||||
onClick = {
|
||||
coroutineScope.launch {
|
||||
sheetState.show()
|
||||
}
|
||||
},
|
||||
modifier = Modifier.align(Alignment.BottomEnd)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.MessageBackupsKeyRecordScreen__next)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (sheetState.isVisible) {
|
||||
ModalBottomSheet(
|
||||
dragHandle = null,
|
||||
onDismissRequest = {
|
||||
coroutineScope.launch {
|
||||
sheetState.hide()
|
||||
}
|
||||
}
|
||||
) {
|
||||
BottomSheetContent(
|
||||
onContinueClick = onNextClick,
|
||||
onSeeKeyAgainClick = {
|
||||
coroutineScope.launch {
|
||||
sheetState.hide()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BottomSheetContent(
|
||||
onContinueClick: () -> Unit,
|
||||
onSeeKeyAgainClick: () -> Unit
|
||||
) {
|
||||
var checked by remember { mutableStateOf(false) }
|
||||
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = dimensionResource(R.dimen.core_ui__gutter))
|
||||
) {
|
||||
BottomSheets.Handle()
|
||||
Text(
|
||||
text = stringResource(R.string.MessageBackupsKeyRecordScreen__keep_your_key_safe),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(top = 30.dp)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.MessageBackupsKeyRecordScreen__signal_will_not),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(top = 12.dp)
|
||||
)
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(vertical = 24.dp)
|
||||
) {
|
||||
Checkbox(
|
||||
checked = checked,
|
||||
onCheckedChange = { checked = it }
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.MessageBackupsKeyRecordScreen__ive_recorded_my_key),
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
}
|
||||
|
||||
Buttons.LargeTonal(
|
||||
enabled = checked,
|
||||
onClick = onContinueClick,
|
||||
modifier = Modifier.padding(bottom = 16.dp)
|
||||
) {
|
||||
Text(text = stringResource(R.string.MessageBackupsKeyRecordScreen__continue))
|
||||
}
|
||||
|
||||
TextButton(
|
||||
onClick = onSeeKeyAgainClick,
|
||||
modifier = Modifier.padding(bottom = 24.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.MessageBackupsKeyRecordScreen__see_key_again)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@Composable
|
||||
private fun MessageBackupsKeyRecordScreenPreview() {
|
||||
Previews.Preview {
|
||||
MessageBackupsKeyRecordScreen(
|
||||
backupKey = BackupKey(Random.nextBytes(32))
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,231 +0,0 @@
|
||||
/*
|
||||
* 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.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TextField
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.res.dimensionResource
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.signal.core.ui.Buttons
|
||||
import org.signal.core.ui.Previews
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.lock.v2.PinKeyboardType
|
||||
|
||||
/**
|
||||
* Screen which requires the user to enter their pin before enabling backups.
|
||||
*/
|
||||
@Composable
|
||||
fun MessageBackupsPinConfirmationScreen(
|
||||
pin: String,
|
||||
isPinIncorrect: Boolean,
|
||||
onPinChanged: (String) -> Unit,
|
||||
pinKeyboardType: PinKeyboardType,
|
||||
onPinKeyboardTypeSelected: (PinKeyboardType) -> Unit,
|
||||
onNextClick: () -> Unit,
|
||||
onCreateNewPinClick: () -> Unit
|
||||
) {
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
Surface {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = dimensionResource(id = R.dimen.core_ui__gutter))
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f)
|
||||
) {
|
||||
item {
|
||||
Text(
|
||||
text = stringResource(id = R.string.MessageBackupsPinConfirmationScreen__enter_your_pin),
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
modifier = Modifier.padding(top = 40.dp)
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Text(
|
||||
text = stringResource(id = R.string.MessageBackupsPinConfirmationScreen__enter_your_signal_pin_to_enable_backups),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 16.dp)
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
val keyboardType = remember(pinKeyboardType) {
|
||||
when (pinKeyboardType) {
|
||||
PinKeyboardType.NUMERIC -> KeyboardType.NumberPassword
|
||||
PinKeyboardType.ALPHA_NUMERIC -> KeyboardType.Password
|
||||
}
|
||||
}
|
||||
|
||||
TextField(
|
||||
value = pin,
|
||||
onValueChange = onPinChanged,
|
||||
textStyle = LocalTextStyle.current.copy(textAlign = TextAlign.Center),
|
||||
keyboardActions = KeyboardActions(
|
||||
onDone = { onNextClick() }
|
||||
),
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = keyboardType,
|
||||
imeAction = ImeAction.Done
|
||||
),
|
||||
modifier = Modifier
|
||||
.padding(top = 72.dp)
|
||||
.fillMaxWidth()
|
||||
.focusRequester(focusRequester),
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
isError = isPinIncorrect,
|
||||
supportingText = {
|
||||
if (isPinIncorrect) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.PinRestoreEntryFragment_incorrect_pin),
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 12.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 48.dp)
|
||||
) {
|
||||
PinKeyboardTypeToggle(
|
||||
pinKeyboardType = pinKeyboardType,
|
||||
onPinKeyboardTypeSelected = onPinKeyboardTypeSelected
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 16.dp)
|
||||
) {
|
||||
if (isPinIncorrect) {
|
||||
TextButton(onClick = onCreateNewPinClick) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.MessageBackupsPinConfirmationScreen__create_new_pin)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
Buttons.LargeTonal(
|
||||
onClick = onNextClick
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.MessageBackupsPinConfirmationScreen__next)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun MessageBackupsPinConfirmationScreenPreview() {
|
||||
Previews.Preview {
|
||||
MessageBackupsPinConfirmationScreen(
|
||||
pin = "",
|
||||
isPinIncorrect = true,
|
||||
onPinChanged = {},
|
||||
pinKeyboardType = PinKeyboardType.ALPHA_NUMERIC,
|
||||
onPinKeyboardTypeSelected = {},
|
||||
onNextClick = {},
|
||||
onCreateNewPinClick = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun PinKeyboardTypeTogglePreview() {
|
||||
Previews.Preview {
|
||||
var type by remember { mutableStateOf(PinKeyboardType.ALPHA_NUMERIC) }
|
||||
PinKeyboardTypeToggle(
|
||||
pinKeyboardType = type,
|
||||
onPinKeyboardTypeSelected = { type = it }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PinKeyboardTypeToggle(
|
||||
pinKeyboardType: PinKeyboardType,
|
||||
onPinKeyboardTypeSelected: (PinKeyboardType) -> Unit
|
||||
) {
|
||||
val callback = remember(pinKeyboardType) {
|
||||
{ onPinKeyboardTypeSelected(pinKeyboardType.other) }
|
||||
}
|
||||
|
||||
val iconRes = remember(pinKeyboardType) {
|
||||
when (pinKeyboardType) {
|
||||
PinKeyboardType.NUMERIC -> R.drawable.symbol_keyboard_24
|
||||
PinKeyboardType.ALPHA_NUMERIC -> R.drawable.symbol_number_pad_24
|
||||
}
|
||||
}
|
||||
|
||||
TextButton(onClick = callback) {
|
||||
Icon(
|
||||
painter = painterResource(id = iconRes),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.padding(end = 8.dp)
|
||||
)
|
||||
Text(
|
||||
text = stringResource(id = R.string.MessageBackupsPinConfirmationScreen__switch_keyboard)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.backup.v2.ui.subscription
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
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.dimensionResource
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.signal.core.ui.Buttons
|
||||
import org.signal.core.ui.Previews
|
||||
import org.signal.core.ui.Scaffolds
|
||||
import org.thoughtcrime.securesms.R
|
||||
|
||||
/**
|
||||
* Explanation screen that details how the user's pin is utilized with backups,
|
||||
* and how long they should make their pin.
|
||||
*/
|
||||
@Composable
|
||||
fun MessageBackupsPinEducationScreen(
|
||||
onNavigationClick: () -> Unit,
|
||||
onCreatePinClick: () -> Unit,
|
||||
onUseCurrentPinClick: () -> Unit,
|
||||
recommendedPinSize: Int
|
||||
) {
|
||||
Scaffolds.Settings(
|
||||
title = stringResource(id = R.string.RemoteBackupsSettingsFragment__signal_backups),
|
||||
onNavigationClick = onNavigationClick,
|
||||
navigationIconPainter = painterResource(id = R.drawable.symbol_arrow_left_24)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(it)
|
||||
.padding(horizontal = dimensionResource(id = R.dimen.core_ui__gutter))
|
||||
) {
|
||||
LazyColumn(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f)
|
||||
) {
|
||||
item {
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.ic_signal_logo_large), // TODO [message-backups] Finalized image
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.padding(top = 48.dp)
|
||||
.size(88.dp)
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Text(
|
||||
text = stringResource(id = R.string.MessageBackupsPinEducationScreen__pins_protect_your_backup),
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
modifier = Modifier.padding(top = 16.dp)
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Text(
|
||||
text = stringResource(id = R.string.MessageBackupsPinEducationScreen__your_signal_pin_lets_you),
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
modifier = Modifier.padding(top = 16.dp)
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Text(
|
||||
text = stringResource(id = R.string.MessageBackupsPinEducationScreen__if_you_forget_your_pin),
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
modifier = Modifier.padding(top = 16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Buttons.LargePrimary(
|
||||
onClick = onUseCurrentPinClick,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.MessageBackupsPinEducationScreen__use_current_signal_pin)
|
||||
)
|
||||
}
|
||||
|
||||
TextButton(
|
||||
onClick = onCreatePinClick,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.MessageBackupsPinEducationScreen__create_new_pin)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun MessageBackupsPinScreenPreview() {
|
||||
Previews.Preview {
|
||||
MessageBackupsPinEducationScreen(
|
||||
onNavigationClick = {},
|
||||
onCreatePinClick = {},
|
||||
onUseCurrentPinClick = {},
|
||||
recommendedPinSize = 16
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.backup.v2.ui.subscription
|
||||
|
||||
enum class MessageBackupsScreen {
|
||||
EDUCATION,
|
||||
PIN_EDUCATION,
|
||||
PIN_CONFIRMATION,
|
||||
TYPE_SELECTION,
|
||||
CANCELLATION_DIALOG,
|
||||
CHECKOUT_SHEET,
|
||||
CREATING_IN_APP_PAYMENT,
|
||||
PROCESS_PAYMENT,
|
||||
PROCESS_CANCELLATION,
|
||||
PROCESS_FREE,
|
||||
COMPLETED;
|
||||
|
||||
fun isAfter(other: MessageBackupsScreen): Boolean = ordinal > other.ordinal
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.backup.v2.ui.subscription
|
||||
|
||||
/**
|
||||
* Pipeline for subscribing to message backups.
|
||||
*/
|
||||
enum class MessageBackupsStage(
|
||||
val route: Route
|
||||
) {
|
||||
EDUCATION(route = Route.EDUCATION),
|
||||
BACKUP_KEY_EDUCATION(route = Route.BACKUP_KEY_EDUCATION),
|
||||
BACKUP_KEY_RECORD(route = Route.BACKUP_KEY_RECORD),
|
||||
TYPE_SELECTION(route = Route.TYPE_SELECTION),
|
||||
CHECKOUT_SHEET(route = Route.TYPE_SELECTION),
|
||||
CREATING_IN_APP_PAYMENT(route = Route.TYPE_SELECTION),
|
||||
PROCESS_PAYMENT(route = Route.TYPE_SELECTION),
|
||||
PROCESS_FREE(route = Route.TYPE_SELECTION),
|
||||
COMPLETED(route = Route.TYPE_SELECTION),
|
||||
FAILURE(route = Route.TYPE_SELECTION);
|
||||
|
||||
/**
|
||||
* Compose navigation route to display while in a given stage.
|
||||
*/
|
||||
enum class Route {
|
||||
EDUCATION,
|
||||
BACKUP_KEY_EDUCATION,
|
||||
BACKUP_KEY_RECORD,
|
||||
TYPE_SELECTION;
|
||||
|
||||
fun isAfter(other: Route): Boolean = ordinal > other.ordinal
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,6 @@ import androidx.compose.foundation.text.ClickableText
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -71,8 +70,7 @@ fun MessageBackupsTypeSelectionScreen(
|
||||
onMessageBackupsTierSelected: (MessageBackupTier) -> Unit,
|
||||
onNavigationClick: () -> Unit,
|
||||
onReadMoreClicked: () -> Unit,
|
||||
onNextClicked: () -> Unit,
|
||||
onCancelSubscriptionClicked: () -> Unit
|
||||
onNextClicked: () -> Unit
|
||||
) {
|
||||
Scaffolds.Settings(
|
||||
title = "",
|
||||
@@ -170,17 +168,6 @@ fun MessageBackupsTypeSelectionScreen(
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
if (hasCurrentBackupTier) {
|
||||
TextButton(
|
||||
onClick = onCancelSubscriptionClicked,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 14.dp)
|
||||
) {
|
||||
Text(text = stringResource(id = R.string.MessageBackupsTypeSelectionScreen__cancel_subscription))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -198,7 +185,6 @@ private fun MessageBackupsTypeSelectionScreenPreview() {
|
||||
onNavigationClick = {},
|
||||
onReadMoreClicked = {},
|
||||
onNextClicked = {},
|
||||
onCancelSubscriptionClicked = {},
|
||||
currentBackupTier = null
|
||||
)
|
||||
}
|
||||
@@ -217,7 +203,6 @@ private fun MessageBackupsTypeSelectionScreenWithCurrentTierPreview() {
|
||||
onNavigationClick = {},
|
||||
onReadMoreClicked = {},
|
||||
onNextClicked = {},
|
||||
onCancelSubscriptionClicked = {},
|
||||
currentBackupTier = MessageBackupTier.PAID
|
||||
)
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ import org.thoughtcrime.securesms.jobs.BackupRestoreMediaJob
|
||||
import org.thoughtcrime.securesms.jobs.SyncArchivedMediaJob
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.registration.RegistrationUtil
|
||||
import org.thoughtcrime.securesms.registration.util.RegistrationUtil
|
||||
import java.io.InputStream
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.backup.v2.util
|
||||
|
||||
import okio.ByteString
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import org.signal.core.util.Base64
|
||||
import org.signal.core.util.orNull
|
||||
import org.thoughtcrime.securesms.attachments.ArchivedAttachment
|
||||
import org.thoughtcrime.securesms.attachments.Attachment
|
||||
import org.thoughtcrime.securesms.attachments.Cdn
|
||||
import org.thoughtcrime.securesms.attachments.DatabaseAttachment
|
||||
import org.thoughtcrime.securesms.attachments.PointerAttachment
|
||||
import org.thoughtcrime.securesms.attachments.TombstoneAttachment
|
||||
import org.thoughtcrime.securesms.backup.v2.BackupRepository.getMediaName
|
||||
import org.thoughtcrime.securesms.backup.v2.ImportState
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.FilePointer
|
||||
import org.thoughtcrime.securesms.database.AttachmentTable
|
||||
import org.thoughtcrime.securesms.stickers.StickerLocator
|
||||
import org.whispersystems.signalservice.api.backup.MediaName
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentPointer
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentRemoteId
|
||||
import org.whispersystems.signalservice.api.util.UuidUtil
|
||||
import java.util.Optional
|
||||
|
||||
/**
|
||||
* Converts a [FilePointer] to a local [Attachment] object for inserting into the database.
|
||||
*/
|
||||
fun FilePointer?.toLocalAttachment(
|
||||
importState: ImportState,
|
||||
voiceNote: Boolean = false,
|
||||
borderless: Boolean = false,
|
||||
gif: Boolean = false,
|
||||
wasDownloaded: Boolean = false,
|
||||
stickerLocator: StickerLocator? = null,
|
||||
contentType: String? = this?.contentType,
|
||||
fileName: String? = this?.fileName,
|
||||
uuid: ByteString? = null
|
||||
): Attachment? {
|
||||
if (this == null) return null
|
||||
|
||||
if (this.attachmentLocator != null) {
|
||||
val signalAttachmentPointer = SignalServiceAttachmentPointer(
|
||||
cdnNumber = this.attachmentLocator.cdnNumber,
|
||||
remoteId = SignalServiceAttachmentRemoteId.from(attachmentLocator.cdnKey),
|
||||
contentType = contentType,
|
||||
key = this.attachmentLocator.key.toByteArray(),
|
||||
size = Optional.ofNullable(attachmentLocator.size),
|
||||
preview = Optional.empty(),
|
||||
width = this.width ?: 0,
|
||||
height = this.height ?: 0,
|
||||
digest = Optional.ofNullable(this.attachmentLocator.digest.toByteArray()),
|
||||
incrementalDigest = Optional.ofNullable(this.incrementalMac?.toByteArray()),
|
||||
incrementalMacChunkSize = this.incrementalMacChunkSize ?: 0,
|
||||
fileName = Optional.ofNullable(fileName),
|
||||
voiceNote = voiceNote,
|
||||
isBorderless = borderless,
|
||||
isGif = gif,
|
||||
caption = Optional.ofNullable(this.caption),
|
||||
blurHash = Optional.ofNullable(this.blurHash),
|
||||
uploadTimestamp = this.attachmentLocator.uploadTimestamp,
|
||||
uuid = 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 (this.invalidAttachmentLocator != null) {
|
||||
return TombstoneAttachment(
|
||||
contentType = contentType,
|
||||
incrementalMac = this.incrementalMac?.toByteArray(),
|
||||
incrementalMacChunkSize = this.incrementalMacChunkSize,
|
||||
width = this.width,
|
||||
height = this.height,
|
||||
caption = this.caption,
|
||||
fileName = this.fileName,
|
||||
blurHash = this.blurHash,
|
||||
voiceNote = voiceNote,
|
||||
borderless = borderless,
|
||||
gif = gif,
|
||||
quote = false,
|
||||
stickerLocator = stickerLocator,
|
||||
uuid = UuidUtil.fromByteStringOrNull(uuid)
|
||||
)
|
||||
} else if (this.backupLocator != null) {
|
||||
return ArchivedAttachment(
|
||||
contentType = contentType,
|
||||
size = this.backupLocator.size.toLong(),
|
||||
cdn = this.backupLocator.transitCdnNumber ?: Cdn.CDN_0.cdnNumber,
|
||||
key = this.backupLocator.key.toByteArray(),
|
||||
iv = null,
|
||||
cdnKey = this.backupLocator.transitCdnKey,
|
||||
archiveCdn = this.backupLocator.cdnNumber,
|
||||
archiveMediaName = this.backupLocator.mediaName,
|
||||
archiveMediaId = importState.backupKey.deriveMediaId(MediaName(this.backupLocator.mediaName)).encode(),
|
||||
archiveThumbnailMediaId = importState.backupKey.deriveMediaId(MediaName.forThumbnailFromMediaName(this.backupLocator.mediaName)).encode(),
|
||||
digest = this.backupLocator.digest.toByteArray(),
|
||||
incrementalMac = this.incrementalMac?.toByteArray(),
|
||||
incrementalMacChunkSize = this.incrementalMacChunkSize,
|
||||
width = this.width,
|
||||
height = this.height,
|
||||
caption = this.caption,
|
||||
blurHash = this.blurHash,
|
||||
voiceNote = voiceNote,
|
||||
borderless = borderless,
|
||||
gif = gif,
|
||||
quote = false,
|
||||
stickerLocator = stickerLocator,
|
||||
uuid = UuidUtil.fromByteStringOrNull(uuid),
|
||||
fileName = fileName
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mediaArchiveEnabled True if this user has enable media backup, otherwise false.
|
||||
*/
|
||||
fun DatabaseAttachment.toRemoteFilePointer(mediaArchiveEnabled: Boolean): FilePointer {
|
||||
val builder = FilePointer.Builder()
|
||||
builder.contentType = this.contentType?.takeUnless { it.isBlank() }
|
||||
builder.incrementalMac = this.incrementalDigest?.toByteString()
|
||||
builder.incrementalMacChunkSize = this.incrementalMacChunkSize.takeIf { it > 0 }
|
||||
builder.fileName = this.fileName
|
||||
builder.width = this.width.takeIf { it > 0 }
|
||||
builder.height = this.height.takeIf { it > 0 }
|
||||
builder.caption = this.caption
|
||||
builder.blurHash = this.blurHash?.hash
|
||||
|
||||
if (this.remoteKey.isNullOrBlank() || this.remoteDigest == null || this.size == 0L) {
|
||||
builder.invalidAttachmentLocator = FilePointer.InvalidAttachmentLocator()
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
if (this.transferState == AttachmentTable.TRANSFER_PROGRESS_PERMANENT_FAILURE && this.archiveTransferState != AttachmentTable.ArchiveTransferState.FINISHED) {
|
||||
builder.invalidAttachmentLocator = FilePointer.InvalidAttachmentLocator()
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
val pending = this.archiveTransferState != AttachmentTable.ArchiveTransferState.FINISHED && (this.transferState != AttachmentTable.TRANSFER_PROGRESS_DONE && this.transferState != AttachmentTable.TRANSFER_RESTORE_OFFLOADED)
|
||||
|
||||
if (mediaArchiveEnabled && !pending) {
|
||||
builder.backupLocator = FilePointer.BackupLocator(
|
||||
mediaName = this.archiveMediaName ?: this.getMediaName().toString(),
|
||||
cdnNumber = if (this.archiveMediaName != null) this.archiveCdn else Cdn.CDN_3.cdnNumber, // TODO [backup]: Update when new proto with optional cdn is landed
|
||||
key = Base64.decode(remoteKey).toByteString(),
|
||||
size = this.size.toInt(),
|
||||
digest = this.remoteDigest.toByteString(),
|
||||
transitCdnNumber = this.cdn.cdnNumber.takeIf { this.remoteLocation != null },
|
||||
transitCdnKey = this.remoteLocation
|
||||
)
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
if (this.remoteLocation.isNullOrBlank()) {
|
||||
builder.invalidAttachmentLocator = FilePointer.InvalidAttachmentLocator()
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
builder.attachmentLocator = FilePointer.AttachmentLocator(
|
||||
cdnKey = this.remoteLocation,
|
||||
cdnNumber = this.cdn.cdnNumber,
|
||||
uploadTimestamp = this.uploadTimestamp,
|
||||
key = Base64.decode(remoteKey).toByteString(),
|
||||
size = this.size.toInt(),
|
||||
digest = this.remoteDigest.toByteString()
|
||||
)
|
||||
return builder.build()
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.backup.v2.util
|
||||
|
||||
import org.thoughtcrime.securesms.attachments.AttachmentId
|
||||
import org.thoughtcrime.securesms.backup.v2.ImportState
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.ChatStyle
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.FilePointer
|
||||
import org.thoughtcrime.securesms.conversation.colors.ChatColors
|
||||
import org.thoughtcrime.securesms.conversation.colors.ChatColorsPalette
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.Wallpaper
|
||||
import org.thoughtcrime.securesms.mms.PartAuthority
|
||||
import org.thoughtcrime.securesms.mms.PartUriParser
|
||||
import org.thoughtcrime.securesms.util.UriUtil
|
||||
import org.thoughtcrime.securesms.wallpaper.ChatWallpaper
|
||||
import org.thoughtcrime.securesms.wallpaper.ChatWallpaperFactory
|
||||
import org.thoughtcrime.securesms.wallpaper.GradientChatWallpaper
|
||||
import org.thoughtcrime.securesms.wallpaper.SingleColorChatWallpaper
|
||||
import org.thoughtcrime.securesms.wallpaper.UriChatWallpaper
|
||||
|
||||
/**
|
||||
* Contains a collection of methods to chat styles to and from their archive format.
|
||||
* These are in a file of their own just because they're rather long (with all of the various constants to map between) and used in multiple places.
|
||||
*/
|
||||
object ChatStyleConverter {
|
||||
fun constructRemoteChatStyle(
|
||||
db: SignalDatabase,
|
||||
chatColors: ChatColors?,
|
||||
chatColorId: ChatColors.Id,
|
||||
chatWallpaper: Wallpaper?
|
||||
): ChatStyle? {
|
||||
if (chatColors == null && chatWallpaper == null) {
|
||||
return null
|
||||
}
|
||||
|
||||
val chatStyleBuilder = ChatStyle.Builder()
|
||||
|
||||
if (chatColors != null) {
|
||||
when (chatColorId) {
|
||||
ChatColors.Id.NotSet -> {}
|
||||
ChatColors.Id.Auto -> {
|
||||
chatStyleBuilder.autoBubbleColor = ChatStyle.AutomaticBubbleColor()
|
||||
}
|
||||
|
||||
ChatColors.Id.BuiltIn -> {
|
||||
chatStyleBuilder.bubbleColorPreset = chatColors.toRemote()
|
||||
}
|
||||
|
||||
is ChatColors.Id.Custom -> {
|
||||
chatStyleBuilder.customColorId = chatColorId.longValue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (chatWallpaper != null) {
|
||||
when {
|
||||
chatWallpaper.singleColor != null -> {
|
||||
chatStyleBuilder.wallpaperPreset = chatWallpaper.singleColor.color.toRemoteWallpaperPreset()
|
||||
}
|
||||
chatWallpaper.linearGradient != null -> {
|
||||
chatStyleBuilder.wallpaperPreset = chatWallpaper.linearGradient.toRemoteWallpaperPreset()
|
||||
}
|
||||
chatWallpaper.file_ != null -> {
|
||||
chatStyleBuilder.wallpaperPhoto = chatWallpaper.file_.toFilePointer(db)
|
||||
}
|
||||
}
|
||||
|
||||
chatStyleBuilder.dimWallpaperInDarkMode = chatWallpaper.dimLevelInDarkTheme > 0
|
||||
}
|
||||
|
||||
return chatStyleBuilder.build()
|
||||
}
|
||||
}
|
||||
|
||||
fun ChatStyle.toLocal(importState: ImportState): ChatColors? {
|
||||
if (this.bubbleColorPreset != null) {
|
||||
return when (this.bubbleColorPreset) {
|
||||
// Solids
|
||||
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
|
||||
// Gradients
|
||||
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 (this.autoBubbleColor != null) {
|
||||
return ChatColorsPalette.Bubbles.default.withId(ChatColors.Id.Auto)
|
||||
}
|
||||
|
||||
if (this.customColorId != null) {
|
||||
return importState.remoteToLocalColorId[this.customColorId]?.let { localId ->
|
||||
val colorId = ChatColors.Id.forLongValue(localId)
|
||||
ChatColorsPalette.Bubbles.default.withId(colorId)
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
fun ChatColors.toRemote(): 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
|
||||
ChatColorsPalette.Bubbles.ULTRAMARINE -> return ChatStyle.BubbleColorPreset.SOLID_ULTRAMARINE
|
||||
// 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
|
||||
}
|
||||
|
||||
fun ChatStyle.WallpaperPreset.toLocal(): ChatWallpaper? {
|
||||
return when (this) {
|
||||
ChatStyle.WallpaperPreset.SOLID_BLUSH -> SingleColorChatWallpaper.BLUSH
|
||||
ChatStyle.WallpaperPreset.SOLID_COPPER -> SingleColorChatWallpaper.COPPER
|
||||
ChatStyle.WallpaperPreset.SOLID_DUST -> SingleColorChatWallpaper.DUST
|
||||
ChatStyle.WallpaperPreset.SOLID_CELADON -> SingleColorChatWallpaper.CELADON
|
||||
ChatStyle.WallpaperPreset.SOLID_RAINFOREST -> SingleColorChatWallpaper.RAINFOREST
|
||||
ChatStyle.WallpaperPreset.SOLID_PACIFIC -> SingleColorChatWallpaper.PACIFIC
|
||||
ChatStyle.WallpaperPreset.SOLID_FROST -> SingleColorChatWallpaper.FROST
|
||||
ChatStyle.WallpaperPreset.SOLID_NAVY -> SingleColorChatWallpaper.NAVY
|
||||
ChatStyle.WallpaperPreset.SOLID_LILAC -> SingleColorChatWallpaper.LILAC
|
||||
ChatStyle.WallpaperPreset.SOLID_PINK -> SingleColorChatWallpaper.PINK
|
||||
ChatStyle.WallpaperPreset.SOLID_EGGPLANT -> SingleColorChatWallpaper.EGGPLANT
|
||||
ChatStyle.WallpaperPreset.SOLID_SILVER -> SingleColorChatWallpaper.SILVER
|
||||
ChatStyle.WallpaperPreset.GRADIENT_SUNSET -> GradientChatWallpaper.SUNSET
|
||||
ChatStyle.WallpaperPreset.GRADIENT_NOIR -> GradientChatWallpaper.NOIR
|
||||
ChatStyle.WallpaperPreset.GRADIENT_HEATMAP -> GradientChatWallpaper.HEATMAP
|
||||
ChatStyle.WallpaperPreset.GRADIENT_AQUA -> GradientChatWallpaper.AQUA
|
||||
ChatStyle.WallpaperPreset.GRADIENT_IRIDESCENT -> GradientChatWallpaper.IRIDESCENT
|
||||
ChatStyle.WallpaperPreset.GRADIENT_MONSTERA -> GradientChatWallpaper.MONSTERA
|
||||
ChatStyle.WallpaperPreset.GRADIENT_BLISS -> GradientChatWallpaper.BLISS
|
||||
ChatStyle.WallpaperPreset.GRADIENT_SKY -> GradientChatWallpaper.SKY
|
||||
ChatStyle.WallpaperPreset.GRADIENT_PEACH -> GradientChatWallpaper.PEACH
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
fun ChatStyle.parseChatWallpaper(wallpaperAttachmentId: AttachmentId?): ChatWallpaper? {
|
||||
val chatWallpaper = if (this.wallpaperPreset != null) {
|
||||
this.wallpaperPreset.toLocal()
|
||||
} else if (wallpaperAttachmentId != null) {
|
||||
UriChatWallpaper(PartAuthority.getAttachmentDataUri(wallpaperAttachmentId), 0f)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
return if (chatWallpaper != null && this.dimWallpaperInDarkMode) {
|
||||
ChatWallpaperFactory.updateWithDimming(chatWallpaper, ChatWallpaper.FIXED_DIM_LEVEL_FOR_DARK_THEME)
|
||||
} else {
|
||||
chatWallpaper
|
||||
}
|
||||
}
|
||||
|
||||
private fun Int.toRemoteWallpaperPreset(): ChatStyle.WallpaperPreset {
|
||||
return when (this) {
|
||||
SingleColorChatWallpaper.BLUSH.color -> ChatStyle.WallpaperPreset.SOLID_BLUSH
|
||||
SingleColorChatWallpaper.COPPER.color -> ChatStyle.WallpaperPreset.SOLID_COPPER
|
||||
SingleColorChatWallpaper.DUST.color -> ChatStyle.WallpaperPreset.SOLID_DUST
|
||||
SingleColorChatWallpaper.CELADON.color -> ChatStyle.WallpaperPreset.SOLID_CELADON
|
||||
SingleColorChatWallpaper.RAINFOREST.color -> ChatStyle.WallpaperPreset.SOLID_RAINFOREST
|
||||
SingleColorChatWallpaper.PACIFIC.color -> ChatStyle.WallpaperPreset.SOLID_PACIFIC
|
||||
SingleColorChatWallpaper.FROST.color -> ChatStyle.WallpaperPreset.SOLID_FROST
|
||||
SingleColorChatWallpaper.NAVY.color -> ChatStyle.WallpaperPreset.SOLID_NAVY
|
||||
SingleColorChatWallpaper.LILAC.color -> ChatStyle.WallpaperPreset.SOLID_LILAC
|
||||
SingleColorChatWallpaper.PINK.color -> ChatStyle.WallpaperPreset.SOLID_PINK
|
||||
SingleColorChatWallpaper.EGGPLANT.color -> ChatStyle.WallpaperPreset.SOLID_EGGPLANT
|
||||
SingleColorChatWallpaper.SILVER.color -> ChatStyle.WallpaperPreset.SOLID_SILVER
|
||||
else -> ChatStyle.WallpaperPreset.UNKNOWN_WALLPAPER_PRESET
|
||||
}
|
||||
}
|
||||
|
||||
private fun Wallpaper.LinearGradient.toRemoteWallpaperPreset(): ChatStyle.WallpaperPreset {
|
||||
val colorArray = colors.toIntArray()
|
||||
return when {
|
||||
colorArray contentEquals GradientChatWallpaper.SUNSET.colors -> ChatStyle.WallpaperPreset.GRADIENT_SUNSET
|
||||
colorArray contentEquals GradientChatWallpaper.NOIR.colors -> ChatStyle.WallpaperPreset.GRADIENT_NOIR
|
||||
colorArray contentEquals GradientChatWallpaper.HEATMAP.colors -> ChatStyle.WallpaperPreset.GRADIENT_HEATMAP
|
||||
colorArray contentEquals GradientChatWallpaper.AQUA.colors -> ChatStyle.WallpaperPreset.GRADIENT_AQUA
|
||||
colorArray contentEquals GradientChatWallpaper.IRIDESCENT.colors -> ChatStyle.WallpaperPreset.GRADIENT_IRIDESCENT
|
||||
colorArray contentEquals GradientChatWallpaper.MONSTERA.colors -> ChatStyle.WallpaperPreset.GRADIENT_MONSTERA
|
||||
colorArray contentEquals GradientChatWallpaper.BLISS.colors -> ChatStyle.WallpaperPreset.GRADIENT_BLISS
|
||||
colorArray contentEquals GradientChatWallpaper.SKY.colors -> ChatStyle.WallpaperPreset.GRADIENT_SKY
|
||||
colorArray contentEquals GradientChatWallpaper.PEACH.colors -> ChatStyle.WallpaperPreset.GRADIENT_PEACH
|
||||
else -> ChatStyle.WallpaperPreset.UNKNOWN_WALLPAPER_PRESET
|
||||
}
|
||||
}
|
||||
|
||||
private fun Wallpaper.File.toFilePointer(db: SignalDatabase): FilePointer? {
|
||||
val attachmentId: AttachmentId = UriUtil.parseOrNull(this.uri)?.let { PartUriParser(it).partId } ?: return null
|
||||
val attachment = db.attachmentTable.getAttachment(attachmentId)
|
||||
return attachment?.toRemoteFilePointer(mediaArchiveEnabled = true)
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.backup.v2.util
|
||||
|
||||
import org.thoughtcrime.securesms.backup.v2.ImportState
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.ChatStyle
|
||||
import org.thoughtcrime.securesms.conversation.colors.ChatColors
|
||||
import org.thoughtcrime.securesms.conversation.colors.ChatColorsPalette
|
||||
|
||||
// TODO [backup] Passing in chatColorId probably unnecessary. Only stored as separate column in recipient table for querying, I believe.
|
||||
object BackupConverters {
|
||||
fun constructRemoteChatStyle(chatColors: ChatColors?, chatColorId: ChatColors.Id): ChatStyle? {
|
||||
var chatStyleBuilder: ChatStyle.Builder? = null
|
||||
|
||||
if (chatColors != null) {
|
||||
chatStyleBuilder = ChatStyle.Builder()
|
||||
when (chatColorId) {
|
||||
ChatColors.Id.NotSet -> {}
|
||||
ChatColors.Id.Auto -> {
|
||||
chatStyleBuilder.autoBubbleColor = ChatStyle.AutomaticBubbleColor()
|
||||
}
|
||||
|
||||
ChatColors.Id.BuiltIn -> {
|
||||
chatStyleBuilder.bubbleColorPreset = chatColors.toRemote()
|
||||
}
|
||||
|
||||
is ChatColors.Id.Custom -> {
|
||||
chatStyleBuilder.customColorId = chatColorId.longValue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO [backup] wallpaper
|
||||
|
||||
return chatStyleBuilder?.build()
|
||||
}
|
||||
}
|
||||
|
||||
fun ChatStyle.toLocal(importState: ImportState): ChatColors? {
|
||||
if (this.bubbleColorPreset != null) {
|
||||
return when (this.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 (this.autoBubbleColor != null) {
|
||||
return ChatColorsPalette.Bubbles.default.withId(ChatColors.Id.Auto)
|
||||
}
|
||||
|
||||
if (this.customColorId != null) {
|
||||
return importState.remoteToLocalColorId[this.customColorId]?.let { localId ->
|
||||
val colorId = ChatColors.Id.forLongValue(localId)
|
||||
ChatColorsPalette.Bubbles.default.withId(colorId)
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
fun ChatColors.toRemote(): 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
|
||||
ChatColorsPalette.Bubbles.ULTRAMARINE -> return ChatStyle.BubbleColorPreset.SOLID_ULTRAMARINE
|
||||
// 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
|
||||
}
|
||||
@@ -5,44 +5,33 @@
|
||||
|
||||
package org.thoughtcrime.securesms.banner
|
||||
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.runtime.Composable
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import org.signal.core.util.logging.Log
|
||||
|
||||
/**
|
||||
* This class represents a banner across the top of the screen.
|
||||
*
|
||||
* Typically, a class will subclass [Banner] and have a nested class that subclasses [BannerFactory].
|
||||
* The constructor for an implementation of [Banner] should be very lightweight, as it is may be called frequently.
|
||||
* Banners are submitted to a [BannerManager], which will render the first [enabled] Banner in it's list.
|
||||
* After a Banner is selected, the [BannerManager] will listen to the [dataFlow] and use the emitted [Model]s to render the [DisplayBanner] composable.
|
||||
*/
|
||||
abstract class Banner {
|
||||
companion object {
|
||||
private val TAG = Log.tag(Banner::class)
|
||||
|
||||
/**
|
||||
* A helper function to create a [Flow] of a [Banner].
|
||||
*
|
||||
* @param bannerFactory a block the produces a [Banner], or null. Returning null will complete the [Flow] without emitting any values.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun <T : Banner> createAndEmit(bannerFactory: () -> T): Flow<T> {
|
||||
return bannerFactory().let {
|
||||
flow { emit(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
abstract class Banner<Model> {
|
||||
|
||||
/**
|
||||
* Whether or not the [Banner] should be shown (enabled) or hidden (disabled).
|
||||
* Whether or not the [Banner] is eligible for display. This is read on the main thread and therefore should be very fast.
|
||||
*/
|
||||
abstract val enabled: Boolean
|
||||
|
||||
/**
|
||||
* Composable function to display content when [enabled] is true.
|
||||
*
|
||||
* @see [org.thoughtcrime.securesms.banner.ui.compose.DefaultBanner]
|
||||
* A [Flow] that emits the model to be displayed in the [DisplayBanner] composable.
|
||||
* This flow will only be subscribed to if the banner is [enabled].
|
||||
*/
|
||||
abstract val dataFlow: Flow<Model>
|
||||
|
||||
/**
|
||||
* Composable function to display the content emitted from [dataFlow].
|
||||
* You likely want to use [org.thoughtcrime.securesms.banner.ui.compose.DefaultBanner].
|
||||
*/
|
||||
@Composable
|
||||
abstract fun DisplayBanner()
|
||||
abstract fun DisplayBanner(model: Model, contentPadding: PaddingValues)
|
||||
}
|
||||
|
||||
@@ -6,11 +6,15 @@
|
||||
package org.thoughtcrime.securesms.banner
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.platform.ComposeView
|
||||
import androidx.compose.ui.platform.ViewCompositionStrategy
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import org.signal.core.ui.theme.SignalTheme
|
||||
import org.signal.core.util.logging.Log
|
||||
|
||||
/**
|
||||
@@ -18,7 +22,7 @@ import org.signal.core.util.logging.Log
|
||||
* Usually, the [Flow]s will come from [Banner.BannerFactory] instances, but may also be produced by the other properties of the host.
|
||||
*/
|
||||
class BannerManager @JvmOverloads constructor(
|
||||
allFlows: Iterable<Flow<Banner>>,
|
||||
private val banners: List<Banner<*>>,
|
||||
private val onNewBannerShownListener: () -> Unit = {},
|
||||
private val onNoBannerShownListener: () -> Unit = {}
|
||||
) {
|
||||
@@ -28,33 +32,31 @@ class BannerManager @JvmOverloads constructor(
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes the flows and combines them into one so that a new [Flow] value from any of them will trigger an update to the UI.
|
||||
*
|
||||
* **NOTE**: This will **not** emit its first value until **all** of the input flows have each emitted *at least one value*.
|
||||
* Re-evaluates the [Banner]s, choosing one to render (if any) and updating the view.
|
||||
*/
|
||||
private val combinedFlow: Flow<List<Banner>> = combine(allFlows) { banners: Array<Banner> ->
|
||||
banners.filter { it.enabled }.toList()
|
||||
}
|
||||
fun updateContent(composeView: ComposeView) {
|
||||
val banner: Banner<Any>? = banners.firstOrNull { it.enabled } as Banner<Any>?
|
||||
|
||||
/**
|
||||
* Sets the content of the provided [ComposeView] to one that consumes the lists emitted by [combinedFlow] and displays them.
|
||||
*/
|
||||
fun setContent(composeView: ComposeView) {
|
||||
composeView.apply {
|
||||
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
|
||||
setContent {
|
||||
val state = combinedFlow.collectAsStateWithLifecycle(initialValue = emptyList())
|
||||
if (banner == null) {
|
||||
onNoBannerShownListener()
|
||||
return@setContent
|
||||
}
|
||||
|
||||
val bannerToDisplay = state.value.firstOrNull()
|
||||
if (bannerToDisplay != null) {
|
||||
Box {
|
||||
bannerToDisplay.DisplayBanner()
|
||||
val state: State<Any?> = banner.dataFlow.collectAsStateWithLifecycle(initialValue = null)
|
||||
val bannerState by state
|
||||
|
||||
bannerState?.let { model ->
|
||||
SignalTheme {
|
||||
Box {
|
||||
banner.DisplayBanner(model, PaddingValues(horizontal = 12.dp, vertical = 8.dp))
|
||||
}
|
||||
}
|
||||
|
||||
onNewBannerShownListener()
|
||||
} else {
|
||||
onNoBannerShownListener()
|
||||
}
|
||||
} ?: onNoBannerShownListener()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.banner
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
|
||||
abstract class DismissibleBannerProducer<T : Banner>(bannerProducer: (dismissListener: () -> Unit) -> T) {
|
||||
abstract fun createDismissedBanner(): T
|
||||
|
||||
private val mutableSharedFlow: MutableSharedFlow<T> = MutableSharedFlow(replay = 1)
|
||||
private val dismissListener = {
|
||||
mutableSharedFlow.tryEmit(createDismissedBanner())
|
||||
}
|
||||
|
||||
init {
|
||||
mutableSharedFlow.tryEmit(bannerProducer(dismissListener))
|
||||
}
|
||||
|
||||
val flow: Flow<T> = mutableSharedFlow
|
||||
}
|
||||
@@ -6,50 +6,54 @@
|
||||
package org.thoughtcrime.securesms.banner.banners
|
||||
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import org.signal.core.ui.Previews
|
||||
import org.signal.core.ui.SignalPreview
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.banner.Banner
|
||||
import org.thoughtcrime.securesms.banner.DismissibleBannerProducer
|
||||
import org.thoughtcrime.securesms.banner.ui.compose.Action
|
||||
import org.thoughtcrime.securesms.banner.ui.compose.DefaultBanner
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
|
||||
class BubbleOptOutBanner(inBubble: Boolean, private val actionListener: (Boolean) -> Unit) : Banner() {
|
||||
class BubbleOptOutBanner(private val inBubble: Boolean, private val actionListener: (Boolean) -> Unit) : Banner<Unit>() {
|
||||
|
||||
override val enabled: Boolean = inBubble && !SignalStore.tooltips.hasSeenBubbleOptOutTooltip() && Build.VERSION.SDK_INT > 29
|
||||
override val enabled: Boolean
|
||||
get() = inBubble && !SignalStore.tooltips.hasSeenBubbleOptOutTooltip() && Build.VERSION.SDK_INT > 29
|
||||
|
||||
override val dataFlow: Flow<Unit>
|
||||
get() = flowOf(Unit)
|
||||
|
||||
@Composable
|
||||
override fun DisplayBanner() {
|
||||
DefaultBanner(
|
||||
title = null,
|
||||
body = stringResource(id = R.string.BubbleOptOutTooltip__description),
|
||||
actions = listOf(
|
||||
Action(R.string.BubbleOptOutTooltip__turn_off) {
|
||||
actionListener(true)
|
||||
},
|
||||
Action(R.string.BubbleOptOutTooltip__not_now) {
|
||||
actionListener(false)
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
override fun DisplayBanner(model: Unit, contentPadding: PaddingValues) = Banner(contentPadding, actionListener)
|
||||
}
|
||||
|
||||
private class Producer(inBubble: Boolean, actionListener: (Boolean) -> Unit) : DismissibleBannerProducer<BubbleOptOutBanner>(bannerProducer = {
|
||||
BubbleOptOutBanner(inBubble) { turnOffBubbles ->
|
||||
actionListener(turnOffBubbles)
|
||||
it()
|
||||
}
|
||||
}) {
|
||||
override fun createDismissedBanner(): BubbleOptOutBanner {
|
||||
return BubbleOptOutBanner(false) {}
|
||||
}
|
||||
}
|
||||
@Composable
|
||||
private fun Banner(contentPadding: PaddingValues, actionListener: (Boolean) -> Unit = {}) {
|
||||
DefaultBanner(
|
||||
title = null,
|
||||
body = stringResource(id = R.string.BubbleOptOutTooltip__description),
|
||||
actions = listOf(
|
||||
Action(R.string.BubbleOptOutTooltip__turn_off) {
|
||||
actionListener(true)
|
||||
},
|
||||
Action(R.string.BubbleOptOutTooltip__not_now) {
|
||||
actionListener(false)
|
||||
}
|
||||
),
|
||||
paddingValues = contentPadding
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun createFlow(inBubble: Boolean, actionListener: (Boolean) -> Unit): Flow<BubbleOptOutBanner> {
|
||||
return Producer(inBubble, actionListener).flow
|
||||
}
|
||||
@SignalPreview
|
||||
@Composable
|
||||
private fun BannerPreview() {
|
||||
Previews.Preview {
|
||||
Banner(PaddingValues(0.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,10 +5,14 @@
|
||||
|
||||
package org.thoughtcrime.securesms.banner.banners
|
||||
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import org.signal.core.ui.Previews
|
||||
import org.signal.core.ui.SignalPreview
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.banner.Banner
|
||||
import org.thoughtcrime.securesms.banner.ui.compose.Action
|
||||
@@ -18,36 +22,53 @@ import org.thoughtcrime.securesms.contacts.sync.CdsPermanentErrorBottomSheet
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import kotlin.time.Duration.Companion.days
|
||||
|
||||
class CdsPermanentErrorBanner(private val fragmentManager: FragmentManager) : Banner() {
|
||||
private val timeUntilUnblock = SignalStore.misc.cdsBlockedUtil - System.currentTimeMillis()
|
||||
|
||||
override val enabled: Boolean = SignalStore.misc.isCdsBlocked && timeUntilUnblock >= PERMANENT_TIME_CUTOFF
|
||||
|
||||
@Composable
|
||||
override fun DisplayBanner() {
|
||||
DefaultBanner(
|
||||
title = null,
|
||||
body = stringResource(id = R.string.reminder_cds_permanent_error_body),
|
||||
importance = Importance.ERROR,
|
||||
actions = listOf(
|
||||
Action(R.string.reminder_cds_permanent_error_learn_more) {
|
||||
CdsPermanentErrorBottomSheet.show(fragmentManager)
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
class CdsPermanentErrorBanner(private val fragmentManager: FragmentManager) : Banner<Unit>() {
|
||||
|
||||
companion object {
|
||||
|
||||
/**
|
||||
* Even if we're not truly "permanently blocked", if the time until we're unblocked is long enough, we'd rather show the permanent error message than
|
||||
* telling the user to wait for 3 months or something.
|
||||
*/
|
||||
val PERMANENT_TIME_CUTOFF = 30.days.inWholeMilliseconds
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun createFlow(childFragmentManager: FragmentManager): Flow<CdsPermanentErrorBanner> = createAndEmit {
|
||||
CdsPermanentErrorBanner(childFragmentManager)
|
||||
override val enabled: Boolean
|
||||
get() {
|
||||
val timeUntilUnblock = SignalStore.misc.cdsBlockedUtil - System.currentTimeMillis()
|
||||
return SignalStore.misc.isCdsBlocked && timeUntilUnblock >= PERMANENT_TIME_CUTOFF
|
||||
}
|
||||
|
||||
override val dataFlow
|
||||
get() = flowOf(Unit)
|
||||
|
||||
@Composable
|
||||
override fun DisplayBanner(model: Unit, contentPadding: PaddingValues) {
|
||||
Banner(
|
||||
contentPadding = contentPadding,
|
||||
onLearnMoreClicked = { CdsPermanentErrorBottomSheet.show(fragmentManager) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Banner(contentPadding: PaddingValues, onLearnMoreClicked: () -> Unit = {}) {
|
||||
DefaultBanner(
|
||||
title = null,
|
||||
body = stringResource(id = R.string.reminder_cds_permanent_error_body),
|
||||
importance = Importance.ERROR,
|
||||
actions = listOf(
|
||||
Action(R.string.reminder_cds_permanent_error_learn_more) {
|
||||
onLearnMoreClicked()
|
||||
}
|
||||
),
|
||||
paddingValues = contentPadding
|
||||
)
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@Composable
|
||||
private fun BannerPreview() {
|
||||
Previews.Preview {
|
||||
Banner(PaddingValues(0.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,10 +5,14 @@
|
||||
|
||||
package org.thoughtcrime.securesms.banner.banners
|
||||
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import org.signal.core.ui.Previews
|
||||
import org.signal.core.ui.SignalPreview
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.banner.Banner
|
||||
import org.thoughtcrime.securesms.banner.ui.compose.Action
|
||||
@@ -17,30 +21,45 @@ import org.thoughtcrime.securesms.banner.ui.compose.Importance
|
||||
import org.thoughtcrime.securesms.contacts.sync.CdsTemporaryErrorBottomSheet
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
|
||||
class CdsTemporaryErrorBanner(private val fragmentManager: FragmentManager) : Banner() {
|
||||
private val timeUntilUnblock = SignalStore.misc.cdsBlockedUtil - System.currentTimeMillis()
|
||||
class CdsTemporaryErrorBanner(private val fragmentManager: FragmentManager) : Banner<Unit>() {
|
||||
|
||||
override val enabled: Boolean = SignalStore.misc.isCdsBlocked && timeUntilUnblock < CdsPermanentErrorBanner.PERMANENT_TIME_CUTOFF
|
||||
override val enabled: Boolean
|
||||
get() {
|
||||
val timeUntilUnblock = SignalStore.misc.cdsBlockedUtil - System.currentTimeMillis()
|
||||
return SignalStore.misc.isCdsBlocked && timeUntilUnblock < CdsPermanentErrorBanner.PERMANENT_TIME_CUTOFF
|
||||
}
|
||||
|
||||
override val dataFlow
|
||||
get() = flowOf(Unit)
|
||||
|
||||
@Composable
|
||||
override fun DisplayBanner() {
|
||||
DefaultBanner(
|
||||
title = null,
|
||||
body = stringResource(id = R.string.reminder_cds_warning_body),
|
||||
importance = Importance.ERROR,
|
||||
actions = listOf(
|
||||
Action(R.string.reminder_cds_warning_learn_more) {
|
||||
CdsTemporaryErrorBottomSheet.show(fragmentManager)
|
||||
}
|
||||
)
|
||||
override fun DisplayBanner(model: Unit, contentPadding: PaddingValues) {
|
||||
Banner(
|
||||
contentPadding = contentPadding,
|
||||
onLearnMoreClicked = { CdsTemporaryErrorBottomSheet.show(fragmentManager) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
@Composable
|
||||
private fun Banner(contentPadding: PaddingValues, onLearnMoreClicked: () -> Unit = {}) {
|
||||
DefaultBanner(
|
||||
title = null,
|
||||
body = stringResource(id = R.string.reminder_cds_warning_body),
|
||||
importance = Importance.ERROR,
|
||||
actions = listOf(
|
||||
Action(R.string.reminder_cds_warning_learn_more) {
|
||||
onLearnMoreClicked()
|
||||
}
|
||||
),
|
||||
paddingValues = contentPadding
|
||||
)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun createFlow(childFragmentManager: FragmentManager): Flow<CdsTemporaryErrorBanner> = createAndEmit {
|
||||
CdsTemporaryErrorBanner(childFragmentManager)
|
||||
}
|
||||
@SignalPreview
|
||||
@Composable
|
||||
private fun BannerPreview() {
|
||||
Previews.Preview {
|
||||
Banner(PaddingValues(0.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.banner.banners
|
||||
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import org.signal.core.ui.Previews
|
||||
import org.signal.core.ui.SignalPreview
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.banner.Banner
|
||||
import org.thoughtcrime.securesms.banner.ui.compose.Action
|
||||
import org.thoughtcrime.securesms.banner.ui.compose.DefaultBanner
|
||||
import org.thoughtcrime.securesms.banner.ui.compose.Importance
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.util.PlayStoreUtil
|
||||
|
||||
/**
|
||||
* Shown when a build is actively deprecated and unable to connect to the service.
|
||||
*/
|
||||
class DeprecatedBuildBanner : Banner<Unit>() {
|
||||
|
||||
override val enabled: Boolean
|
||||
get() = SignalStore.misc.isClientDeprecated
|
||||
|
||||
override val dataFlow: Flow<Unit>
|
||||
get() = flowOf(Unit)
|
||||
|
||||
@Composable
|
||||
override fun DisplayBanner(model: Unit, contentPadding: PaddingValues) {
|
||||
val context = LocalContext.current
|
||||
Banner(
|
||||
contentPadding = contentPadding,
|
||||
onUpdateClicked = {
|
||||
PlayStoreUtil.openPlayStoreOrOurApkDownloadPage(context)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Banner(contentPadding: PaddingValues, onUpdateClicked: () -> Unit = {}) {
|
||||
DefaultBanner(
|
||||
title = null,
|
||||
body = stringResource(id = R.string.ExpiredBuildReminder_this_version_of_signal_has_expired),
|
||||
importance = Importance.ERROR,
|
||||
actions = listOf(
|
||||
Action(R.string.ExpiredBuildReminder_update_now) {
|
||||
onUpdateClicked()
|
||||
}
|
||||
),
|
||||
paddingValues = contentPadding
|
||||
)
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@Composable
|
||||
private fun BannerPreview() {
|
||||
Previews.Preview {
|
||||
Banner(contentPadding = PaddingValues(0.dp))
|
||||
}
|
||||
}
|
||||
@@ -7,12 +7,16 @@ package org.thoughtcrime.securesms.banner.banners
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import org.signal.core.ui.Previews
|
||||
import org.signal.core.ui.SignalPreview
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.banner.Banner
|
||||
import org.thoughtcrime.securesms.banner.DismissibleBannerProducer
|
||||
import org.thoughtcrime.securesms.banner.ui.compose.Action
|
||||
import org.thoughtcrime.securesms.banner.ui.compose.DefaultBanner
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
@@ -20,43 +24,52 @@ import org.thoughtcrime.securesms.util.PowerManagerCompat
|
||||
import org.thoughtcrime.securesms.util.ServiceUtil
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences
|
||||
|
||||
class DozeBanner(private val context: Context, val dismissed: Boolean, private val onDismiss: () -> Unit) : Banner() {
|
||||
override val enabled: Boolean = !dismissed &&
|
||||
Build.VERSION.SDK_INT >= 23 && !SignalStore.account.fcmEnabled && !TextSecurePreferences.hasPromptedOptimizeDoze(context) && !ServiceUtil.getPowerManager(context).isIgnoringBatteryOptimizations(context.packageName)
|
||||
class DozeBanner(private val context: Context) : Banner<Unit>() {
|
||||
|
||||
override val enabled: Boolean
|
||||
get() = Build.VERSION.SDK_INT >= 23 && !SignalStore.account.fcmEnabled && !TextSecurePreferences.hasPromptedOptimizeDoze(context) && !ServiceUtil.getPowerManager(context).isIgnoringBatteryOptimizations(context.packageName)
|
||||
|
||||
override val dataFlow: Flow<Unit>
|
||||
get() = flowOf(Unit)
|
||||
|
||||
@Composable
|
||||
override fun DisplayBanner() {
|
||||
override fun DisplayBanner(model: Unit, contentPadding: PaddingValues) {
|
||||
if (Build.VERSION.SDK_INT < 23) {
|
||||
throw IllegalStateException("Showing a Doze banner for an OS prior to Android 6.0")
|
||||
}
|
||||
DefaultBanner(
|
||||
title = stringResource(id = R.string.DozeReminder_optimize_for_missing_play_services),
|
||||
body = stringResource(id = R.string.DozeReminder_this_device_does_not_support_play_services_tap_to_disable_system_battery),
|
||||
actions = listOf(
|
||||
Action(android.R.string.ok) {
|
||||
TextSecurePreferences.setPromptedOptimizeDoze(context, true)
|
||||
PowerManagerCompat.requestIgnoreBatteryOptimizations(context)
|
||||
}
|
||||
),
|
||||
|
||||
Banner(
|
||||
contentPadding = contentPadding,
|
||||
onDismissListener = {
|
||||
TextSecurePreferences.setPromptedOptimizeDoze(context, true)
|
||||
onDismiss()
|
||||
},
|
||||
onOkListener = {
|
||||
TextSecurePreferences.setPromptedOptimizeDoze(context, true)
|
||||
PowerManagerCompat.requestIgnoreBatteryOptimizations(context)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private class Producer(private val context: Context) : DismissibleBannerProducer<DozeBanner>(bannerProducer = {
|
||||
DozeBanner(context = context, dismissed = false, onDismiss = it)
|
||||
}) {
|
||||
override fun createDismissedBanner(): DozeBanner {
|
||||
return DozeBanner(context, true) {}
|
||||
}
|
||||
}
|
||||
@Composable
|
||||
private fun Banner(contentPadding: PaddingValues, onDismissListener: () -> Unit = {}, onOkListener: () -> Unit = {}) {
|
||||
DefaultBanner(
|
||||
title = stringResource(id = R.string.DozeReminder_optimize_for_missing_play_services),
|
||||
body = stringResource(id = R.string.DozeReminder_this_device_does_not_support_play_services_tap_to_disable_system_battery),
|
||||
onDismissListener = onDismissListener,
|
||||
actions = listOf(
|
||||
Action(android.R.string.ok) {
|
||||
onOkListener()
|
||||
}
|
||||
),
|
||||
paddingValues = contentPadding
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
fun createFlow(context: Context): Flow<DozeBanner> {
|
||||
return Producer(context).flow
|
||||
}
|
||||
@SignalPreview
|
||||
@Composable
|
||||
private fun BannerPreview() {
|
||||
Previews.Preview {
|
||||
Banner(contentPadding = PaddingValues(0.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,11 +5,15 @@
|
||||
|
||||
package org.thoughtcrime.securesms.banner.banners
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import org.signal.core.ui.Previews
|
||||
import org.signal.core.ui.SignalPreview
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.banner.Banner
|
||||
import org.thoughtcrime.securesms.banner.ui.compose.Action
|
||||
@@ -17,27 +21,45 @@ import org.thoughtcrime.securesms.banner.ui.compose.DefaultBanner
|
||||
import org.thoughtcrime.securesms.banner.ui.compose.Importance
|
||||
import org.thoughtcrime.securesms.util.PlayStoreUtil
|
||||
|
||||
class EnclaveFailureBanner(enclaveFailed: Boolean, private val context: Context) : Banner() {
|
||||
override val enabled: Boolean = enclaveFailed
|
||||
class EnclaveFailureBanner(private val enclaveFailed: Boolean) : Banner<Unit>() {
|
||||
override val enabled: Boolean
|
||||
get() = enclaveFailed
|
||||
|
||||
override val dataFlow: Flow<Unit>
|
||||
get() = flowOf(Unit)
|
||||
|
||||
@Composable
|
||||
override fun DisplayBanner() {
|
||||
DefaultBanner(
|
||||
title = null,
|
||||
body = stringResource(id = R.string.EnclaveFailureReminder_update_signal),
|
||||
importance = Importance.ERROR,
|
||||
actions = listOf(
|
||||
Action(R.string.ExpiredBuildReminder_update_now) {
|
||||
PlayStoreUtil.openPlayStoreOrOurApkDownloadPage(context)
|
||||
}
|
||||
)
|
||||
override fun DisplayBanner(model: Unit, contentPadding: PaddingValues) {
|
||||
val context = LocalContext.current
|
||||
|
||||
Banner(
|
||||
contentPadding = contentPadding,
|
||||
onUpdateNow = {
|
||||
PlayStoreUtil.openPlayStoreOrOurApkDownloadPage(context)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
fun Flow<Boolean>.mapBooleanFlowToBannerFlow(context: Context): Flow<EnclaveFailureBanner> {
|
||||
return map { EnclaveFailureBanner(it, context) }
|
||||
}
|
||||
@Composable
|
||||
private fun Banner(contentPadding: PaddingValues, onUpdateNow: () -> Unit = {}) {
|
||||
DefaultBanner(
|
||||
title = null,
|
||||
body = stringResource(id = R.string.EnclaveFailureReminder_update_signal),
|
||||
importance = Importance.ERROR,
|
||||
actions = listOf(
|
||||
Action(R.string.ExpiredBuildReminder_update_now) {
|
||||
onUpdateNow()
|
||||
}
|
||||
),
|
||||
paddingValues = contentPadding
|
||||
)
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@Composable
|
||||
private fun BannerPreview() {
|
||||
Previews.Preview {
|
||||
Banner(contentPadding = PaddingValues(0.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,51 +5,75 @@
|
||||
|
||||
package org.thoughtcrime.securesms.banner.banners
|
||||
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import org.signal.core.ui.Previews
|
||||
import org.signal.core.ui.SignalPreview
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.banner.Banner
|
||||
import org.thoughtcrime.securesms.banner.DismissibleBannerProducer
|
||||
import org.thoughtcrime.securesms.banner.ui.compose.Action
|
||||
import org.thoughtcrime.securesms.banner.ui.compose.DefaultBanner
|
||||
|
||||
class GroupsV1MigrationSuggestionsBanner(private val suggestionsSize: Int, private val onAddMembers: () -> Unit, private val onNoThanks: () -> Unit) : Banner() {
|
||||
override val enabled: Boolean = suggestionsSize > 0
|
||||
/**
|
||||
* After migrating a group from v1 -> v2, this banner is used to show suggestions for members to add who couldn't be added automatically.
|
||||
* Intended to be shown only in a conversation.
|
||||
*/
|
||||
class GroupsV1MigrationSuggestionsBanner(
|
||||
private val suggestionsSize: Int,
|
||||
private val onAddMembers: () -> Unit,
|
||||
private val onNoThanks: () -> Unit
|
||||
) : Banner<Int>() {
|
||||
|
||||
override val enabled: Boolean
|
||||
get() = suggestionsSize > 0
|
||||
|
||||
override val dataFlow: Flow<Int>
|
||||
get() = flowOf(suggestionsSize)
|
||||
|
||||
@Composable
|
||||
override fun DisplayBanner() {
|
||||
DefaultBanner(
|
||||
title = null,
|
||||
body = pluralStringResource(
|
||||
id = R.plurals.GroupsV1MigrationSuggestionsReminder_members_couldnt_be_added_to_the_new_group,
|
||||
count = suggestionsSize,
|
||||
suggestionsSize
|
||||
),
|
||||
actions = listOf(
|
||||
Action(R.plurals.GroupsV1MigrationSuggestionsReminder_add_members, isPluralizedLabel = true, pluralQuantity = suggestionsSize, onAddMembers),
|
||||
Action(R.string.GroupsV1MigrationSuggestionsReminder_no_thanks, onClick = onNoThanks)
|
||||
)
|
||||
override fun DisplayBanner(model: Int, contentPadding: PaddingValues) {
|
||||
Banner(
|
||||
contentPadding = contentPadding,
|
||||
suggestionsSize = model,
|
||||
onAddMembers = onAddMembers,
|
||||
onNoThanks = onNoThanks
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private class Producer(suggestionsSize: Int, onAddMembers: () -> Unit, onNoThanks: () -> Unit) : DismissibleBannerProducer<GroupsV1MigrationSuggestionsBanner>(bannerProducer = {
|
||||
GroupsV1MigrationSuggestionsBanner(
|
||||
suggestionsSize,
|
||||
onAddMembers
|
||||
) {
|
||||
onNoThanks()
|
||||
it()
|
||||
}
|
||||
}) {
|
||||
override fun createDismissedBanner(): GroupsV1MigrationSuggestionsBanner {
|
||||
return GroupsV1MigrationSuggestionsBanner(0, {}, {})
|
||||
}
|
||||
}
|
||||
@Composable
|
||||
private fun Banner(contentPadding: PaddingValues, suggestionsSize: Int, onAddMembers: () -> Unit = {}, onNoThanks: () -> Unit = {}) {
|
||||
DefaultBanner(
|
||||
title = null,
|
||||
body = pluralStringResource(
|
||||
id = R.plurals.GroupsV1MigrationSuggestionsReminder_members_couldnt_be_added_to_the_new_group,
|
||||
count = suggestionsSize,
|
||||
suggestionsSize
|
||||
),
|
||||
actions = listOf(
|
||||
Action(R.plurals.GroupsV1MigrationSuggestionsReminder_add_members, isPluralizedLabel = true, pluralQuantity = suggestionsSize, onAddMembers),
|
||||
Action(R.string.GroupsV1MigrationSuggestionsReminder_no_thanks, onClick = onNoThanks)
|
||||
),
|
||||
paddingValues = contentPadding
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun createFlow(suggestionsSize: Int, onAddMembers: () -> Unit, onNoThanks: () -> Unit): Flow<GroupsV1MigrationSuggestionsBanner> {
|
||||
return Producer(suggestionsSize, onAddMembers, onNoThanks).flow
|
||||
}
|
||||
@SignalPreview
|
||||
@Composable
|
||||
private fun BannerPreviewSingular() {
|
||||
Previews.Preview {
|
||||
Banner(contentPadding = PaddingValues(0.dp), suggestionsSize = 1)
|
||||
}
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@Composable
|
||||
private fun BannerPreviewPlural() {
|
||||
Previews.Preview {
|
||||
Banner(contentPadding = PaddingValues(0.dp), suggestionsSize = 2)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,18 +5,16 @@
|
||||
|
||||
package org.thoughtcrime.securesms.banner.banners
|
||||
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.lifecycle.DefaultLifecycleObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.core.util.throttleLatest
|
||||
import org.thoughtcrime.securesms.backup.v2.ui.status.BackupStatus
|
||||
import org.thoughtcrime.securesms.backup.v2.ui.status.BackupStatusData
|
||||
import org.thoughtcrime.securesms.banner.Banner
|
||||
@@ -24,68 +22,46 @@ import org.thoughtcrime.securesms.database.DatabaseObserver
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
class MediaRestoreProgressBanner(private val data: MediaRestoreEvent) : Banner() {
|
||||
class MediaRestoreProgressBanner : Banner<BackupStatusData>() {
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(MediaRestoreProgressBanner::class)
|
||||
override val enabled: Boolean
|
||||
get() = SignalStore.backup.isRestoreInProgress
|
||||
|
||||
/**
|
||||
* Create a Lifecycle-aware [Flow] of [MediaRestoreProgressBanner] that observes the database for changes in attachments and emits banners when attachments are updated.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun createLifecycleAwareFlow(lifecycleOwner: LifecycleOwner): Flow<MediaRestoreProgressBanner> {
|
||||
if (SignalStore.backup.isRestoreInProgress) {
|
||||
val observer = LifecycleObserver()
|
||||
lifecycleOwner.lifecycle.addObserver(observer)
|
||||
return observer.flow
|
||||
} else {
|
||||
return flow {
|
||||
emit(MediaRestoreProgressBanner(MediaRestoreEvent(0L, 0L)))
|
||||
override val dataFlow: Flow<BackupStatusData>
|
||||
get() {
|
||||
if (!SignalStore.backup.isRestoreInProgress) {
|
||||
return flowOf(BackupStatusData.RestoringMedia(0, 0))
|
||||
}
|
||||
|
||||
val dbNotificationFlow = callbackFlow {
|
||||
val queryObserver = DatabaseObserver.Observer {
|
||||
trySend(Unit)
|
||||
}
|
||||
|
||||
queryObserver.onChanged()
|
||||
AppDependencies.databaseObserver.registerAttachmentUpdatedObserver(queryObserver)
|
||||
|
||||
awaitClose {
|
||||
AppDependencies.databaseObserver.unregisterObserver(queryObserver)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override var enabled: Boolean = data.totalBytes > 0L && data.totalBytes != data.completedBytes
|
||||
return dbNotificationFlow
|
||||
.throttleLatest(1.seconds)
|
||||
.map {
|
||||
val totalRestoreSize = SignalStore.backup.totalRestorableAttachmentSize
|
||||
val remainingAttachmentSize = SignalDatabase.attachments.getRemainingRestorableAttachmentSize()
|
||||
val completedBytes = totalRestoreSize - remainingAttachmentSize
|
||||
|
||||
BackupStatusData.RestoringMedia(completedBytes, totalRestoreSize)
|
||||
}
|
||||
.flowOn(Dispatchers.IO)
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun DisplayBanner() {
|
||||
BackupStatus(data = BackupStatusData.RestoringMedia(data.completedBytes, data.totalBytes))
|
||||
}
|
||||
|
||||
data class MediaRestoreEvent(val completedBytes: Long, val totalBytes: Long)
|
||||
|
||||
private class LifecycleObserver : DefaultLifecycleObserver {
|
||||
private var attachmentObserver: DatabaseObserver.Observer? = null
|
||||
private val _mutableSharedFlow = MutableSharedFlow<MediaRestoreEvent>(replay = 1)
|
||||
|
||||
val flow = _mutableSharedFlow.map { MediaRestoreProgressBanner(it) }
|
||||
|
||||
override fun onStart(owner: LifecycleOwner) {
|
||||
val queryObserver = DatabaseObserver.Observer {
|
||||
owner.lifecycleScope.launch {
|
||||
_mutableSharedFlow.emit(loadData())
|
||||
}
|
||||
}
|
||||
|
||||
attachmentObserver = queryObserver
|
||||
queryObserver.onChanged()
|
||||
AppDependencies.databaseObserver.registerAttachmentObserver(queryObserver)
|
||||
}
|
||||
|
||||
override fun onStop(owner: LifecycleOwner) {
|
||||
attachmentObserver?.let {
|
||||
AppDependencies.databaseObserver.unregisterObserver(it)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadData() = withContext(Dispatchers.IO) {
|
||||
// TODO [backups]: define and query data for interrupted/paused restores
|
||||
val totalRestoreSize = SignalStore.backup.totalRestorableAttachmentSize
|
||||
val remainingAttachmentSize = SignalDatabase.attachments.getTotalRestorableAttachmentSize()
|
||||
val completedBytes = totalRestoreSize - remainingAttachmentSize
|
||||
MediaRestoreEvent(completedBytes, totalRestoreSize)
|
||||
}
|
||||
override fun DisplayBanner(model: BackupStatusData, contentPadding: PaddingValues) {
|
||||
BackupStatus(data = model)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,11 +5,16 @@
|
||||
|
||||
package org.thoughtcrime.securesms.banner.banners
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import org.signal.core.ui.Previews
|
||||
import org.signal.core.ui.SignalPreview
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.banner.Banner
|
||||
import org.thoughtcrime.securesms.banner.ui.compose.Action
|
||||
@@ -21,71 +26,96 @@ import org.thoughtcrime.securesms.util.Util
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
/**
|
||||
* Banner to let the user know their build is about to expire or has expired.
|
||||
*
|
||||
* @param status can be used to filter which conditions are shown.
|
||||
* Banner to let the user know their build is about to expire.
|
||||
*/
|
||||
class OutdatedBuildBanner(val context: Context, private val daysUntilExpiry: Int, private val status: ExpiryStatus) : Banner() {
|
||||
|
||||
override val enabled = when (status) {
|
||||
ExpiryStatus.OUTDATED_ONLY -> SignalStore.misc.isClientDeprecated
|
||||
ExpiryStatus.EXPIRED_ONLY -> daysUntilExpiry <= MAX_DAYS_UNTIL_EXPIRE
|
||||
ExpiryStatus.OUTDATED_OR_EXPIRED -> SignalStore.misc.isClientDeprecated || daysUntilExpiry <= MAX_DAYS_UNTIL_EXPIRE
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun DisplayBanner() {
|
||||
val bodyText = when (status) {
|
||||
ExpiryStatus.OUTDATED_ONLY -> if (daysUntilExpiry == 0) {
|
||||
stringResource(id = R.string.OutdatedBuildReminder_your_version_of_signal_will_expire_today)
|
||||
} else {
|
||||
pluralStringResource(id = R.plurals.OutdatedBuildReminder_your_version_of_signal_will_expire_in_n_days, count = daysUntilExpiry, daysUntilExpiry)
|
||||
}
|
||||
|
||||
ExpiryStatus.EXPIRED_ONLY -> stringResource(id = R.string.OutdatedBuildReminder_your_version_of_signal_will_expire_today)
|
||||
ExpiryStatus.OUTDATED_OR_EXPIRED -> if (SignalStore.misc.isClientDeprecated) {
|
||||
stringResource(id = R.string.OutdatedBuildReminder_your_version_of_signal_will_expire_today)
|
||||
} else if (daysUntilExpiry == 0) {
|
||||
stringResource(id = R.string.OutdatedBuildReminder_your_version_of_signal_will_expire_today)
|
||||
} else {
|
||||
pluralStringResource(id = R.plurals.OutdatedBuildReminder_your_version_of_signal_will_expire_in_n_days, count = daysUntilExpiry, daysUntilExpiry)
|
||||
}
|
||||
}
|
||||
DefaultBanner(
|
||||
title = null,
|
||||
body = bodyText,
|
||||
importance = if (SignalStore.misc.isClientDeprecated) {
|
||||
Importance.ERROR
|
||||
} else {
|
||||
Importance.NORMAL
|
||||
},
|
||||
actions = listOf(
|
||||
Action(R.string.ExpiredBuildReminder_update_now) {
|
||||
PlayStoreUtil.openPlayStoreOrOurApkDownloadPage(context)
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A enumeration for [OutdatedBuildBanner] to limit it to showing either [OUTDATED_ONLY] status, [EXPIRED_ONLY] status, or both.
|
||||
*
|
||||
* [OUTDATED_ONLY] refers to builds that are still valid but need to be updated.
|
||||
* [EXPIRED_ONLY] refers to builds that are no longer allowed to connect to the service.
|
||||
*/
|
||||
enum class ExpiryStatus {
|
||||
OUTDATED_ONLY,
|
||||
EXPIRED_ONLY,
|
||||
OUTDATED_OR_EXPIRED
|
||||
}
|
||||
class OutdatedBuildBanner : Banner<Int>() {
|
||||
|
||||
companion object {
|
||||
private const val MAX_DAYS_UNTIL_EXPIRE = 10
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun createFlow(context: Context, status: ExpiryStatus): Flow<OutdatedBuildBanner> = createAndEmit {
|
||||
override val enabled: Boolean
|
||||
get() {
|
||||
val daysUntilExpiry = Util.getTimeUntilBuildExpiry(SignalStore.misc.estimatedServerTime).milliseconds.inWholeDays.toInt()
|
||||
OutdatedBuildBanner(context, daysUntilExpiry, status)
|
||||
return daysUntilExpiry <= MAX_DAYS_UNTIL_EXPIRE
|
||||
}
|
||||
|
||||
override val dataFlow: Flow<Int>
|
||||
get() = flowOf(Util.getTimeUntilBuildExpiry(SignalStore.misc.estimatedServerTime).milliseconds.inWholeDays.toInt())
|
||||
|
||||
@Composable
|
||||
override fun DisplayBanner(model: Int, contentPadding: PaddingValues) {
|
||||
val context = LocalContext.current
|
||||
|
||||
Banner(
|
||||
contentPadding = contentPadding,
|
||||
daysUntilExpiry = model,
|
||||
onUpdateClicked = {
|
||||
PlayStoreUtil.openPlayStoreOrOurApkDownloadPage(context)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
data class Model(
|
||||
val daysUntilExpiry: Int,
|
||||
val isClientDeprecated: Boolean
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Banner(contentPadding: PaddingValues, daysUntilExpiry: Int, onUpdateClicked: () -> Unit = {}) {
|
||||
val bodyText = if (daysUntilExpiry == 0) {
|
||||
stringResource(id = R.string.OutdatedBuildReminder_your_version_of_signal_will_expire_today)
|
||||
} else {
|
||||
pluralStringResource(id = R.plurals.OutdatedBuildReminder_your_version_of_signal_will_expire_in_n_days, count = daysUntilExpiry, daysUntilExpiry)
|
||||
}
|
||||
|
||||
DefaultBanner(
|
||||
title = null,
|
||||
body = bodyText,
|
||||
importance = if (daysUntilExpiry == 0) {
|
||||
Importance.ERROR
|
||||
} else {
|
||||
Importance.NORMAL
|
||||
},
|
||||
actions = listOf(
|
||||
Action(R.string.ExpiredBuildReminder_update_now) {
|
||||
onUpdateClicked()
|
||||
}
|
||||
),
|
||||
paddingValues = contentPadding
|
||||
)
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@Composable
|
||||
private fun BannerPreviewExpireToday() {
|
||||
Previews.Preview {
|
||||
Banner(
|
||||
contentPadding = PaddingValues(0.dp),
|
||||
daysUntilExpiry = 0
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@Composable
|
||||
private fun BannerPreviewExpireTomorrow() {
|
||||
Previews.Preview {
|
||||
Banner(
|
||||
contentPadding = PaddingValues(0.dp),
|
||||
daysUntilExpiry = 1
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@Composable
|
||||
private fun BannerPreviewExpireLater() {
|
||||
Previews.Preview {
|
||||
Banner(
|
||||
contentPadding = PaddingValues(0.dp),
|
||||
daysUntilExpiry = 3
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,44 +5,79 @@
|
||||
|
||||
package org.thoughtcrime.securesms.banner.banners
|
||||
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import org.signal.core.ui.Previews
|
||||
import org.signal.core.ui.SignalPreview
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.banner.Banner
|
||||
import org.thoughtcrime.securesms.banner.DismissibleBannerProducer
|
||||
import org.thoughtcrime.securesms.banner.ui.compose.Action
|
||||
import org.thoughtcrime.securesms.banner.ui.compose.DefaultBanner
|
||||
|
||||
class PendingGroupJoinRequestsBanner(override val enabled: Boolean, private val suggestionsSize: Int, private val onViewClicked: () -> Unit, private val onDismissListener: (() -> Unit)?) : Banner() {
|
||||
/**
|
||||
* Shows the number of pending requests to join the group.
|
||||
* Intended to be shown at the top of a conversation.
|
||||
*/
|
||||
class PendingGroupJoinRequestsBanner(private val suggestionsSize: Int, private val onViewClicked: () -> Unit) : Banner<Int>() {
|
||||
|
||||
override val enabled: Boolean
|
||||
get() = suggestionsSize > 0
|
||||
|
||||
override val dataFlow: Flow<Int> = flowOf(suggestionsSize)
|
||||
|
||||
@Composable
|
||||
override fun DisplayBanner() {
|
||||
DefaultBanner(
|
||||
title = null,
|
||||
body = pluralStringResource(
|
||||
id = R.plurals.GroupsV1MigrationSuggestionsReminder_members_couldnt_be_added_to_the_new_group,
|
||||
count = suggestionsSize,
|
||||
suggestionsSize
|
||||
),
|
||||
actions = listOf(
|
||||
Action(R.string.PendingGroupJoinRequestsReminder_view, onClick = onViewClicked)
|
||||
),
|
||||
onDismissListener = onDismissListener
|
||||
override fun DisplayBanner(model: Int, contentPadding: PaddingValues) {
|
||||
Banner(
|
||||
contentPadding = contentPadding,
|
||||
suggestionsSize = model,
|
||||
onViewClicked = onViewClicked
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private class Producer(suggestionsSize: Int, onViewClicked: () -> Unit) : DismissibleBannerProducer<PendingGroupJoinRequestsBanner>(bannerProducer = {
|
||||
PendingGroupJoinRequestsBanner(suggestionsSize > 0, suggestionsSize, onViewClicked, it)
|
||||
}) {
|
||||
override fun createDismissedBanner(): PendingGroupJoinRequestsBanner {
|
||||
return PendingGroupJoinRequestsBanner(false, 0, {}, null)
|
||||
}
|
||||
@Composable
|
||||
private fun Banner(contentPadding: PaddingValues, suggestionsSize: Int, onViewClicked: () -> Unit = {}) {
|
||||
var visible by remember { mutableStateOf(true) }
|
||||
|
||||
if (!visible) {
|
||||
return
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun createFlow(suggestionsSize: Int, onViewClicked: () -> Unit): Flow<PendingGroupJoinRequestsBanner> {
|
||||
return Producer(suggestionsSize, onViewClicked).flow
|
||||
}
|
||||
DefaultBanner(
|
||||
title = null,
|
||||
body = pluralStringResource(
|
||||
id = R.plurals.PendingGroupJoinRequestsReminder_d_pending_member_requests,
|
||||
count = suggestionsSize,
|
||||
suggestionsSize
|
||||
),
|
||||
onDismissListener = { visible = false },
|
||||
actions = listOf(
|
||||
Action(R.string.PendingGroupJoinRequestsReminder_view, onClick = onViewClicked)
|
||||
),
|
||||
paddingValues = contentPadding
|
||||
)
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@Composable
|
||||
private fun BannerPreviewSingular() {
|
||||
Previews.Preview {
|
||||
Banner(contentPadding = PaddingValues(0.dp), suggestionsSize = 1)
|
||||
}
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@Composable
|
||||
private fun BannerPreviewPlural() {
|
||||
Previews.Preview {
|
||||
Banner(contentPadding = PaddingValues(0.dp), suggestionsSize = 2)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,51 +6,45 @@
|
||||
package org.thoughtcrime.securesms.banner.banners
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import org.signal.core.util.logging.Log
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import org.signal.core.ui.Previews
|
||||
import org.signal.core.ui.SignalPreview
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.banner.Banner
|
||||
import org.thoughtcrime.securesms.banner.ui.compose.DefaultBanner
|
||||
import org.thoughtcrime.securesms.banner.ui.compose.Importance
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences
|
||||
|
||||
class ServiceOutageBanner(outageInProgress: Boolean) : Banner() {
|
||||
class ServiceOutageBanner(val context: Context) : Banner<Unit>() {
|
||||
|
||||
constructor(context: Context) : this(TextSecurePreferences.getServiceOutage(context))
|
||||
override val enabled: Boolean
|
||||
get() = TextSecurePreferences.getServiceOutage(context)
|
||||
|
||||
override val enabled = outageInProgress
|
||||
override val dataFlow: Flow<Unit> = flowOf(Unit)
|
||||
|
||||
@Composable
|
||||
override fun DisplayBanner() {
|
||||
DefaultBanner(
|
||||
title = null,
|
||||
body = stringResource(id = R.string.reminder_header_service_outage_text),
|
||||
importance = Importance.ERROR
|
||||
)
|
||||
}
|
||||
override fun DisplayBanner(model: Unit, contentPadding: PaddingValues) = Banner(contentPadding)
|
||||
}
|
||||
|
||||
/**
|
||||
* A class that can be held by a listener but still produce new [ServiceOutageBanner] in its flow.
|
||||
* Designed for being called upon by a listener that is listening to changes in [TextSecurePreferences]
|
||||
*/
|
||||
class Producer(private val context: Context) {
|
||||
private val _flow = MutableSharedFlow<Boolean>(replay = 1)
|
||||
val flow: Flow<ServiceOutageBanner> = _flow.map { ServiceOutageBanner(context) }
|
||||
@Composable
|
||||
private fun Banner(contentPadding: PaddingValues) {
|
||||
DefaultBanner(
|
||||
title = null,
|
||||
body = stringResource(id = R.string.reminder_header_service_outage_text),
|
||||
importance = Importance.ERROR,
|
||||
paddingValues = contentPadding
|
||||
)
|
||||
}
|
||||
|
||||
init {
|
||||
queryAndEmit()
|
||||
}
|
||||
|
||||
fun queryAndEmit() {
|
||||
_flow.tryEmit(TextSecurePreferences.getServiceOutage(context))
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(ServiceOutageBanner::class)
|
||||
@SignalPreview
|
||||
@Composable
|
||||
private fun BannerPreview() {
|
||||
Previews.Preview {
|
||||
Banner(contentPadding = PaddingValues(0.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,12 +6,15 @@
|
||||
package org.thoughtcrime.securesms.banner.banners
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import org.signal.core.util.logging.Log
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import org.signal.core.ui.Previews
|
||||
import org.signal.core.ui.SignalPreview
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.banner.Banner
|
||||
import org.thoughtcrime.securesms.banner.ui.compose.Action
|
||||
@@ -24,43 +27,42 @@ import org.thoughtcrime.securesms.util.TextSecurePreferences
|
||||
/**
|
||||
* A banner displayed when the client is unauthorized (deregistered).
|
||||
*/
|
||||
class UnauthorizedBanner(val context: Context) : Banner() {
|
||||
class UnauthorizedBanner(val context: Context) : Banner<Unit>() {
|
||||
|
||||
override val enabled = TextSecurePreferences.isUnauthorizedReceived(context) || !SignalStore.account.isRegistered
|
||||
override val enabled: Boolean
|
||||
get() = TextSecurePreferences.isUnauthorizedReceived(context) || !SignalStore.account.isRegistered
|
||||
|
||||
override val dataFlow: Flow<Unit>
|
||||
get() = flowOf(Unit)
|
||||
|
||||
@Composable
|
||||
override fun DisplayBanner() {
|
||||
DefaultBanner(
|
||||
title = null,
|
||||
body = stringResource(id = R.string.UnauthorizedReminder_this_is_likely_because_you_registered_your_phone_number_with_Signal_on_a_different_device),
|
||||
importance = Importance.ERROR,
|
||||
actions = listOf(
|
||||
Action(R.string.UnauthorizedReminder_reregister_action) {
|
||||
val registrationIntent = RegistrationActivity.newIntentForReRegistration(context)
|
||||
context.startActivity(registrationIntent)
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A class that can be held by a listener but still produce new [UnauthorizedBanner] in its flow.
|
||||
* Designed for being called upon by a listener that is listening to changes in [TextSecurePreferences]
|
||||
*/
|
||||
class Producer(private val context: Context) {
|
||||
private val _flow = MutableSharedFlow<Boolean>(replay = 1)
|
||||
val flow: Flow<UnauthorizedBanner> = _flow.map { UnauthorizedBanner(context) }
|
||||
|
||||
init {
|
||||
queryAndEmit()
|
||||
}
|
||||
|
||||
fun queryAndEmit() {
|
||||
_flow.tryEmit(TextSecurePreferences.isUnauthorizedReceived(context))
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(UnauthorizedBanner::class)
|
||||
override fun DisplayBanner(model: Unit, contentPadding: PaddingValues) {
|
||||
Banner(contentPadding)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Banner(contentPadding: PaddingValues) {
|
||||
val context = LocalContext.current
|
||||
|
||||
DefaultBanner(
|
||||
title = null,
|
||||
body = stringResource(id = R.string.UnauthorizedReminder_this_is_likely_because_you_registered_your_phone_number_with_Signal_on_a_different_device),
|
||||
importance = Importance.ERROR,
|
||||
actions = listOf(
|
||||
Action(R.string.UnauthorizedReminder_reregister_action) {
|
||||
val registrationIntent = RegistrationActivity.newIntentForReRegistration(context)
|
||||
context.startActivity(registrationIntent)
|
||||
}
|
||||
),
|
||||
paddingValues = contentPadding
|
||||
)
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@Composable
|
||||
private fun BannerPreview() {
|
||||
Previews.Preview {
|
||||
Banner(PaddingValues(0.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,10 +5,14 @@
|
||||
|
||||
package org.thoughtcrime.securesms.banner.banners
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import org.signal.core.ui.Previews
|
||||
import org.signal.core.ui.SignalPreview
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.banner.Banner
|
||||
import org.thoughtcrime.securesms.banner.ui.compose.Action
|
||||
@@ -18,40 +22,61 @@ import org.thoughtcrime.securesms.keyvalue.AccountValues
|
||||
import org.thoughtcrime.securesms.keyvalue.AccountValues.UsernameSyncState
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
|
||||
class UsernameOutOfSyncBanner(private val context: Context, private val usernameSyncState: UsernameSyncState, private val onActionClick: (Boolean) -> Unit) : Banner() {
|
||||
class UsernameOutOfSyncBanner(private val onActionClick: (UsernameSyncState) -> Unit) : Banner<UsernameSyncState>() {
|
||||
|
||||
override val enabled = when (usernameSyncState) {
|
||||
AccountValues.UsernameSyncState.USERNAME_AND_LINK_CORRUPTED -> true
|
||||
AccountValues.UsernameSyncState.LINK_CORRUPTED -> true
|
||||
AccountValues.UsernameSyncState.IN_SYNC -> false
|
||||
}
|
||||
override val enabled: Boolean
|
||||
get() {
|
||||
return when (SignalStore.account.usernameSyncState) {
|
||||
AccountValues.UsernameSyncState.USERNAME_AND_LINK_CORRUPTED -> true
|
||||
AccountValues.UsernameSyncState.LINK_CORRUPTED -> true
|
||||
AccountValues.UsernameSyncState.IN_SYNC -> false
|
||||
}
|
||||
}
|
||||
|
||||
override val dataFlow: Flow<UsernameSyncState>
|
||||
get() = flowOf(SignalStore.account.usernameSyncState)
|
||||
|
||||
@Composable
|
||||
override fun DisplayBanner() {
|
||||
DefaultBanner(
|
||||
title = null,
|
||||
body = if (usernameSyncState == UsernameSyncState.USERNAME_AND_LINK_CORRUPTED) {
|
||||
stringResource(id = R.string.UsernameOutOfSyncReminder__username_and_link_corrupt)
|
||||
} else {
|
||||
stringResource(id = R.string.UsernameOutOfSyncReminder__link_corrupt)
|
||||
},
|
||||
importance = Importance.ERROR,
|
||||
actions = listOf(
|
||||
Action(R.string.UsernameOutOfSyncReminder__fix_now) {
|
||||
onActionClick(usernameSyncState == UsernameSyncState.USERNAME_AND_LINK_CORRUPTED)
|
||||
}
|
||||
)
|
||||
override fun DisplayBanner(model: UsernameSyncState, contentPadding: PaddingValues) {
|
||||
Banner(
|
||||
contentPadding = contentPadding,
|
||||
usernameSyncState = model,
|
||||
onFixClicked = onActionClick
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
@Composable
|
||||
private fun Banner(contentPadding: PaddingValues, usernameSyncState: UsernameSyncState, onFixClicked: (UsernameSyncState) -> Unit = {}) {
|
||||
DefaultBanner(
|
||||
title = null,
|
||||
body = if (usernameSyncState == UsernameSyncState.USERNAME_AND_LINK_CORRUPTED) {
|
||||
stringResource(id = R.string.UsernameOutOfSyncReminder__username_and_link_corrupt)
|
||||
} else {
|
||||
stringResource(id = R.string.UsernameOutOfSyncReminder__link_corrupt)
|
||||
},
|
||||
importance = Importance.ERROR,
|
||||
actions = listOf(
|
||||
Action(R.string.UsernameOutOfSyncReminder__fix_now) {
|
||||
onFixClicked(usernameSyncState)
|
||||
}
|
||||
),
|
||||
paddingValues = contentPadding
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param onActionClick input is true if both the username and the link are corrupted, false if only the link is corrupted
|
||||
*/
|
||||
@JvmStatic
|
||||
fun createFlow(context: Context, onActionClick: (Boolean) -> Unit): Flow<UsernameOutOfSyncBanner> = createAndEmit {
|
||||
UsernameOutOfSyncBanner(context, SignalStore.account.usernameSyncState, onActionClick)
|
||||
}
|
||||
@SignalPreview
|
||||
@Composable
|
||||
private fun BannerPreviewUsernameCorrupted() {
|
||||
Previews.Preview {
|
||||
Banner(contentPadding = PaddingValues(0.dp), usernameSyncState = UsernameSyncState.USERNAME_AND_LINK_CORRUPTED)
|
||||
}
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@Composable
|
||||
private fun BannerPreviewLinkCorrupted() {
|
||||
Previews.Preview {
|
||||
Banner(contentPadding = PaddingValues(0.dp), usernameSyncState = UsernameSyncState.LINK_CORRUPTED)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,12 +10,14 @@ import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
@@ -25,6 +27,7 @@ import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.res.colorResource
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
@@ -49,11 +52,13 @@ fun DefaultBanner(
|
||||
actions: List<Action> = emptyList(),
|
||||
showProgress: Boolean = false,
|
||||
progressText: String = "",
|
||||
progressPercent: Int = -1
|
||||
progressPercent: Int = -1,
|
||||
paddingValues: PaddingValues
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp)
|
||||
.padding(paddingValues)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(
|
||||
color = when (importance) {
|
||||
Importance.NORMAL -> MaterialTheme.colorScheme.surface
|
||||
@@ -102,26 +107,34 @@ fun DefaultBanner(
|
||||
if (progressPercent >= 0) {
|
||||
LinearProgressIndicator(
|
||||
progress = { progressPercent / 100f },
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
color = when (importance) {
|
||||
Importance.NORMAL -> MaterialTheme.colorScheme.primary
|
||||
Importance.ERROR -> colorResource(id = R.color.signal_light_colorPrimary)
|
||||
},
|
||||
trackColor = MaterialTheme.colorScheme.primaryContainer,
|
||||
modifier = Modifier.padding(vertical = 12.dp)
|
||||
modifier = Modifier
|
||||
.padding(vertical = 12.dp)
|
||||
.fillMaxWidth()
|
||||
)
|
||||
} else {
|
||||
LinearProgressIndicator(
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
color = when (importance) {
|
||||
Importance.NORMAL -> MaterialTheme.colorScheme.primary
|
||||
Importance.ERROR -> colorResource(id = R.color.signal_light_colorPrimary)
|
||||
},
|
||||
trackColor = MaterialTheme.colorScheme.primaryContainer,
|
||||
modifier = Modifier.padding(vertical = 12.dp)
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = progressText,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = when (importance) {
|
||||
Importance.NORMAL -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||
Importance.ERROR -> colorResource(id = R.color.signal_light_colorOnSurface)
|
||||
}
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = progressText,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = when (importance) {
|
||||
Importance.NORMAL -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||
Importance.ERROR -> colorResource(id = R.color.signal_light_colorOnSurface)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
Box(modifier = Modifier.size(48.dp)) {
|
||||
@@ -143,11 +156,18 @@ fun DefaultBanner(
|
||||
}
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.End,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(end = 8.dp)
|
||||
) {
|
||||
for (action in actions) {
|
||||
TextButton(onClick = action.onClick) {
|
||||
TextButton(
|
||||
onClick = action.onClick,
|
||||
colors = when (importance) {
|
||||
Importance.NORMAL -> ButtonDefaults.textButtonColors()
|
||||
Importance.ERROR -> ButtonDefaults.textButtonColors(contentColor = colorResource(R.color.signal_light_colorPrimary))
|
||||
}
|
||||
) {
|
||||
Text(
|
||||
text = if (!action.isPluralizedLabel) {
|
||||
stringResource(id = action.label)
|
||||
@@ -179,7 +199,8 @@ private fun BubblesOptOutPreview() {
|
||||
actions = listOf(
|
||||
Action(R.string.BubbleOptOutTooltip__turn_off) {},
|
||||
Action(R.string.BubbleOptOutTooltip__not_now) {}
|
||||
)
|
||||
),
|
||||
paddingValues = PaddingValues(horizontal = 12.dp, vertical = 8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -192,9 +213,10 @@ private fun ForcedUpgradePreview() {
|
||||
title = null,
|
||||
body = stringResource(id = R.string.OutdatedBuildReminder_your_version_of_signal_will_expire_today),
|
||||
importance = Importance.ERROR,
|
||||
actions = listOf(Action(R.string.ExpiredBuildReminder_update_now) {}),
|
||||
onDismissListener = {},
|
||||
onHideListener = { },
|
||||
onDismissListener = {}
|
||||
actions = listOf(Action(R.string.ExpiredBuildReminder_update_now) {}),
|
||||
paddingValues = PaddingValues(horizontal = 12.dp, vertical = 8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -211,11 +233,12 @@ private fun FullyLoadedErrorPreview() {
|
||||
title = "Error",
|
||||
body = "Creating more errors.",
|
||||
importance = Importance.ERROR,
|
||||
onDismissListener = {},
|
||||
actions = actions,
|
||||
showProgress = true,
|
||||
progressText = "4 out of 10 errors created.",
|
||||
progressPercent = 40,
|
||||
onDismissListener = {}
|
||||
paddingValues = PaddingValues(horizontal = 12.dp, vertical = 8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.calls
|
||||
|
||||
import android.view.View
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import org.signal.core.ui.Snackbars
|
||||
import org.thoughtcrime.securesms.R
|
||||
|
||||
/**
|
||||
* Snackbar which can be displayed whenever the user tries to join a call but is already in another.
|
||||
*/
|
||||
object YouAreAlreadyInACallSnackbar {
|
||||
/**
|
||||
* Composable component
|
||||
*/
|
||||
@Composable
|
||||
fun YouAreAlreadyInACallSnackbar(
|
||||
displaySnackbar: Boolean,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val message = stringResource(R.string.CommunicationActions__you_are_already_in_a_call)
|
||||
val hostState = remember { SnackbarHostState() }
|
||||
Snackbars.Host(hostState, modifier = modifier)
|
||||
|
||||
LaunchedEffect(displaySnackbar) {
|
||||
if (displaySnackbar) {
|
||||
hostState.showSnackbar(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* View system component
|
||||
*/
|
||||
@JvmStatic
|
||||
fun show(view: View) {
|
||||
Snackbar.make(
|
||||
view,
|
||||
view.context.getString(R.string.CommunicationActions__you_are_already_in_a_call),
|
||||
Snackbar.LENGTH_LONG
|
||||
).show()
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.calls.links
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.view.View
|
||||
import androidx.annotation.ColorRes
|
||||
import androidx.appcompat.widget.LinearLayoutCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.google.android.material.button.MaterialButton
|
||||
import org.thoughtcrime.securesms.R
|
||||
|
||||
/**
|
||||
* ConversationItem action button for joining a call link.
|
||||
*/
|
||||
class CallLinkJoinButton @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null
|
||||
) : LinearLayoutCompat(context, attrs) {
|
||||
init {
|
||||
orientation = VERTICAL
|
||||
inflate(context, R.layout.call_link_join_button, this)
|
||||
}
|
||||
|
||||
private val joinStroke: View = findViewById(R.id.join_stroke)
|
||||
private val joinButton: MaterialButton = findViewById(R.id.join_button)
|
||||
|
||||
fun setTextColor(@ColorRes textColorResId: Int) {
|
||||
val color = ContextCompat.getColor(context, textColorResId)
|
||||
|
||||
joinButton.setTextColor(color)
|
||||
}
|
||||
|
||||
fun setStrokeColor(@ColorRes strokeColorResId: Int) {
|
||||
val color = ContextCompat.getColor(context, strokeColorResId)
|
||||
|
||||
joinStroke.setBackgroundColor(color)
|
||||
}
|
||||
|
||||
fun setJoinClickListener(onClickListener: OnClickListener) {
|
||||
joinButton.setOnClickListener(onClickListener)
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user