mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-03 00:42:58 +01:00
Compare commits
210 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
316b071c81 | ||
|
|
5a6f55c0a8 | ||
|
|
e008a50acc | ||
|
|
41c3913482 | ||
|
|
803ff76678 | ||
|
|
309081437a | ||
|
|
5f152b73c2 | ||
|
|
f8d3336a1e | ||
|
|
dc1fdffe6a | ||
|
|
622d9c909f | ||
|
|
4e3ef19c1f | ||
|
|
b054a30fa7 | ||
|
|
7266c24354 | ||
|
|
5ec2877bcc | ||
|
|
0d93446c7d | ||
|
|
1e395ab416 | ||
|
|
0acb5ac7cd | ||
|
|
3b18b5d2b7 | ||
|
|
16e63a061d | ||
|
|
a6c8b940c9 | ||
|
|
74d9e3248b | ||
|
|
3af8b6050c | ||
|
|
da966753a1 | ||
|
|
0ad4b3f73e | ||
|
|
e8d072d4be | ||
|
|
b0eed4a095 | ||
|
|
ba720efe61 | ||
|
|
e23d575460 | ||
|
|
7fbcd17759 | ||
|
|
a95ebb2158 | ||
|
|
8a36425cac | ||
|
|
4261ed39dc | ||
|
|
ca37a884fd | ||
|
|
9fbb7683bc | ||
|
|
42e275ef0a | ||
|
|
19ece12e93 | ||
|
|
3ef0d3e4a3 | ||
|
|
602ea46b8b | ||
|
|
95c0bc6052 | ||
|
|
bd4ce1788c | ||
|
|
20d16a8433 | ||
|
|
db4c11cd53 | ||
|
|
f439e1f8e3 | ||
|
|
080b1aab83 | ||
|
|
61ba2ac97a | ||
|
|
7eebb38eda | ||
|
|
43e7d65af5 | ||
|
|
386d8bb312 | ||
|
|
3fbd72092c | ||
|
|
4e5b15cd88 | ||
|
|
8b2aeba3bd | ||
|
|
1d2334b920 | ||
|
|
38a234ae66 | ||
|
|
2c1226dc02 | ||
|
|
1df8ef6464 | ||
|
|
f8d40bf86d | ||
|
|
58ab03b4e3 | ||
|
|
0bf54e6b45 | ||
|
|
8fca0c69ac | ||
|
|
70eb4ca2a1 | ||
|
|
9d9e30725e | ||
|
|
ff9585ec7d | ||
|
|
a418c2750a | ||
|
|
9581994050 | ||
|
|
316d0e67c5 | ||
|
|
503bf04ec5 | ||
|
|
d6b76936dd | ||
|
|
c53d16717b | ||
|
|
2c747daa50 | ||
|
|
0b2d3edcce | ||
|
|
955bcde062 | ||
|
|
a91aa72fb4 | ||
|
|
163ece75b2 | ||
|
|
a8fb5f2598 | ||
|
|
3a62ad67e1 | ||
|
|
48f4e1ddc6 | ||
|
|
c37bb96aab | ||
|
|
a2057e20d2 | ||
|
|
577e05eb51 | ||
|
|
65a30cf2a7 | ||
|
|
121f0c6134 | ||
|
|
7d1897a9d2 | ||
|
|
415dbd1b61 | ||
|
|
cfc1c35203 | ||
|
|
911d7f3be8 | ||
|
|
c06944da13 | ||
|
|
b6dd4a3579 | ||
|
|
b057e145c5 | ||
|
|
772ad3b929 | ||
|
|
46681868d3 | ||
|
|
75795bd7d5 | ||
|
|
1908723fbe | ||
|
|
549992c08a | ||
|
|
845704b9fe | ||
|
|
ba03ca5e0c | ||
|
|
92a9f12b58 | ||
|
|
3437ac63bb | ||
|
|
d798a35c38 | ||
|
|
01b56995d9 | ||
|
|
3f190efb4e | ||
|
|
bb6b149c2e | ||
|
|
65b96fff16 | ||
|
|
0b8e8a7b2f | ||
|
|
a8a6fec19d | ||
|
|
a3fce4c149 | ||
|
|
85265412da | ||
|
|
e636a94de0 | ||
|
|
08509f6693 | ||
|
|
d28fc98cfd | ||
|
|
f584ef1d72 | ||
|
|
67a6df57c8 | ||
|
|
fadbb0adc5 | ||
|
|
58774033b7 | ||
|
|
66f0470960 | ||
|
|
68137cb66f | ||
|
|
4d6cacdb3d | ||
|
|
cf862af3ca | ||
|
|
a8d106a292 | ||
|
|
6155140de4 | ||
|
|
a4637248e8 | ||
|
|
8c4470a27e | ||
|
|
071fbfd916 | ||
|
|
1968438ebb | ||
|
|
7b31383b88 | ||
|
|
093a79045d | ||
|
|
e4928b0084 | ||
|
|
03420cf501 | ||
|
|
541b4674a8 | ||
|
|
6e108a03d1 | ||
|
|
c9dd332abd | ||
|
|
7e605fb6de | ||
|
|
fa2b0aedb0 | ||
|
|
402f49edd9 | ||
|
|
caf2e555dd | ||
|
|
32dc36d937 | ||
|
|
771d49bfa8 | ||
|
|
70dc78601a | ||
|
|
b4d781ddbb | ||
|
|
9c29601b55 | ||
|
|
28c37cb3ac | ||
|
|
bd121e47c8 | ||
|
|
7428e1e2ea | ||
|
|
376cb926b0 | ||
|
|
4ed0056d2a | ||
|
|
177ef8a555 | ||
|
|
7244a1f52f | ||
|
|
8d311923c1 | ||
|
|
9359d56880 | ||
|
|
3214200188 | ||
|
|
841ab7f983 | ||
|
|
53b3728432 | ||
|
|
cf9f98efc9 | ||
|
|
b5c666a1f4 | ||
|
|
b1954a509c | ||
|
|
c2c91cfe42 | ||
|
|
cccbec5744 | ||
|
|
4c89b20fad | ||
|
|
2328fa3e88 | ||
|
|
e19d4624c1 | ||
|
|
345f58ed48 | ||
|
|
4c14ce3937 | ||
|
|
82684c0169 | ||
|
|
2607328255 | ||
|
|
484ce3a1da | ||
|
|
85d5f62301 | ||
|
|
b0571f8184 | ||
|
|
b80dd28b40 | ||
|
|
e0cf0808cf | ||
|
|
ffdd5b62ae | ||
|
|
3b5376ef8b | ||
|
|
cd57fb0d76 | ||
|
|
6986acd6f4 | ||
|
|
2bc571ffd3 | ||
|
|
a8dddf33f8 | ||
|
|
46582a685b | ||
|
|
ad381783f7 | ||
|
|
b81c1eb65c | ||
|
|
2c4d3b3ee4 | ||
|
|
d1400928ce | ||
|
|
49abece92b | ||
|
|
b48b1f031e | ||
|
|
9cefe0bc04 | ||
|
|
ee73b0e229 | ||
|
|
ab0ce58812 | ||
|
|
333a206d36 | ||
|
|
86bb7666ea | ||
|
|
58b5ebf39d | ||
|
|
47947b85c7 | ||
|
|
6910ba6d2e | ||
|
|
08254edae6 | ||
|
|
e67307a961 | ||
|
|
9922621945 | ||
|
|
c7476a2a07 | ||
|
|
ac59528f5c | ||
|
|
97c9728c65 | ||
|
|
80d1694e6e | ||
|
|
28c6e31c7d | ||
|
|
8836b2a570 | ||
|
|
786c2b888b | ||
|
|
c91275c5da | ||
|
|
7b362460e7 | ||
|
|
a1862c3420 | ||
|
|
44ea9ccc59 | ||
|
|
4c9cdf3b8f | ||
|
|
4a6d4f197d | ||
|
|
ae04749336 | ||
|
|
caa743aba2 | ||
|
|
a4469a4285 | ||
|
|
2771b31aab | ||
|
|
5c418a4260 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -33,3 +33,4 @@ maps.key
|
||||
kls_database.db
|
||||
.kotlin
|
||||
lefthook-local.yml
|
||||
sample-videos/
|
||||
|
||||
@@ -1,17 +1,11 @@
|
||||
@file:Suppress("UnstableApiUsage")
|
||||
|
||||
import com.android.build.api.dsl.ManagedVirtualDevice
|
||||
import org.gradle.api.file.DirectoryProperty
|
||||
import org.gradle.api.file.RegularFileProperty
|
||||
import org.gradle.api.provider.ValueSource
|
||||
import org.gradle.api.provider.ValueSourceParameters
|
||||
import org.gradle.api.tasks.InputDirectory
|
||||
import org.gradle.api.tasks.InputFile
|
||||
import org.gradle.api.tasks.Optional
|
||||
import org.gradle.api.tasks.PathSensitive
|
||||
import org.gradle.api.tasks.PathSensitivity
|
||||
import org.gradle.api.tasks.testing.logging.TestExceptionFormat
|
||||
import java.io.File
|
||||
import java.time.Instant
|
||||
import java.time.ZoneOffset
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.Locale
|
||||
import java.util.Properties
|
||||
|
||||
plugins {
|
||||
@@ -30,8 +24,8 @@ plugins {
|
||||
|
||||
apply(from = "static-ips.gradle.kts")
|
||||
|
||||
val canonicalVersionCode = 1651
|
||||
val canonicalVersionName = "7.74.3"
|
||||
val canonicalVersionCode = 1662
|
||||
val canonicalVersionName = "8.2.1"
|
||||
val currentHotfixVersion = 0
|
||||
val maxHotfixVersions = 100
|
||||
|
||||
@@ -50,9 +44,15 @@ val languagesForBuildConfigProvider = languagesProvider.map { languages ->
|
||||
languages.joinToString(separator = ", ") { language -> "\"$language\"" }
|
||||
}
|
||||
|
||||
val localPropertiesFile = File(rootProject.projectDir, "local.properties")
|
||||
val localProperties: Properties? = if (localPropertiesFile.exists()) {
|
||||
Properties().apply { localPropertiesFile.inputStream().use { load(it) } }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val quickstartCredentialsDir: String? = localProperties?.getProperty("quickstart.credentials.dir")
|
||||
|
||||
val selectableVariants = listOf(
|
||||
"nightlyBackupRelease",
|
||||
"nightlyBackupSpinner",
|
||||
"nightlyProdSpinner",
|
||||
"nightlyProdPerf",
|
||||
"nightlyProdRelease",
|
||||
@@ -72,6 +72,8 @@ val selectableVariants = listOf(
|
||||
"playStagingPerf",
|
||||
"playStagingInstrumentation",
|
||||
"playStagingRelease",
|
||||
"playProdQuickstart",
|
||||
"playStagingQuickstart",
|
||||
"websiteProdSpinner",
|
||||
"websiteProdRelease",
|
||||
"githubProdSpinner",
|
||||
@@ -259,7 +261,7 @@ android {
|
||||
buildConfigField("String", "STRIPE_PUBLISHABLE_KEY", "\"pk_live_6cmGZopuTsV8novGgJJW9JpC00vLIgtQ1D\"")
|
||||
buildConfigField("boolean", "TRACING_ENABLED", "false")
|
||||
buildConfigField("boolean", "LINK_DEVICE_UX_ENABLED", "false")
|
||||
buildConfigField("boolean", "USE_STRING_ID", "true")
|
||||
buildConfigField("boolean", "USE_STRING_ID", "false")
|
||||
|
||||
ndk {
|
||||
abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64")
|
||||
@@ -358,8 +360,13 @@ android {
|
||||
isDebuggable = false
|
||||
isMinifyEnabled = true
|
||||
matchingFallbacks += "debug"
|
||||
applicationIdSuffix = ".benchmark"
|
||||
|
||||
buildConfigField("String", "BUILD_VARIANT_TYPE", "\"Benchmark\"")
|
||||
buildConfigField("boolean", "TRACING_ENABLED", "true")
|
||||
buildConfigField("String[]", "UNIDENTIFIED_SENDER_TRUST_ROOTS", "new String[]{ \"BVT/2gHqbrG1xzuIypLIOjFgMtihrMld1/5TGADL6Dhv\"}")
|
||||
|
||||
manifestPlaceholders["applicationClass"] = "org.thoughtcrime.securesms.BenchmarkApplicationContext"
|
||||
}
|
||||
|
||||
create("mocked") {
|
||||
@@ -370,6 +377,8 @@ android {
|
||||
matchingFallbacks += "debug"
|
||||
buildConfigField("String", "BUILD_VARIANT_TYPE", "\"Benchmark\"")
|
||||
buildConfigField("boolean", "TRACING_ENABLED", "true")
|
||||
|
||||
manifestPlaceholders["applicationClass"] = "org.thoughtcrime.securesms.ApplicationContext"
|
||||
}
|
||||
|
||||
create("canary") {
|
||||
@@ -379,6 +388,14 @@ android {
|
||||
matchingFallbacks += "debug"
|
||||
buildConfigField("String", "BUILD_VARIANT_TYPE", "\"Canary\"")
|
||||
}
|
||||
|
||||
create("quickstart") {
|
||||
initWith(getByName("debug"))
|
||||
isDefault = false
|
||||
isMinifyEnabled = false
|
||||
matchingFallbacks += "debug"
|
||||
buildConfigField("String", "BUILD_VARIANT_TYPE", "\"Quickstart\"")
|
||||
}
|
||||
}
|
||||
|
||||
productFlavors {
|
||||
@@ -445,22 +462,10 @@ android {
|
||||
buildConfigField("String", "RECAPTCHA_PROOF_URL", "\"https://signalcaptchas.org/staging/challenge/generate.html\"")
|
||||
buildConfigField("org.signal.libsignal.net.Network.Environment", "LIBSIGNAL_NET_ENV", "org.signal.libsignal.net.Network.Environment.STAGING")
|
||||
buildConfigField("int", "LIBSIGNAL_LOG_LEVEL", "org.signal.libsignal.protocol.logging.SignalProtocolLogger.DEBUG")
|
||||
buildConfigField("boolean", "USE_STRING_ID", "false")
|
||||
|
||||
buildConfigField("String", "BUILD_ENVIRONMENT_TYPE", "\"Staging\"")
|
||||
buildConfigField("String", "STRIPE_PUBLISHABLE_KEY", "\"pk_test_sngOd8FnXNkpce9nPXawKrJD00kIDngZkD\"")
|
||||
}
|
||||
|
||||
create("backup") {
|
||||
initWith(getByName("staging"))
|
||||
|
||||
dimension = "environment"
|
||||
|
||||
applicationIdSuffix = ".backup"
|
||||
|
||||
buildConfigField("boolean", "MANAGES_APP_UPDATES", "true")
|
||||
buildConfigField("String", "BUILD_ENVIRONMENT_TYPE", "\"Backup\"")
|
||||
}
|
||||
}
|
||||
|
||||
lint {
|
||||
@@ -502,12 +507,30 @@ android {
|
||||
val nightlyVersionCode = (canonicalVersionCode * maxHotfixVersions) + (getNightlyBuildNumber(tag) * 10) + nightlyBuffer
|
||||
|
||||
variant.outputs.forEach { output ->
|
||||
output.versionName.set(tag)
|
||||
output.versionName.set("$tag | ${getLastCommitDateTimeUtc()}")
|
||||
output.versionCode.set(nightlyVersionCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onVariants(selector().withBuildType("quickstart")) { variant ->
|
||||
val environment = variant.flavorName?.let { name ->
|
||||
when {
|
||||
name.contains("staging", ignoreCase = true) -> "staging"
|
||||
name.contains("prod", ignoreCase = true) -> "prod"
|
||||
else -> "prod"
|
||||
}
|
||||
} ?: "prod"
|
||||
|
||||
val taskProvider = tasks.register<CopyQuickstartCredentialsTask>("copyQuickstartCredentials${variant.name.capitalize()}") {
|
||||
if (quickstartCredentialsDir != null) {
|
||||
inputDir.set(File(quickstartCredentialsDir))
|
||||
}
|
||||
filePrefix.set("${environment}_")
|
||||
}
|
||||
variant.sources.assets?.addGeneratedSourceDirectory(taskProvider) { it.outputDir }
|
||||
}
|
||||
}
|
||||
|
||||
val releaseDir = "$projectDir/src/release/java"
|
||||
@@ -535,7 +558,8 @@ android {
|
||||
applicationVariants.configureEach {
|
||||
outputs.configureEach {
|
||||
if (this is com.android.build.gradle.internal.api.BaseVariantOutputImpl) {
|
||||
outputFileName = outputFileName.replace(".apk", "-$versionName.apk")
|
||||
val fileVersionName = versionName.substringBefore(" |")
|
||||
outputFileName = outputFileName.replace(".apk", "-$fileVersionName.apk")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -578,6 +602,7 @@ dependencies {
|
||||
implementation(project(":core:models"))
|
||||
implementation(project(":core:models-jvm"))
|
||||
implementation(project(":feature:camera"))
|
||||
implementation(project(":feature:registration"))
|
||||
|
||||
implementation(libs.androidx.fragment.ktx)
|
||||
implementation(libs.androidx.appcompat) {
|
||||
@@ -773,6 +798,16 @@ fun getNightlyBuildNumber(tag: String?): Int {
|
||||
return match?.groupValues?.get(1)?.toIntOrNull() ?: 0
|
||||
}
|
||||
|
||||
fun getLastCommitDateTimeUtc(): String {
|
||||
val timestamp = providers.exec {
|
||||
commandLine("git", "log", "-1", "--pretty=format:%ct")
|
||||
}.standardOutput.asText.get().trim().toLong()
|
||||
val instant = Instant.ofEpochSecond(timestamp)
|
||||
val formatter = DateTimeFormatter.ofPattern("MMM d '@' HH:mm 'UTC'", Locale.US)
|
||||
.withZone(ZoneOffset.UTC)
|
||||
return formatter.format(instant)
|
||||
}
|
||||
|
||||
fun getMapsKey(): String {
|
||||
return providers
|
||||
.gradleProperty("mapsKey")
|
||||
@@ -832,3 +867,38 @@ abstract class PropertiesFileValueSource : ValueSource<Properties?, PropertiesFi
|
||||
fun String.capitalize(): String {
|
||||
return this.replaceFirstChar { it.uppercase() }
|
||||
}
|
||||
|
||||
abstract class CopyQuickstartCredentialsTask : DefaultTask() {
|
||||
@get:InputDirectory
|
||||
@get:Optional
|
||||
abstract val inputDir: DirectoryProperty
|
||||
|
||||
@get:Input
|
||||
abstract val filePrefix: Property<String>
|
||||
|
||||
@get:OutputDirectory
|
||||
abstract val outputDir: DirectoryProperty
|
||||
|
||||
@TaskAction
|
||||
fun copy() {
|
||||
if (!inputDir.isPresent) {
|
||||
throw GradleException("quickstart.credentials.dir is not set in local.properties. This is required for quickstart builds.")
|
||||
}
|
||||
|
||||
val prefix = filePrefix.get()
|
||||
val candidates = inputDir.get().asFile.listFiles()
|
||||
?.filter { it.extension == "json" && it.name.startsWith(prefix) }
|
||||
?: emptyList()
|
||||
|
||||
if (candidates.isEmpty()) {
|
||||
throw GradleException("No credential files matching '$prefix*.json' found in ${inputDir.get().asFile}. Add files like '${prefix}account1.json' to your credentials directory.")
|
||||
}
|
||||
|
||||
val chosen = candidates.random()
|
||||
logger.lifecycle("Selected quickstart credential: ${chosen.name}")
|
||||
|
||||
val dest = outputDir.get().asFile.resolve("quickstart")
|
||||
dest.mkdirs()
|
||||
chosen.copyTo(dest.resolve(chosen.name), overwrite = true)
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -71,6 +71,11 @@ class ArchiveImportExportTests {
|
||||
runTests { it.startsWith("chat_folder_") }
|
||||
}
|
||||
|
||||
// @Test
|
||||
fun chatItemAdminDelete() {
|
||||
runTests { it.startsWith("chat_item_admin_deleted_") }
|
||||
}
|
||||
|
||||
// @Test
|
||||
fun chatItemContactMessage() {
|
||||
runTests { it.startsWith("chat_item_contact_message_") }
|
||||
|
||||
@@ -18,7 +18,6 @@ import com.bumptech.glide.RequestManager
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.signal.ringrtc.CallLinkEpoch
|
||||
import org.signal.ringrtc.CallLinkRootKey
|
||||
import org.thoughtcrime.securesms.components.voice.VoiceNotePlaybackState
|
||||
import org.thoughtcrime.securesms.contactshare.Contact
|
||||
@@ -27,6 +26,7 @@ import org.thoughtcrime.securesms.conversation.ConversationItem
|
||||
import org.thoughtcrime.securesms.conversation.ConversationItemDisplayMode
|
||||
import org.thoughtcrime.securesms.conversation.ConversationMessage
|
||||
import org.thoughtcrime.securesms.conversation.colors.Colorizer
|
||||
import org.thoughtcrime.securesms.conversation.colors.ColorizerV2
|
||||
import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectPart
|
||||
import org.thoughtcrime.securesms.database.FakeMessageRecords
|
||||
import org.thoughtcrime.securesms.database.model.InMemoryMessageRecord
|
||||
@@ -209,7 +209,7 @@ class V2ConversationItemShapeTest {
|
||||
private val nextMessage: MessageRecord? = null
|
||||
) : V2ConversationContext {
|
||||
|
||||
private val colorizer = Colorizer()
|
||||
private val colorizer = ColorizerV2()
|
||||
|
||||
override val lifecycleOwner: LifecycleOwner = object : LifecycleOwner {
|
||||
override val lifecycle: Lifecycle = LifecycleRegistry(this)
|
||||
@@ -329,7 +329,7 @@ class V2ConversationItemShapeTest {
|
||||
|
||||
override fun onShowGroupDescriptionClicked(groupName: String, description: String, shouldLinkifyWebLinks: Boolean) = Unit
|
||||
|
||||
override fun onJoinCallLink(callLinkRootKey: CallLinkRootKey, callLinkEpoch: CallLinkEpoch?) = Unit
|
||||
override fun onJoinCallLink(callLinkRootKey: CallLinkRootKey) = Unit
|
||||
|
||||
override fun onItemClick(item: MultiselectPart?) = Unit
|
||||
|
||||
|
||||
@@ -81,8 +81,7 @@ class CallLinkTableTest {
|
||||
roomId = CallLinkRoomId.fromBytes(roomId),
|
||||
credentials = CallLinkCredentials(
|
||||
linkKeyBytes = roomId,
|
||||
adminPassBytes = null,
|
||||
epochBytes = null
|
||||
adminPassBytes = null
|
||||
),
|
||||
state = SignalCallLinkState(),
|
||||
deletionTimestamp = 0L
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
package org.thoughtcrime.securesms.database
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.isEqualTo
|
||||
import assertk.assertions.isNotNull
|
||||
import assertk.assertions.isNull
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.signal.core.models.ServiceId.ACI
|
||||
import org.signal.core.util.CursorUtil
|
||||
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
|
||||
import org.thoughtcrime.securesms.mms.IncomingMessage
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.testing.SignalDatabaseRule
|
||||
import java.util.UUID
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class EditMessageRevisionTest {
|
||||
|
||||
@get:Rule
|
||||
val databaseRule = SignalDatabaseRule()
|
||||
|
||||
private lateinit var senderId: RecipientId
|
||||
private var threadId: Long = 0
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
val senderAci = ACI.from(UUID.randomUUID())
|
||||
senderId = SignalDatabase.recipients.getOrInsertFromServiceId(senderAci)
|
||||
threadId = SignalDatabase.threads.getOrCreateThreadIdFor(senderId, false, ThreadTable.DistributionTypes.DEFAULT)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun singleEditSetsLatestRevisionIdOnOriginal() {
|
||||
val originalId = insertOriginalMessage(sentTimeMillis = 1000)
|
||||
val editId = insertEdit(originalSentTimestamp = 1000, editSentTimeMillis = 1001)
|
||||
|
||||
assertThat(getLatestRevisionId(originalId)).isNotNull().isEqualTo(editId)
|
||||
assertThat(getLatestRevisionId(editId)).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun singleEditOnlyLatestRevisionAppearsInNotificationState() {
|
||||
val originalId = insertOriginalMessage(sentTimeMillis = 1000)
|
||||
val editId = insertEdit(originalSentTimestamp = 1000, editSentTimeMillis = 1001)
|
||||
|
||||
val notificationIds = getNotificationStateMessageIds()
|
||||
assertEquals(listOf(editId), notificationIds)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun multiEditSetsLatestRevisionIdOnAllPreviousRevisions() {
|
||||
val originalId = insertOriginalMessage(sentTimeMillis = 1000)
|
||||
|
||||
val edit1Id = insertEdit(originalSentTimestamp = 1000, editSentTimeMillis = 1001)
|
||||
|
||||
assertThat(getLatestRevisionId(originalId)).isNotNull().isEqualTo(edit1Id)
|
||||
assertThat(getLatestRevisionId(edit1Id)).isNull()
|
||||
|
||||
val edit2Id = insertEdit(originalSentTimestamp = 1000, editSentTimeMillis = 1002)
|
||||
|
||||
assertThat(getLatestRevisionId(originalId)).isNotNull().isEqualTo(edit2Id)
|
||||
assertThat(getLatestRevisionId(edit1Id)).isNotNull().isEqualTo(edit2Id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun multiEditOnlyLatestRevisionAppearsInNotificationState() {
|
||||
val originalId = insertOriginalMessage(sentTimeMillis = 1000)
|
||||
|
||||
insertEdit(originalSentTimestamp = 1000, editSentTimeMillis = 1001)
|
||||
val edit2Id = insertEdit(originalSentTimestamp = 1000, editSentTimeMillis = 1002)
|
||||
|
||||
val notificationIds = getNotificationStateMessageIds()
|
||||
assertEquals("Only the latest revision should appear in notification state", listOf(edit2Id), notificationIds)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun readSyncThenMultipleEditsDoNotCreateOrphanedUnreadRevisions() {
|
||||
val originalId = insertOriginalMessage(sentTimeMillis = 1000)
|
||||
|
||||
markAsRead(originalId)
|
||||
assertEquals("No notifications after read sync", 0, getNotificationStateMessageIds().size)
|
||||
|
||||
insertEdit(originalSentTimestamp = 1000, editSentTimeMillis = 1001)
|
||||
insertEdit(originalSentTimestamp = 1000, editSentTimeMillis = 1002)
|
||||
|
||||
val notificationIds = getNotificationStateMessageIds()
|
||||
assertEquals(
|
||||
"No notifications should appear after edits to a message that was already read via sync",
|
||||
emptyList<Long>(),
|
||||
notificationIds
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun readSyncOnLatestRevisionThenSecondEditDoesNotCreateOrphanedNotification() {
|
||||
val originalId = insertOriginalMessage(sentTimeMillis = 1000)
|
||||
|
||||
val edit1Id = insertEdit(originalSentTimestamp = 1000, editSentTimeMillis = 1001)
|
||||
|
||||
// Read sync updates the latestRevisionId (edit1), not the original
|
||||
markAsRead(edit1Id)
|
||||
assertEquals("No notifications after read sync on edited message", 0, getNotificationStateMessageIds().size)
|
||||
|
||||
val edit2Id = insertEdit(originalSentTimestamp = 1000, editSentTimeMillis = 1002)
|
||||
|
||||
val notificationIds = getNotificationStateMessageIds()
|
||||
assertEquals(
|
||||
"Only the latest revision or no revisions should appear depending on read state",
|
||||
notificationIds.filter { it != edit2Id },
|
||||
emptyList<Long>()
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun tripleEditCorrectlyChainsAllRevisions() {
|
||||
val originalId = insertOriginalMessage(sentTimeMillis = 1000)
|
||||
|
||||
val edit1Id = insertEdit(originalSentTimestamp = 1000, editSentTimeMillis = 1001)
|
||||
val edit2Id = insertEdit(originalSentTimestamp = 1000, editSentTimeMillis = 1002)
|
||||
val edit3Id = insertEdit(originalSentTimestamp = 1000, editSentTimeMillis = 1003)
|
||||
|
||||
assertThat(getLatestRevisionId(originalId)).isNotNull().isEqualTo(edit3Id)
|
||||
assertThat(getLatestRevisionId(edit1Id)).isNotNull().isEqualTo(edit3Id)
|
||||
assertThat(getLatestRevisionId(edit2Id)).isNotNull().isEqualTo(edit3Id)
|
||||
assertThat(getLatestRevisionId(edit3Id)).isNull()
|
||||
|
||||
assertEquals(listOf(edit3Id), getNotificationStateMessageIds())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun multiEditWithReadSyncBetweenEditsNotificationDismissedAndStaysDismissed() {
|
||||
val originalId = insertOriginalMessage(sentTimeMillis = 1000)
|
||||
|
||||
assertEquals("Original unread message should be in notification state", 1, getNotificationStateMessageIds().size)
|
||||
|
||||
markAsReadAndNotified(originalId)
|
||||
assertEquals("No notifications after read sync", 0, getNotificationStateMessageIds().size)
|
||||
|
||||
insertEdit(originalSentTimestamp = 1000, editSentTimeMillis = 1001)
|
||||
assertEquals("No notifications after first edit (original was read)", 0, getNotificationStateMessageIds().size)
|
||||
|
||||
val edit2Id = insertEdit(originalSentTimestamp = 1000, editSentTimeMillis = 1002)
|
||||
|
||||
val notificationIds = getNotificationStateMessageIds()
|
||||
assertEquals(
|
||||
"No notifications should appear - message was read via sync before edits arrived",
|
||||
emptyList<Long>(),
|
||||
notificationIds
|
||||
)
|
||||
|
||||
// Verify revision chain integrity
|
||||
assertThat(getLatestRevisionId(originalId)).isNotNull().isEqualTo(edit2Id)
|
||||
val edit1Id = edit2Id - 1 // edit1 was inserted right before edit2
|
||||
assertThat(getLatestRevisionId(edit1Id)).isNotNull().isEqualTo(edit2Id)
|
||||
assertThat(getLatestRevisionId(edit2Id)).isNull()
|
||||
}
|
||||
|
||||
private fun insertOriginalMessage(sentTimeMillis: Long): Long {
|
||||
val message = IncomingMessage(
|
||||
type = MessageType.NORMAL,
|
||||
from = senderId,
|
||||
sentTimeMillis = sentTimeMillis,
|
||||
serverTimeMillis = sentTimeMillis,
|
||||
receivedTimeMillis = System.currentTimeMillis(),
|
||||
body = "original message"
|
||||
)
|
||||
return SignalDatabase.messages.insertMessageInbox(message, threadId).get().messageId
|
||||
}
|
||||
|
||||
/**
|
||||
* The target is always retrieved via [MessageTable.getMessageFor] using the original sent
|
||||
* timestamp — this matches what [EditMessageProcessor] does and means targetMessage.id
|
||||
* is always the original message's row ID.
|
||||
*/
|
||||
private fun insertEdit(originalSentTimestamp: Long, editSentTimeMillis: Long): Long {
|
||||
val targetMessage = SignalDatabase.messages.getMessageFor(originalSentTimestamp, senderId) as MmsMessageRecord
|
||||
|
||||
val editMessage = IncomingMessage(
|
||||
type = MessageType.NORMAL,
|
||||
from = senderId,
|
||||
sentTimeMillis = editSentTimeMillis,
|
||||
serverTimeMillis = editSentTimeMillis,
|
||||
receivedTimeMillis = System.currentTimeMillis(),
|
||||
body = "edited at $editSentTimeMillis"
|
||||
)
|
||||
return SignalDatabase.messages.insertEditMessageInbox(editMessage, targetMessage).get().messageId
|
||||
}
|
||||
|
||||
private fun getLatestRevisionId(messageId: Long): Long? {
|
||||
return SignalDatabase.rawDatabase
|
||||
.query(MessageTable.TABLE_NAME, arrayOf(MessageTable.LATEST_REVISION_ID), "${MessageTable.ID} = ?", arrayOf(messageId.toString()), null, null, null)
|
||||
.use { cursor ->
|
||||
if (cursor.moveToFirst()) {
|
||||
val idx = cursor.getColumnIndexOrThrow(MessageTable.LATEST_REVISION_ID)
|
||||
if (cursor.isNull(idx)) null else cursor.getLong(idx)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getNotificationStateMessageIds(): List<Long> {
|
||||
return SignalDatabase.messages.getMessagesForNotificationState(emptyList()).use { cursor ->
|
||||
val ids = mutableListOf<Long>()
|
||||
while (cursor.moveToNext()) {
|
||||
ids.add(CursorUtil.requireLong(cursor, MessageTable.ID))
|
||||
}
|
||||
ids
|
||||
}
|
||||
}
|
||||
|
||||
private fun markAsRead(messageId: Long) {
|
||||
SignalDatabase.rawDatabase.execSQL(
|
||||
"UPDATE ${MessageTable.TABLE_NAME} SET ${MessageTable.READ} = 1 WHERE ${MessageTable.ID} = ?",
|
||||
arrayOf(messageId)
|
||||
)
|
||||
}
|
||||
|
||||
private fun markAsReadAndNotified(messageId: Long) {
|
||||
SignalDatabase.rawDatabase.execSQL(
|
||||
"UPDATE ${MessageTable.TABLE_NAME} SET ${MessageTable.READ} = 1, ${MessageTable.NOTIFIED} = 1 WHERE ${MessageTable.ID} = ?",
|
||||
arrayOf(messageId)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -190,7 +190,7 @@ class StorySendTableTest {
|
||||
@Test
|
||||
fun getRemoteDeleteRecipients_overlapWithPreviousDeletes() {
|
||||
storySends.insert(messageId1, recipients1to10, 200, false, distributionId1)
|
||||
SignalDatabase.messages.markAsRemoteDelete(messageId1)
|
||||
SignalDatabase.messages.markAsDeleteBySelf(messageId1)
|
||||
|
||||
storySends.insert(messageId2, recipients6to15, 200, true, distributionId2)
|
||||
|
||||
@@ -287,7 +287,7 @@ class StorySendTableTest {
|
||||
fun givenTwoStoriesAndOneIsRemoteDeleted_whenIGetFullSentStorySyncManifestForStory2_thenIExpectNonNullResult() {
|
||||
storySends.insert(messageId1, recipients1to10, 200, false, distributionId1)
|
||||
storySends.insert(messageId2, recipients1to10, 200, true, distributionId2)
|
||||
SignalDatabase.messages.markAsRemoteDelete(messageId1)
|
||||
SignalDatabase.messages.markAsDeleteBySelf(messageId1)
|
||||
|
||||
val manifest = storySends.getFullSentStorySyncManifest(messageId2, 200)!!
|
||||
|
||||
|
||||
@@ -1,188 +0,0 @@
|
||||
package org.thoughtcrime.securesms.messages
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import io.mockk.every
|
||||
import io.mockk.mockkObject
|
||||
import io.mockk.mockkStatic
|
||||
import io.mockk.unmockkStatic
|
||||
import okio.ByteString
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Ignore
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.libsignal.protocol.ecc.ECKeyPair
|
||||
import org.signal.libsignal.zkgroup.profiles.ProfileKey
|
||||
import org.thoughtcrime.securesms.crypto.SealedSenderAccessUtil
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.testing.AliceClient
|
||||
import org.thoughtcrime.securesms.testing.BobClient
|
||||
import org.thoughtcrime.securesms.testing.Entry
|
||||
import org.thoughtcrime.securesms.testing.FakeClientHelpers
|
||||
import org.thoughtcrime.securesms.testing.SignalActivityRule
|
||||
import org.thoughtcrime.securesms.testing.awaitFor
|
||||
import org.whispersystems.signalservice.internal.push.Envelope
|
||||
import org.whispersystems.signalservice.internal.websocket.WebSocketMessage
|
||||
import org.whispersystems.signalservice.internal.websocket.WebSocketRequestMessage
|
||||
import java.util.regex.Pattern
|
||||
import kotlin.random.Random
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
import android.util.Log as AndroidLog
|
||||
|
||||
/**
|
||||
* Sends N messages from Bob to Alice to track performance of Alice's processing of messages.
|
||||
*/
|
||||
@Ignore("Ignore test in normal testing as it's a performance test with no assertions")
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class MessageProcessingPerformanceTest {
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(MessageProcessingPerformanceTest::class.java)
|
||||
private val TIMING_TAG = "TIMING_$TAG".substring(0..23)
|
||||
|
||||
private val DECRYPTION_TIME_PATTERN = Pattern.compile("^Decrypted (?<count>\\d+) envelopes in (?<duration>\\d+) ms.*$")
|
||||
}
|
||||
|
||||
@get:Rule
|
||||
val harness = SignalActivityRule()
|
||||
|
||||
private val trustRoot: ECKeyPair = ECKeyPair.generate()
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
mockkStatic(SealedSenderAccessUtil::class)
|
||||
every { SealedSenderAccessUtil.getCertificateValidator() } returns FakeClientHelpers.noOpCertificateValidator
|
||||
|
||||
mockkObject(MessageContentProcessor)
|
||||
every { MessageContentProcessor.create(harness.application) } returns TimingMessageContentProcessor(harness.application)
|
||||
}
|
||||
|
||||
@After
|
||||
fun after() {
|
||||
unmockkStatic(SealedSenderAccessUtil::class)
|
||||
unmockkStatic(MessageContentProcessor::class)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testPerformance() {
|
||||
val aliceClient = AliceClient(
|
||||
serviceId = harness.self.requireServiceId(),
|
||||
e164 = harness.self.requireE164(),
|
||||
trustRoot = trustRoot
|
||||
)
|
||||
|
||||
val bob = Recipient.resolved(harness.others[0])
|
||||
val bobClient = BobClient(
|
||||
serviceId = bob.requireServiceId(),
|
||||
e164 = bob.requireE164(),
|
||||
identityKeyPair = harness.othersKeys[0],
|
||||
trustRoot = trustRoot,
|
||||
profileKey = ProfileKey(bob.profileKey)
|
||||
)
|
||||
|
||||
// Send the initial messages to get past the prekey phase
|
||||
establishSession(aliceClient, bobClient, bob)
|
||||
|
||||
// Have Bob generate N messages that will be received by Alice
|
||||
val messageCount = 100
|
||||
val envelopes = generateInboundEnvelopes(bobClient, messageCount)
|
||||
val firstTimestamp = envelopes.first().timestamp
|
||||
val lastTimestamp = envelopes.last().timestamp ?: 0
|
||||
|
||||
// Inject the envelopes into the websocket
|
||||
// TODO: mock websocket messages
|
||||
|
||||
// Wait until they've all been fully decrypted + processed
|
||||
harness
|
||||
.inMemoryLogger
|
||||
.getLockForUntil(TimingMessageContentProcessor.endTagPredicate(lastTimestamp))
|
||||
.awaitFor(1.minutes)
|
||||
|
||||
harness.inMemoryLogger.flush()
|
||||
|
||||
// Process logs for timing data
|
||||
val entries = harness.inMemoryLogger.entries()
|
||||
|
||||
// Calculate decryption average
|
||||
val totalDecryptDuration: Long = entries
|
||||
.mapNotNull { entry -> entry.message?.let { DECRYPTION_TIME_PATTERN.matcher(it) } }
|
||||
.filter { it.matches() }
|
||||
.drop(1) // Ignore the first message, which represents the prekey exchange
|
||||
.sumOf { it.group("duration")!!.toLong() }
|
||||
|
||||
AndroidLog.w(TAG, "Decryption: Average runtime: ${totalDecryptDuration.toFloat() / messageCount.toFloat()}ms")
|
||||
|
||||
// Calculate MessageContentProcessor
|
||||
|
||||
val takeLast: List<Entry> = entries.filter { it.tag == TimingMessageContentProcessor.TAG }.drop(2)
|
||||
val iterator = takeLast.iterator()
|
||||
var processCount = 0L
|
||||
var processDuration = 0L
|
||||
while (iterator.hasNext()) {
|
||||
val start = iterator.next()
|
||||
val end = iterator.next()
|
||||
processCount++
|
||||
processDuration += end.timestamp - start.timestamp
|
||||
}
|
||||
|
||||
AndroidLog.w(TAG, "MessageContentProcessor.process: Average runtime: ${processDuration.toFloat() / processCount.toFloat()}ms")
|
||||
|
||||
// Calculate messages per second from "retrieving" first message post session initialization to processing last message
|
||||
|
||||
val start = entries.first { it.message == "Retrieved envelope! $firstTimestamp" }
|
||||
val end = entries.first { it.message == TimingMessageContentProcessor.endTag(lastTimestamp) }
|
||||
|
||||
val duration = (end.timestamp - start.timestamp).toFloat() / 1000f
|
||||
val messagePerSecond = messageCount.toFloat() / duration
|
||||
|
||||
AndroidLog.w(TAG, "Processing $messageCount messages took ${duration}s or ${messagePerSecond}m/s")
|
||||
}
|
||||
|
||||
private fun establishSession(aliceClient: AliceClient, bobClient: BobClient, bob: Recipient) {
|
||||
// Send message from Bob to Alice (self)
|
||||
val firstPreKeyMessageTimestamp = System.currentTimeMillis()
|
||||
val encryptedEnvelope = bobClient.encrypt(firstPreKeyMessageTimestamp)
|
||||
|
||||
val aliceProcessFirstMessageLatch = harness
|
||||
.inMemoryLogger
|
||||
.getLockForUntil(TimingMessageContentProcessor.endTagPredicate(firstPreKeyMessageTimestamp))
|
||||
|
||||
Thread { aliceClient.process(encryptedEnvelope, System.currentTimeMillis()) }.start()
|
||||
aliceProcessFirstMessageLatch.awaitFor(15.seconds)
|
||||
|
||||
// Send message from Alice to Bob
|
||||
val aliceNow = System.currentTimeMillis()
|
||||
bobClient.decrypt(aliceClient.encrypt(aliceNow, bob), aliceNow)
|
||||
}
|
||||
|
||||
private fun generateInboundEnvelopes(bobClient: BobClient, count: Int): List<Envelope> {
|
||||
val envelopes = ArrayList<Envelope>(count)
|
||||
var now = System.currentTimeMillis()
|
||||
for (i in 0..count) {
|
||||
envelopes += bobClient.encrypt(now)
|
||||
now += 3
|
||||
}
|
||||
|
||||
return envelopes
|
||||
}
|
||||
|
||||
private fun webSocketTombstone(): ByteString {
|
||||
return WebSocketMessage(request = WebSocketRequestMessage(verb = "PUT", path = "/api/v1/queue/empty")).encodeByteString()
|
||||
}
|
||||
|
||||
private fun Envelope.toWebSocketPayload(): ByteString {
|
||||
return WebSocketMessage(
|
||||
type = WebSocketMessage.Type.REQUEST,
|
||||
request = WebSocketRequestMessage(
|
||||
verb = "PUT",
|
||||
path = "/api/v1/message",
|
||||
id = Random(System.currentTimeMillis()).nextLong(),
|
||||
headers = listOf("X-Signal-Timestamp: ${this.timestamp}"),
|
||||
body = this.encodeByteString()
|
||||
)
|
||||
).encodeByteString()
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
package org.thoughtcrime.securesms.messages
|
||||
|
||||
import android.content.Context
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.testing.LogPredicate
|
||||
import org.thoughtcrime.securesms.util.SignalLocalMetrics
|
||||
import org.whispersystems.signalservice.api.crypto.EnvelopeMetadata
|
||||
import org.whispersystems.signalservice.internal.push.Content
|
||||
import org.whispersystems.signalservice.internal.push.Envelope
|
||||
|
||||
class TimingMessageContentProcessor(context: Context) : MessageContentProcessor(context) {
|
||||
companion object {
|
||||
val TAG = Log.tag(TimingMessageContentProcessor::class.java)
|
||||
|
||||
fun endTagPredicate(timestamp: Long): LogPredicate = { entry ->
|
||||
entry.tag == TAG && entry.message == endTag(timestamp)
|
||||
}
|
||||
|
||||
private fun startTag(timestamp: Long) = "$timestamp start"
|
||||
fun endTag(timestamp: Long) = "$timestamp end"
|
||||
}
|
||||
|
||||
override fun process(envelope: Envelope, content: Content, metadata: EnvelopeMetadata, serverDeliveredTimestamp: Long, processingEarlyContent: Boolean, localMetric: SignalLocalMetrics.MessageReceive?) {
|
||||
Log.d(TAG, startTag(envelope.timestamp!!))
|
||||
super.process(envelope, content, metadata, serverDeliveredTimestamp, processingEarlyContent, localMetric)
|
||||
Log.d(TAG, endTag(envelope.timestamp!!))
|
||||
}
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
package org.thoughtcrime.securesms.testing
|
||||
|
||||
import org.signal.core.models.ServiceId
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.libsignal.protocol.ecc.ECKeyPair
|
||||
import org.signal.libsignal.zkgroup.profiles.ProfileKey
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.messages.protocol.BufferedProtocolStore
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.testing.FakeClientHelpers.toEnvelope
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress
|
||||
import org.whispersystems.signalservice.internal.push.Envelope
|
||||
|
||||
/**
|
||||
* Welcome to Alice's Client.
|
||||
*
|
||||
* Alice represent the Android instrumentation test user. Unlike [BobClient] much less is needed here
|
||||
* as it can make use of the standard Signal Android App infrastructure.
|
||||
*/
|
||||
class AliceClient(val serviceId: ServiceId, val e164: String, val trustRoot: ECKeyPair) {
|
||||
|
||||
companion object {
|
||||
val TAG = Log.tag(AliceClient::class.java)
|
||||
}
|
||||
|
||||
private val aliceSenderCertificate = FakeClientHelpers.createCertificateFor(
|
||||
trustRoot = trustRoot,
|
||||
uuid = serviceId.rawUuid,
|
||||
e164 = e164,
|
||||
deviceId = 1,
|
||||
identityKey = SignalStore.account.aciIdentityKey.publicKey.publicKey,
|
||||
expires = 31337
|
||||
)
|
||||
|
||||
fun process(envelope: Envelope, serverDeliveredTimestamp: Long) {
|
||||
val start = System.currentTimeMillis()
|
||||
val bufferedStore = BufferedProtocolStore.create()
|
||||
AppDependencies.incomingMessageObserver
|
||||
.processEnvelope(bufferedStore, envelope, serverDeliveredTimestamp)
|
||||
?.mapNotNull { it.run() }
|
||||
?.forEach { it.enqueue() }
|
||||
|
||||
bufferedStore.flushToDisk()
|
||||
val end = System.currentTimeMillis()
|
||||
Log.d(TAG, "${end - start}")
|
||||
}
|
||||
|
||||
fun encrypt(now: Long, destination: Recipient): Envelope {
|
||||
return AppDependencies.signalServiceMessageSender.getEncryptedMessage(
|
||||
SignalServiceAddress(destination.requireServiceId(), destination.requireE164()),
|
||||
FakeClientHelpers.getSealedSenderAccess(ProfileKey(destination.profileKey), aliceSenderCertificate),
|
||||
1,
|
||||
FakeClientHelpers.encryptedTextMessage(now),
|
||||
false
|
||||
).toEnvelope(now, destination.requireServiceId())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms
|
||||
|
||||
import android.app.Application
|
||||
import org.signal.libsignal.net.Network
|
||||
import org.thoughtcrime.securesms.database.JobDatabase
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencyProvider
|
||||
import org.thoughtcrime.securesms.jobmanager.Job
|
||||
import org.thoughtcrime.securesms.jobmanager.JobManager
|
||||
import org.thoughtcrime.securesms.jobmanager.JobMigrator
|
||||
import org.thoughtcrime.securesms.jobmanager.impl.FactoryJobPredicate
|
||||
import org.thoughtcrime.securesms.jobs.AccountConsistencyWorkerJob
|
||||
import org.thoughtcrime.securesms.jobs.ArchiveBackupIdReservationJob
|
||||
import org.thoughtcrime.securesms.jobs.AttachmentCompressionJob
|
||||
import org.thoughtcrime.securesms.jobs.AttachmentUploadJob
|
||||
import org.thoughtcrime.securesms.jobs.CreateReleaseChannelJob
|
||||
import org.thoughtcrime.securesms.jobs.DirectoryRefreshJob
|
||||
import org.thoughtcrime.securesms.jobs.DownloadLatestEmojiDataJob
|
||||
import org.thoughtcrime.securesms.jobs.EmojiSearchIndexDownloadJob
|
||||
import org.thoughtcrime.securesms.jobs.FastJobStorage
|
||||
import org.thoughtcrime.securesms.jobs.FontDownloaderJob
|
||||
import org.thoughtcrime.securesms.jobs.GroupCallUpdateSendJob
|
||||
import org.thoughtcrime.securesms.jobs.GroupRingCleanupJob
|
||||
import org.thoughtcrime.securesms.jobs.GroupV2UpdateSelfProfileKeyJob
|
||||
import org.thoughtcrime.securesms.jobs.IndividualSendJob
|
||||
import org.thoughtcrime.securesms.jobs.JobManagerFactories
|
||||
import org.thoughtcrime.securesms.jobs.LinkedDeviceInactiveCheckJob
|
||||
import org.thoughtcrime.securesms.jobs.MarkerJob
|
||||
import org.thoughtcrime.securesms.jobs.MultiDeviceProfileKeyUpdateJob
|
||||
import org.thoughtcrime.securesms.jobs.PostRegistrationBackupRedemptionJob
|
||||
import org.thoughtcrime.securesms.jobs.PreKeysSyncJob
|
||||
import org.thoughtcrime.securesms.jobs.ProfileUploadJob
|
||||
import org.thoughtcrime.securesms.jobs.PushGroupSendJob
|
||||
import org.thoughtcrime.securesms.jobs.PushProcessMessageJob
|
||||
import org.thoughtcrime.securesms.jobs.ReactionSendJob
|
||||
import org.thoughtcrime.securesms.jobs.RefreshAttributesJob
|
||||
import org.thoughtcrime.securesms.jobs.RetrieveRemoteAnnouncementsJob
|
||||
import org.thoughtcrime.securesms.jobs.RotateCertificateJob
|
||||
import org.thoughtcrime.securesms.jobs.SendDeliveryReceiptJob
|
||||
import org.thoughtcrime.securesms.jobs.StickerPackDownloadJob
|
||||
import org.thoughtcrime.securesms.jobs.StorageSyncJob
|
||||
import org.thoughtcrime.securesms.jobs.StoryOnboardingDownloadJob
|
||||
import org.thoughtcrime.securesms.jobs.TypingSendJob
|
||||
import org.thoughtcrime.securesms.net.DeviceTransferBlockingInterceptor
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences
|
||||
import org.whispersystems.signalservice.api.util.UptimeSleepTimer
|
||||
import org.whispersystems.signalservice.api.websocket.SignalWebSocket
|
||||
import org.whispersystems.signalservice.internal.configuration.SignalServiceConfiguration
|
||||
import org.whispersystems.signalservice.internal.websocket.BenchmarkWebSocketConnection
|
||||
import java.util.function.Supplier
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
class BenchmarkApplicationContext : ApplicationContext() {
|
||||
|
||||
override fun initializeAppDependencies() {
|
||||
AppDependencies.init(this, BenchmarkDependencyProvider(this, ApplicationDependencyProvider(this)))
|
||||
|
||||
DeviceTransferBlockingInterceptor.getInstance().blockNetwork()
|
||||
}
|
||||
|
||||
override fun onForeground() = Unit
|
||||
|
||||
class BenchmarkDependencyProvider(val application: Application, private val default: ApplicationDependencyProvider) : AppDependencies.Provider by default {
|
||||
override fun provideAuthWebSocket(
|
||||
signalServiceConfigurationSupplier: Supplier<SignalServiceConfiguration>,
|
||||
libSignalNetworkSupplier: Supplier<Network>
|
||||
): SignalWebSocket.AuthenticatedWebSocket {
|
||||
return SignalWebSocket.AuthenticatedWebSocket(
|
||||
connectionFactory = { BenchmarkWebSocketConnection.createAuthInstance() },
|
||||
canConnect = { true },
|
||||
sleepTimer = UptimeSleepTimer(),
|
||||
disconnectTimeoutMs = 15.seconds.inWholeMilliseconds
|
||||
)
|
||||
}
|
||||
|
||||
override fun provideUnauthWebSocket(
|
||||
signalServiceConfigurationSupplier: Supplier<SignalServiceConfiguration>,
|
||||
libSignalNetworkSupplier: Supplier<Network>
|
||||
): SignalWebSocket.UnauthenticatedWebSocket {
|
||||
return SignalWebSocket.UnauthenticatedWebSocket(
|
||||
connectionFactory = { BenchmarkWebSocketConnection.createUnauthInstance() },
|
||||
canConnect = { true },
|
||||
sleepTimer = UptimeSleepTimer(),
|
||||
disconnectTimeoutMs = 15.seconds.inWholeMilliseconds
|
||||
)
|
||||
}
|
||||
|
||||
override fun provideJobManager(): JobManager {
|
||||
val config = JobManager.Configuration.Builder()
|
||||
.setJobFactories(filterJobFactories(JobManagerFactories.getJobFactories(application)))
|
||||
.setConstraintFactories(JobManagerFactories.getConstraintFactories(application))
|
||||
.setConstraintObservers(JobManagerFactories.getConstraintObservers(application))
|
||||
.setJobStorage(FastJobStorage(JobDatabase.getInstance(application)))
|
||||
.setJobMigrator(JobMigrator(TextSecurePreferences.getJobManagerVersion(application), JobManager.CURRENT_VERSION, JobManagerFactories.getJobMigrations(application)))
|
||||
.addReservedJobRunner(FactoryJobPredicate(PushProcessMessageJob.KEY, MarkerJob.KEY))
|
||||
.addReservedJobRunner(FactoryJobPredicate(AttachmentUploadJob.KEY, AttachmentCompressionJob.KEY))
|
||||
.addReservedJobRunner(
|
||||
FactoryJobPredicate(
|
||||
IndividualSendJob.KEY,
|
||||
PushGroupSendJob.KEY,
|
||||
ReactionSendJob.KEY,
|
||||
TypingSendJob.KEY,
|
||||
GroupCallUpdateSendJob.KEY,
|
||||
SendDeliveryReceiptJob.KEY
|
||||
)
|
||||
)
|
||||
.build()
|
||||
return JobManager(application, config)
|
||||
}
|
||||
|
||||
private fun filterJobFactories(jobFactories: Map<String, Job.Factory<*>>): Map<String, Job.Factory<*>> {
|
||||
val blockedJobs = setOf(
|
||||
AccountConsistencyWorkerJob.KEY,
|
||||
ArchiveBackupIdReservationJob.KEY,
|
||||
CreateReleaseChannelJob.KEY,
|
||||
DirectoryRefreshJob.KEY,
|
||||
DownloadLatestEmojiDataJob.KEY,
|
||||
EmojiSearchIndexDownloadJob.KEY,
|
||||
FontDownloaderJob.KEY,
|
||||
GroupRingCleanupJob.KEY,
|
||||
GroupV2UpdateSelfProfileKeyJob.KEY,
|
||||
LinkedDeviceInactiveCheckJob.KEY,
|
||||
MultiDeviceProfileKeyUpdateJob.KEY,
|
||||
PostRegistrationBackupRedemptionJob.KEY,
|
||||
PreKeysSyncJob.KEY,
|
||||
ProfileUploadJob.KEY,
|
||||
RefreshAttributesJob.KEY,
|
||||
RetrieveRemoteAnnouncementsJob.KEY,
|
||||
RotateCertificateJob.KEY,
|
||||
StickerPackDownloadJob.KEY,
|
||||
StorageSyncJob.KEY,
|
||||
StoryOnboardingDownloadJob.KEY
|
||||
)
|
||||
|
||||
return jobFactories.mapValues {
|
||||
if (it.key in blockedJobs) {
|
||||
NoOpJob.Factory()
|
||||
} else {
|
||||
it.value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class NoOpJob(parameters: Parameters) : Job(parameters) {
|
||||
|
||||
companion object {
|
||||
const val KEY = "NoOpJob"
|
||||
}
|
||||
|
||||
override fun serialize(): ByteArray? = null
|
||||
override fun getFactoryKey(): String = KEY
|
||||
override fun run(): Result = Result.success()
|
||||
override fun onFailure() = Unit
|
||||
|
||||
class Factory : Job.Factory<NoOpJob> {
|
||||
override fun create(parameters: Parameters, serializedData: ByteArray?): NoOpJob {
|
||||
return NoOpJob(parameters)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,28 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<application>
|
||||
<application
|
||||
android:name="${applicationClass}"
|
||||
tools:replace="name">
|
||||
<profileable android:shell="true" />
|
||||
|
||||
<activity android:name="org.signal.benchmark.BenchmarkSetupActivity"
|
||||
<activity
|
||||
android:name="org.signal.benchmark.BenchmarkSetupActivity"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTask"
|
||||
android:theme="@style/Theme.Signal.DayNight.NoActionBar"
|
||||
android:windowSoftInputMode="stateHidden"
|
||||
android:exported="true"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
|
||||
android:windowSoftInputMode="stateHidden" />
|
||||
|
||||
<receiver
|
||||
android:name="org.signal.benchmark.BenchmarkCommandReceiver"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="org.signal.benchmark.action.COMMAND" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
</manifest>
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.benchmark
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.signal.benchmark.setup.Generator
|
||||
import org.signal.benchmark.setup.Harness
|
||||
import org.signal.benchmark.setup.OtherClient
|
||||
import org.signal.core.util.ThreadUtil
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.TestDbUtils
|
||||
import org.thoughtcrime.securesms.groups.GroupId
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.whispersystems.signalservice.internal.push.Envelope
|
||||
import org.whispersystems.signalservice.internal.websocket.BenchmarkWebSocketConnection
|
||||
import org.whispersystems.signalservice.internal.websocket.WebSocketRequestMessage
|
||||
import kotlin.random.Random
|
||||
|
||||
/**
|
||||
* A BroadcastReceiver that accepts commands sent from the benchmark app to perform
|
||||
* background operations on the client.
|
||||
*/
|
||||
class BenchmarkCommandReceiver : BroadcastReceiver() {
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(BenchmarkCommandReceiver::class)
|
||||
|
||||
const val ACTION_COMMAND = "org.signal.benchmark.action.COMMAND"
|
||||
const val EXTRA_COMMAND = "command"
|
||||
}
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (intent.action != ACTION_COMMAND) {
|
||||
Log.w(TAG, "Ignoring unknown action: ${intent.action}")
|
||||
return
|
||||
}
|
||||
|
||||
val command = intent.getStringExtra(EXTRA_COMMAND)
|
||||
Log.i(TAG, "Received command: $command")
|
||||
|
||||
when (command) {
|
||||
"individual-send" -> handlePrepareIndividualSend()
|
||||
"group-send" -> handlePrepareGroupSend()
|
||||
"group-delivery-receipt" -> handlePrepareGroupReceipts { client, timestamps -> client.generateInboundDeliveryReceipts(timestamps) }
|
||||
"group-read-receipt" -> handlePrepareGroupReceipts { client, timestamps -> client.generateInboundReadReceipts(timestamps) }
|
||||
"release-messages" -> {
|
||||
BenchmarkWebSocketConnection.startWholeBatchTrace()
|
||||
BenchmarkWebSocketConnection.releaseMessages()
|
||||
}
|
||||
"delete-thread" -> {
|
||||
val pendingResult = goAsync()
|
||||
Thread {
|
||||
handleDeleteThread()
|
||||
pendingResult.finish()
|
||||
}.start()
|
||||
}
|
||||
else -> Log.w(TAG, "Unknown command: $command")
|
||||
}
|
||||
}
|
||||
|
||||
private fun handlePrepareIndividualSend() {
|
||||
val client = Harness.otherClients[0]
|
||||
|
||||
// Send message from Bob to Self
|
||||
val encryptedEnvelope = client.encrypt(Generator.encryptedTextMessage(System.currentTimeMillis()))
|
||||
|
||||
runBlocking {
|
||||
launch(Dispatchers.IO) {
|
||||
Log.i(TAG, "Sending initial message form Bob to establish session.")
|
||||
BenchmarkWebSocketConnection.addPendingMessages(listOf(encryptedEnvelope.toWebSocketPayload()))
|
||||
BenchmarkWebSocketConnection.releaseMessages()
|
||||
|
||||
// Sleep briefly to let the message be processed.
|
||||
ThreadUtil.sleep(1000)
|
||||
}
|
||||
}
|
||||
|
||||
// Have Bob generate N messages that will be received by Alice
|
||||
val messageCount = 500
|
||||
val envelopes = client.generateInboundEnvelopes(messageCount)
|
||||
|
||||
val messages = envelopes.map { e -> e.toWebSocketPayload() }
|
||||
|
||||
BenchmarkWebSocketConnection.addPendingMessages(messages)
|
||||
BenchmarkWebSocketConnection.addQueueEmptyMessage()
|
||||
}
|
||||
|
||||
private fun handlePrepareGroupSend() {
|
||||
val clients = Harness.otherClients.take(5)
|
||||
|
||||
// Send message from others to Self in the group
|
||||
val encryptedEnvelopes = clients.map { it.encrypt(Generator.encryptedTextMessage(System.currentTimeMillis(), groupMasterKey = Harness.groupMasterKey)) }
|
||||
|
||||
runBlocking {
|
||||
launch(Dispatchers.IO) {
|
||||
Log.i(TAG, "Sending initial group messages from client to establish sessions.")
|
||||
BenchmarkWebSocketConnection.addPendingMessages(encryptedEnvelopes.map { it.toWebSocketPayload() })
|
||||
BenchmarkWebSocketConnection.releaseMessages()
|
||||
|
||||
// Sleep briefly to let the messages be processed.
|
||||
ThreadUtil.sleep(1000)
|
||||
}
|
||||
}
|
||||
|
||||
// Have clients generate N group messages that will be received by Alice
|
||||
val allClientMessages = clients.map { client ->
|
||||
val messageCount = 100
|
||||
val envelopes = client.generateInboundGroupEnvelopes(messageCount, Harness.groupMasterKey)
|
||||
envelopes.map { e -> e.toWebSocketPayload() }
|
||||
}
|
||||
|
||||
BenchmarkWebSocketConnection.addPendingMessages(interleave(allClientMessages))
|
||||
BenchmarkWebSocketConnection.addQueueEmptyMessage()
|
||||
}
|
||||
|
||||
private fun handlePrepareGroupReceipts(generateReceipts: (OtherClient, List<Long>) -> List<Envelope>) {
|
||||
val clients = Harness.otherClients.take(5)
|
||||
|
||||
establishGroupSessions(clients)
|
||||
|
||||
val timestamps = getOutgoingGroupMessageTimestamps()
|
||||
Log.i(TAG, "Found ${timestamps.size} outgoing message timestamps for receipts")
|
||||
|
||||
val allClientEnvelopes = clients.map { client ->
|
||||
generateReceipts(client, timestamps).map { it.toWebSocketPayload() }
|
||||
}
|
||||
|
||||
BenchmarkWebSocketConnection.addPendingMessages(interleave(allClientEnvelopes))
|
||||
BenchmarkWebSocketConnection.addQueueEmptyMessage()
|
||||
}
|
||||
|
||||
private fun establishGroupSessions(clients: List<OtherClient>) {
|
||||
val encryptedEnvelopes = clients.map { it.encrypt(Generator.encryptedTextMessage(System.currentTimeMillis(), groupMasterKey = Harness.groupMasterKey)) }
|
||||
|
||||
runBlocking {
|
||||
launch(Dispatchers.IO) {
|
||||
Log.i(TAG, "Sending initial group messages from clients to establish sessions.")
|
||||
BenchmarkWebSocketConnection.addPendingMessages(encryptedEnvelopes.map { it.toWebSocketPayload() })
|
||||
BenchmarkWebSocketConnection.releaseMessages()
|
||||
ThreadUtil.sleep(1000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleDeleteThread() {
|
||||
val threadId = SignalDatabase.threads.getRecentConversationList(1, false, false).use { cursor ->
|
||||
if (cursor.moveToFirst()) {
|
||||
cursor.getLong(cursor.getColumnIndexOrThrow("_id"))
|
||||
} else {
|
||||
Log.w(TAG, "No active threads found for deletion benchmark")
|
||||
return
|
||||
}
|
||||
}
|
||||
Log.i(TAG, "Deleting thread $threadId")
|
||||
SignalDatabase.threads.deleteConversation(threadId, syncThreadDelete = false)
|
||||
Log.i(TAG, "Thread $threadId deleted")
|
||||
}
|
||||
|
||||
private fun getOutgoingGroupMessageTimestamps(): List<Long> {
|
||||
val groupId = GroupId.v2(Harness.groupMasterKey)
|
||||
val groupRecipient = Recipient.externalGroupExact(groupId)
|
||||
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(groupRecipient)
|
||||
val selfId = Recipient.self().id.toLong()
|
||||
return TestDbUtils.getOutgoingMessageTimestamps(threadId, selfId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Interleaves lists so that items from different lists alternate:
|
||||
* [[a1, a2], [b1, b2], [c1, c2]] -> [a1, b1, c1, a2, b2, c2]
|
||||
*/
|
||||
private fun <T> interleave(lists: List<List<T>>): List<T> {
|
||||
val result = mutableListOf<T>()
|
||||
val maxSize = lists.maxOf { it.size }
|
||||
for (i in 0 until maxSize) {
|
||||
for (list in lists) {
|
||||
if (i < list.size) {
|
||||
result += list[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private fun Envelope.toWebSocketPayload(): WebSocketRequestMessage {
|
||||
return WebSocketRequestMessage(
|
||||
verb = "PUT",
|
||||
path = "/api/v1/message",
|
||||
id = Random.nextLong(),
|
||||
headers = listOf("X-Signal-Timestamp: ${this.timestamp}"),
|
||||
body = this.encodeByteString()
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,26 +1,51 @@
|
||||
package org.signal.benchmark
|
||||
|
||||
import android.os.Bundle
|
||||
import android.widget.TextView
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import org.signal.benchmark.setup.TestMessages
|
||||
import org.signal.benchmark.setup.TestUsers
|
||||
import org.thoughtcrime.securesms.BaseActivity
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.TestDbUtils
|
||||
import org.thoughtcrime.securesms.mms.OutgoingMessage
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences
|
||||
|
||||
class BenchmarkSetupActivity : BaseActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
when (intent.extras!!.getString("setup-type")) {
|
||||
"cold-start" -> setupColdStart()
|
||||
"conversation-open" -> setupConversationOpen()
|
||||
var setupComplete by mutableStateOf(false)
|
||||
|
||||
setContent {
|
||||
if (setupComplete) {
|
||||
Text("done")
|
||||
} else {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
|
||||
val textView: TextView = TextView(this).apply {
|
||||
text = "done"
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
when (intent.extras!!.getString("setup-type")) {
|
||||
"cold-start" -> setupColdStart()
|
||||
"conversation-open" -> setupConversationOpen()
|
||||
"message-send" -> setupMessageSend()
|
||||
"group-message-send" -> setupGroupMessageSend()
|
||||
"group-delivery-receipt" -> setupGroupReceipt(includeMsl = true)
|
||||
"group-read-receipt" -> setupGroupReceipt(enableReadReceipts = true)
|
||||
"thread-delete" -> setupThreadDelete()
|
||||
"thread-delete-group" -> setupThreadDeleteGroup()
|
||||
}
|
||||
setupComplete = true
|
||||
}
|
||||
setContentView(textView)
|
||||
}
|
||||
|
||||
private fun setupColdStart() {
|
||||
@@ -56,4 +81,109 @@ class BenchmarkSetupActivity : BaseActivity() {
|
||||
SignalDatabase.threads.update(SignalDatabase.threads.getOrCreateThreadIdFor(recipient = recipient), true)
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupMessageSend() {
|
||||
TestUsers.setupSelf()
|
||||
TestUsers.setupTestClients(1)
|
||||
}
|
||||
|
||||
private fun setupGroupMessageSend() {
|
||||
TestUsers.setupSelf()
|
||||
TestUsers.setupGroup()
|
||||
}
|
||||
|
||||
private fun setupThreadDelete() {
|
||||
TestUsers.setupSelf()
|
||||
val recipientIds = TestUsers.setupTestRecipients(2)
|
||||
val recipient = Recipient.resolved(recipientIds[0])
|
||||
val reactionAuthor = recipientIds[1]
|
||||
val messagesToAdd = 20_000
|
||||
val generator = TestMessages.TimestampGenerator(System.currentTimeMillis() - (messagesToAdd * 2000L) - 60_000L)
|
||||
|
||||
for (i in 0 until messagesToAdd) {
|
||||
val timestamp = generator.nextTimestamp()
|
||||
when {
|
||||
i % 20 == 0 -> TestMessages.insertIncomingVoiceMessage(other = recipient, timestamp = timestamp)
|
||||
i % 4 == 0 -> TestMessages.insertIncomingImageMessage(other = recipient, attachmentCount = 1, timestamp = timestamp)
|
||||
else -> TestMessages.insertIncomingTextMessage(other = recipient, body = "Message $i", timestamp = timestamp)
|
||||
}
|
||||
}
|
||||
|
||||
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(recipient = recipient)
|
||||
TestDbUtils.insertReactionsForThread(threadId, reactionAuthor, moduloFilter = 5)
|
||||
|
||||
SignalDatabase.threads.update(threadId, true)
|
||||
}
|
||||
|
||||
private fun setupThreadDeleteGroup() {
|
||||
TestUsers.setupSelf()
|
||||
val groupId = TestUsers.setupGroup()
|
||||
val groupRecipient = Recipient.externalGroupExact(groupId)
|
||||
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(groupRecipient)
|
||||
|
||||
val selfId = Recipient.self().id
|
||||
val memberRecipientIds = SignalDatabase.groups.getGroup(groupId).get().members.filter { it != selfId }
|
||||
|
||||
val messagesToAdd = 20_000
|
||||
val generator = TestMessages.TimestampGenerator(System.currentTimeMillis() - (messagesToAdd * 2000L) - 60_000L)
|
||||
|
||||
for (i in 0 until messagesToAdd) {
|
||||
val timestamp = generator.nextTimestamp()
|
||||
when {
|
||||
i % 4 == 0 -> TestMessages.insertOutgoingImageMessage(other = groupRecipient, attachmentCount = 1, timestamp = timestamp)
|
||||
else -> {
|
||||
val message = OutgoingMessage(
|
||||
recipient = groupRecipient,
|
||||
body = "Message $i",
|
||||
timestamp = timestamp,
|
||||
isSecure = true
|
||||
)
|
||||
val insert = SignalDatabase.messages.insertMessageOutbox(message, threadId, false, null)
|
||||
SignalDatabase.messages.markAsSent(insert.messageId, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TestDbUtils.insertGroupReceiptsForThread(threadId, memberRecipientIds)
|
||||
TestDbUtils.insertReactionsForThread(threadId, memberRecipientIds[0], moduloFilter = 5)
|
||||
TestDbUtils.insertMentionsForThread(threadId, memberRecipientIds[0], moduloFilter = 10)
|
||||
|
||||
SignalDatabase.threads.update(threadId, true)
|
||||
}
|
||||
|
||||
private fun setupGroupReceipt(includeMsl: Boolean = false, enableReadReceipts: Boolean = false) {
|
||||
TestUsers.setupSelf()
|
||||
val groupId = TestUsers.setupGroup()
|
||||
|
||||
val groupRecipient = Recipient.externalGroupExact(groupId)
|
||||
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(groupRecipient)
|
||||
|
||||
val messageIds = mutableListOf<Long>()
|
||||
val timestamps = mutableListOf<Long>()
|
||||
val baseTimestamp = 2_000_000L
|
||||
|
||||
for (i in 0 until 100) {
|
||||
val timestamp = baseTimestamp + i
|
||||
val message = OutgoingMessage(
|
||||
recipient = groupRecipient,
|
||||
body = "Outgoing message $i",
|
||||
timestamp = timestamp,
|
||||
isSecure = true
|
||||
)
|
||||
val insert = SignalDatabase.messages.insertMessageOutbox(message, threadId, false, null)
|
||||
SignalDatabase.messages.markAsSent(insert.messageId, true)
|
||||
messageIds += insert.messageId
|
||||
timestamps += timestamp
|
||||
}
|
||||
|
||||
if (includeMsl) {
|
||||
val selfId = Recipient.self().id
|
||||
val memberRecipientIds = SignalDatabase.groups.getGroup(groupId).get().members.filter { it != selfId }
|
||||
TestDbUtils.insertMessageSendLogEntries(messageIds, timestamps, memberRecipientIds)
|
||||
}
|
||||
|
||||
if (enableReadReceipts) {
|
||||
TextSecurePreferences.setReadReceiptsEnabled(this, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,57 +1,71 @@
|
||||
package org.thoughtcrime.securesms.testing
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.benchmark.setup
|
||||
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import org.signal.core.models.ServiceId
|
||||
import org.signal.core.util.Base64
|
||||
import org.signal.core.util.toByteArray
|
||||
import org.signal.libsignal.metadata.certificate.CertificateValidator
|
||||
import org.signal.libsignal.metadata.certificate.SenderCertificate
|
||||
import org.signal.libsignal.metadata.certificate.ServerCertificate
|
||||
import org.signal.libsignal.protocol.ecc.ECKeyPair
|
||||
import org.signal.libsignal.protocol.ecc.ECPublicKey
|
||||
import org.signal.libsignal.zkgroup.profiles.ProfileKey
|
||||
import org.signal.libsignal.zkgroup.groups.GroupMasterKey
|
||||
import org.thoughtcrime.securesms.messages.SignalServiceProtoUtil.buildWith
|
||||
import org.whispersystems.signalservice.api.crypto.ContentHint
|
||||
import org.whispersystems.signalservice.api.crypto.EnvelopeContent
|
||||
import org.whispersystems.signalservice.api.crypto.SealedSenderAccess
|
||||
import org.whispersystems.signalservice.api.crypto.UnidentifiedAccess
|
||||
import org.whispersystems.signalservice.internal.push.Content
|
||||
import org.whispersystems.signalservice.internal.push.DataMessage
|
||||
import org.whispersystems.signalservice.internal.push.Envelope
|
||||
import org.whispersystems.signalservice.internal.push.GroupContextV2
|
||||
import org.whispersystems.signalservice.internal.push.OutgoingPushMessage
|
||||
import org.whispersystems.signalservice.internal.push.ReceiptMessage
|
||||
import java.util.Optional
|
||||
import java.util.UUID
|
||||
|
||||
object FakeClientHelpers {
|
||||
object Generator {
|
||||
|
||||
val noOpCertificateValidator = object : CertificateValidator(ECKeyPair.generate().publicKey) {
|
||||
override fun validate(certificate: SenderCertificate, validationTime: Long) = Unit
|
||||
}
|
||||
|
||||
fun createCertificateFor(trustRoot: ECKeyPair, uuid: UUID, e164: String, deviceId: Int, identityKey: ECPublicKey, expires: Long): SenderCertificate {
|
||||
val serverKey: ECKeyPair = ECKeyPair.generate()
|
||||
val serverCertificate = ServerCertificate(trustRoot.privateKey, 1, serverKey.publicKey)
|
||||
return serverCertificate.issue(serverKey.privateKey, uuid.toString(), Optional.of(e164), deviceId, identityKey, expires)
|
||||
}
|
||||
|
||||
fun getSealedSenderAccess(theirProfileKey: ProfileKey, senderCertificate: SenderCertificate): SealedSenderAccess? {
|
||||
val themUnidentifiedAccessKey = UnidentifiedAccess(UnidentifiedAccess.deriveAccessKeyFrom(theirProfileKey), senderCertificate.serialized, false)
|
||||
|
||||
return SealedSenderAccess.forIndividual(themUnidentifiedAccessKey)
|
||||
}
|
||||
|
||||
fun encryptedTextMessage(now: Long, message: String = "Test body message"): EnvelopeContent {
|
||||
fun encryptedTextMessage(
|
||||
now: Long,
|
||||
message: String = "Test message",
|
||||
groupMasterKey: GroupMasterKey? = null
|
||||
): EnvelopeContent {
|
||||
val content = Content.Builder().apply {
|
||||
dataMessage(
|
||||
DataMessage.Builder().buildWith {
|
||||
body = message
|
||||
timestamp = now
|
||||
if (groupMasterKey != null) {
|
||||
groupV2 = GroupContextV2.Builder().buildWith {
|
||||
masterKey = groupMasterKey.serialize().toByteString()
|
||||
revision = 1
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
return EnvelopeContent.encrypted(content.build(), ContentHint.RESENDABLE, Optional.empty())
|
||||
}
|
||||
|
||||
fun encryptedDeliveryReceipt(now: Long, timestamps: List<Long>): EnvelopeContent {
|
||||
return encryptedReceipt(ReceiptMessage.Type.DELIVERY, timestamps)
|
||||
}
|
||||
|
||||
fun encryptedReadReceipt(now: Long, timestamps: List<Long>): EnvelopeContent {
|
||||
return encryptedReceipt(ReceiptMessage.Type.READ, timestamps)
|
||||
}
|
||||
|
||||
private fun encryptedReceipt(type: ReceiptMessage.Type, timestamps: List<Long>): EnvelopeContent {
|
||||
val content = Content.Builder().apply {
|
||||
receiptMessage(
|
||||
ReceiptMessage.Builder().buildWith {
|
||||
this.type = type
|
||||
timestamp = timestamps
|
||||
}
|
||||
)
|
||||
}
|
||||
return EnvelopeContent.encrypted(content.build(), ContentHint.IMPLICIT, Optional.empty())
|
||||
}
|
||||
|
||||
fun OutgoingPushMessage.toEnvelope(timestamp: Long, destination: ServiceId): Envelope {
|
||||
val serverGuid = UUID.randomUUID()
|
||||
return Envelope.Builder()
|
||||
@@ -0,0 +1,58 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.benchmark.setup
|
||||
|
||||
import org.signal.core.models.ServiceId.ACI
|
||||
import org.signal.core.util.Base64
|
||||
import org.signal.core.util.Hex
|
||||
import org.signal.core.util.UuidUtil
|
||||
import org.signal.libsignal.metadata.certificate.SenderCertificate
|
||||
import org.signal.libsignal.metadata.certificate.ServerCertificate
|
||||
import org.signal.libsignal.protocol.IdentityKeyPair
|
||||
import org.signal.libsignal.protocol.ecc.ECKeyPair
|
||||
import org.signal.libsignal.protocol.ecc.ECPrivateKey
|
||||
import org.signal.libsignal.protocol.ecc.ECPublicKey
|
||||
import org.signal.libsignal.zkgroup.groups.GroupMasterKey
|
||||
import org.signal.libsignal.zkgroup.profiles.ProfileKey
|
||||
import java.util.Optional
|
||||
import java.util.UUID
|
||||
import kotlin.random.Random
|
||||
import kotlin.time.Duration
|
||||
|
||||
object Harness {
|
||||
const val SELF_E164 = "+15555559999"
|
||||
val SELF_ACI = ACI.from(UuidUtil.parseOrThrow("d81b9a54-0ec9-43aa-a73f-7e99280ad53e"))
|
||||
|
||||
private val OTHERS_IDENTITY_KEY = IdentityKeyPair(Base64.decode("CiEFbAw403SCGPB+tjqfk+jrH7r9ma1P2hcujqydHRYVzzISIGiWYdWYBBdBzDdF06wgEm+HKcc6ETuWB7Jnvk7Wjw1u"))
|
||||
private val OTHERS_PROFILE_KEY = ProfileKey(Base64.decode("aJJ/A7GBCSnU9HJ1DdMWcKMMeXQKRUguTlAbtlfo/ik"))
|
||||
|
||||
val groupMasterKey = GroupMasterKey(Hex.fromStringCondensed("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"))
|
||||
|
||||
val trustRoot = ECKeyPair(
|
||||
ECPublicKey(Base64.decode("BVT/2gHqbrG1xzuIypLIOjFgMtihrMld1/5TGADL6Dhv")),
|
||||
ECPrivateKey(Base64.decode("2B1zU7JQdPol/XWiom4pQXrSrHFeO8jzZ1u7wfrtY3o"))
|
||||
)
|
||||
|
||||
val otherClients: List<OtherClient> by lazy {
|
||||
val random = Random(4242)
|
||||
buildList {
|
||||
(0 until 1000).forEach { i ->
|
||||
val aci = ACI.from(UUID(random.nextLong(), random.nextLong()))
|
||||
val e164 = "+1555555%04d".format(i)
|
||||
val identityKey = OTHERS_IDENTITY_KEY
|
||||
val profileKey = OTHERS_PROFILE_KEY
|
||||
|
||||
add(OtherClient(aci, e164, identityKey, profileKey))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun createCertificateFor(uuid: UUID, e164: String?, deviceId: Int, identityKey: ECPublicKey, expires: Duration): SenderCertificate {
|
||||
val serverKey: ECKeyPair = ECKeyPair.generate()
|
||||
val serverCertificate = ServerCertificate(trustRoot.privateKey, 1, serverKey.publicKey)
|
||||
return serverCertificate.issue(serverKey.privateKey, uuid.toString(), Optional.ofNullable(e164), deviceId, identityKey, expires.inWholeMilliseconds)
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,16 @@
|
||||
package org.thoughtcrime.securesms.testing
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.benchmark.setup
|
||||
|
||||
import org.signal.benchmark.setup.Generator.toEnvelope
|
||||
import org.signal.core.models.ServiceId
|
||||
import org.signal.core.util.readToSingleInt
|
||||
import org.signal.core.util.select
|
||||
import org.signal.libsignal.protocol.IdentityKey
|
||||
import org.signal.libsignal.protocol.IdentityKeyPair
|
||||
import org.signal.libsignal.protocol.SessionBuilder
|
||||
import org.signal.libsignal.protocol.SignalProtocolAddress
|
||||
import org.signal.libsignal.protocol.ecc.ECKeyPair
|
||||
import org.signal.libsignal.protocol.ecc.ECPublicKey
|
||||
import org.signal.libsignal.protocol.groups.state.SenderKeyRecord
|
||||
import org.signal.libsignal.protocol.state.IdentityKeyStore
|
||||
@@ -18,40 +21,36 @@ import org.signal.libsignal.protocol.state.PreKeyRecord
|
||||
import org.signal.libsignal.protocol.state.SessionRecord
|
||||
import org.signal.libsignal.protocol.state.SignedPreKeyRecord
|
||||
import org.signal.libsignal.protocol.util.KeyHelper
|
||||
import org.signal.libsignal.zkgroup.groups.GroupMasterKey
|
||||
import org.signal.libsignal.zkgroup.profiles.ProfileKey
|
||||
import org.thoughtcrime.securesms.crypto.ProfileKeyUtil
|
||||
import org.thoughtcrime.securesms.crypto.SealedSenderAccessUtil
|
||||
import org.thoughtcrime.securesms.database.KyberPreKeyTable
|
||||
import org.thoughtcrime.securesms.database.OneTimePreKeyTable
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.SignedPreKeyTable
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.testing.FakeClientHelpers.toEnvelope
|
||||
import org.whispersystems.signalservice.api.SignalServiceAccountDataStore
|
||||
import org.whispersystems.signalservice.api.SignalSessionLock
|
||||
import org.whispersystems.signalservice.api.crypto.EnvelopeContent
|
||||
import org.whispersystems.signalservice.api.crypto.SealedSenderAccess
|
||||
import org.whispersystems.signalservice.api.crypto.SignalServiceCipher
|
||||
import org.whispersystems.signalservice.api.crypto.SignalSessionBuilder
|
||||
import org.whispersystems.signalservice.api.crypto.UnidentifiedAccess
|
||||
import org.whispersystems.signalservice.api.push.DistributionId
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress
|
||||
import org.whispersystems.signalservice.internal.push.Envelope
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.locks.ReentrantLock
|
||||
import kotlin.time.Duration.Companion.days
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
/**
|
||||
* Welcome to Bob's Client.
|
||||
*
|
||||
* Bob is a "fake" client that can start a session with the Android instrumentation test user (Alice).
|
||||
*
|
||||
* Bob can create a new session using a prekey bundle created from Alice's prekeys, send a message, decrypt
|
||||
* a return message from Alice, and that'll start a standard Signal session with normal keys/ratcheting.
|
||||
* This is a "fake" client that can start a session with the running app's user, referred to as Alice in this
|
||||
* code.
|
||||
*/
|
||||
class BobClient(val serviceId: ServiceId, val e164: String, val identityKeyPair: IdentityKeyPair, val trustRoot: ECKeyPair, val profileKey: ProfileKey) {
|
||||
class OtherClient(val serviceId: ServiceId, val e164: String, val identityKeyPair: IdentityKeyPair, val profileKey: ProfileKey) {
|
||||
|
||||
private val serviceAddress = SignalServiceAddress(serviceId, e164)
|
||||
private val registrationId = KeyHelper.generateRegistrationId(false)
|
||||
private val aciStore = BobSignalServiceAccountDataStore(registrationId, identityKeyPair)
|
||||
private val senderCertificate = FakeClientHelpers.createCertificateFor(trustRoot, serviceId.rawUuid, e164, 1, identityKeyPair.publicKey.publicKey, 31337)
|
||||
private val senderCertificate = Harness.createCertificateFor(serviceId.rawUuid, e164, 1, identityKeyPair.publicKey.publicKey, System.currentTimeMillis().milliseconds + 30.days)
|
||||
private val sessionLock = object : SignalSessionLock {
|
||||
private val lock = ReentrantLock()
|
||||
|
||||
@@ -62,9 +61,11 @@ class BobClient(val serviceId: ServiceId, val e164: String, val identityKeyPair:
|
||||
}
|
||||
|
||||
/** Inspired by SignalServiceMessageSender#getEncryptedMessage */
|
||||
fun encrypt(now: Long): Envelope {
|
||||
val envelopeContent = FakeClientHelpers.encryptedTextMessage(now)
|
||||
fun encrypt(envelopeContent: EnvelopeContent): Envelope {
|
||||
return encrypt(envelopeContent, envelopeContent.content.get().dataMessage!!.timestamp!!)
|
||||
}
|
||||
|
||||
fun encrypt(envelopeContent: EnvelopeContent, timestamp: Long): Envelope {
|
||||
val cipher = SignalServiceCipher(serviceAddress, 1, aciStore, sessionLock, null)
|
||||
|
||||
if (!aciStore.containsSession(getAliceProtocolAddress())) {
|
||||
@@ -73,12 +74,47 @@ class BobClient(val serviceId: ServiceId, val e164: String, val identityKeyPair:
|
||||
}
|
||||
|
||||
return cipher.encrypt(getAliceProtocolAddress(), getAliceUnidentifiedAccess(), envelopeContent)
|
||||
.toEnvelope(envelopeContent.content.get().dataMessage!!.timestamp!!, getAliceServiceId())
|
||||
.toEnvelope(timestamp, getAliceServiceId())
|
||||
}
|
||||
|
||||
fun decrypt(envelope: Envelope, serverDeliveredTimestamp: Long) {
|
||||
val cipher = SignalServiceCipher(serviceAddress, 1, aciStore, sessionLock, SealedSenderAccessUtil.getCertificateValidator())
|
||||
cipher.decrypt(envelope, serverDeliveredTimestamp)
|
||||
fun generateInboundEnvelopes(count: Int): List<Envelope> {
|
||||
val envelopes = ArrayList<Envelope>(count)
|
||||
var now = System.currentTimeMillis()
|
||||
for (i in 0 until count) {
|
||||
envelopes += encrypt(Generator.encryptedTextMessage(now))
|
||||
now += 3
|
||||
}
|
||||
|
||||
return envelopes
|
||||
}
|
||||
|
||||
fun generateInboundDeliveryReceipts(messageTimestamps: List<Long>): List<Envelope> {
|
||||
return generateInboundReceipts(messageTimestamps, Generator::encryptedDeliveryReceipt)
|
||||
}
|
||||
|
||||
fun generateInboundReadReceipts(messageTimestamps: List<Long>): List<Envelope> {
|
||||
return generateInboundReceipts(messageTimestamps, Generator::encryptedReadReceipt)
|
||||
}
|
||||
|
||||
private fun generateInboundReceipts(messageTimestamps: List<Long>, receiptFactory: (Long, List<Long>) -> EnvelopeContent): List<Envelope> {
|
||||
val envelopes = ArrayList<Envelope>(messageTimestamps.size)
|
||||
var now = System.currentTimeMillis()
|
||||
for (messageTimestamp in messageTimestamps) {
|
||||
envelopes += encrypt(receiptFactory(now, listOf(messageTimestamp)), now)
|
||||
now += 3
|
||||
}
|
||||
return envelopes
|
||||
}
|
||||
|
||||
fun generateInboundGroupEnvelopes(count: Int, groupMasterKey: GroupMasterKey): List<Envelope> {
|
||||
val envelopes = ArrayList<Envelope>(count)
|
||||
var now = System.currentTimeMillis()
|
||||
for (i in 0 until count) {
|
||||
envelopes += encrypt(Generator.encryptedTextMessage(now, groupMasterKey = groupMasterKey))
|
||||
now += 3
|
||||
}
|
||||
|
||||
return envelopes
|
||||
}
|
||||
|
||||
private fun getAliceServiceId(): ServiceId {
|
||||
@@ -86,45 +122,22 @@ class BobClient(val serviceId: ServiceId, val e164: String, val identityKeyPair:
|
||||
}
|
||||
|
||||
private fun getAlicePreKeyBundle(): PreKeyBundle {
|
||||
val selfPreKeyId = SignalDatabase.rawDatabase
|
||||
.select(OneTimePreKeyTable.KEY_ID)
|
||||
.from(OneTimePreKeyTable.TABLE_NAME)
|
||||
.where("${OneTimePreKeyTable.ACCOUNT_ID} = ?", getAliceServiceId().toString())
|
||||
.run()
|
||||
.readToSingleInt(-1)
|
||||
val aliceSignedPreKeyRecord = SignalDatabase.signedPreKeys.getAll(getAliceServiceId()).first()
|
||||
|
||||
val selfPreKeyRecord = SignalDatabase.oneTimePreKeys.get(getAliceServiceId(), selfPreKeyId)!!
|
||||
|
||||
val selfSignedPreKeyId = SignalDatabase.rawDatabase
|
||||
.select(SignedPreKeyTable.KEY_ID)
|
||||
.from(SignedPreKeyTable.TABLE_NAME)
|
||||
.where("${SignedPreKeyTable.ACCOUNT_ID} = ?", getAliceServiceId().toString())
|
||||
.run()
|
||||
.readToSingleInt(-1)
|
||||
|
||||
val selfSignedPreKeyRecord = SignalDatabase.signedPreKeys.get(getAliceServiceId(), selfSignedPreKeyId)!!
|
||||
|
||||
val selfSignedKyberPreKeyId = SignalDatabase.rawDatabase
|
||||
.select(KyberPreKeyTable.KEY_ID)
|
||||
.from(KyberPreKeyTable.TABLE_NAME)
|
||||
.where("${KyberPreKeyTable.ACCOUNT_ID} = ?", getAliceServiceId().toString())
|
||||
.run()
|
||||
.readToSingleInt(-1)
|
||||
|
||||
val selfSignedKyberPreKeyRecord = SignalDatabase.kyberPreKeys.get(getAliceServiceId(), selfSignedKyberPreKeyId)!!.record
|
||||
val aliceSignedKyberPreKeyRecord = SignalDatabase.kyberPreKeys.getAllLastResort(getAliceServiceId()).first().record
|
||||
|
||||
return PreKeyBundle(
|
||||
SignalStore.account.registrationId,
|
||||
1,
|
||||
selfPreKeyId,
|
||||
selfPreKeyRecord.keyPair.publicKey,
|
||||
selfSignedPreKeyId,
|
||||
selfSignedPreKeyRecord.keyPair.publicKey,
|
||||
selfSignedPreKeyRecord.signature,
|
||||
getAlicePublicKey(),
|
||||
selfSignedKyberPreKeyId,
|
||||
selfSignedKyberPreKeyRecord.keyPair.publicKey,
|
||||
selfSignedKyberPreKeyRecord.signature
|
||||
registrationId = SignalStore.account.registrationId,
|
||||
deviceId = 1,
|
||||
preKeyId = PreKeyBundle.NULL_PRE_KEY_ID,
|
||||
preKeyPublic = null,
|
||||
signedPreKeyId = aliceSignedPreKeyRecord.id,
|
||||
signedPreKeyPublic = aliceSignedPreKeyRecord.keyPair.publicKey,
|
||||
signedPreKeySignature = aliceSignedPreKeyRecord.signature,
|
||||
identityKey = getAlicePublicKey(),
|
||||
kyberPreKeyId = aliceSignedKyberPreKeyRecord.id,
|
||||
kyberPreKeyPublic = aliceSignedKyberPreKeyRecord.keyPair.publicKey,
|
||||
kyberPreKeySignature = aliceSignedKyberPreKeyRecord.signature
|
||||
)
|
||||
}
|
||||
|
||||
@@ -141,7 +154,10 @@ class BobClient(val serviceId: ServiceId, val e164: String, val identityKeyPair:
|
||||
}
|
||||
|
||||
private fun getAliceUnidentifiedAccess(): SealedSenderAccess? {
|
||||
return FakeClientHelpers.getSealedSenderAccess(getAliceProfileKey(), senderCertificate)
|
||||
val theirProfileKey = getAliceProfileKey()
|
||||
val themUnidentifiedAccessKey = UnidentifiedAccess(UnidentifiedAccess.deriveAccessKeyFrom(theirProfileKey), senderCertificate.serialized, false)
|
||||
|
||||
return SealedSenderAccess.forIndividual(themUnidentifiedAccessKey)
|
||||
}
|
||||
|
||||
private class BobSignalServiceAccountDataStore(private val registrationId: Int, private val identityKeyPair: IdentityKeyPair) : SignalServiceAccountDataStore {
|
||||
@@ -152,7 +168,7 @@ class BobClient(val serviceId: ServiceId, val e164: String, val identityKeyPair:
|
||||
override fun getLocalRegistrationId(): Int = registrationId
|
||||
override fun isTrustedIdentity(address: SignalProtocolAddress?, identityKey: IdentityKey?, direction: IdentityKeyStore.Direction?): Boolean = true
|
||||
override fun loadSession(address: SignalProtocolAddress?): SessionRecord = aliceSessionRecord ?: SessionRecord()
|
||||
override fun saveIdentity(address: SignalProtocolAddress?, identityKey: IdentityKey?): IdentityKeyStore.IdentityChange = IdentityChange.NEW_OR_UNCHANGED
|
||||
override fun saveIdentity(address: SignalProtocolAddress?, identityKey: IdentityKey?): IdentityChange = IdentityChange.NEW_OR_UNCHANGED
|
||||
override fun storeSession(address: SignalProtocolAddress?, record: SessionRecord?) {
|
||||
aliceSessionRecord = record
|
||||
}
|
||||
@@ -4,15 +4,25 @@ import android.app.Application
|
||||
import android.content.SharedPreferences
|
||||
import android.preference.PreferenceManager
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import okio.ByteString
|
||||
import org.signal.core.models.ServiceId.ACI
|
||||
import org.signal.core.util.Util
|
||||
import org.signal.libsignal.protocol.IdentityKeyPair
|
||||
import org.signal.libsignal.protocol.SignalProtocolAddress
|
||||
import org.signal.storageservice.storage.protos.groups.AccessControl
|
||||
import org.signal.storageservice.storage.protos.groups.Member
|
||||
import org.signal.storageservice.storage.protos.groups.local.DecryptedGroup
|
||||
import org.signal.storageservice.storage.protos.groups.local.DecryptedMember
|
||||
import org.signal.storageservice.storage.protos.groups.local.DecryptedTimer
|
||||
import org.signal.storageservice.storage.protos.groups.local.EnabledState
|
||||
import org.thoughtcrime.securesms.crypto.MasterSecretUtil
|
||||
import org.thoughtcrime.securesms.crypto.PreKeyUtil
|
||||
import org.thoughtcrime.securesms.crypto.ProfileKeyUtil
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.RestoreDecisionState
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.groups.GroupId
|
||||
import org.thoughtcrime.securesms.keyvalue.CertificateType
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.keyvalue.Skipped
|
||||
import org.thoughtcrime.securesms.net.DeviceTransferBlockingInterceptor
|
||||
@@ -28,11 +38,12 @@ import org.thoughtcrime.securesms.util.TextSecurePreferences
|
||||
import org.whispersystems.signalservice.api.profiles.SignalServiceProfile
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress
|
||||
import java.util.UUID
|
||||
import kotlin.time.Duration.Companion.days
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
object TestUsers {
|
||||
|
||||
private var generatedOthers: Int = 0
|
||||
private val TEST_E164 = "+15555550101"
|
||||
private var generatedOthers: Int = 1
|
||||
|
||||
fun setupSelf(): Recipient {
|
||||
val application: Application = AppDependencies.application
|
||||
@@ -50,19 +61,19 @@ object TestUsers {
|
||||
runBlocking {
|
||||
val registrationData = RegistrationData(
|
||||
code = "123123",
|
||||
e164 = TEST_E164,
|
||||
e164 = Harness.SELF_E164,
|
||||
password = Util.getSecret(18),
|
||||
registrationId = RegistrationRepository.getRegistrationId(),
|
||||
profileKey = RegistrationRepository.getProfileKey(TEST_E164),
|
||||
profileKey = RegistrationRepository.getProfileKey(Harness.SELF_E164),
|
||||
fcmToken = null,
|
||||
pniRegistrationId = RegistrationRepository.getPniRegistrationId(),
|
||||
recoveryPassword = "asdfasdfasdfasdf"
|
||||
)
|
||||
val remoteResult = AccountRegistrationResult(
|
||||
uuid = UUID.randomUUID().toString(),
|
||||
uuid = Harness.SELF_ACI.toString(),
|
||||
pni = UUID.randomUUID().toString(),
|
||||
storageCapable = false,
|
||||
number = TEST_E164,
|
||||
number = Harness.SELF_E164,
|
||||
masterKey = null,
|
||||
pin = null,
|
||||
aciPreKeyCollection = RegistrationRepository.generateSignedAndLastResortPreKeys(SignalStore.account.aciIdentityKey, SignalStore.account.aciPreKeys),
|
||||
@@ -78,6 +89,31 @@ object TestUsers {
|
||||
RegistrationUtil.maybeMarkRegistrationComplete()
|
||||
SignalDatabase.recipients.setProfileName(Recipient.self().id, ProfileName.fromParts("Tester", "McTesterson"))
|
||||
TextSecurePreferences.setPromptedOptimizeDoze(application, true)
|
||||
TextSecurePreferences.setRatingEnabled(application, false)
|
||||
|
||||
PreKeyUtil.generateAndStoreSignedPreKey(AppDependencies.protocolStore.aci(), SignalStore.account.aciPreKeys)
|
||||
PreKeyUtil.generateAndStoreOneTimeEcPreKeys(AppDependencies.protocolStore.aci(), SignalStore.account.aciPreKeys)
|
||||
PreKeyUtil.generateAndStoreOneTimeKyberPreKeys(AppDependencies.protocolStore.aci(), SignalStore.account.aciPreKeys)
|
||||
|
||||
val aliceSenderCertificate = Harness.createCertificateFor(
|
||||
uuid = Harness.SELF_ACI.rawUuid,
|
||||
e164 = Harness.SELF_E164,
|
||||
deviceId = 1,
|
||||
identityKey = SignalStore.account.aciIdentityKey.publicKey.publicKey,
|
||||
expires = System.currentTimeMillis().milliseconds + 30.days
|
||||
)
|
||||
|
||||
val aliceSenderCertificate2 = Harness.createCertificateFor(
|
||||
uuid = Harness.SELF_ACI.rawUuid,
|
||||
e164 = null,
|
||||
deviceId = 1,
|
||||
identityKey = SignalStore.account.aciIdentityKey.publicKey.publicKey,
|
||||
expires = System.currentTimeMillis().milliseconds + 30.days
|
||||
)
|
||||
|
||||
SignalStore.certificate.setUnidentifiedAccessCertificate(CertificateType.ACI_AND_E164, aliceSenderCertificate.serialized)
|
||||
SignalStore.certificate.setUnidentifiedAccessCertificate(CertificateType.ACI_ONLY, aliceSenderCertificate2.serialized)
|
||||
|
||||
return Recipient.self()
|
||||
}
|
||||
|
||||
@@ -111,4 +147,73 @@ object TestUsers {
|
||||
|
||||
return others
|
||||
}
|
||||
|
||||
fun setupTestClients(othersCount: Int): List<RecipientId> {
|
||||
val others = mutableListOf<RecipientId>()
|
||||
synchronized(this) {
|
||||
for (i in 0 until othersCount) {
|
||||
val otherClient = Harness.otherClients[i]
|
||||
|
||||
val recipientId = RecipientId.from(SignalServiceAddress(otherClient.serviceId, otherClient.e164))
|
||||
SignalDatabase.recipients.setProfileName(recipientId, ProfileName.fromParts("Buddy", "#$i"))
|
||||
SignalDatabase.recipients.setProfileKeyIfAbsent(recipientId, otherClient.profileKey)
|
||||
SignalDatabase.recipients.setCapabilities(recipientId, SignalServiceProfile.Capabilities(true, true))
|
||||
SignalDatabase.recipients.setProfileSharing(recipientId, true)
|
||||
SignalDatabase.recipients.markRegistered(recipientId, otherClient.serviceId)
|
||||
AppDependencies.protocolStore.aci().saveIdentity(SignalProtocolAddress(otherClient.serviceId.toString(), 1), otherClient.identityKeyPair.publicKey)
|
||||
|
||||
others += recipientId
|
||||
}
|
||||
|
||||
generatedOthers += othersCount
|
||||
}
|
||||
|
||||
return others
|
||||
}
|
||||
|
||||
fun setupGroup(): GroupId.V2 {
|
||||
val members = setupTestClients(5)
|
||||
val self = Recipient.self()
|
||||
|
||||
val fullMembers = buildList {
|
||||
add(member(aci = self.requireAci()))
|
||||
addAll(members.map { member(aci = Recipient.resolved(it).requireAci()) })
|
||||
}
|
||||
|
||||
val group = DecryptedGroup(
|
||||
title = "Title",
|
||||
avatar = "",
|
||||
disappearingMessagesTimer = DecryptedTimer(),
|
||||
accessControl = AccessControl(),
|
||||
revision = 1,
|
||||
members = fullMembers,
|
||||
pendingMembers = emptyList(),
|
||||
requestingMembers = emptyList(),
|
||||
inviteLinkPassword = ByteString.EMPTY,
|
||||
description = "Description",
|
||||
isAnnouncementGroup = EnabledState.DISABLED,
|
||||
bannedMembers = emptyList(),
|
||||
isPlaceholderGroup = false
|
||||
)
|
||||
|
||||
val groupId = SignalDatabase.groups.create(
|
||||
groupMasterKey = Harness.groupMasterKey,
|
||||
groupState = group,
|
||||
groupSendEndorsements = null
|
||||
)
|
||||
|
||||
SignalDatabase.recipients.setProfileSharing(Recipient.externalGroupExact(groupId!!).id, true)
|
||||
|
||||
return groupId
|
||||
}
|
||||
|
||||
private fun member(aci: ACI, role: Member.Role = Member.Role.DEFAULT, joinedAt: Int = 0, labelEmoji: String = "", labelString: String = ""): DecryptedMember {
|
||||
return DecryptedMember(
|
||||
role = role,
|
||||
aciBytes = aci.toByteString(),
|
||||
joinedAtRevision = joinedAt,
|
||||
labelEmoji = labelEmoji,
|
||||
labelString = labelString
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ package org.thoughtcrime.securesms.database
|
||||
|
||||
import android.content.ContentValues
|
||||
import org.signal.core.util.SqlUtil.buildArgs
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.whispersystems.signalservice.internal.push.Content
|
||||
|
||||
object TestDbUtils {
|
||||
|
||||
@@ -11,4 +13,114 @@ object TestDbUtils {
|
||||
contentValues.put(MessageTable.DATE_RECEIVED, timestamp)
|
||||
val rowsUpdated = database.update(MessageTable.TABLE_NAME, contentValues, DatabaseTable.ID_WHERE, buildArgs(messageId))
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk-inserts a reaction on every Nth message (by _id modulo) in the given thread.
|
||||
*/
|
||||
fun insertReactionsForThread(threadId: Long, authorId: RecipientId, moduloFilter: Int) {
|
||||
val db = SignalDatabase.messages.databaseHelper.signalWritableDatabase
|
||||
db.execSQL(
|
||||
"""
|
||||
INSERT INTO reaction (message_id, author_id, emoji, date_sent, date_received)
|
||||
SELECT ${MessageTable.ID}, ?, '👍', ${MessageTable.DATE_SENT}, ${MessageTable.DATE_RECEIVED}
|
||||
FROM ${MessageTable.TABLE_NAME}
|
||||
WHERE ${MessageTable.THREAD_ID} = ? AND ${MessageTable.ID} % ? = 0
|
||||
""".trimIndent(),
|
||||
arrayOf(authorId.toLong().toString(), threadId.toString(), moduloFilter.toString())
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk-inserts group receipt rows for every message in the given thread, one row per member.
|
||||
*/
|
||||
fun insertGroupReceiptsForThread(threadId: Long, memberRecipientIds: List<RecipientId>) {
|
||||
val db = SignalDatabase.messages.databaseHelper.signalWritableDatabase
|
||||
db.beginTransaction()
|
||||
try {
|
||||
for (recipientId in memberRecipientIds) {
|
||||
db.execSQL(
|
||||
"""
|
||||
INSERT INTO group_receipts (mms_id, address, status, timestamp)
|
||||
SELECT ${MessageTable.ID}, ?, 2, ${MessageTable.DATE_SENT}
|
||||
FROM ${MessageTable.TABLE_NAME}
|
||||
WHERE ${MessageTable.THREAD_ID} = ?
|
||||
""".trimIndent(),
|
||||
arrayOf(recipientId.toLong().toString(), threadId.toString())
|
||||
)
|
||||
}
|
||||
db.setTransactionSuccessful()
|
||||
} finally {
|
||||
db.endTransaction()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk-inserts a mention on every Nth message (by _id modulo) in the given thread.
|
||||
*/
|
||||
fun insertMentionsForThread(threadId: Long, mentionedRecipientId: RecipientId, moduloFilter: Int) {
|
||||
val db = SignalDatabase.messages.databaseHelper.signalWritableDatabase
|
||||
db.execSQL(
|
||||
"""
|
||||
INSERT INTO mention (thread_id, message_id, recipient_id, range_start, range_length)
|
||||
SELECT ${MessageTable.THREAD_ID}, ${MessageTable.ID}, ?, 0, 5
|
||||
FROM ${MessageTable.TABLE_NAME}
|
||||
WHERE ${MessageTable.THREAD_ID} = ? AND ${MessageTable.ID} % ? = 0
|
||||
""".trimIndent(),
|
||||
arrayOf(mentionedRecipientId.toLong().toString(), threadId.toString(), moduloFilter.toString())
|
||||
)
|
||||
}
|
||||
|
||||
fun getOutgoingMessageTimestamps(threadId: Long, selfRecipientId: Long): List<Long> {
|
||||
val timestamps = mutableListOf<Long>()
|
||||
SignalDatabase.messages.databaseHelper.signalReadableDatabase.query(
|
||||
MessageTable.TABLE_NAME,
|
||||
arrayOf(MessageTable.DATE_SENT),
|
||||
"${MessageTable.THREAD_ID} = ? AND ${MessageTable.FROM_RECIPIENT_ID} = ?",
|
||||
arrayOf(threadId.toString(), selfRecipientId.toString()),
|
||||
null,
|
||||
null,
|
||||
"${MessageTable.DATE_SENT} ASC"
|
||||
).use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
timestamps += cursor.getLong(0)
|
||||
}
|
||||
}
|
||||
return timestamps
|
||||
}
|
||||
|
||||
fun insertMessageSendLogEntries(messageIds: List<Long>, timestamps: List<Long>, recipientIds: List<RecipientId>) {
|
||||
val db = SignalDatabase.messages.databaseHelper.signalWritableDatabase
|
||||
val dummyContent = Content.Builder().build().encode()
|
||||
|
||||
db.beginTransaction()
|
||||
try {
|
||||
for (i in messageIds.indices) {
|
||||
val payloadValues = ContentValues().apply {
|
||||
put("date_sent", timestamps[i])
|
||||
put("content", dummyContent)
|
||||
put("content_hint", 0)
|
||||
put("urgent", 1)
|
||||
}
|
||||
val payloadId = db.insert("msl_payload", null, payloadValues)
|
||||
|
||||
val messageValues = ContentValues().apply {
|
||||
put("payload_id", payloadId)
|
||||
put("message_id", messageIds[i])
|
||||
}
|
||||
db.insert("msl_message", null, messageValues)
|
||||
|
||||
for (recipientId in recipientIds) {
|
||||
val recipientValues = ContentValues().apply {
|
||||
put("payload_id", payloadId)
|
||||
put("recipient_id", recipientId.toLong())
|
||||
put("device", 1)
|
||||
}
|
||||
db.insert("msl_recipient", null, recipientValues)
|
||||
}
|
||||
}
|
||||
db.setTransactionSuccessful()
|
||||
} finally {
|
||||
db.endTransaction()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.signalservice.internal.websocket
|
||||
|
||||
import io.reactivex.rxjava3.core.Observable
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.subjects.BehaviorSubject
|
||||
import org.thoughtcrime.securesms.util.JsonUtils
|
||||
import org.thoughtcrime.securesms.util.SignalTrace
|
||||
import org.whispersystems.signalservice.api.websocket.WebSocketConnectionState
|
||||
import org.whispersystems.signalservice.internal.push.SendMessageResponse
|
||||
import java.net.SocketException
|
||||
import java.util.LinkedList
|
||||
import java.util.Optional
|
||||
import java.util.concurrent.Semaphore
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.TimeoutException
|
||||
|
||||
/**
|
||||
* A [WebSocketConnection] that provides a way to add "incoming" WebSocket payloads
|
||||
* and have client code pull them off the "wire" as they would a normal socket.
|
||||
*
|
||||
* Add messages with [addPendingMessages] and then can release them to the requestor via
|
||||
* [releaseMessages].
|
||||
*/
|
||||
class BenchmarkWebSocketConnection : WebSocketConnection {
|
||||
|
||||
companion object {
|
||||
private val authInstances = mutableListOf<BenchmarkWebSocketConnection>()
|
||||
private val unauthInstances = mutableListOf<BenchmarkWebSocketConnection>()
|
||||
|
||||
@Synchronized
|
||||
fun createAuthInstance(): WebSocketConnection {
|
||||
val authInstance = BenchmarkWebSocketConnection()
|
||||
authInstances += authInstance
|
||||
return authInstance
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun createUnauthInstance(): WebSocketConnection {
|
||||
val unauthInstance = BenchmarkWebSocketConnection()
|
||||
unauthInstances += unauthInstance
|
||||
return unauthInstance
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun startWholeBatchTrace() {
|
||||
authInstances.filterNot(BenchmarkWebSocketConnection::isShutdown).forEach { it.startWholeBatchTrace = true }
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun releaseMessages() {
|
||||
authInstances.filterNot(BenchmarkWebSocketConnection::isShutdown).forEach { it.releaseMessages() }
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun addPendingMessages(messages: List<WebSocketRequestMessage>) {
|
||||
authInstances.filterNot(BenchmarkWebSocketConnection::isShutdown).forEach { it.addPendingMessages(messages) }
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun addQueueEmptyMessage() {
|
||||
authInstances.filterNot(BenchmarkWebSocketConnection::isShutdown).forEach { it.addQueueEmptyMessage() }
|
||||
}
|
||||
}
|
||||
|
||||
override val name: String = "bench-${System.identityHashCode(this)}"
|
||||
|
||||
private val state = BehaviorSubject.create<WebSocketConnectionState>()
|
||||
|
||||
private val incomingRequests = LinkedList<WebSocketRequestMessage>()
|
||||
private val incomingSemaphore = Semaphore(0)
|
||||
|
||||
var startWholeBatchTrace = false
|
||||
|
||||
@Volatile
|
||||
var isShutdown = false
|
||||
private set
|
||||
|
||||
override fun connect(): Observable<WebSocketConnectionState> {
|
||||
state.onNext(WebSocketConnectionState.CONNECTED)
|
||||
return state
|
||||
}
|
||||
|
||||
override fun isDead(): Boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
override fun disconnect() {
|
||||
state.onNext(WebSocketConnectionState.DISCONNECTED)
|
||||
|
||||
// Signal shutdown
|
||||
isShutdown = true
|
||||
|
||||
val queuedThreads = incomingSemaphore.queueLength
|
||||
if (queuedThreads > 0) {
|
||||
incomingSemaphore.release(queuedThreads)
|
||||
}
|
||||
}
|
||||
|
||||
override fun readRequest(timeoutMillis: Long): WebSocketRequestMessage {
|
||||
if (incomingSemaphore.tryAcquire(1, 10, TimeUnit.SECONDS)) {
|
||||
// Check if we were woken up due to shutdown
|
||||
if (isShutdown) {
|
||||
throw SocketException("WebSocket connection closed")
|
||||
}
|
||||
return getNextRequest()
|
||||
}
|
||||
|
||||
throw TimeoutException("Timeout exceeded")
|
||||
}
|
||||
|
||||
override fun readRequestIfAvailable(): Optional<WebSocketRequestMessage> {
|
||||
return if (incomingSemaphore.tryAcquire()) {
|
||||
Optional.of(getNextRequest())
|
||||
} else {
|
||||
Optional.empty()
|
||||
}
|
||||
}
|
||||
|
||||
private fun getNextRequest(): WebSocketRequestMessage {
|
||||
if (startWholeBatchTrace) {
|
||||
startWholeBatchTrace = false
|
||||
SignalTrace.beginSection("IncomingMessageObserver#totalProcessing")
|
||||
}
|
||||
|
||||
return incomingRequests.removeFirst()
|
||||
}
|
||||
|
||||
override fun sendResponse(response: WebSocketResponseMessage) = Unit
|
||||
|
||||
fun addPendingMessages(messages: List<WebSocketRequestMessage>) {
|
||||
incomingRequests.addAll(messages)
|
||||
}
|
||||
|
||||
fun releaseMessages() {
|
||||
incomingSemaphore.release(incomingRequests.size)
|
||||
}
|
||||
|
||||
override fun sendRequest(
|
||||
request: WebSocketRequestMessage,
|
||||
timeoutSeconds: Long
|
||||
): Single<WebsocketResponse> {
|
||||
if (request.verb != null && request.path != null) {
|
||||
if (request.verb == "PUT" && request.path!!.startsWith("/v1/messages/")) {
|
||||
return Single.just(WebsocketResponse(200, SendMessageResponse().toJson(), emptyList<String>(), true))
|
||||
}
|
||||
}
|
||||
|
||||
return Single.error(okio.IOException("fake timeout"))
|
||||
}
|
||||
|
||||
override fun sendKeepAlive() = Unit
|
||||
|
||||
fun addQueueEmptyMessage() {
|
||||
addPendingMessages(
|
||||
listOf(
|
||||
WebSocketRequestMessage(
|
||||
verb = "PUT",
|
||||
path = "/api/v1/queue/empty"
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun Any.toJson(): String {
|
||||
return JsonUtils.toJson(this)
|
||||
}
|
||||
@@ -105,7 +105,6 @@ class ConversationElementGenerator {
|
||||
false,
|
||||
emptyList(),
|
||||
false,
|
||||
false,
|
||||
now,
|
||||
true,
|
||||
now,
|
||||
@@ -122,6 +121,7 @@ class ConversationElementGenerator {
|
||||
0,
|
||||
false,
|
||||
0,
|
||||
null,
|
||||
null
|
||||
)
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@ import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.kotlin.subscribeBy
|
||||
import org.signal.core.util.concurrent.LifecycleDisposable
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.ringrtc.CallLinkEpoch
|
||||
import org.signal.ringrtc.CallLinkRootKey
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.ViewBinderDelegate
|
||||
@@ -31,7 +30,7 @@ import org.thoughtcrime.securesms.conversation.ConversationItem
|
||||
import org.thoughtcrime.securesms.conversation.ConversationMessage
|
||||
import org.thoughtcrime.securesms.conversation.colors.ChatColors
|
||||
import org.thoughtcrime.securesms.conversation.colors.ChatColorsPalette
|
||||
import org.thoughtcrime.securesms.conversation.colors.Colorizer
|
||||
import org.thoughtcrime.securesms.conversation.colors.ColorizerV2
|
||||
import org.thoughtcrime.securesms.conversation.colors.RecyclerViewColorizer
|
||||
import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectPart
|
||||
import org.thoughtcrime.securesms.conversation.v2.ConversationAdapterV2
|
||||
@@ -68,7 +67,7 @@ class InternalConversationTestFragment : Fragment(R.layout.conversation_test_fra
|
||||
requestManager = Glide.with(this),
|
||||
clickListener = ClickListener(),
|
||||
hasWallpaper = springboardViewModel.hasWallpaper.value,
|
||||
colorizer = Colorizer(),
|
||||
colorizer = ColorizerV2(),
|
||||
startExpirationTimeout = {},
|
||||
chatColorsDataProvider = { ChatColorsDrawable.ChatColorsData(null, null) },
|
||||
displayDialogFragment = {}
|
||||
@@ -295,7 +294,7 @@ class InternalConversationTestFragment : Fragment(R.layout.conversation_test_fra
|
||||
Toast.makeText(requireContext(), "Can't touch this.", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
override fun onJoinCallLink(callLinkRootKey: CallLinkRootKey, callLinkEpoch: CallLinkEpoch?) {
|
||||
override fun onJoinCallLink(callLinkRootKey: CallLinkRootKey) {
|
||||
Toast.makeText(requireContext(), "Can't touch this.", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
|
||||
@@ -752,7 +752,7 @@
|
||||
android:theme="@style/Signal.DayNight.NoActionBar" />
|
||||
|
||||
<activity
|
||||
android:name=".registration.ui.restore.local.InternalNewLocalRestoreActivity"
|
||||
android:name=".registration.ui.restore.local.RestoreLocalBackupActivity"
|
||||
android:exported="false"
|
||||
android:theme="@style/Signal.DayNight.NoActionBar" />
|
||||
|
||||
@@ -933,6 +933,11 @@
|
||||
android:exported="false"
|
||||
android:theme="@style/Signal.DayNight.NoActionBar" />
|
||||
|
||||
<activity
|
||||
android:name=".groups.memberlabel.MemberLabelActivity"
|
||||
android:exported="false"
|
||||
android:theme="@style/Signal.DayNight.NoActionBar" />
|
||||
|
||||
<!-- ======================================= -->
|
||||
<!-- Activity Aliases -->
|
||||
<!-- ======================================= -->
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -11,7 +11,6 @@ import androidx.lifecycle.Observer;
|
||||
|
||||
import com.bumptech.glide.RequestManager;
|
||||
|
||||
import org.signal.ringrtc.CallLinkEpoch;
|
||||
import org.signal.ringrtc.CallLinkRootKey;
|
||||
import org.thoughtcrime.securesms.components.voice.VoiceNotePlaybackState;
|
||||
import org.thoughtcrime.securesms.contactshare.Contact;
|
||||
@@ -30,8 +29,8 @@ import org.thoughtcrime.securesms.groups.GroupId;
|
||||
import org.thoughtcrime.securesms.groups.GroupMigrationMembershipChange;
|
||||
import org.thoughtcrime.securesms.linkpreview.LinkPreview;
|
||||
import org.thoughtcrime.securesms.mediapreview.MediaIntentFactory;
|
||||
import org.thoughtcrime.securesms.polls.PollRecord;
|
||||
import org.thoughtcrime.securesms.polls.PollOption;
|
||||
import org.thoughtcrime.securesms.polls.PollRecord;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.stickers.StickerLocator;
|
||||
@@ -136,7 +135,7 @@ public interface BindableConversationItem extends Unbindable, GiphyMp4Playable,
|
||||
void goToMediaPreview(ConversationItem parent, View sharedElement, MediaIntentFactory.MediaPreviewArgs args);
|
||||
void onEditedIndicatorClicked(@NonNull ConversationMessage conversationMessage);
|
||||
void onShowGroupDescriptionClicked(@NonNull String groupName, @NonNull String description, boolean shouldLinkifyWebLinks);
|
||||
void onJoinCallLink(@NonNull CallLinkRootKey callLinkRootKey, @Nullable CallLinkEpoch callLinkEpoch);
|
||||
void onJoinCallLink(@NonNull CallLinkRootKey callLinkRootKey);
|
||||
void onShowSafetyTips(boolean forGroup);
|
||||
void onReportSpamLearnMoreClicked();
|
||||
void onMessageRequestAcceptOptionsClicked();
|
||||
|
||||
@@ -342,6 +342,7 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
.map(r -> new ContactSearchKey.RecipientSearchKey(r, false))
|
||||
.collect(java.util.stream.Collectors.toSet()),
|
||||
selectionLimit,
|
||||
isMulti,
|
||||
new ContactSearchAdapter.DisplayOptions(
|
||||
isMulti,
|
||||
ContactSearchAdapter.DisplaySecondaryInformation.ALWAYS,
|
||||
@@ -558,6 +559,7 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
public void onDataRefreshed() {
|
||||
this.resetPositionOnCommit = true;
|
||||
swipeRefresh.setRefreshing(false);
|
||||
contactSearchMediator.refresh();
|
||||
}
|
||||
|
||||
public boolean hasQueryFilter() {
|
||||
@@ -574,6 +576,7 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
|
||||
public void reset() {
|
||||
contactSearchMediator.clearSelection();
|
||||
contactSearchMediator.refresh();
|
||||
fastScroller.setVisibility(View.GONE);
|
||||
headerActionView.setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
package org.thoughtcrime.securesms;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
import androidx.fragment.app.FragmentActivity;
|
||||
import androidx.lifecycle.LiveData;
|
||||
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
|
||||
|
||||
import org.thoughtcrime.securesms.groups.LiveGroup;
|
||||
import org.thoughtcrime.securesms.groups.ui.GroupMemberEntry;
|
||||
import org.thoughtcrime.securesms.groups.ui.GroupMemberListView;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.ui.bottomsheet.RecipientBottomSheetDialogFragment;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public final class GroupMembersDialog {
|
||||
|
||||
private final FragmentActivity fragmentActivity;
|
||||
private final Recipient groupRecipient;
|
||||
|
||||
public GroupMembersDialog(@NonNull FragmentActivity activity,
|
||||
@NonNull Recipient groupRecipient)
|
||||
{
|
||||
this.fragmentActivity = activity;
|
||||
this.groupRecipient = groupRecipient;
|
||||
}
|
||||
|
||||
public void display() {
|
||||
AlertDialog dialog = new MaterialAlertDialogBuilder(fragmentActivity)
|
||||
.setTitle(R.string.ConversationActivity_group_members)
|
||||
.setIcon(R.drawable.ic_group_24)
|
||||
.setCancelable(true)
|
||||
.setView(R.layout.dialog_group_members)
|
||||
.setPositiveButton(android.R.string.ok, null)
|
||||
.show();
|
||||
|
||||
GroupMemberListView memberListView = dialog.findViewById(R.id.list_members);
|
||||
memberListView.initializeAdapter(fragmentActivity);
|
||||
|
||||
LiveGroup liveGroup = new LiveGroup(groupRecipient.requireGroupId());
|
||||
LiveData<List<GroupMemberEntry.FullMember>> fullMembers = liveGroup.getFullMembers();
|
||||
|
||||
//noinspection ConstantConditions
|
||||
fullMembers.observe(fragmentActivity, memberListView::setMembers);
|
||||
|
||||
dialog.setOnDismissListener(d -> fullMembers.removeObservers(fragmentActivity));
|
||||
|
||||
memberListView.setRecipientClickListener(recipient -> {
|
||||
dialog.dismiss();
|
||||
contactClick(recipient);
|
||||
});
|
||||
}
|
||||
|
||||
private void contactClick(@NonNull Recipient recipient) {
|
||||
RecipientBottomSheetDialogFragment.show(fragmentActivity.getSupportFragmentManager(), recipient.getId(), groupRecipient.requireGroupId());
|
||||
}
|
||||
}
|
||||
@@ -97,7 +97,6 @@ import org.signal.mediasend.MediaSendActivityContract
|
||||
import org.thoughtcrime.securesms.backup.v2.ArchiveRestoreProgress
|
||||
import org.thoughtcrime.securesms.backup.v2.ui.verify.VerifyBackupKeyActivity
|
||||
import org.thoughtcrime.securesms.calls.YouAreAlreadyInACallSnackbar.show
|
||||
import org.thoughtcrime.securesms.calls.links.details.CallLinkDetailsActivity
|
||||
import org.thoughtcrime.securesms.calls.log.CallLogFilter
|
||||
import org.thoughtcrime.securesms.calls.log.CallLogFragment
|
||||
import org.thoughtcrime.securesms.calls.new.NewCallActivity
|
||||
@@ -140,6 +139,7 @@ import org.thoughtcrime.securesms.main.MainContentLayoutData
|
||||
import org.thoughtcrime.securesms.main.MainMegaphoneState
|
||||
import org.thoughtcrime.securesms.main.MainNavigationBar
|
||||
import org.thoughtcrime.securesms.main.MainNavigationDetailLocation
|
||||
import org.thoughtcrime.securesms.main.MainNavigationDetailLocationEffect
|
||||
import org.thoughtcrime.securesms.main.MainNavigationListLocation
|
||||
import org.thoughtcrime.securesms.main.MainNavigationRail
|
||||
import org.thoughtcrime.securesms.main.MainNavigationViewModel
|
||||
@@ -156,7 +156,6 @@ import org.thoughtcrime.securesms.main.chatNavGraphBuilder
|
||||
import org.thoughtcrime.securesms.main.navigateToDetailLocation
|
||||
import org.thoughtcrime.securesms.main.rememberDetailNavHostController
|
||||
import org.thoughtcrime.securesms.main.rememberFocusRequester
|
||||
import org.thoughtcrime.securesms.main.rememberMainNavigationDetailLocation
|
||||
import org.thoughtcrime.securesms.main.storiesNavGraphBuilder
|
||||
import org.thoughtcrime.securesms.mediasend.camerax.CameraXUtil
|
||||
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionActivity
|
||||
@@ -447,7 +446,7 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
|
||||
|
||||
val chatNavGraphState = ChatNavGraphState.remember(windowSizeClass)
|
||||
val mutableInteractionSource = remember { MutableInteractionSource() }
|
||||
val mainNavigationDetailLocation by rememberMainNavigationDetailLocation(mainNavigationViewModel, chatNavGraphState::writeGraphicsLayerToBitmap)
|
||||
MainNavigationDetailLocationEffect(mainNavigationViewModel, chatNavGraphState::writeGraphicsLayerToBitmap)
|
||||
|
||||
val chatsNavHostController = rememberDetailNavHostController(
|
||||
onRequestFocus = rememberFocusRequester(
|
||||
@@ -477,25 +476,33 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
|
||||
storiesNavGraphBuilder()
|
||||
}
|
||||
|
||||
LaunchedEffect(mainNavigationDetailLocation) {
|
||||
mainNavigationViewModel.clearEarlyDetailLocation()
|
||||
when (mainNavigationDetailLocation) {
|
||||
is MainNavigationDetailLocation.Empty -> {
|
||||
when (mainNavigationState.currentListLocation) {
|
||||
MainNavigationListLocation.CHATS, MainNavigationListLocation.ARCHIVE -> chatsNavHostController
|
||||
MainNavigationListLocation.CALLS -> callsNavHostController
|
||||
MainNavigationListLocation.STORIES -> storiesNavHostController
|
||||
}.navigateToDetailLocation(mainNavigationDetailLocation)
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
suspend fun navigateToLocation(location: MainNavigationDetailLocation) {
|
||||
when (location) {
|
||||
is MainNavigationDetailLocation.Empty -> {
|
||||
when (mainNavigationState.currentListLocation) {
|
||||
MainNavigationListLocation.CHATS, MainNavigationListLocation.ARCHIVE -> chatsNavHostController
|
||||
MainNavigationListLocation.CALLS -> callsNavHostController
|
||||
MainNavigationListLocation.STORIES -> storiesNavHostController
|
||||
}.navigateToDetailLocation(location)
|
||||
}
|
||||
|
||||
is MainNavigationDetailLocation.Chats -> {
|
||||
chatNavGraphState.writeGraphicsLayerToBitmap()
|
||||
chatsNavHostController.navigateToDetailLocation(mainNavigationDetailLocation)
|
||||
}
|
||||
is MainNavigationDetailLocation.Chats -> {
|
||||
if (location is MainNavigationDetailLocation.Chats.Conversation) {
|
||||
chatNavGraphState.writeGraphicsLayerToBitmap()
|
||||
}
|
||||
chatsNavHostController.navigateToDetailLocation(location)
|
||||
}
|
||||
|
||||
is MainNavigationDetailLocation.Calls -> callsNavHostController.navigateToDetailLocation(mainNavigationDetailLocation)
|
||||
is MainNavigationDetailLocation.Stories -> storiesNavHostController.navigateToDetailLocation(mainNavigationDetailLocation)
|
||||
is MainNavigationDetailLocation.Calls -> callsNavHostController.navigateToDetailLocation(location)
|
||||
is MainNavigationDetailLocation.Stories -> storiesNavHostController.navigateToDetailLocation(location)
|
||||
}
|
||||
}
|
||||
|
||||
mainNavigationViewModel.earlyNavigationDetailLocationRequested?.let { navigateToLocation(it) }
|
||||
mainNavigationViewModel.clearEarlyDetailLocation()
|
||||
|
||||
mainNavigationViewModel.detailLocation.collect { navigateToLocation(it) }
|
||||
}
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
@@ -752,27 +759,7 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
|
||||
val coroutine = rememberCoroutineScope()
|
||||
|
||||
return remember(scaffoldNavigator, coroutine) {
|
||||
mainNavigationViewModel.wrapNavigator(coroutine, scaffoldNavigator) { detailLocation ->
|
||||
when (detailLocation) {
|
||||
is MainNavigationDetailLocation.Chats.Conversation -> {
|
||||
startActivity(
|
||||
ConversationIntents.createBuilderSync(this, detailLocation.conversationArgs.recipientId, detailLocation.conversationArgs.threadId)
|
||||
.withArgs(detailLocation.conversationArgs)
|
||||
.build()
|
||||
)
|
||||
}
|
||||
|
||||
is MainNavigationDetailLocation.Calls.CallLinks.CallLinkDetails -> {
|
||||
startActivity(CallLinkDetailsActivity.createIntent(this, detailLocation.callLinkRoomId))
|
||||
}
|
||||
|
||||
is MainNavigationDetailLocation.Calls.CallLinks.EditCallLinkName -> {
|
||||
error("Unexpected subroute EditCallLinkName.")
|
||||
}
|
||||
|
||||
MainNavigationDetailLocation.Empty -> Unit
|
||||
}
|
||||
}
|
||||
mainNavigationViewModel.wrapNavigator(coroutine, scaffoldNavigator)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
package org.thoughtcrime.securesms;
|
||||
|
||||
import android.content.Context;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
|
||||
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
public class MuteDialog extends AlertDialog {
|
||||
|
||||
|
||||
protected MuteDialog(Context context) {
|
||||
super(context);
|
||||
}
|
||||
|
||||
protected MuteDialog(Context context, boolean cancelable, OnCancelListener cancelListener) {
|
||||
super(context, cancelable, cancelListener);
|
||||
}
|
||||
|
||||
protected MuteDialog(Context context, int theme) {
|
||||
super(context, theme);
|
||||
}
|
||||
|
||||
public static void show(final Context context, final @NonNull MuteSelectionListener listener) {
|
||||
show(context, listener, null);
|
||||
}
|
||||
|
||||
public static void show(final Context context, final @NonNull MuteSelectionListener listener, @Nullable Runnable cancelListener) {
|
||||
AlertDialog.Builder builder = new MaterialAlertDialogBuilder(context);
|
||||
builder.setTitle(R.string.MuteDialog_mute_notifications);
|
||||
builder.setItems(R.array.mute_durations, (dialog, which) -> {
|
||||
final long muteUntil;
|
||||
|
||||
switch (which) {
|
||||
case 0: muteUntil = System.currentTimeMillis() + TimeUnit.HOURS.toMillis(1); break;
|
||||
case 1: muteUntil = System.currentTimeMillis() + TimeUnit.HOURS.toMillis(8); break;
|
||||
case 2: muteUntil = System.currentTimeMillis() + TimeUnit.DAYS.toMillis(1); break;
|
||||
case 3: muteUntil = System.currentTimeMillis() + TimeUnit.DAYS.toMillis(7); break;
|
||||
case 4: muteUntil = Long.MAX_VALUE; break;
|
||||
default: muteUntil = System.currentTimeMillis() + TimeUnit.HOURS.toMillis(1); break;
|
||||
}
|
||||
|
||||
listener.onMuted(muteUntil);
|
||||
});
|
||||
|
||||
if (cancelListener != null) {
|
||||
builder.setOnCancelListener(dialog -> {
|
||||
cancelListener.run();
|
||||
dialog.dismiss();
|
||||
});
|
||||
}
|
||||
|
||||
builder.show();
|
||||
|
||||
}
|
||||
|
||||
public interface MuteSelectionListener {
|
||||
public void onMuted(long until);
|
||||
}
|
||||
|
||||
}
|
||||
73
app/src/main/java/org/thoughtcrime/securesms/MuteDialog.kt
Normal file
73
app/src/main/java/org/thoughtcrime/securesms/MuteDialog.kt
Normal file
@@ -0,0 +1,73 @@
|
||||
package org.thoughtcrime.securesms
|
||||
|
||||
import android.content.Context
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.BaseAdapter
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import org.thoughtcrime.securesms.components.settings.conversation.MuteUntilTimePickerBottomSheet
|
||||
import kotlin.time.Duration.Companion.days
|
||||
import kotlin.time.Duration.Companion.hours
|
||||
|
||||
object MuteDialog {
|
||||
|
||||
private const val MUTE_UNTIL: Long = -1L
|
||||
|
||||
private data class MuteOption(
|
||||
@DrawableRes val iconRes: Int,
|
||||
val title: String,
|
||||
val duration: Long
|
||||
)
|
||||
|
||||
@JvmStatic
|
||||
fun show(context: Context, fragmentManager: FragmentManager, lifecycleOwner: LifecycleOwner, action: MuteSelectionListener) {
|
||||
fragmentManager.setFragmentResultListener(MuteUntilTimePickerBottomSheet.REQUEST_KEY, lifecycleOwner) { _, bundle ->
|
||||
action.onMuted(bundle.getLong(MuteUntilTimePickerBottomSheet.RESULT_TIMESTAMP))
|
||||
}
|
||||
|
||||
val options = listOf(
|
||||
MuteOption(R.drawable.ic_daytime_24, context.getString(R.string.arrays__mute_for_one_hour), 1.hours.inWholeMilliseconds),
|
||||
MuteOption(R.drawable.ic_nighttime_26, context.getString(R.string.arrays__mute_for_eight_hours), 8.hours.inWholeMilliseconds),
|
||||
MuteOption(R.drawable.symbol_calendar_one, context.getString(R.string.arrays__mute_for_one_day), 1.days.inWholeMilliseconds),
|
||||
MuteOption(R.drawable.symbol_calendar_week, context.getString(R.string.arrays__mute_for_seven_days), 7.days.inWholeMilliseconds),
|
||||
MuteOption(R.drawable.symbol_calendar_24, context.getString(R.string.MuteDialog__mute_until), MUTE_UNTIL),
|
||||
MuteOption(R.drawable.symbol_bell_slash_24, context.getString(R.string.arrays__always), Long.MAX_VALUE)
|
||||
)
|
||||
|
||||
val adapter = object : BaseAdapter() {
|
||||
override fun getCount(): Int = options.size
|
||||
override fun getItem(position: Int): MuteOption = options[position]
|
||||
override fun getItemId(position: Int): Long = position.toLong()
|
||||
|
||||
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
|
||||
val view = convertView ?: LayoutInflater.from(context).inflate(R.layout.mute_dialog_item, parent, false)
|
||||
val option = options[position]
|
||||
view.findViewById<ImageView>(R.id.mute_dialog_icon).setImageResource(option.iconRes)
|
||||
view.findViewById<TextView>(R.id.mute_dialog_title).text = option.title
|
||||
return view
|
||||
}
|
||||
}
|
||||
|
||||
MaterialAlertDialogBuilder(context)
|
||||
.setTitle(R.string.MuteDialog_mute_notifications)
|
||||
.setAdapter(adapter) { _, which ->
|
||||
val option = options[which]
|
||||
when (option.duration) {
|
||||
MUTE_UNTIL -> MuteUntilTimePickerBottomSheet.show(fragmentManager)
|
||||
Long.MAX_VALUE -> action.onMuted(Long.MAX_VALUE)
|
||||
else -> action.onMuted(System.currentTimeMillis() + option.duration)
|
||||
}
|
||||
}
|
||||
.show()
|
||||
}
|
||||
|
||||
fun interface MuteSelectionListener {
|
||||
fun onMuted(until: Long)
|
||||
}
|
||||
}
|
||||
@@ -153,7 +153,7 @@ abstract class Attachment(
|
||||
* Denotes whether the media for the given attachment is no longer available for download.
|
||||
*/
|
||||
val isMediaNoLongerAvailableForDownload: Boolean
|
||||
get() = isPermanentlyFailed && uploadTimestamp.milliseconds > 30.days
|
||||
get() = isPermanentlyFailed && (System.currentTimeMillis().milliseconds - uploadTimestamp.milliseconds) > 30.days
|
||||
|
||||
val isSticker: Boolean
|
||||
get() = stickerLocator != null
|
||||
|
||||
@@ -155,6 +155,10 @@ object ExportSkips {
|
||||
return log(sentTimestamp, "An incoming message author did not have an aci or e164.")
|
||||
}
|
||||
|
||||
fun directionlessMessageAuthorDoesNotHaveAciOrE164(sentTimestamp: Long): String {
|
||||
return log(sentTimestamp, "A directionlessmessage author did not have an aci or e164.")
|
||||
}
|
||||
|
||||
fun outgoingMessageToReleaseNotesChat(sentTimestamp: Long): String {
|
||||
return log(sentTimestamp, "An outgoing message was sent to the release notes chat.")
|
||||
}
|
||||
@@ -214,6 +218,14 @@ object ExportOddities {
|
||||
return log(0, "Distribution list had self as a member. Removing it.")
|
||||
}
|
||||
|
||||
fun quoteAuthorNotFound(sentTimestamp: Long): String {
|
||||
return log(sentTimestamp, "Quote author was not found in the exported recipients. Removing the quote.")
|
||||
}
|
||||
|
||||
fun quoteAuthorHasNoAciOrE164(sentTimestamp: Long): String {
|
||||
return log(sentTimestamp, "Quote author has neither an ACI nor an E164. Removing the quote.")
|
||||
}
|
||||
|
||||
fun emptyQuote(sentTimestamp: Long): String {
|
||||
return log(sentTimestamp, "Quote had no text or attachments. Removing it.")
|
||||
}
|
||||
@@ -283,6 +295,10 @@ object ImportSkips {
|
||||
return log(0, "Missing recipient for chat $chatId")
|
||||
}
|
||||
|
||||
fun missingAdminDeleteRecipient(sentTimestamp: Long, chatId: Long): String {
|
||||
return log(sentTimestamp, "Missing admin delete recipient for chat $chatId")
|
||||
}
|
||||
|
||||
private fun log(sentTimestamp: Long, message: String): String {
|
||||
return "[SKIP][$sentTimestamp] $message"
|
||||
}
|
||||
|
||||
@@ -44,7 +44,6 @@ fun MessageTable.getMessagesForBackup(db: SignalDatabase, backupTime: Long, self
|
||||
${MessageTable.FROM_RECIPIENT_ID},
|
||||
${MessageTable.TO_RECIPIENT_ID},
|
||||
${MessageTable.EXPIRE_STARTED},
|
||||
${MessageTable.REMOTE_DELETED},
|
||||
${MessageTable.UNIDENTIFIED},
|
||||
${MessageTable.LINK_PREVIEWS},
|
||||
${MessageTable.SHARED_CONTACTS},
|
||||
@@ -68,7 +67,8 @@ fun MessageTable.getMessagesForBackup(db: SignalDatabase, backupTime: Long, self
|
||||
${MessageTable.VIEW_ONCE},
|
||||
${MessageTable.PINNED_UNTIL},
|
||||
${MessageTable.PINNING_MESSAGE_ID},
|
||||
${MessageTable.PINNED_AT}
|
||||
${MessageTable.PINNED_AT},
|
||||
${MessageTable.DELETED_BY}
|
||||
)
|
||||
WHERE $STORY_TYPE = 0 AND $PARENT_STORY_ID <= 0 AND $SCHEDULED_DATE = -1
|
||||
""".trimMargin()
|
||||
@@ -136,7 +136,6 @@ fun MessageTable.getMessagesForBackup(db: SignalDatabase, backupTime: Long, self
|
||||
MessageTable.TO_RECIPIENT_ID,
|
||||
EXPIRES_IN,
|
||||
MessageTable.EXPIRE_STARTED,
|
||||
MessageTable.REMOTE_DELETED,
|
||||
MessageTable.UNIDENTIFIED,
|
||||
MessageTable.LINK_PREVIEWS,
|
||||
MessageTable.SHARED_CONTACTS,
|
||||
@@ -161,7 +160,8 @@ fun MessageTable.getMessagesForBackup(db: SignalDatabase, backupTime: Long, self
|
||||
PARENT_STORY_ID,
|
||||
MessageTable.PINNED_UNTIL,
|
||||
MessageTable.PINNING_MESSAGE_ID,
|
||||
MessageTable.PINNED_AT
|
||||
MessageTable.PINNED_AT,
|
||||
MessageTable.DELETED_BY
|
||||
)
|
||||
.from("${MessageTable.TABLE_NAME} INDEXED BY $dateReceivedIndex")
|
||||
.where("$STORY_TYPE = 0 AND $PARENT_STORY_ID <= 0 AND $SCHEDULED_DATE = -1 AND ($EXPIRES_IN == 0 OR $EXPIRES_IN > ${1.days.inWholeMilliseconds}) AND $DATE_RECEIVED >= $lastSeenReceivedTime $cutoffQuery")
|
||||
|
||||
@@ -40,7 +40,6 @@ class CallLinkArchiveExporter(private val cursor: Cursor) : Iterator<ArchiveReci
|
||||
id = callLink.recipientId.toLong(),
|
||||
callLink = CallLink(
|
||||
rootKey = callLink.credentials!!.linkKeyBytes.toByteString(),
|
||||
epoch = callLink.credentials.epochBytes?.takeIf { it.size == 4 }?.toByteString(),
|
||||
adminKey = callLink.credentials.adminPassBytes?.toByteString()?.nullIfEmpty(),
|
||||
name = callLink.state.name,
|
||||
expirationMs = expirationTime.takeIf { it != Long.MAX_VALUE }?.clampToValidBackupRange() ?: 0,
|
||||
|
||||
@@ -62,7 +62,7 @@ class ChatArchiveExporter(private val cursor: Cursor, private val db: SignalData
|
||||
expireTimerVersion = cursor.requireInt(RecipientTable.MESSAGE_EXPIRATION_TIME_VERSION),
|
||||
muteUntilMs = cursor.requireLong(RecipientTable.MUTE_UNTIL).takeIf { it > 0 },
|
||||
markedUnread = ThreadTable.ReadStatus.deserialize(cursor.requireInt(ThreadTable.READ)) == ThreadTable.ReadStatus.FORCED_UNREAD,
|
||||
dontNotifyForMentionsIfMuted = RecipientTable.MentionSetting.DO_NOT_NOTIFY.id == cursor.requireInt(RecipientTable.MENTION_SETTING),
|
||||
dontNotifyForMentionsIfMuted = RecipientTable.NotificationSetting.DO_NOT_NOTIFY.id == cursor.requireInt(RecipientTable.MENTION_SETTING),
|
||||
style = ChatStyleConverter.constructRemoteChatStyle(
|
||||
db = db,
|
||||
chatColors = chatColors,
|
||||
|
||||
@@ -19,7 +19,6 @@ import org.signal.core.util.UuidUtil
|
||||
import org.signal.core.util.bytes
|
||||
import org.signal.core.util.concurrent.SignalExecutors
|
||||
import org.signal.core.util.emptyIfNull
|
||||
import org.signal.core.util.isEmpty
|
||||
import org.signal.core.util.isNotEmpty
|
||||
import org.signal.core.util.isNotNullOrBlank
|
||||
import org.signal.core.util.kibiBytes
|
||||
@@ -42,6 +41,7 @@ import org.thoughtcrime.securesms.backup.v2.BackupMode
|
||||
import org.thoughtcrime.securesms.backup.v2.ExportOddities
|
||||
import org.thoughtcrime.securesms.backup.v2.ExportSkips
|
||||
import org.thoughtcrime.securesms.backup.v2.ExportState
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.AdminDeletedMessage
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.ChatItem
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.ChatUpdateMessage
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.ContactAttachment
|
||||
@@ -193,11 +193,16 @@ class ChatItemArchiveExporter(
|
||||
}
|
||||
|
||||
when {
|
||||
record.remoteDeleted -> {
|
||||
record.deletedBy == record.fromRecipientId -> {
|
||||
builder.remoteDeletedMessage = RemoteDeletedMessage()
|
||||
transformTimer.emit("remote-delete")
|
||||
}
|
||||
|
||||
record.deletedBy != null -> {
|
||||
builder.adminDeletedMessage = AdminDeletedMessage(adminId = record.deletedBy)
|
||||
transformTimer.emit("admin-delete")
|
||||
}
|
||||
|
||||
MessageTypes.isJoinedType(record.type) -> {
|
||||
builder.updateMessage = simpleUpdate(SimpleChatUpdate.Type.JOINED_SIGNAL)
|
||||
transformTimer.emit("simple-update")
|
||||
@@ -564,7 +569,7 @@ private fun BackupMessageRecord.toBasicChatItemBuilder(selfRecipientId: Recipien
|
||||
}
|
||||
|
||||
val direction = when {
|
||||
record.type.isDirectionlessType() && !record.remoteDeleted -> {
|
||||
record.type.isDirectionlessType() && record.deletedBy == null -> {
|
||||
Direction.DIRECTIONLESS
|
||||
}
|
||||
MessageTypes.isOutgoingMessageType(record.type) || record.fromRecipientId == selfRecipientId.toLong() -> {
|
||||
@@ -1169,6 +1174,16 @@ private fun BackupMessageRecord.toRemoteQuote(exportState: ExportState, attachme
|
||||
return null
|
||||
}
|
||||
|
||||
if (!exportState.recipientIds.contains(this.quoteAuthor)) {
|
||||
Log.w(TAG, ExportOddities.quoteAuthorNotFound(this.dateSent))
|
||||
return null
|
||||
}
|
||||
|
||||
if (exportState.recipientIdToAci[this.quoteAuthor] == null && exportState.recipientIdToE164[this.quoteAuthor] == null) {
|
||||
Log.w(TAG, ExportOddities.quoteAuthorHasNoAciOrE164(this.dateSent))
|
||||
return null
|
||||
}
|
||||
|
||||
val localType = QuoteModel.Type.fromCode(this.quoteType)
|
||||
val remoteType = when (localType) {
|
||||
QuoteModel.Type.NORMAL -> {
|
||||
@@ -1360,11 +1375,12 @@ private fun FailureReason?.toRemote(): PaymentNotification.TransactionDetails.Fa
|
||||
}
|
||||
|
||||
private fun List<Mention>.toRemoteBodyRanges(exportState: ExportState): List<BackupBodyRange> {
|
||||
return this.map {
|
||||
return this.mapNotNull {
|
||||
val aci = exportState.recipientIdToAci[it.recipientId.toLong()] ?: return@mapNotNull null
|
||||
BackupBodyRange(
|
||||
start = it.start,
|
||||
length = it.length,
|
||||
mentionAci = exportState.recipientIdToAci[it.recipientId.toLong()]
|
||||
mentionAci = aci
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1377,16 +1393,16 @@ private fun ByteArray.toRemoteBodyRanges(dateSent: Long): List<BackupBodyRange>
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
return decoded.ranges.map { range ->
|
||||
return decoded.ranges.mapNotNull { range ->
|
||||
val mention = range.mentionUuid?.let { UuidUtil.parseOrNull(it) }?.toByteArray()?.toByteString()?.takeIf { it.isNotEmpty() }
|
||||
val style = if (mention == null) {
|
||||
range.style?.toRemote() ?: BackupBodyRange.Style.NONE
|
||||
range.style?.toRemote()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
if (mention == null && style == null) {
|
||||
return emptyList()
|
||||
return@mapNotNull null
|
||||
}
|
||||
|
||||
BackupBodyRange(
|
||||
@@ -1662,7 +1678,8 @@ private fun ChatItem.validateChatItem(exportState: ExportState, selfRecipientId:
|
||||
this.giftBadge == null &&
|
||||
this.viewOnceMessage == null &&
|
||||
this.directStoryReplyMessage == null &&
|
||||
this.poll == null
|
||||
this.poll == null &&
|
||||
this.adminDeletedMessage == null
|
||||
) {
|
||||
Log.w(TAG, ExportSkips.emptyChatItem(this.dateSent))
|
||||
return null
|
||||
@@ -1688,6 +1705,11 @@ private fun ChatItem.validateChatItem(exportState: ExportState, selfRecipientId:
|
||||
return null
|
||||
}
|
||||
|
||||
if (this.directionless != null && this.authorId != selfRecipientId.toLong() && exportState.recipientIdToAci[this.authorId] == null && exportState.recipientIdToE164[this.authorId] == null) {
|
||||
Log.w(TAG, ExportSkips.directionlessMessageAuthorDoesNotHaveAciOrE164(this.dateSent))
|
||||
return null
|
||||
}
|
||||
|
||||
if (this.outgoing != null && exportState.releaseNoteRecipientId != null && exportState.threadIdToRecipientId[this.chatId] == exportState.releaseNoteRecipientId) {
|
||||
Log.w(TAG, ExportSkips.outgoingMessageToReleaseNotesChat(this.dateSent))
|
||||
return null
|
||||
@@ -1805,7 +1827,6 @@ private fun Cursor.toBackupMessageRecord(pastIds: Set<Long>, backupStartTime: Lo
|
||||
toRecipientId = this.requireLong(MessageTable.TO_RECIPIENT_ID),
|
||||
expiresIn = expiresIn,
|
||||
expireStarted = expireStarted,
|
||||
remoteDeleted = this.requireBoolean(MessageTable.REMOTE_DELETED),
|
||||
sealedSender = this.requireBoolean(MessageTable.UNIDENTIFIED),
|
||||
linkPreview = this.requireString(MessageTable.LINK_PREVIEWS),
|
||||
sharedContacts = this.requireString(MessageTable.SHARED_CONTACTS),
|
||||
@@ -1830,6 +1851,7 @@ private fun Cursor.toBackupMessageRecord(pastIds: Set<Long>, backupStartTime: Lo
|
||||
parentStoryId = this.requireLong(MessageTable.PARENT_STORY_ID),
|
||||
pinnedAt = this.requireLong(MessageTable.PINNED_AT),
|
||||
pinnedUntil = this.requireLong(MessageTable.PINNED_UNTIL),
|
||||
deletedBy = this.requireLongOrNull(MessageTable.DELETED_BY),
|
||||
messageExtrasSize = messageExtras?.size ?: 0
|
||||
)
|
||||
}
|
||||
@@ -1847,7 +1869,6 @@ private class BackupMessageRecord(
|
||||
val toRecipientId: Long,
|
||||
val expiresIn: Long,
|
||||
val expireStarted: Long,
|
||||
val remoteDeleted: Boolean,
|
||||
val sealedSender: Boolean,
|
||||
val linkPreview: String?,
|
||||
val sharedContacts: String?,
|
||||
@@ -1872,6 +1893,7 @@ private class BackupMessageRecord(
|
||||
val viewOnce: Boolean,
|
||||
val pinnedAt: Long,
|
||||
val pinnedUntil: Long,
|
||||
val deletedBy: Long?,
|
||||
private val messageExtrasSize: Int
|
||||
) {
|
||||
val estimatedSizeInBytes: Int = (body?.length ?: 0) +
|
||||
|
||||
@@ -24,6 +24,7 @@ import org.thoughtcrime.securesms.backup.v2.util.clampToValidBackupRange
|
||||
import org.thoughtcrime.securesms.backup.v2.util.isValidUsername
|
||||
import org.thoughtcrime.securesms.backup.v2.util.toRemote
|
||||
import org.thoughtcrime.securesms.conversation.colors.AvatarColor
|
||||
import org.thoughtcrime.securesms.crypto.ProfileKeyUtil
|
||||
import org.thoughtcrime.securesms.database.IdentityTable
|
||||
import org.thoughtcrime.securesms.database.RecipientTable
|
||||
import org.thoughtcrime.securesms.database.RecipientTableCursorUtil
|
||||
@@ -75,7 +76,7 @@ class ContactArchiveExporter(private val cursor: Cursor, private val selfId: Lon
|
||||
.e164(cursor.requireString(RecipientTable.E164)?.e164ToLong())
|
||||
.blocked(cursor.requireBoolean(RecipientTable.BLOCKED))
|
||||
.visibility(Recipient.HiddenState.deserialize(cursor.requireInt(RecipientTable.HIDDEN)).toRemote())
|
||||
.profileKey(cursor.requireString(RecipientTable.PROFILE_KEY)?.let { Base64.decode(it) }?.toByteString())
|
||||
.profileKey(cursor.requireString(RecipientTable.PROFILE_KEY)?.let { ProfileKeyUtil.profileKeyOrNull(it)?.serialize()?.toByteString() })
|
||||
.profileSharing(cursor.requireBoolean(RecipientTable.PROFILE_SHARING))
|
||||
.profileGivenName(cursor.requireString(RecipientTable.PROFILE_GIVEN_NAME))
|
||||
.profileFamilyName(cursor.requireString(RecipientTable.PROFILE_FAMILY_NAME))
|
||||
|
||||
@@ -116,7 +116,7 @@ private fun AccessControl.toRemote(): Group.AccessControl {
|
||||
|
||||
private fun Member.Role.toRemote(): Group.Member.Role {
|
||||
return when (this) {
|
||||
Member.Role.UNKNOWN -> Group.Member.Role.UNKNOWN
|
||||
Member.Role.UNKNOWN -> Group.Member.Role.DEFAULT
|
||||
Member.Role.DEFAULT -> Group.Member.Role.DEFAULT
|
||||
Member.Role.ADMINISTRATOR -> Group.Member.Role.ADMINISTRATOR
|
||||
}
|
||||
|
||||
@@ -42,11 +42,7 @@ object CallLinkArchiveImporter {
|
||||
CallLinkTable.CallLink(
|
||||
recipientId = RecipientId.UNKNOWN,
|
||||
roomId = CallLinkRoomId.fromCallLinkRootKey(rootKey),
|
||||
credentials = CallLinkCredentials(
|
||||
callLink.rootKey.toByteArray(),
|
||||
callLink.epoch?.toByteArray(),
|
||||
callLink.adminKey?.toByteArray()
|
||||
),
|
||||
credentials = CallLinkCredentials(callLink.rootKey.toByteArray(), callLink.adminKey?.toByteArray()),
|
||||
state = SignalCallLinkState(
|
||||
name = callLink.name,
|
||||
restrictions = callLink.restrictions.toLocal(),
|
||||
|
||||
@@ -60,7 +60,7 @@ object ChatArchiveImporter {
|
||||
.update(
|
||||
RecipientTable.TABLE_NAME,
|
||||
contentValuesOf(
|
||||
RecipientTable.MENTION_SETTING to (if (chat.dontNotifyForMentionsIfMuted) RecipientTable.MentionSetting.DO_NOT_NOTIFY.id else RecipientTable.MentionSetting.ALWAYS_NOTIFY.id),
|
||||
RecipientTable.MENTION_SETTING to (if (chat.dontNotifyForMentionsIfMuted) RecipientTable.NotificationSetting.DO_NOT_NOTIFY.id else RecipientTable.NotificationSetting.ALWAYS_NOTIFY.id),
|
||||
RecipientTable.MUTE_UNTIL to (chat.muteUntilMs ?: 0),
|
||||
RecipientTable.MESSAGE_EXPIRATION_TIME to (chat.expirationTimerMs?.milliseconds?.inWholeSeconds ?: 0),
|
||||
RecipientTable.MESSAGE_EXPIRATION_TIME_VERSION to chat.expireTimerVersion,
|
||||
|
||||
@@ -121,7 +121,6 @@ class ChatItemArchiveImporter(
|
||||
MessageTable.EXPIRES_IN,
|
||||
MessageTable.EXPIRE_STARTED,
|
||||
MessageTable.UNIDENTIFIED,
|
||||
MessageTable.REMOTE_DELETED,
|
||||
MessageTable.NETWORK_FAILURES,
|
||||
MessageTable.QUOTE_ID,
|
||||
MessageTable.QUOTE_AUTHOR,
|
||||
@@ -141,7 +140,8 @@ class ChatItemArchiveImporter(
|
||||
MessageTable.NOTIFIED,
|
||||
MessageTable.PINNED_UNTIL,
|
||||
MessageTable.PINNING_MESSAGE_ID,
|
||||
MessageTable.PINNED_AT
|
||||
MessageTable.PINNED_AT,
|
||||
MessageTable.DELETED_BY
|
||||
)
|
||||
|
||||
private val REACTION_COLUMNS = arrayOf(
|
||||
@@ -193,6 +193,12 @@ class ChatItemArchiveImporter(
|
||||
Log.w(TAG, ImportSkips.chatIdRemoteRecipientNotFound(chatItem.dateSent, chatItem.chatId))
|
||||
return
|
||||
}
|
||||
|
||||
if (chatItem.adminDeletedMessage != null && importState.remoteToLocalRecipientId[chatItem.adminDeletedMessage.adminId] == null) {
|
||||
Log.w(TAG, ImportSkips.missingAdminDeleteRecipient(chatItem.dateSent, chatItem.chatId))
|
||||
return
|
||||
}
|
||||
|
||||
val messageInsert = chatItem.toMessageInsert(fromLocalRecipientId, chatLocalRecipientId, localThreadId)
|
||||
if (chatItem.revisions.isNotEmpty()) {
|
||||
// Flush to avoid having revisions cross batch boundaries, which will cause a foreign key failure
|
||||
@@ -672,7 +678,6 @@ class ChatItemArchiveImporter(
|
||||
contentValues.put(MessageTable.QUOTE_MISSING, 0)
|
||||
contentValues.put(MessageTable.QUOTE_TYPE, 0)
|
||||
contentValues.put(MessageTable.VIEW_ONCE, 0)
|
||||
contentValues.put(MessageTable.REMOTE_DELETED, 0)
|
||||
contentValues.put(MessageTable.PARENT_STORY_ID, 0)
|
||||
|
||||
if (this.pinDetails != null) {
|
||||
@@ -683,12 +688,13 @@ class ChatItemArchiveImporter(
|
||||
|
||||
when {
|
||||
this.standardMessage != null -> contentValues.addStandardMessage(this.standardMessage)
|
||||
this.remoteDeletedMessage != null -> contentValues.put(MessageTable.REMOTE_DELETED, 1)
|
||||
this.remoteDeletedMessage != null -> contentValues.put(MessageTable.DELETED_BY, fromRecipientId.toLong())
|
||||
this.updateMessage != null -> contentValues.addUpdateMessage(this.updateMessage, fromRecipientId, toRecipientId)
|
||||
this.paymentNotification != null -> contentValues.addPaymentNotification(this, chatRecipientId)
|
||||
this.giftBadge != null -> contentValues.addGiftBadge(this.giftBadge)
|
||||
this.viewOnceMessage != null -> contentValues.addViewOnce(this.viewOnceMessage)
|
||||
this.directStoryReplyMessage != null -> contentValues.addDirectStoryReply(this.directStoryReplyMessage, toRecipientId)
|
||||
this.adminDeletedMessage != null -> contentValues.put(MessageTable.DELETED_BY, importState.remoteToLocalRecipientId[this.adminDeletedMessage.adminId]!!.toLong())
|
||||
}
|
||||
|
||||
return contentValues
|
||||
|
||||
@@ -125,7 +125,7 @@ object AccountDataArchiveProcessor {
|
||||
hasSeenGroupStoryEducationSheet = signalStore.storyValues.userHasSeenGroupStoryEducationSheet,
|
||||
hasCompletedUsernameOnboarding = signalStore.uiHintValues.hasCompletedUsernameOnboarding(),
|
||||
customChatColors = db.chatColorsTable.getSavedChatColors().toRemoteChatColors().also { colors -> exportState.customChatColorIds.addAll(colors.map { it.id }) },
|
||||
optimizeOnDeviceStorage = signalStore.backupValues.optimizeStorage,
|
||||
optimizeOnDeviceStorage = signalStore.backupValues.optimizeStorage && signalStore.backupValues.backupTier == MessageBackupTier.PAID,
|
||||
backupTier = signalStore.backupValues.backupTier.toRemoteBackupTier(),
|
||||
defaultSentMediaQuality = signalStore.settingsValues.sentMediaQuality.toRemoteSentMediaQuality(),
|
||||
autoDownloadSettings = AccountData.AutoDownloadSettings(
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.backup.v2.ui
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
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.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.dimensionResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.signal.core.ui.compose.BottomSheets
|
||||
import org.signal.core.ui.compose.Buttons
|
||||
import org.signal.core.ui.compose.ComposeBottomSheetDialogFragment
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.Rows
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.jobs.BackupMessagesJob
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.signal.core.ui.R as CoreUiR
|
||||
|
||||
/**
|
||||
* Bottom sheet shown after a successful paid backup subscription from a storage upsell megaphone.
|
||||
* Allows the user to start their first backup and optionally enable storage optimization.
|
||||
*/
|
||||
class BackupSetupCompleteBottomSheet : ComposeBottomSheetDialogFragment() {
|
||||
|
||||
override val peekHeightPercentage: Float = 0.75f
|
||||
|
||||
@Composable
|
||||
override fun SheetContent() {
|
||||
SetupCompleteSheetContent(
|
||||
onBackUpNowClick = { optimizeStorage ->
|
||||
SignalStore.backup.optimizeStorage = optimizeStorage
|
||||
BackupMessagesJob.enqueue()
|
||||
dismissAllowingStateLoss()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SetupCompleteSheetContent(
|
||||
onBackUpNowClick: (optimizeStorage: Boolean) -> Unit
|
||||
) {
|
||||
var optimizeStorage by rememberSaveable { mutableStateOf(true) }
|
||||
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = dimensionResource(id = CoreUiR.dimen.gutter))
|
||||
) {
|
||||
BottomSheets.Handle()
|
||||
|
||||
Spacer(modifier = Modifier.size(26.dp))
|
||||
|
||||
Image(
|
||||
imageVector = ImageVector.vectorResource(id = R.drawable.image_signal_backups_subscribed),
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.size(80.dp)
|
||||
.padding(2.dp)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.BackupSetupCompleteBottomSheet__title),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(top = 16.dp, bottom = 12.dp)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.BackupSetupCompleteBottomSheet__body),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(bottom = 24.dp)
|
||||
)
|
||||
|
||||
Rows.ToggleRow(
|
||||
checked = optimizeStorage,
|
||||
text = stringResource(R.string.BackupSetupCompleteBottomSheet__optimize_storage),
|
||||
label = stringResource(R.string.BackupSetupCompleteBottomSheet__optimize_subtitle),
|
||||
onCheckChanged = { optimizeStorage = it },
|
||||
modifier = Modifier.padding(bottom = 24.dp)
|
||||
)
|
||||
|
||||
Buttons.LargeTonal(
|
||||
onClick = { onBackUpNowClick(optimizeStorage) },
|
||||
modifier = Modifier
|
||||
.defaultMinSize(minWidth = 220.dp)
|
||||
.padding(bottom = 56.dp)
|
||||
) {
|
||||
Text(text = stringResource(R.string.BackupSetupCompleteBottomSheet__back_up_now))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun BackupSetupCompleteBottomSheetPreview() {
|
||||
Previews.BottomSheetContentPreview {
|
||||
SetupCompleteSheetContent(
|
||||
onBackUpNowClick = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.backup.v2.ui
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.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.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.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.dimensionResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.os.bundleOf
|
||||
import androidx.fragment.app.DialogFragment
|
||||
import org.signal.core.ui.compose.BottomSheets
|
||||
import org.signal.core.ui.compose.Buttons
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.util.gibiBytes
|
||||
import org.signal.core.util.money.FiatMoney
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsType
|
||||
import org.thoughtcrime.securesms.billing.upgrade.UpgradeToPaidTierBottomSheet
|
||||
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
|
||||
import java.math.BigDecimal
|
||||
import java.util.Currency
|
||||
import kotlin.time.Duration.Companion.days
|
||||
import org.signal.core.ui.R as CoreUiR
|
||||
|
||||
/**
|
||||
* Bottom sheet that upsells paid backup plans to users.
|
||||
*/
|
||||
class BackupUpsellBottomSheet : UpgradeToPaidTierBottomSheet() {
|
||||
|
||||
companion object {
|
||||
private const val ARG_SHOW_POST_PAYMENT = "show_post_payment"
|
||||
|
||||
@JvmStatic
|
||||
fun create(showPostPaymentSheet: Boolean): DialogFragment {
|
||||
return BackupUpsellBottomSheet().apply {
|
||||
arguments = bundleOf(ARG_SHOW_POST_PAYMENT to showPostPaymentSheet)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val showPostPaymentSheet: Boolean by lazy(LazyThreadSafetyMode.NONE) {
|
||||
requireArguments().getBoolean(ARG_SHOW_POST_PAYMENT, false)
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
if (showPostPaymentSheet) {
|
||||
parentFragmentManager.setFragmentResultListener(RESULT_KEY, requireActivity()) { _, bundle ->
|
||||
if (bundle.getBoolean(RESULT_KEY, false)) {
|
||||
BackupSetupCompleteBottomSheet().show(parentFragmentManager, "backup_setup_complete")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun UpgradeSheetContent(
|
||||
paidBackupType: MessageBackupsType.Paid,
|
||||
freeBackupType: MessageBackupsType.Free,
|
||||
isSubscribeEnabled: Boolean,
|
||||
onSubscribeClick: () -> Unit
|
||||
) {
|
||||
UpsellSheetContent(
|
||||
paidBackupType = paidBackupType,
|
||||
isSubscribeEnabled = isSubscribeEnabled,
|
||||
onSubscribeClick = onSubscribeClick,
|
||||
onNoThanksClick = { dismissAllowingStateLoss() }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UpsellSheetContent(
|
||||
paidBackupType: MessageBackupsType.Paid,
|
||||
isSubscribeEnabled: Boolean,
|
||||
onSubscribeClick: () -> Unit,
|
||||
onNoThanksClick: () -> Unit
|
||||
) {
|
||||
val resources = LocalContext.current.resources
|
||||
val pricePerMonth = remember(paidBackupType) {
|
||||
FiatMoneyUtil.format(resources, paidBackupType.pricePerMonth, FiatMoneyUtil.formatOptions().trimZerosAfterDecimal())
|
||||
}
|
||||
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = dimensionResource(id = CoreUiR.dimen.gutter))
|
||||
) {
|
||||
BottomSheets.Handle()
|
||||
|
||||
Spacer(modifier = Modifier.size(26.dp))
|
||||
|
||||
Image(
|
||||
imageVector = ImageVector.vectorResource(id = R.drawable.image_signal_backups),
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.size(80.dp)
|
||||
.padding(2.dp)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.BackupUpsellBottomSheet__title),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(top = 16.dp, bottom = 12.dp)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.BackupUpsellBottomSheet__body),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(bottom = 24.dp)
|
||||
)
|
||||
|
||||
FeatureCard(pricePerMonth = pricePerMonth)
|
||||
|
||||
Buttons.LargeTonal(
|
||||
enabled = isSubscribeEnabled,
|
||||
onClick = onSubscribeClick,
|
||||
modifier = Modifier
|
||||
.defaultMinSize(minWidth = 220.dp)
|
||||
.padding(bottom = 16.dp)
|
||||
) {
|
||||
Text(text = stringResource(R.string.BackupUpsellBottomSheet__subscribe_for, pricePerMonth))
|
||||
}
|
||||
|
||||
TextButton(
|
||||
enabled = isSubscribeEnabled,
|
||||
onClick = onNoThanksClick,
|
||||
modifier = Modifier.padding(bottom = 32.dp)
|
||||
) {
|
||||
Text(text = stringResource(R.string.BackupUpsellBottomSheet__no_thanks))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FeatureCard(pricePerMonth: String) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 24.dp)
|
||||
.background(
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
)
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.BackupUpsellBottomSheet__price_per_month, pricePerMonth),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.BackupUpsellBottomSheet__text_and_all_media),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(bottom = 12.dp)
|
||||
)
|
||||
|
||||
FeatureBullet(text = stringResource(R.string.BackupUpsellBottomSheet__full_text_media_backup))
|
||||
FeatureBullet(text = stringResource(R.string.BackupUpsellBottomSheet__storage_100gb))
|
||||
FeatureBullet(text = stringResource(R.string.BackupUpsellBottomSheet__save_on_device_storage))
|
||||
FeatureBullet(text = stringResource(R.string.BackupUpsellBottomSheet__thanks_for_supporting))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FeatureBullet(text: String) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.padding(vertical = 2.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = ImageVector.vectorResource(id = R.drawable.symbol_check_24),
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun BackupUpsellBottomSheetPreview() {
|
||||
Previews.BottomSheetContentPreview {
|
||||
UpsellSheetContent(
|
||||
paidBackupType = MessageBackupsType.Paid(
|
||||
pricePerMonth = FiatMoney(BigDecimal("1.99"), Currency.getInstance("USD")),
|
||||
mediaTtl = 30.days,
|
||||
storageAllowanceBytes = 100.gibiBytes.inWholeBytes
|
||||
),
|
||||
isSubscribeEnabled = true,
|
||||
onSubscribeClick = {},
|
||||
onNoThanksClick = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -33,8 +33,6 @@ import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.Scaffolds
|
||||
import org.signal.core.ui.compose.SignalIcons
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.compose.BetaHeader
|
||||
import org.thoughtcrime.securesms.components.compose.TextWithBetaLabel
|
||||
import org.signal.core.ui.R as CoreUiR
|
||||
|
||||
/**
|
||||
@@ -65,10 +63,6 @@ fun MessageBackupsEducationScreen(
|
||||
.fillMaxWidth()
|
||||
.weight(1f)
|
||||
) {
|
||||
item {
|
||||
BetaHeader()
|
||||
}
|
||||
|
||||
item {
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.image_signal_backups),
|
||||
@@ -80,9 +74,9 @@ fun MessageBackupsEducationScreen(
|
||||
}
|
||||
|
||||
item {
|
||||
TextWithBetaLabel(
|
||||
Text(
|
||||
text = stringResource(id = R.string.RemoteBackupsSettingsFragment__signal_backups),
|
||||
textStyle = MaterialTheme.typography.headlineMedium,
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
modifier = Modifier.padding(top = 15.dp)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ package org.thoughtcrime.securesms.calls.links
|
||||
import io.reactivex.rxjava3.core.Observable
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.ringrtc.CallException
|
||||
import org.signal.ringrtc.CallLinkEpoch
|
||||
import org.signal.ringrtc.CallLinkRootKey
|
||||
import org.thoughtcrime.securesms.database.CallLinkTable
|
||||
import org.thoughtcrime.securesms.database.DatabaseObserver
|
||||
@@ -23,7 +22,6 @@ import java.net.URLDecoder
|
||||
*/
|
||||
object CallLinks {
|
||||
private const val ROOT_KEY = "key"
|
||||
private const val EPOCH = "epoch"
|
||||
private const val LEGACY_HTTPS_LINK_PREFIX = "https://signal.link/call#key="
|
||||
private const val LEGACY_SGNL_LINK_PREFIX = "sgnl://signal.link/call#key="
|
||||
private const val HTTPS_LINK_PREFIX = "https://signal.link/call/#key="
|
||||
@@ -31,13 +29,7 @@ object CallLinks {
|
||||
|
||||
private val TAG = Log.tag(CallLinks::class.java)
|
||||
|
||||
fun url(rootKeyBytes: ByteArray, epochBytes: ByteArray?): String {
|
||||
return if (epochBytes == null) {
|
||||
"$HTTPS_LINK_PREFIX${CallLinkRootKey(rootKeyBytes)}"
|
||||
} else {
|
||||
"$HTTPS_LINK_PREFIX${CallLinkRootKey(rootKeyBytes)}&epoch=${CallLinkEpoch.fromBytes(epochBytes)}"
|
||||
}
|
||||
}
|
||||
fun url(rootKeyBytes: ByteArray): String = "$HTTPS_LINK_PREFIX${CallLinkRootKey(rootKeyBytes)}"
|
||||
|
||||
fun watchCallLink(roomId: CallLinkRoomId): Observable<CallLinkTable.CallLink> {
|
||||
return Observable.create { emitter ->
|
||||
@@ -78,13 +70,8 @@ object CallLinks {
|
||||
return url.split("#").last().startsWith("key=")
|
||||
}
|
||||
|
||||
data class CallLinkParseResult(
|
||||
val rootKey: CallLinkRootKey,
|
||||
val epoch: CallLinkEpoch?
|
||||
)
|
||||
|
||||
@JvmStatic
|
||||
fun parseUrl(url: String): CallLinkParseResult? {
|
||||
fun parseUrl(url: String): CallLinkRootKey? {
|
||||
if (!isPrefixedCallLink(url)) {
|
||||
Log.w(TAG, "Invalid url prefix.")
|
||||
return null
|
||||
@@ -132,13 +119,9 @@ object CallLinks {
|
||||
}
|
||||
|
||||
return try {
|
||||
val epoch = fragmentQuery[EPOCH]?.let { s -> CallLinkEpoch(s) }
|
||||
CallLinkParseResult(
|
||||
rootKey = CallLinkRootKey(key),
|
||||
epoch = epoch
|
||||
)
|
||||
return CallLinkRootKey(key)
|
||||
} catch (e: CallException) {
|
||||
Log.w(TAG, "Invalid root key or epoch found in fragment query string.")
|
||||
Log.w(TAG, "Invalid root key found in fragment query string.")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ import org.signal.core.ui.R as CoreUiR
|
||||
@Composable
|
||||
private fun SignalCallRowPreview() {
|
||||
val callLink = remember {
|
||||
val credentials = CallLinkCredentials(byteArrayOf(1, 2, 3, 4), byteArrayOf(0, 1, 2, 3), byteArrayOf(5, 6, 7, 8))
|
||||
val credentials = CallLinkCredentials(byteArrayOf(1, 2, 3, 4), byteArrayOf(5, 6, 7, 8))
|
||||
CallLinkTable.CallLink(
|
||||
recipientId = RecipientId.UNKNOWN,
|
||||
roomId = CallLinkRoomId.fromBytes(byteArrayOf(1, 3, 5, 7)),
|
||||
@@ -97,7 +97,7 @@ fun SignalCallRow(
|
||||
"https://signal.call.example.com"
|
||||
} else {
|
||||
remember(callLink.credentials) {
|
||||
callLink.credentials?.let { CallLinks.url(it.linkKeyBytes, it.epochBytes) } ?: ""
|
||||
callLink.credentials?.let { CallLinks.url(it.linkKeyBytes) } ?: ""
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -162,7 +162,7 @@ class CreateCallLinkBottomSheetDialogFragment : ComposeBottomSheetDialogFragment
|
||||
startActivity(
|
||||
ShareActivity.sendSimpleText(
|
||||
requireContext(),
|
||||
getString(R.string.CreateCallLink__use_this_link_to_join_a_signal_call, CallLinks.url(viewModel.linkKeyBytes, viewModel.epochBytes))
|
||||
getString(R.string.CreateCallLink__use_this_link_to_join_a_signal_call, CallLinks.url(viewModel.linkKeyBytes))
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -176,7 +176,7 @@ class CreateCallLinkBottomSheetDialogFragment : ComposeBottomSheetDialogFragment
|
||||
lifecycleDisposable += viewModel.commitCallLink().subscribeBy(onSuccess = {
|
||||
when (it) {
|
||||
is EnsureCallLinkCreatedResult.Success -> {
|
||||
Util.copyToClipboard(requireContext(), CallLinks.url(viewModel.linkKeyBytes, viewModel.epochBytes))
|
||||
Util.copyToClipboard(requireContext(), CallLinks.url(viewModel.linkKeyBytes))
|
||||
Toast.makeText(requireContext(), R.string.CreateCallLinkBottomSheetDialogFragment__copied_to_clipboard, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
|
||||
@@ -191,7 +191,7 @@ class CreateCallLinkBottomSheetDialogFragment : ComposeBottomSheetDialogFragment
|
||||
is EnsureCallLinkCreatedResult.Success -> {
|
||||
val mimeType = Intent.normalizeMimeType("text/plain")
|
||||
val shareIntent = ShareCompat.IntentBuilder(requireContext())
|
||||
.setText(CallLinks.url(viewModel.linkKeyBytes, viewModel.epochBytes))
|
||||
.setText(CallLinks.url(viewModel.linkKeyBytes))
|
||||
.setType(mimeType)
|
||||
.createChooserIntent()
|
||||
|
||||
|
||||
@@ -52,9 +52,6 @@ class CreateCallLinkViewModel(
|
||||
val linkKeyBytes: ByteArray
|
||||
get() = callLink.value.credentials!!.linkKeyBytes
|
||||
|
||||
val epochBytes: ByteArray?
|
||||
get() = callLink.value.credentials!!.epochBytes
|
||||
|
||||
private val internalShowAlreadyInACall = MutableStateFlow(false)
|
||||
val showAlreadyInACall: StateFlow<Boolean> = internalShowAlreadyInACall
|
||||
|
||||
|
||||
@@ -119,7 +119,7 @@ class DefaultCallLinkDetailsCallback(
|
||||
override fun onShareClicked() {
|
||||
val mimeType = Intent.normalizeMimeType("text/plain")
|
||||
val shareIntent = ShareCompat.IntentBuilder(activity)
|
||||
.setText(CallLinks.url(viewModel.rootKeySnapshot, viewModel.epochSnapshot))
|
||||
.setText(CallLinks.url(viewModel.rootKeySnapshot))
|
||||
.setType(mimeType)
|
||||
.createChooserIntent()
|
||||
|
||||
@@ -131,7 +131,7 @@ class DefaultCallLinkDetailsCallback(
|
||||
}
|
||||
|
||||
override fun onCopyClicked() {
|
||||
Util.copyToClipboard(activity, CallLinks.url(viewModel.rootKeySnapshot, viewModel.epochSnapshot))
|
||||
Util.copyToClipboard(activity, CallLinks.url(viewModel.rootKeySnapshot))
|
||||
Toast.makeText(activity, R.string.CreateCallLinkBottomSheetDialogFragment__copied_to_clipboard, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
|
||||
@@ -139,7 +139,7 @@ class DefaultCallLinkDetailsCallback(
|
||||
activity.startActivity(
|
||||
ShareActivity.sendSimpleText(
|
||||
activity,
|
||||
activity.getString(R.string.CreateCallLink__use_this_link_to_join_a_signal_call, CallLinks.url(viewModel.rootKeySnapshot, viewModel.epochSnapshot))
|
||||
activity.getString(R.string.CreateCallLink__use_this_link_to_join_a_signal_call, CallLinks.url(viewModel.rootKeySnapshot))
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -324,7 +324,6 @@ private fun CallLinkDetailsScreenPreview() {
|
||||
val callLink = remember {
|
||||
val credentials = CallLinkCredentials(
|
||||
byteArrayOf(1, 2, 3, 4),
|
||||
byteArrayOf(0, 1, 2, 3),
|
||||
byteArrayOf(3, 4, 5, 6)
|
||||
)
|
||||
CallLinkTable.CallLink(
|
||||
|
||||
@@ -48,9 +48,6 @@ class CallLinkDetailsViewModel(
|
||||
val rootKeySnapshot: ByteArray
|
||||
get() = state.value.callLink?.credentials?.linkKeyBytes ?: error("Call link not loaded yet.")
|
||||
|
||||
val epochSnapshot: ByteArray?
|
||||
get() = state.value.callLink?.credentials?.epochBytes
|
||||
|
||||
private val recipientSubject = BehaviorSubject.create<Recipient>()
|
||||
val recipientSnapshot: Recipient?
|
||||
get() = recipientSubject.value
|
||||
|
||||
@@ -219,9 +219,16 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
|
||||
|
||||
private fun handleDeleteSelectedRows() {
|
||||
val count = callLogActionMode.getCount()
|
||||
val selectionState = viewModel.selectionStateSnapshot
|
||||
val hasCallLinks = selectionState.isExclusionary() || selectionState.selected().any { it is CallLogRow.Id.CallLink }
|
||||
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(resources.getQuantityString(R.plurals.CallLogFragment__delete_d_calls, count, count))
|
||||
.setMessage(getString(R.string.CallLogFragment__call_links_youve_created))
|
||||
.apply {
|
||||
if (hasCallLinks) {
|
||||
setMessage(getString(R.string.CallLogFragment__call_links_youve_created))
|
||||
}
|
||||
}
|
||||
.setPositiveButton(R.string.CallLogFragment__delete) { _, _ ->
|
||||
performDeletion(count, viewModel.stageSelectionDeletion())
|
||||
callLogActionMode.end()
|
||||
@@ -380,7 +387,11 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
|
||||
override fun deleteCall(call: CallLogRow) {
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(resources.getQuantityString(R.plurals.CallLogFragment__delete_d_calls, 1, 1))
|
||||
.setMessage(getString(R.string.CallLogFragment__call_links_youve_created))
|
||||
.apply {
|
||||
if (call is CallLogRow.CallLink) {
|
||||
setMessage(getString(R.string.CallLogFragment__call_links_youve_created))
|
||||
}
|
||||
}
|
||||
.setPositiveButton(R.string.CallLogFragment__delete) { _, _ ->
|
||||
performDeletion(1, viewModel.stageCallDeletion(call))
|
||||
}
|
||||
|
||||
@@ -84,7 +84,7 @@ private fun NewCallScreen(
|
||||
val context = LocalActivity.current as FragmentActivity
|
||||
|
||||
val callbacks = remember {
|
||||
object : UiCallbacks {
|
||||
object : NewCallUiCallbacks {
|
||||
override fun onSearchQueryChanged(query: String) = viewModel.onSearchQueryChanged(query)
|
||||
override fun onRecipientSelected(selection: RecipientSelection) = viewModel.startCall(selection)
|
||||
override fun onInviteToSignal() = context.startActivity(AppSettingsActivity.invite(context))
|
||||
@@ -111,7 +111,7 @@ private fun NewCallScreen(
|
||||
)
|
||||
}
|
||||
|
||||
private interface UiCallbacks :
|
||||
private interface NewCallUiCallbacks :
|
||||
RecipientPickerCallbacks.ListActions,
|
||||
RecipientPickerCallbacks.Refresh,
|
||||
RecipientPickerCallbacks.NewCall {
|
||||
@@ -120,7 +120,7 @@ private interface UiCallbacks :
|
||||
fun onUserMessageDismissed(userMessage: UserMessage)
|
||||
fun onBackPressed()
|
||||
|
||||
object Empty : UiCallbacks {
|
||||
object Empty : NewCallUiCallbacks {
|
||||
override fun onSearchQueryChanged(query: String) = Unit
|
||||
override fun onRecipientSelected(selection: RecipientSelection) = Unit
|
||||
override fun onInviteToSignal() = Unit
|
||||
@@ -134,7 +134,7 @@ private interface UiCallbacks :
|
||||
@Composable
|
||||
private fun NewCallScreenUi(
|
||||
uiState: NewCallUiState,
|
||||
callbacks: UiCallbacks
|
||||
callbacks: NewCallUiCallbacks
|
||||
) {
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
|
||||
@@ -173,7 +173,7 @@ private fun NewCallScreenUi(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TopAppBarActions(callbacks: UiCallbacks) {
|
||||
private fun TopAppBarActions(callbacks: NewCallUiCallbacks) {
|
||||
val menuController = remember { DropdownMenus.MenuController() }
|
||||
IconButton(
|
||||
onClick = { menuController.show() },
|
||||
@@ -250,7 +250,7 @@ private fun NewCallScreenPreview() {
|
||||
uiState = NewCallUiState(
|
||||
forceSplitPane = false
|
||||
),
|
||||
callbacks = UiCallbacks.Empty
|
||||
callbacks = NewCallUiCallbacks.Empty
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import android.text.TextUtils;
|
||||
import android.text.TextUtils.TruncateAt;
|
||||
import android.util.AttributeSet;
|
||||
import android.view.ActionMode;
|
||||
import android.view.View;
|
||||
import android.view.Menu;
|
||||
import android.view.MenuItem;
|
||||
import android.view.inputmethod.EditorInfo;
|
||||
@@ -66,6 +67,7 @@ public class ComposeText extends EmojiEditText {
|
||||
private MentionRendererDelegate mentionRendererDelegate;
|
||||
private SpoilerRendererDelegate spoilerRendererDelegate;
|
||||
private MentionValidatorWatcher mentionValidatorWatcher;
|
||||
private MessageSendType lastMessageSendType;
|
||||
|
||||
@Nullable private InputPanel.MediaListener mediaListener;
|
||||
@Nullable private CursorPositionChangedListener cursorPositionChangedListener;
|
||||
@@ -221,6 +223,11 @@ public class ComposeText extends EmojiEditText {
|
||||
}
|
||||
|
||||
public void setMessageSendType(MessageSendType messageSendType) {
|
||||
if (messageSendType.equals(lastMessageSendType)) {
|
||||
return;
|
||||
}
|
||||
lastMessageSendType = messageSendType;
|
||||
|
||||
int imeOptions = (getImeOptions() & ~EditorInfo.IME_MASK_ACTION) | EditorInfo.IME_ACTION_SEND;
|
||||
int inputType = getInputType();
|
||||
|
||||
@@ -278,6 +285,10 @@ public class ComposeText extends EmojiEditText {
|
||||
}
|
||||
|
||||
private void initialize() {
|
||||
if (Build.VERSION.SDK_INT >= 26) {
|
||||
setImportantForAutofill(View.IMPORTANT_FOR_AUTOFILL_NO);
|
||||
}
|
||||
|
||||
if (TextSecurePreferences.isIncognitoKeyboardEnabled(getContext())) {
|
||||
setImeOptions(getImeOptions() | 16777216);
|
||||
}
|
||||
|
||||
@@ -291,7 +291,11 @@ public class ConversationItemFooter extends ConstraintLayout {
|
||||
dateView.setText(null);
|
||||
} else if (messageRecord.isFailed()) {
|
||||
int errorMsg;
|
||||
if (messageRecord.hasFailedWithNetworkFailures()) {
|
||||
if (messageRecord.isFailedAdminDelete() && messageRecord.isIdentityMismatchFailure()) {
|
||||
errorMsg = R.string.ConversationItem_error_partially_not_deleted;
|
||||
} else if (messageRecord.isFailedAdminDelete()) {
|
||||
errorMsg = R.string.ConversationItem_error_delete_failed;
|
||||
} else if (messageRecord.hasFailedWithNetworkFailures()) {
|
||||
errorMsg = R.string.ConversationItem_error_network_not_delivered;
|
||||
} else if (messageRecord.getToRecipient().isPushGroup() && messageRecord.isIdentityMismatchFailure()) {
|
||||
errorMsg = R.string.ConversationItem_error_partially_not_delivered;
|
||||
@@ -397,7 +401,7 @@ public class ConversationItemFooter extends ConstraintLayout {
|
||||
}
|
||||
|
||||
if (onlyShowSendingStatus) {
|
||||
if (messageRecord.isOutgoing() && messageRecord.isPending()) {
|
||||
if (messageRecord.isPending()) {
|
||||
deliveryStatusView.setPending();
|
||||
} else {
|
||||
deliveryStatusView.setNone();
|
||||
|
||||
@@ -47,7 +47,10 @@ public abstract class FullScreenDialogFragment extends DialogFragment {
|
||||
@Override
|
||||
public void onResume() {
|
||||
super.onResume();
|
||||
WindowUtil.initializeScreenshotSecurity(requireContext(), requireDialog().getWindow());
|
||||
|
||||
if (getShowsDialog()) {
|
||||
WindowUtil.initializeScreenshotSecurity(requireContext(), requireDialog().getWindow());
|
||||
}
|
||||
}
|
||||
|
||||
protected void onNavigateUp() {
|
||||
|
||||
@@ -64,6 +64,7 @@ open class InsetAwareConstraintLayout @JvmOverloads constructor(
|
||||
|
||||
private var insets: WindowInsetsCompat? = null
|
||||
private var windowTypes: Int = InsetAwareConstraintLayout.windowTypes
|
||||
private var navigationBarInsetOverride: Int? = null
|
||||
|
||||
private val windowInsetsListener = androidx.core.view.OnApplyWindowInsetsListener { _, insets ->
|
||||
this.insets = insets
|
||||
@@ -114,6 +115,23 @@ open class InsetAwareConstraintLayout @JvmOverloads constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun setNavigationBarInsetOverride(inset: Int?) {
|
||||
if (navigationBarInsetOverride == inset) return
|
||||
navigationBarInsetOverride = inset
|
||||
if (inset != null) {
|
||||
// Apply immediately so layout is correct before next inset dispatch (important for
|
||||
// Android 15 bubble where insets can arrive late or with different values).
|
||||
navigationBarGuideline?.setGuidelineEnd(inset)
|
||||
if (!isKeyboardShowing) {
|
||||
keyboardGuideline?.setGuidelineEnd(inset)
|
||||
}
|
||||
requestLayout()
|
||||
}
|
||||
if (insets != null) {
|
||||
applyInsets(insets!!.getInsets(windowTypes), insets!!.getInsets(keyboardType))
|
||||
}
|
||||
}
|
||||
|
||||
fun addKeyboardStateListener(listener: KeyboardStateListener) {
|
||||
keyboardStateListeners += listener
|
||||
}
|
||||
@@ -134,7 +152,7 @@ open class InsetAwareConstraintLayout @JvmOverloads constructor(
|
||||
val isLtr = ViewUtil.isLtr(this)
|
||||
|
||||
val statusBar = windowInsets.top
|
||||
val navigationBar = if (windowInsets.bottom == 0 && Build.VERSION.SDK_INT <= 29) {
|
||||
val navigationBar = navigationBarInsetOverride ?: if (windowInsets.bottom == 0 && Build.VERSION.SDK_INT <= 29) {
|
||||
ViewUtil.getNavigationBarHeight(resources)
|
||||
} else {
|
||||
windowInsets.bottom
|
||||
|
||||
@@ -172,11 +172,11 @@ public class LinkPreviewView extends FrameLayout {
|
||||
spinner.setVisibility(GONE);
|
||||
noPreview.setVisibility(GONE);
|
||||
|
||||
CallLinks.CallLinkParseResult callLinkParseResult = CallLinks.isCallLink(linkPreview.getUrl()) ? CallLinks.parseUrl(linkPreview.getUrl()) : null;
|
||||
CallLinkRootKey callLinkRootKey = CallLinks.isCallLink(linkPreview.getUrl()) ? CallLinks.parseUrl(linkPreview.getUrl()) : null;
|
||||
if (!Util.isEmpty(linkPreview.getTitle())) {
|
||||
title.setText(linkPreview.getTitle());
|
||||
title.setVisibility(VISIBLE);
|
||||
} else if (callLinkParseResult != null) {
|
||||
} else if (callLinkRootKey != null) {
|
||||
title.setText(R.string.Recipient_signal_call);
|
||||
title.setVisibility(VISIBLE);
|
||||
} else {
|
||||
@@ -186,7 +186,7 @@ public class LinkPreviewView extends FrameLayout {
|
||||
if (showDescription && !Util.isEmpty(linkPreview.getDescription())) {
|
||||
description.setText(linkPreview.getDescription());
|
||||
description.setVisibility(VISIBLE);
|
||||
} else if (callLinkParseResult != null) {
|
||||
} else if (callLinkRootKey != null) {
|
||||
description.setText(R.string.LinkPreviewView__use_this_link_to_join_a_signal_call);
|
||||
description.setVisibility(VISIBLE);
|
||||
} else {
|
||||
@@ -221,14 +221,14 @@ public class LinkPreviewView extends FrameLayout {
|
||||
thumbnail.get().setImageResource(requestManager, new ImageSlide(linkPreview.getThumbnail().get()), type == TYPE_CONVERSATION && !scheduleMessageMode, false);
|
||||
thumbnail.get().showSecondaryText(false);
|
||||
thumbnail.get().setOutlineEnabled(true);
|
||||
} else if (callLinkParseResult != null) {
|
||||
} else if (callLinkRootKey != null) {
|
||||
thumbnail.setVisibility(VISIBLE);
|
||||
thumbnailState.applyState(thumbnail);
|
||||
thumbnail.get().setImageDrawable(
|
||||
requestManager,
|
||||
new FallbackAvatarDrawable(
|
||||
getContext(),
|
||||
new FallbackAvatar.Resource.CallLink(AvatarColorHash.forCallLink(callLinkParseResult.getRootKey().getKeyBytes()))
|
||||
new FallbackAvatar.Resource.CallLink(AvatarColorHash.forCallLink(callLinkRootKey.getKeyBytes()))
|
||||
).circleCrop()
|
||||
);
|
||||
thumbnail.get().showSecondaryText(false);
|
||||
@@ -272,7 +272,7 @@ public class LinkPreviewView extends FrameLayout {
|
||||
thumbnailState.applyState(thumbnail);
|
||||
}
|
||||
|
||||
private @StringRes static int getLinkPreviewErrorString(@Nullable LinkPreviewRepository.Error customError) {
|
||||
private @StringRes static int getLinkPreviewErrorString(@Nullable LinkPreviewRepository.Error customError) {
|
||||
return customError == LinkPreviewRepository.Error.GROUP_LINK_INACTIVE ? R.string.LinkPreviewView_this_group_link_is_not_active
|
||||
: R.string.LinkPreviewView_no_link_preview_available;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import android.view.View
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.fragment.app.DialogFragment
|
||||
import androidx.fragment.app.Fragment
|
||||
import org.signal.core.ui.initializeScreenshotSecurity
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.util.fragments.findListener
|
||||
|
||||
@@ -57,6 +58,11 @@ abstract class WrapperDialogFragment : DialogFragment(R.layout.fragment_containe
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
dialog?.window?.initializeScreenshotSecurity()
|
||||
}
|
||||
|
||||
open fun onHandleBackPressed() {
|
||||
dismissAllowingStateLoss()
|
||||
}
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
package org.thoughtcrime.securesms.components.compose
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.Rows
|
||||
import org.signal.core.ui.compose.SignalIcons
|
||||
import org.signal.core.ui.compose.horizontalGutters
|
||||
import org.signal.core.ui.compose.theme.SignalTheme
|
||||
import org.thoughtcrime.securesms.R
|
||||
|
||||
/**
|
||||
* Adds a 'Beta' label next to [text] to indicate a feature is in development
|
||||
*/
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun TextWithBetaLabel(
|
||||
text: String,
|
||||
textStyle: TextStyle = TextStyle.Default,
|
||||
enabled: Boolean = true,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
FlowRow(
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
modifier = modifier
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
style = textStyle,
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterVertically)
|
||||
.alpha(if (enabled) 1f else Rows.DISABLED_ALPHA)
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.Beta__beta_title).uppercase(),
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
modifier = Modifier
|
||||
.padding(start = 6.dp)
|
||||
.padding(vertical = 6.dp)
|
||||
.background(color = MaterialTheme.colorScheme.surfaceVariant, shape = RoundedCornerShape(28.dp))
|
||||
.padding(horizontal = 12.dp, vertical = 4.dp)
|
||||
.alpha(if (enabled) 1f else Rows.DISABLED_ALPHA)
|
||||
.align(Alignment.CenterVertically)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 'Beta' header to indicate a feature is currently in development
|
||||
*/
|
||||
@Composable
|
||||
fun BetaHeader(modifier: Modifier = Modifier) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = modifier
|
||||
.background(
|
||||
color = SignalTheme.colors.colorSurface2,
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
)
|
||||
.padding(16.dp)
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
Icon(
|
||||
imageVector = SignalIcons.Info.imageVector,
|
||||
contentDescription = stringResource(id = R.string.Beta__info),
|
||||
tint = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
)
|
||||
Text(
|
||||
text = stringResource(id = R.string.Beta__this_is_beta),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(start = 12.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
fun BetaLabelPreview() {
|
||||
Previews.Preview {
|
||||
TextWithBetaLabel("Signal Backups")
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
fun BetaLabelDisabledPreview() {
|
||||
Previews.Preview {
|
||||
TextWithBetaLabel("Signal Backups", enabled = false)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(locale = "de")
|
||||
@Composable
|
||||
fun LongTextBetaLabelPreview() {
|
||||
Previews.Preview {
|
||||
Scaffold {
|
||||
TextWithBetaLabel(
|
||||
text = stringResource(id = R.string.RemoteBackupsSettingsFragment__signal_backups),
|
||||
textStyle = MaterialTheme.typography.headlineMedium,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.horizontalGutters()
|
||||
.padding(it)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
fun BetaHeaderPreview() {
|
||||
Previews.Preview {
|
||||
BetaHeader()
|
||||
}
|
||||
}
|
||||
@@ -135,7 +135,17 @@ public class EmojiTextView extends AppCompatTextView {
|
||||
spoilerRendererDelegate = new SpoilerRendererDelegate(this);
|
||||
}
|
||||
|
||||
textDirection = getLayoutDirection() == LAYOUT_DIRECTION_LTR ? TextDirectionHeuristics.FIRSTSTRONG_RTL : TextDirectionHeuristics.ANYRTL_LTR;
|
||||
if (getLayoutDirection() == LAYOUT_DIRECTION_LTR) {
|
||||
textDirection = TextDirectionHeuristics.FIRSTSTRONG_RTL;
|
||||
if (getTextDirection() == TEXT_DIRECTION_INHERIT) {
|
||||
setTextDirection(TEXT_DIRECTION_FIRST_STRONG_RTL);
|
||||
}
|
||||
} else {
|
||||
textDirection = TextDirectionHeuristics.ANYRTL_LTR;
|
||||
if (getTextDirection() == TEXT_DIRECTION_INHERIT) {
|
||||
setTextDirection(TEXT_DIRECTION_ANY_RTL);
|
||||
}
|
||||
}
|
||||
|
||||
setEmojiCompatEnabled(useSystemEmoji());
|
||||
}
|
||||
@@ -264,6 +274,8 @@ public class EmojiTextView extends AppCompatTextView {
|
||||
previousOverflowText = overflowText;
|
||||
useSystemEmoji = useSystemEmoji();
|
||||
previousTransformationMethod = getTransformationMethod();
|
||||
lastSizeChangedWidth = -1;
|
||||
lastSizeChangedHeight = -1;
|
||||
|
||||
// Android fails to ellipsize spannable strings. (https://issuetracker.google.com/issues/36991688)
|
||||
// We ellipsize them ourselves by manually truncating the appropriate section.
|
||||
@@ -590,7 +602,7 @@ public class EmojiTextView extends AppCompatTextView {
|
||||
lastSizeChangedWidth = w;
|
||||
lastSizeChangedHeight = h;
|
||||
|
||||
if (!sizeChangeInProgress) {
|
||||
if (!sizeChangeInProgress && getMaxLines() > 0 && getMaxLines() < Integer.MAX_VALUE) {
|
||||
sizeChangeInProgress = true;
|
||||
resetText();
|
||||
}
|
||||
|
||||
@@ -5,11 +5,14 @@
|
||||
|
||||
package org.thoughtcrime.securesms.components.emoji
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.text.InlineTextContent
|
||||
import androidx.compose.foundation.text.appendInlineContent
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -21,10 +24,12 @@ import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.Placeholder
|
||||
import androidx.compose.ui.text.PlaceholderVerticalAlign
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import com.google.accompanist.drawablepainter.rememberDrawablePainter
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.thoughtcrime.securesms.components.emoji.parsing.EmojiParser
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
|
||||
/**
|
||||
* Applies Signal or System emoji to the given content based off user settings.
|
||||
@@ -34,6 +39,7 @@ import org.signal.core.ui.compose.Previews
|
||||
@Composable
|
||||
fun Emojifier(
|
||||
text: String,
|
||||
useSystemEmoji: Boolean = !LocalInspectionMode.current && SignalStore.settings.isPreferSystemEmoji,
|
||||
content: @Composable (AnnotatedString, Map<String, InlineTextContent>) -> Unit = { annotatedText, inlineContent ->
|
||||
Text(
|
||||
text = annotatedText,
|
||||
@@ -41,38 +47,56 @@ fun Emojifier(
|
||||
)
|
||||
}
|
||||
) {
|
||||
if (LocalInspectionMode.current) {
|
||||
if (useSystemEmoji) {
|
||||
content(buildAnnotatedString { append(text) }, emptyMap())
|
||||
return
|
||||
}
|
||||
|
||||
val context = LocalContext.current
|
||||
val candidates = remember(text) { EmojiProvider.getCandidates(text) }
|
||||
val candidateMap: Map<String, InlineTextContent> = remember(text) {
|
||||
candidates?.associate { candidate ->
|
||||
candidate.drawInfo.emoji to InlineTextContent(placeholder = Placeholder(20.sp, 20.sp, PlaceholderVerticalAlign.TextCenter)) {
|
||||
Image(
|
||||
painter = rememberDrawablePainter(EmojiProvider.getEmojiDrawable(context, candidate.drawInfo.emoji)),
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
} ?: emptyMap()
|
||||
val fontSize = LocalTextStyle.current.fontSize
|
||||
|
||||
val foundEmojis: List<EmojiParser.Candidate> = remember(text) {
|
||||
EmojiProvider.getCandidates(text)?.list.orEmpty()
|
||||
}
|
||||
val inlineContentByEmoji: Map<String, InlineTextContent> = remember(text, fontSize) {
|
||||
foundEmojis.associate { it.drawInfo.emoji to createInlineContent(context, it.drawInfo.emoji, fontSize) }
|
||||
}
|
||||
|
||||
val annotatedString = buildAnnotatedString {
|
||||
append(text)
|
||||
val annotatedString = remember(text) { buildAnnotatedString(text, foundEmojis) }
|
||||
content(annotatedString, inlineContentByEmoji)
|
||||
}
|
||||
|
||||
candidates?.forEach {
|
||||
addStringAnnotation(
|
||||
tag = "EMOJI",
|
||||
annotation = it.drawInfo.emoji,
|
||||
start = it.startIndex,
|
||||
end = it.endIndex
|
||||
)
|
||||
private fun createInlineContent(context: Context, emoji: String, fontSize: TextUnit): InlineTextContent {
|
||||
return InlineTextContent(
|
||||
placeholder = Placeholder(width = fontSize, height = fontSize, PlaceholderVerticalAlign.TextCenter)
|
||||
) {
|
||||
Image(
|
||||
painter = rememberDrawablePainter(EmojiProvider.getEmojiDrawable(context, emoji)),
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructs an [AnnotatedString] from [text], substituting each emoji in [foundEmojis] with an inline content placeholder.
|
||||
*/
|
||||
private fun buildAnnotatedString(
|
||||
text: String,
|
||||
foundEmojis: List<EmojiParser.Candidate>
|
||||
): AnnotatedString = buildAnnotatedString {
|
||||
var nextSegmentStartIndex = 0
|
||||
|
||||
foundEmojis.forEach { emoji ->
|
||||
if (emoji.startIndex > nextSegmentStartIndex) {
|
||||
append(text, start = nextSegmentStartIndex, end = emoji.startIndex)
|
||||
}
|
||||
appendInlineContent(emoji.drawInfo.emoji)
|
||||
nextSegmentStartIndex = emoji.endIndex
|
||||
}
|
||||
|
||||
content(annotatedString, candidateMap)
|
||||
if (nextSegmentStartIndex < text.length) {
|
||||
append(text, start = nextSegmentStartIndex, end = text.length)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -41,6 +41,9 @@ public class EmojiParser {
|
||||
this.emojiTree = emojiTree;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an ordered list of every emoji occurrence found in the given text.
|
||||
*/
|
||||
public @NonNull CandidateList findCandidates(@Nullable CharSequence text) {
|
||||
List<Candidate> results = new LinkedList<>();
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.service.KeyCachingService
|
||||
import org.thoughtcrime.securesms.util.CachedInflater
|
||||
import org.thoughtcrime.securesms.util.DynamicTheme
|
||||
import org.thoughtcrime.securesms.util.RemoteConfig
|
||||
import org.thoughtcrime.securesms.util.Environment
|
||||
import org.thoughtcrime.securesms.util.SignalE164Util
|
||||
import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
||||
|
||||
@@ -52,7 +52,7 @@ class AppSettingsActivity : DSLSettingsActivity(), GooglePayComponent {
|
||||
when (val appSettingsRoute: AppSettingsRoute? = intent?.getParcelableExtraCompat(START_ROUTE, AppSettingsRoute::class.java)) {
|
||||
AppSettingsRoute.Empty -> null
|
||||
is AppSettingsRoute.BackupsRoute.Local -> {
|
||||
if (SignalStore.backup.newLocalBackupsEnabled || RemoteConfig.unifiedLocalBackups && (!SignalStore.settings.isBackupEnabled || appSettingsRoute.triggerUpdateFlow)) {
|
||||
if (SignalStore.backup.newLocalBackupsEnabled || (Environment.Backups.isNewFormatSupportedForLocalBackup() && (!SignalStore.settings.isBackupEnabled || appSettingsRoute.triggerUpdateFlow))) {
|
||||
AppSettingsFragmentDirections.actionDirectToLocalBackupsFragment()
|
||||
.setTriggerUpdateFlow(appSettingsRoute.triggerUpdateFlow)
|
||||
} else {
|
||||
|
||||
@@ -68,7 +68,6 @@ import org.thoughtcrime.securesms.banner.banners.UnauthorizedBanner
|
||||
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.components.compose.TextWithBetaLabel
|
||||
import org.thoughtcrime.securesms.components.emoji.Emojifier
|
||||
import org.thoughtcrime.securesms.components.settings.app.routes.AppSettingsRoute
|
||||
import org.thoughtcrime.securesms.components.settings.app.routes.AppSettingsRouter
|
||||
@@ -415,10 +414,9 @@ private fun AppSettingsContent(
|
||||
item {
|
||||
Rows.TextRow(
|
||||
text = {
|
||||
TextWithBetaLabel(
|
||||
Text(
|
||||
text = stringResource(R.string.preferences_chats__backups),
|
||||
textStyle = MaterialTheme.typography.bodyLarge,
|
||||
enabled = isRegisteredAndUpToDate
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
},
|
||||
icon = {
|
||||
|
||||
@@ -166,8 +166,7 @@ class BackupStateObserver(
|
||||
}
|
||||
|
||||
val price = latestPayment.data.amount!!.toFiatMoney()
|
||||
val isKeepAlive = latestPayment.data.redemption?.keepAlive == true
|
||||
val isPending = latestPayment.state == InAppPaymentTable.State.PENDING && !isKeepAlive
|
||||
val isPending = SignalDatabase.inAppPayments.hasPendingBackupRedemption()
|
||||
if (isPending) {
|
||||
Log.d(TAG, "[getDatabaseBackupState] We have a pending subscription.")
|
||||
return BackupState.Pending(price = price)
|
||||
@@ -243,8 +242,7 @@ class BackupStateObserver(
|
||||
* Utilizes everything we can to resolve the most accurate backup state available, including database and network.
|
||||
*/
|
||||
private suspend fun getNetworkBackupState(lastPurchase: InAppPaymentTable.InAppPayment?): BackupState {
|
||||
val isKeepAlive = lastPurchase?.data?.redemption?.keepAlive == true
|
||||
if (lastPurchase?.state == InAppPaymentTable.State.PENDING && !isKeepAlive) {
|
||||
if (lastPurchase?.state == InAppPaymentTable.State.PENDING) {
|
||||
Log.d(TAG, "[getNetworkBackupState] We have a pending subscription.")
|
||||
return BackupState.Pending(
|
||||
price = lastPurchase.data.amount!!.toFiatMoney()
|
||||
|
||||
@@ -53,12 +53,11 @@ import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.backup.DeletionState
|
||||
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
|
||||
import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsType
|
||||
import org.thoughtcrime.securesms.components.compose.TextWithBetaLabel
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.MessageBackupsCheckoutLauncher.createBackupsCheckoutLauncher
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
|
||||
import org.thoughtcrime.securesms.util.DateUtils
|
||||
import org.thoughtcrime.securesms.util.RemoteConfig
|
||||
import org.thoughtcrime.securesms.util.Environment
|
||||
import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
||||
import java.math.BigDecimal
|
||||
import java.util.Currency
|
||||
@@ -105,13 +104,12 @@ class BackupsSettingsFragment : ComposeFragment() {
|
||||
}
|
||||
},
|
||||
onOnDeviceBackupsRowClick = {
|
||||
if (SignalStore.backup.newLocalBackupsEnabled || RemoteConfig.unifiedLocalBackups && !SignalStore.settings.isBackupEnabled) {
|
||||
if (SignalStore.backup.newLocalBackupsEnabled || (Environment.Backups.isNewFormatSupportedForLocalBackup() && !SignalStore.settings.isBackupEnabled)) {
|
||||
findNavController().safeNavigate(R.id.action_backupsSettingsFragment_to_localBackupsFragment)
|
||||
} else {
|
||||
findNavController().safeNavigate(R.id.action_backupsSettingsFragment_to_backupsPreferenceFragment)
|
||||
}
|
||||
},
|
||||
onNewOnDeviceBackupsRowClick = { findNavController().safeNavigate(R.id.action_backupsSettingsFragment_to_internalLocalBackupFragment) },
|
||||
onBackupTierInternalOverrideChanged = { viewModel.onBackupTierInternalOverrideChanged(it) }
|
||||
)
|
||||
}
|
||||
@@ -123,7 +121,6 @@ private fun BackupsSettingsContent(
|
||||
onNavigationClick: () -> Unit = {},
|
||||
onBackupsRowClick: () -> Unit = {},
|
||||
onOnDeviceBackupsRowClick: () -> Unit = {},
|
||||
onNewOnDeviceBackupsRowClick: () -> Unit = {},
|
||||
onBackupTierInternalOverrideChanged: (MessageBackupTier?) -> Unit = {}
|
||||
) {
|
||||
Scaffolds.Settings(
|
||||
@@ -242,16 +239,6 @@ private fun BackupsSettingsContent(
|
||||
onClick = onOnDeviceBackupsRowClick
|
||||
)
|
||||
}
|
||||
|
||||
if (backupsSettingsState.showNewLocalBackup) {
|
||||
item {
|
||||
Rows.TextRow(
|
||||
text = "INTERNAL ONLY - New Local Backup",
|
||||
label = "Use new local backup format",
|
||||
onClick = onNewOnDeviceBackupsRowClick
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -285,9 +272,9 @@ private fun NeverEnabledBackupsRow(
|
||||
},
|
||||
text = {
|
||||
Column {
|
||||
TextWithBetaLabel(
|
||||
Text(
|
||||
text = stringResource(R.string.RemoteBackupsSettingsFragment__signal_backups),
|
||||
textStyle = MaterialTheme.typography.bodyLarge
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
|
||||
Text(
|
||||
@@ -331,9 +318,9 @@ private fun InactiveBackupsRow(
|
||||
Rows.TextRow(
|
||||
text = {
|
||||
Column {
|
||||
TextWithBetaLabel(
|
||||
Text(
|
||||
text = stringResource(R.string.RemoteBackupsSettingsFragment__signal_backups),
|
||||
textStyle = MaterialTheme.typography.bodyLarge
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
|
||||
Text(
|
||||
@@ -377,9 +364,9 @@ private fun NotFoundBackupRow(
|
||||
},
|
||||
text = {
|
||||
Column {
|
||||
TextWithBetaLabel(
|
||||
Text(
|
||||
text = stringResource(R.string.RemoteBackupsSettingsFragment__signal_backups),
|
||||
textStyle = MaterialTheme.typography.bodyLarge
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
|
||||
Text(
|
||||
@@ -412,9 +399,9 @@ private fun PendingBackupRow(
|
||||
},
|
||||
text = {
|
||||
Column {
|
||||
TextWithBetaLabel(
|
||||
Text(
|
||||
text = stringResource(R.string.RemoteBackupsSettingsFragment__signal_backups),
|
||||
textStyle = MaterialTheme.typography.bodyLarge
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
|
||||
Text(
|
||||
@@ -463,9 +450,9 @@ private fun LocalStoreBackupRow(
|
||||
},
|
||||
text = {
|
||||
Column {
|
||||
TextWithBetaLabel(
|
||||
Text(
|
||||
text = stringResource(R.string.RemoteBackupsSettingsFragment__signal_backups),
|
||||
textStyle = MaterialTheme.typography.bodyLarge
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
|
||||
val tierText = when (backupState.tier) {
|
||||
@@ -508,9 +495,9 @@ private fun ActiveBackupsRow(
|
||||
},
|
||||
text = {
|
||||
Column {
|
||||
TextWithBetaLabel(
|
||||
Text(
|
||||
text = stringResource(R.string.RemoteBackupsSettingsFragment__signal_backups),
|
||||
textStyle = MaterialTheme.typography.bodyLarge
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
|
||||
when (val type = backupState.messageBackupsType) {
|
||||
|
||||
@@ -17,6 +17,5 @@ data class BackupsSettingsState(
|
||||
val backupState: BackupState,
|
||||
val lastBackupAt: Duration = SignalStore.backup.lastBackupTime.milliseconds,
|
||||
val showBackupTierInternalOverride: Boolean = false,
|
||||
val backupTierInternalOverride: MessageBackupTier? = null,
|
||||
val showNewLocalBackup: Boolean = false
|
||||
val backupTierInternalOverride: MessageBackupTier? = null
|
||||
)
|
||||
|
||||
@@ -21,7 +21,6 @@ import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.storage.StorageSyncHelper
|
||||
import org.thoughtcrime.securesms.util.Environment
|
||||
import org.thoughtcrime.securesms.util.RemoteConfig
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
class BackupsSettingsViewModel : ViewModel() {
|
||||
@@ -46,8 +45,7 @@ class BackupsSettingsViewModel : ViewModel() {
|
||||
backupState = enabledState,
|
||||
lastBackupAt = SignalStore.backup.lastBackupTime.milliseconds,
|
||||
showBackupTierInternalOverride = Environment.IS_STAGING,
|
||||
backupTierInternalOverride = SignalStore.backup.backupTierInternalOverride,
|
||||
showNewLocalBackup = RemoteConfig.internalUser || Environment.IS_NIGHTLY
|
||||
backupTierInternalOverride = SignalStore.backup.backupTierInternalOverride
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,275 +0,0 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.settings.app.backups.local
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.provider.DocumentsContract
|
||||
import android.widget.Toast
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
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.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import kotlinx.coroutines.flow.map
|
||||
import org.greenrobot.eventbus.EventBus
|
||||
import org.greenrobot.eventbus.Subscribe
|
||||
import org.greenrobot.eventbus.ThreadMode
|
||||
import org.signal.core.ui.compose.ComposeFragment
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.Rows
|
||||
import org.signal.core.ui.compose.Scaffolds
|
||||
import org.signal.core.ui.compose.SignalIcons
|
||||
import org.signal.core.ui.util.StorageUtil
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.backup.v2.LocalBackupV2Event
|
||||
import org.thoughtcrime.securesms.conversation.v2.registerForLifecycle
|
||||
import org.thoughtcrime.securesms.jobs.LocalBackupJob
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.service.LocalBackupListener
|
||||
import org.thoughtcrime.securesms.util.DateUtils
|
||||
import org.thoughtcrime.securesms.util.formatHours
|
||||
import java.time.LocalTime
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* App settings internal screen for enabling and creating new local backups.
|
||||
*/
|
||||
class InternalNewLocalBackupCreateFragment : ComposeFragment() {
|
||||
|
||||
private val TAG = Log.tag(InternalNewLocalBackupCreateFragment::class)
|
||||
|
||||
private lateinit var chooseBackupLocationLauncher: ActivityResultLauncher<Intent>
|
||||
|
||||
private var createStatus by mutableStateOf("None")
|
||||
private val directoryFlow = SignalStore.backup.newLocalBackupsDirectoryFlow.map { if (Build.VERSION.SDK_INT >= 24 && it != null) StorageUtil.getDisplayPath(requireContext(), Uri.parse(it)) else it }
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
chooseBackupLocationLauncher = registerForActivityResult(
|
||||
ActivityResultContracts.StartActivityForResult()
|
||||
) { result ->
|
||||
if (result.resultCode == Activity.RESULT_OK && result.data?.data != null) {
|
||||
handleBackupLocationSelected(result.data!!.data!!)
|
||||
} else {
|
||||
Log.w(TAG, "Backup location selection cancelled or failed")
|
||||
}
|
||||
}
|
||||
|
||||
EventBus.getDefault().registerForLifecycle(subscriber = this, lifecycleOwner = this)
|
||||
}
|
||||
|
||||
@Subscribe(threadMode = ThreadMode.MAIN)
|
||||
fun onEvent(event: LocalBackupV2Event) {
|
||||
createStatus = "${event.type}: ${event.count} / ${event.estimatedTotalCount}"
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun FragmentContent() {
|
||||
val context = LocalContext.current
|
||||
val backupsEnabled by SignalStore.backup.newLocalBackupsEnabledFlow.collectAsStateWithLifecycle(SignalStore.backup.newLocalBackupsEnabled)
|
||||
val selectedDirectory by directoryFlow.collectAsStateWithLifecycle(SignalStore.backup.newLocalBackupsDirectory)
|
||||
val lastBackupTime by SignalStore.backup.newLocalBackupsLastBackupTimeFlow.collectAsStateWithLifecycle(SignalStore.backup.newLocalBackupsLastBackupTime)
|
||||
val lastBackupTimeString = remember(lastBackupTime) { calculateLastBackupTimeString(context, lastBackupTime) }
|
||||
val backupTime = remember { LocalTime.of(SignalStore.settings.backupHour, SignalStore.settings.backupMinute).formatHours(requireContext()) }
|
||||
|
||||
InternalLocalBackupScreen(
|
||||
backupsEnabled = backupsEnabled,
|
||||
selectedDirectory = selectedDirectory,
|
||||
lastBackupTimeString = lastBackupTimeString,
|
||||
backupTime = backupTime,
|
||||
createStatus = createStatus,
|
||||
callback = CallbackImpl()
|
||||
)
|
||||
}
|
||||
|
||||
private fun launchBackupDirectoryPicker() {
|
||||
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
|
||||
|
||||
if (Build.VERSION.SDK_INT >= 26) {
|
||||
val latestDirectory = SignalStore.settings.latestSignalBackupDirectory
|
||||
if (latestDirectory != null) {
|
||||
intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, latestDirectory)
|
||||
}
|
||||
}
|
||||
|
||||
intent.addFlags(
|
||||
Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION or
|
||||
Intent.FLAG_GRANT_WRITE_URI_PERMISSION or
|
||||
Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||
)
|
||||
|
||||
try {
|
||||
Log.d(TAG, "Launching backup directory picker")
|
||||
chooseBackupLocationLauncher.launch(intent)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to launch backup directory picker", e)
|
||||
Toast.makeText(requireContext(), R.string.BackupDialog_no_file_picker_available, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleBackupLocationSelected(uri: Uri) {
|
||||
Log.i(TAG, "Backup location selected: $uri")
|
||||
|
||||
val takeFlags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
||||
|
||||
requireContext().contentResolver.takePersistableUriPermission(uri, takeFlags)
|
||||
|
||||
SignalStore.backup.newLocalBackupsDirectory = uri.toString()
|
||||
|
||||
Toast.makeText(requireContext(), "Directory selected: $uri", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
private fun calculateLastBackupTimeString(context: Context, lastBackupTimestamp: Long): String {
|
||||
return if (lastBackupTimestamp > 0) {
|
||||
val relativeTime = DateUtils.getDatelessRelativeTimeSpanFormattedDate(
|
||||
context,
|
||||
Locale.getDefault(),
|
||||
lastBackupTimestamp
|
||||
)
|
||||
|
||||
if (relativeTime.isRelative) {
|
||||
relativeTime.value
|
||||
} else {
|
||||
val day = DateUtils.getDayPrecisionTimeString(context, Locale.getDefault(), lastBackupTimestamp)
|
||||
val time = relativeTime.value
|
||||
|
||||
context.getString(R.string.RemoteBackupsSettingsFragment__s_at_s, day, time)
|
||||
}
|
||||
} else {
|
||||
context.getString(R.string.RemoteBackupsSettingsFragment__never)
|
||||
}
|
||||
}
|
||||
|
||||
private inner class CallbackImpl : Callback {
|
||||
override fun onNavigationClick() {
|
||||
requireActivity().onBackPressedDispatcher.onBackPressed()
|
||||
}
|
||||
|
||||
override fun onToggleBackupsClick(enabled: Boolean) {
|
||||
SignalStore.backup.newLocalBackupsEnabled = enabled
|
||||
if (enabled) {
|
||||
LocalBackupListener.schedule(requireContext())
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSelectDirectoryClick() {
|
||||
launchBackupDirectoryPicker()
|
||||
}
|
||||
|
||||
override fun onEnqueueBackupClick() {
|
||||
createStatus = "Starting..."
|
||||
LocalBackupJob.enqueueArchive(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private interface Callback {
|
||||
fun onNavigationClick()
|
||||
fun onToggleBackupsClick(enabled: Boolean)
|
||||
fun onSelectDirectoryClick()
|
||||
fun onEnqueueBackupClick()
|
||||
|
||||
object Empty : Callback {
|
||||
override fun onNavigationClick() = Unit
|
||||
override fun onToggleBackupsClick(enabled: Boolean) = Unit
|
||||
override fun onSelectDirectoryClick() = Unit
|
||||
override fun onEnqueueBackupClick() = Unit
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InternalLocalBackupScreen(
|
||||
backupsEnabled: Boolean = false,
|
||||
selectedDirectory: String? = null,
|
||||
lastBackupTimeString: String = "Never",
|
||||
backupTime: String = "Unknown",
|
||||
createStatus: String = "None",
|
||||
callback: Callback
|
||||
) {
|
||||
Scaffolds.Settings(
|
||||
title = "New Local Backups",
|
||||
navigationIcon = SignalIcons.ArrowStart.imageVector,
|
||||
onNavigationClick = callback::onNavigationClick
|
||||
) { paddingValues ->
|
||||
LazyColumn(
|
||||
modifier = Modifier.padding(paddingValues)
|
||||
) {
|
||||
item {
|
||||
Rows.ToggleRow(
|
||||
checked = backupsEnabled,
|
||||
text = "Enable New Local Backups",
|
||||
label = if (backupsEnabled) "Backups are enabled" else "Backups are disabled",
|
||||
onCheckChanged = callback::onToggleBackupsClick
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Rows.TextRow(
|
||||
text = "Last Backup",
|
||||
label = lastBackupTimeString
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Rows.TextRow(
|
||||
text = "Backup Schedule Time (same as v1)",
|
||||
label = backupTime
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Rows.TextRow(
|
||||
text = "Select Backup Directory",
|
||||
label = selectedDirectory ?: "No directory selected",
|
||||
onClick = callback::onSelectDirectoryClick
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Rows.TextRow(
|
||||
text = "Create Backup Now",
|
||||
label = "Enqueue LocalArchiveJob",
|
||||
onClick = callback::onEnqueueBackupClick
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Rows.TextRow(
|
||||
text = "Create Status",
|
||||
label = createStatus
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
fun InternalLocalBackupScreenPreview() {
|
||||
Previews.Preview {
|
||||
InternalLocalBackupScreen(
|
||||
backupsEnabled = true,
|
||||
selectedDirectory = "/storage/emulated/0/Signal/Backups",
|
||||
lastBackupTimeString = "1 hour ago",
|
||||
callback = Callback.Empty
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -4,19 +4,19 @@
|
||||
*/
|
||||
package org.thoughtcrime.securesms.components.settings.app.backups.local
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.LocalOnBackPressedDispatcherOwner
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
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.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.lifecycle.compose.LifecycleResumeEffect
|
||||
@@ -31,6 +31,9 @@ import androidx.navigation3.ui.NavDisplay
|
||||
import androidx.navigationevent.compose.LocalNavigationEventDispatcherOwner
|
||||
import kotlinx.coroutines.launch
|
||||
import org.signal.core.ui.compose.ComposeFragment
|
||||
import org.signal.core.ui.compose.Dialogs
|
||||
import org.signal.core.ui.compose.Launchers
|
||||
import org.signal.core.ui.util.StorageUtil
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsKeyEducationScreen
|
||||
@@ -39,6 +42,7 @@ import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsKeyRec
|
||||
import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsKeyRecordScreen
|
||||
import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsKeyVerifyScreen
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
private val TAG = Log.tag(LocalBackupsFragment::class)
|
||||
|
||||
@@ -127,6 +131,7 @@ class LocalBackupsFragment : ComposeFragment() {
|
||||
val state: LocalBackupsKeyState by viewModel.backupState.collectAsStateWithLifecycle()
|
||||
val scope = rememberCoroutineScope()
|
||||
val backupKeyUpdatedMessage = stringResource(R.string.OnDeviceBackupsFragment__backup_key_updated)
|
||||
var upgradeInProgress by remember { mutableStateOf(false) }
|
||||
|
||||
MessageBackupsKeyVerifyScreen(
|
||||
backupKey = state.accountEntropyPool.displayValue,
|
||||
@@ -139,7 +144,9 @@ class LocalBackupsFragment : ComposeFragment() {
|
||||
backstack.removeAll { it != LocalBackupsNavKey.SETTINGS }
|
||||
|
||||
scope.launch {
|
||||
upgradeInProgress = true
|
||||
viewModel.handleUpgrade(requireContext())
|
||||
upgradeInProgress = false
|
||||
|
||||
snackbarHostState.showSnackbar(
|
||||
message = backupKeyUpdatedMessage
|
||||
@@ -147,6 +154,12 @@ class LocalBackupsFragment : ComposeFragment() {
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
Dialogs.IndeterminateProgressDialog(
|
||||
visible = upgradeInProgress,
|
||||
delayDuration = 100.milliseconds,
|
||||
minimumDisplayDuration = 500.milliseconds
|
||||
)
|
||||
}
|
||||
|
||||
else -> error("Unknown key: $key")
|
||||
@@ -158,18 +171,17 @@ class LocalBackupsFragment : ComposeFragment() {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun rememberChooseBackupLocationLauncher(backStack: NavBackStack<NavKey>): ActivityResultLauncher<Intent> {
|
||||
private fun rememberChooseBackupLocationLauncher(backStack: NavBackStack<NavKey>): ActivityResultLauncher<Uri?> {
|
||||
val context = LocalContext.current
|
||||
return rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
val uri = result.data?.data
|
||||
if (result.resultCode == Activity.RESULT_OK && uri != null) {
|
||||
return Launchers.rememberOpenDocumentTreeLauncher { uri ->
|
||||
if (uri != null) {
|
||||
Log.i(TAG, "Backup location selected: $uri")
|
||||
val takeFlags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
||||
context.contentResolver.takePersistableUriPermission(uri, takeFlags)
|
||||
SignalStore.backup.newLocalBackupsDirectory = uri.toString()
|
||||
backStack.add(LocalBackupsNavKey.YOUR_RECOVERY_KEY)
|
||||
|
||||
Toast.makeText(context, context.getString(R.string.OnDeviceBackupsFragment__directory_selected, uri), Toast.LENGTH_SHORT).show()
|
||||
Toast.makeText(context, context.getString(R.string.OnDeviceBackupsFragment__directory_selected, StorageUtil.getDisplayPath(context, uri)), Toast.LENGTH_SHORT).show()
|
||||
} else {
|
||||
Log.w(TAG, "Unified backup location selection cancelled or failed")
|
||||
}
|
||||
|
||||
@@ -6,9 +6,7 @@ package org.thoughtcrime.securesms.components.settings.app.backups.local
|
||||
|
||||
import android.Manifest
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.provider.DocumentsContract
|
||||
import android.net.Uri
|
||||
import android.text.format.DateFormat
|
||||
import android.widget.Toast
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
@@ -52,7 +50,7 @@ sealed interface LocalBackupsSettingsCallback {
|
||||
|
||||
class DefaultLocalBackupsSettingsCallback(
|
||||
private val fragment: LocalBackupsFragment,
|
||||
private val chooseBackupLocationLauncher: ActivityResultLauncher<Intent>,
|
||||
private val chooseBackupLocationLauncher: ActivityResultLauncher<Uri?>,
|
||||
private val viewModel: LocalBackupsViewModel
|
||||
) : LocalBackupsSettingsCallback {
|
||||
|
||||
@@ -65,22 +63,10 @@ class DefaultLocalBackupsSettingsCallback(
|
||||
}
|
||||
|
||||
override fun onLaunchBackupLocationPickerClick() {
|
||||
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
|
||||
|
||||
if (Build.VERSION.SDK_INT >= 26) {
|
||||
intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, SignalStore.settings.latestSignalBackupDirectory)
|
||||
}
|
||||
|
||||
intent.addFlags(
|
||||
Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION or
|
||||
Intent.FLAG_GRANT_WRITE_URI_PERMISSION or
|
||||
Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||
)
|
||||
|
||||
try {
|
||||
Log.d(TAG, "Starting choose backup location dialog")
|
||||
chooseBackupLocationLauncher.launch(intent)
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
chooseBackupLocationLauncher.launch(SignalStore.settings.latestSignalBackupDirectory)
|
||||
} catch (_: ActivityNotFoundException) {
|
||||
Toast.makeText(fragment.requireContext(), R.string.BackupDialog_no_file_picker_available, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
@@ -112,6 +98,7 @@ class DefaultLocalBackupsSettingsCallback(
|
||||
override fun onCreateBackupClick() {
|
||||
if (BackupUtil.isUserSelectionRequired(fragment.requireContext())) {
|
||||
Log.i(TAG, "Queueing backup...")
|
||||
viewModel.onBackupStarted()
|
||||
enqueueArchive(false)
|
||||
} else {
|
||||
Permissions.with(fragment)
|
||||
@@ -119,6 +106,7 @@ class DefaultLocalBackupsSettingsCallback(
|
||||
.ifNecessary()
|
||||
.onAllGranted {
|
||||
Log.i(TAG, "Queuing backup...")
|
||||
viewModel.onBackupStarted()
|
||||
enqueueArchive(false)
|
||||
}
|
||||
.withPermanentDenialDialog(
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.backups.local
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -16,6 +17,7 @@ import kotlinx.coroutines.withContext
|
||||
import org.greenrobot.eventbus.EventBus
|
||||
import org.greenrobot.eventbus.Subscribe
|
||||
import org.greenrobot.eventbus.ThreadMode
|
||||
import org.signal.core.ui.util.StorageUtil
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.backup.BackupPassphrase
|
||||
@@ -27,7 +29,6 @@ import org.thoughtcrime.securesms.jobs.LocalBackupJob
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.util.BackupUtil
|
||||
import org.thoughtcrime.securesms.util.DateUtils
|
||||
import org.thoughtcrime.securesms.util.RemoteConfig
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences
|
||||
import org.thoughtcrime.securesms.util.formatHours
|
||||
import java.text.NumberFormat
|
||||
@@ -51,7 +52,7 @@ class LocalBackupsViewModel : ViewModel(), BackupKeyCredentialManagerHandler {
|
||||
private val internalSettingsState = MutableStateFlow(
|
||||
LocalBackupsSettingsState(
|
||||
backupsEnabled = SignalStore.backup.newLocalBackupsEnabled,
|
||||
folderDisplayName = SignalStore.backup.newLocalBackupsDirectory
|
||||
folderDisplayName = getDisplayName(AppDependencies.application, SignalStore.backup.newLocalBackupsDirectory)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -71,7 +72,7 @@ class LocalBackupsViewModel : ViewModel(), BackupKeyCredentialManagerHandler {
|
||||
|
||||
viewModelScope.launch {
|
||||
SignalStore.backup.newLocalBackupsDirectoryFlow.collect { directory ->
|
||||
internalSettingsState.update { it.copy(folderDisplayName = directory) }
|
||||
internalSettingsState.update { it.copy(folderDisplayName = getDisplayName(applicationContext, directory)) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,7 +97,6 @@ class LocalBackupsViewModel : ViewModel(), BackupKeyCredentialManagerHandler {
|
||||
val clientDeprecated = SignalStore.misc.isClientDeprecated
|
||||
val legacyLocalBackupsEnabled = SignalStore.settings.isBackupEnabled && BackupUtil.canUserAccessBackupDirectory(context)
|
||||
val canTurnOn = legacyLocalBackupsEnabled || (!userUnregistered && !clientDeprecated)
|
||||
val isLegacyBackup = !RemoteConfig.unifiedLocalBackups || (SignalStore.settings.isBackupEnabled && !SignalStore.backup.newLocalBackupsEnabled)
|
||||
|
||||
if (SignalStore.backup.newLocalBackupsEnabled) {
|
||||
if (!BackupUtil.canUserAccessUnifiedBackupDirectory(context)) {
|
||||
@@ -116,6 +116,19 @@ class LocalBackupsViewModel : ViewModel(), BackupKeyCredentialManagerHandler {
|
||||
}
|
||||
}
|
||||
|
||||
fun onBackupStarted() {
|
||||
val context = AppDependencies.application
|
||||
internalSettingsState.update {
|
||||
it.copy(
|
||||
progress = BackupProgressState.InProgress(
|
||||
summary = context.getString(R.string.BackupsPreferenceFragment__in_progress),
|
||||
percentLabel = context.getString(R.string.BackupsPreferenceFragment__d_so_far, 0),
|
||||
progressFraction = null
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Subscribe(threadMode = ThreadMode.MAIN)
|
||||
fun onBackupEvent(event: LocalBackupV2Event) {
|
||||
val context = AppDependencies.application
|
||||
@@ -155,13 +168,13 @@ class LocalBackupsViewModel : ViewModel(), BackupKeyCredentialManagerHandler {
|
||||
withContext(Dispatchers.IO) {
|
||||
AppDependencies.jobManager.cancelAllInQueue(LocalBackupJob.QUEUE)
|
||||
AppDependencies.jobManager.flush()
|
||||
|
||||
SignalStore.backup.newLocalBackupsDirectory = SignalStore.settings.signalBackupDirectory?.toString()
|
||||
|
||||
BackupPassphrase.set(context, null)
|
||||
SignalStore.settings.isBackupEnabled = false
|
||||
BackupUtil.deleteAllBackups()
|
||||
}
|
||||
|
||||
SignalStore.backup.newLocalBackupsDirectory = SignalStore.settings.signalBackupDirectory?.toString()
|
||||
|
||||
BackupPassphrase.set(context, null)
|
||||
SignalStore.settings.isBackupEnabled = false
|
||||
BackupUtil.deleteAllBackups()
|
||||
}
|
||||
|
||||
SignalStore.backup.newLocalBackupsEnabled = true
|
||||
@@ -169,6 +182,13 @@ class LocalBackupsViewModel : ViewModel(), BackupKeyCredentialManagerHandler {
|
||||
}
|
||||
}
|
||||
|
||||
private fun getDisplayName(context: Context, directoryUri: String?): String? {
|
||||
if (directoryUri == null) {
|
||||
return null
|
||||
}
|
||||
return StorageUtil.getDisplayPath(context, Uri.parse(directoryUri))
|
||||
}
|
||||
|
||||
private fun calculateLastBackupTimeString(context: Context, lastBackupTimestamp: Long): String {
|
||||
return if (lastBackupTimestamp > 0) {
|
||||
val relativeTime = DateUtils.getDatelessRelativeTimeSpanFormattedDate(
|
||||
|
||||
@@ -101,7 +101,6 @@ import org.thoughtcrime.securesms.backup.v2.ui.status.BackupStatusRow
|
||||
import org.thoughtcrime.securesms.backup.v2.ui.status.RestoreType
|
||||
import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsType
|
||||
import org.thoughtcrime.securesms.billing.launchManageBackupsSubscription
|
||||
import org.thoughtcrime.securesms.components.compose.BetaHeader
|
||||
import org.thoughtcrime.securesms.components.compose.BiometricsAuthentication
|
||||
import org.thoughtcrime.securesms.components.compose.rememberBiometricsAuthentication
|
||||
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
|
||||
@@ -419,10 +418,6 @@ private fun RemoteBackupsSettingsContent(
|
||||
modifier = Modifier
|
||||
.padding(it)
|
||||
) {
|
||||
item {
|
||||
BetaHeader(modifier = Modifier.padding(horizontal = 16.dp))
|
||||
}
|
||||
|
||||
if (state.isOutOfStorageSpace) {
|
||||
item {
|
||||
OutOfStorageSpaceBlock(
|
||||
|
||||
@@ -65,6 +65,7 @@ import org.thoughtcrime.securesms.megaphone.Megaphones
|
||||
import org.thoughtcrime.securesms.payments.DataExportUtil
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.registration.data.QuickstartCredentialExporter
|
||||
import org.thoughtcrime.securesms.storage.StorageSyncHelper
|
||||
import org.thoughtcrime.securesms.util.ConversationUtil
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
|
||||
@@ -164,6 +165,16 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
|
||||
}
|
||||
)
|
||||
|
||||
if (BuildConfig.DEBUG) {
|
||||
clickPref(
|
||||
title = DSLSettingsText.from("Export quickstart credentials"),
|
||||
summary = DSLSettingsText.from("Export registration credentials to a JSON file for quickstart builds."),
|
||||
onClick = {
|
||||
exportQuickstartCredentials()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
clickPref(
|
||||
title = DSLSettingsText.from("Unregister"),
|
||||
summary = DSLSettingsText.from("This will unregister your account without deleting it."),
|
||||
@@ -179,6 +190,16 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
|
||||
promptUserForSentTimestamp()
|
||||
}
|
||||
)
|
||||
|
||||
switchPref(
|
||||
title = DSLSettingsText.from("Disable internal user flag"),
|
||||
summary = DSLSettingsText.from("Experience life as a non-internal user. Force-stop the app to be an internal user again."),
|
||||
isChecked = state.disableInternalUser,
|
||||
onClick = {
|
||||
viewModel.setDisableInternalUser(!state.disableInternalUser)
|
||||
}
|
||||
)
|
||||
|
||||
dividerPref()
|
||||
|
||||
sectionHeaderPref(DSLSettingsText.from("App UI"))
|
||||
@@ -787,6 +808,13 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
|
||||
}
|
||||
)
|
||||
|
||||
clickPref(
|
||||
title = DSLSettingsText.from("Add remote backups note"),
|
||||
onClick = {
|
||||
viewModel.addSampleReleaseNote("remote_backups")
|
||||
}
|
||||
)
|
||||
|
||||
clickPref(
|
||||
title = DSLSettingsText.from("Add remote donate megaphone"),
|
||||
onClick = {
|
||||
@@ -1144,6 +1172,21 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
|
||||
}
|
||||
}
|
||||
|
||||
private fun exportQuickstartCredentials() {
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle("Export quickstart credentials?")
|
||||
.setMessage("This will export your account's private keys and credentials to an unencrypted file on disk. This is very dangerous! Only use it with test accounts.")
|
||||
.setPositiveButton("Export") { _, _ ->
|
||||
SimpleTask.run({
|
||||
QuickstartCredentialExporter.export(requireContext())
|
||||
}) { file ->
|
||||
Toast.makeText(requireContext(), "Exported to ${file.absolutePath}", Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun promptUserForSentTimestamp() {
|
||||
val input = EditText(requireContext()).apply {
|
||||
inputType = android.text.InputType.TYPE_CLASS_NUMBER
|
||||
|
||||
@@ -42,7 +42,7 @@ class InternalSettingsRepository(context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
fun addSampleReleaseNote() {
|
||||
fun addSampleReleaseNote(callToAction: String) {
|
||||
SignalExecutors.UNBOUNDED.execute {
|
||||
AppDependencies.jobManager.runSynchronously(CreateReleaseChannelJob.create(), 5000)
|
||||
|
||||
@@ -52,7 +52,7 @@ class InternalSettingsRepository(context: Context) {
|
||||
val bodyRangeList = BodyRangeList.Builder()
|
||||
.addStyle(BodyRangeList.BodyRange.Style.BOLD, 0, title.length)
|
||||
|
||||
bodyRangeList.addButton("Call to Action Text", "action", body.lastIndex, 0)
|
||||
bodyRangeList.addButton("Call to Action Text", callToAction, body.lastIndex, 0)
|
||||
|
||||
val recipientId = SignalStore.releaseChannel.releaseChannelRecipientId!!
|
||||
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(Recipient.resolved(recipientId))
|
||||
|
||||
@@ -31,5 +31,6 @@ data class InternalSettingsState(
|
||||
val hasPendingOneTimeDonation: Boolean,
|
||||
val hevcEncoding: Boolean,
|
||||
val forceSplitPane: Boolean,
|
||||
val useNewMediaActivity: Boolean
|
||||
val useNewMediaActivity: Boolean,
|
||||
val disableInternalUser: Boolean
|
||||
)
|
||||
|
||||
@@ -11,6 +11,7 @@ import org.thoughtcrime.securesms.keyvalue.InternalValues
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.stories.Stories
|
||||
import org.thoughtcrime.securesms.util.RemoteConfig
|
||||
import org.thoughtcrime.securesms.util.livedata.Store
|
||||
|
||||
class InternalSettingsViewModel(private val repository: InternalSettingsRepository) : ViewModel() {
|
||||
@@ -154,8 +155,8 @@ class InternalSettingsViewModel(private val repository: InternalSettingsReposito
|
||||
refresh()
|
||||
}
|
||||
|
||||
fun addSampleReleaseNote() {
|
||||
repository.addSampleReleaseNote()
|
||||
fun addSampleReleaseNote(callToAction: String = "action") {
|
||||
repository.addSampleReleaseNote(callToAction)
|
||||
}
|
||||
|
||||
fun addRemoteDonateMegaphone() {
|
||||
@@ -202,7 +203,8 @@ class InternalSettingsViewModel(private val repository: InternalSettingsReposito
|
||||
hasPendingOneTimeDonation = SignalStore.inAppPayments.getPendingOneTimeDonation() != null,
|
||||
hevcEncoding = SignalStore.internal.hevcEncoding,
|
||||
forceSplitPane = SignalStore.internal.forceSplitPane,
|
||||
useNewMediaActivity = SignalStore.internal.useNewMediaActivity
|
||||
useNewMediaActivity = SignalStore.internal.useNewMediaActivity,
|
||||
disableInternalUser = RemoteConfig.internalUserDisabled
|
||||
)
|
||||
|
||||
fun onClearOnboardingState() {
|
||||
@@ -213,6 +215,11 @@ class InternalSettingsViewModel(private val repository: InternalSettingsReposito
|
||||
StoryOnboardingDownloadJob.enqueueIfNeeded()
|
||||
}
|
||||
|
||||
fun setDisableInternalUser(disabled: Boolean) {
|
||||
RemoteConfig.internalUserDisabled = disabled
|
||||
refresh()
|
||||
}
|
||||
|
||||
fun setForceSplitPane(forceSplitPane: Boolean) {
|
||||
SignalStore.internal.forceSplitPane = forceSplitPane
|
||||
refresh()
|
||||
|
||||
@@ -84,7 +84,7 @@ import org.thoughtcrime.securesms.jobs.BackupRestoreMediaJob
|
||||
import org.thoughtcrime.securesms.jobs.LocalBackupJob
|
||||
import org.thoughtcrime.securesms.keyvalue.BackupValues
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.registration.ui.restore.local.InternalNewLocalRestoreActivity
|
||||
import org.thoughtcrime.securesms.registration.ui.restore.local.RestoreLocalBackupActivity
|
||||
|
||||
class InternalBackupPlaygroundFragment : ComposeFragment() {
|
||||
|
||||
@@ -230,7 +230,7 @@ class InternalBackupPlaygroundFragment : ComposeFragment() {
|
||||
.setTitle("Are you sure?")
|
||||
.setMessage("After you choose a file to import, this will delete all of your chats, then restore them from the file! Only do this on a test device!")
|
||||
.setPositiveButton("Wipe and restore") { _, _ ->
|
||||
startActivity(InternalNewLocalRestoreActivity.getIntent(context, finish = false))
|
||||
startActivity(RestoreLocalBackupActivity.getIntent(context, finish = false))
|
||||
}
|
||||
.show()
|
||||
},
|
||||
|
||||
@@ -30,26 +30,28 @@ class CheckoutNavHostFragment : NavHostFragment() {
|
||||
get() = requireArguments().getSerializableCompat(ARG_TYPE, InAppPaymentType::class.java)!!
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
if (savedInstanceState == null) {
|
||||
val navGraph = navController.navInflater.inflate(R.navigation.checkout)
|
||||
navGraph.setStartDestination(
|
||||
when (inAppPaymentType) {
|
||||
InAppPaymentType.UNKNOWN -> error("Unsupported start destination")
|
||||
InAppPaymentType.ONE_TIME_GIFT -> R.id.giftFlowStartFragment
|
||||
InAppPaymentType.ONE_TIME_DONATION, InAppPaymentType.RECURRING_DONATION -> R.id.donateToSignalFragment
|
||||
InAppPaymentType.RECURRING_BACKUP -> error("Unsupported start destination")
|
||||
}
|
||||
)
|
||||
val navGraph = navController.navInflater.inflate(R.navigation.checkout)
|
||||
navGraph.setStartDestination(
|
||||
when (inAppPaymentType) {
|
||||
InAppPaymentType.UNKNOWN -> error("Unsupported start destination")
|
||||
InAppPaymentType.ONE_TIME_GIFT -> R.id.giftFlowStartFragment
|
||||
InAppPaymentType.ONE_TIME_DONATION, InAppPaymentType.RECURRING_DONATION -> R.id.donateToSignalFragment
|
||||
InAppPaymentType.RECURRING_BACKUP -> error("Unsupported start destination")
|
||||
}
|
||||
)
|
||||
|
||||
val startBundle = when (inAppPaymentType) {
|
||||
val startBundle = if (savedInstanceState == null) {
|
||||
when (inAppPaymentType) {
|
||||
InAppPaymentType.UNKNOWN -> error("Unknown payment type")
|
||||
InAppPaymentType.ONE_TIME_GIFT, InAppPaymentType.RECURRING_BACKUP -> null
|
||||
InAppPaymentType.ONE_TIME_DONATION, InAppPaymentType.RECURRING_DONATION -> DonateToSignalFragmentArgs.Builder(inAppPaymentType).build().toBundle()
|
||||
}
|
||||
|
||||
navController.setGraph(navGraph, startBundle)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
navController.setGraph(navGraph, startBundle)
|
||||
|
||||
super.onCreate(savedInstanceState)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -310,7 +310,7 @@ class DonateToSignalFragment :
|
||||
text = DSLSettingsText.from(R.string.SubscribeFragment__cancel_subscription),
|
||||
isEnabled = state.areFieldsEnabled,
|
||||
onClick = {
|
||||
if (state.monthlyDonationState.transactionState.isTransactionJobPending) {
|
||||
if (state.monthlyDonationState.transactionState.isTransactionJobPending && !state.monthlyDonationState.transactionState.isKeepAlive) {
|
||||
showDonationPendingDialog(state)
|
||||
} else {
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
|
||||
@@ -139,7 +139,8 @@ data class DonateToSignalState(
|
||||
|
||||
data class TransactionState(
|
||||
val isTransactionJobPending: Boolean = false,
|
||||
val isLevelUpdateInProgress: Boolean = false
|
||||
val isLevelUpdateInProgress: Boolean = false,
|
||||
val isKeepAlive: Boolean = false
|
||||
) {
|
||||
val isInProgress: Boolean = isTransactionJobPending || isLevelUpdateInProgress
|
||||
}
|
||||
|
||||
@@ -341,7 +341,7 @@ class DonateToSignalViewModel(
|
||||
state.copy(
|
||||
monthlyDonationState = state.monthlyDonationState.copy(
|
||||
nonVerifiedMonthlyDonation = if (jobStatus is DonationRedemptionJobStatus.PendingExternalVerification) jobStatus.nonVerifiedMonthlyDonation else null,
|
||||
transactionState = DonateToSignalState.TransactionState(jobStatus.isInProgress(), levelUpdateProcessing)
|
||||
transactionState = DonateToSignalState.TransactionState(jobStatus.isInProgress(), levelUpdateProcessing, jobStatus is DonationRedemptionJobStatus.PendingKeepAlive)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ object BankDetailsValidator {
|
||||
private val EMAIL_REGEX: Regex = ".+@.+\\..+".toRegex()
|
||||
|
||||
fun validName(name: String): Boolean {
|
||||
return name.length >= 2
|
||||
return name.length >= 3
|
||||
}
|
||||
|
||||
fun validEmail(email: String): Boolean {
|
||||
|
||||
@@ -312,7 +312,7 @@ private fun BankTransferDetailsContent(
|
||||
isError = state.showNameError(),
|
||||
supportingText = {
|
||||
if (state.showNameError()) {
|
||||
Text(text = stringResource(id = R.string.BankTransferDetailsFragment__minimum_2_characters))
|
||||
Text(text = stringResource(id = R.string.BankTransferDetailsFragment__minimum_3_characters))
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
|
||||
@@ -266,7 +266,7 @@ private fun IdealTransferDetailsContent(
|
||||
isError = state.showNameError(),
|
||||
supportingText = {
|
||||
if (state.showNameError()) {
|
||||
Text(text = stringResource(id = R.string.BankTransferDetailsFragment__minimum_2_characters))
|
||||
Text(text = stringResource(id = R.string.BankTransferDetailsFragment__minimum_3_characters))
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
|
||||
@@ -34,7 +34,6 @@ object ActiveSubscriptionPreference {
|
||||
val activeSubscription: ActiveSubscription.Subscription?,
|
||||
val subscriberRequiresCancel: Boolean,
|
||||
val onContactSupport: () -> Unit,
|
||||
val onPendingClick: (FiatMoney) -> Unit,
|
||||
val onRowClick: (ManageDonationsState.RedemptionState) -> Unit
|
||||
) : PreferenceModel<Model>() {
|
||||
override fun areItemsTheSame(newItem: Model): Boolean {
|
||||
@@ -79,7 +78,7 @@ object ActiveSubscriptionPreference {
|
||||
|
||||
when (model.redemptionState) {
|
||||
ManageDonationsState.RedemptionState.NONE -> presentRenewalState(model)
|
||||
ManageDonationsState.RedemptionState.IS_PENDING_BANK_TRANSFER -> presentPendingBankTransferState(model)
|
||||
ManageDonationsState.RedemptionState.IS_PENDING_BANK_TRANSFER -> presentPendingBankTransferState()
|
||||
ManageDonationsState.RedemptionState.IN_PROGRESS -> presentInProgressState()
|
||||
ManageDonationsState.RedemptionState.FAILED -> presentFailureState(model)
|
||||
ManageDonationsState.RedemptionState.SUBSCRIPTION_REFRESH -> presentRefreshState()
|
||||
@@ -102,10 +101,9 @@ object ActiveSubscriptionPreference {
|
||||
progress.visible = true
|
||||
}
|
||||
|
||||
private fun presentPendingBankTransferState(model: Model) {
|
||||
private fun presentPendingBankTransferState() {
|
||||
expiry.text = context.getString(R.string.MySupportPreference__payment_pending)
|
||||
progress.visible = true
|
||||
itemView.setOnClickListener { model.onPendingClick(model.price) }
|
||||
}
|
||||
|
||||
private fun presentInProgressState() {
|
||||
|
||||
@@ -294,9 +294,6 @@ class ManageDonationsFragment :
|
||||
subscriberRequiresCancel = state.subscriberRequiresCancel,
|
||||
onRowClick = {
|
||||
launcher.launch(InAppPaymentType.RECURRING_DONATION)
|
||||
},
|
||||
onPendingClick = {
|
||||
displayPendingDialog(it)
|
||||
}
|
||||
)
|
||||
)
|
||||
@@ -317,7 +314,6 @@ class ManageDonationsFragment :
|
||||
onContactSupport = {},
|
||||
activeSubscription = null,
|
||||
subscriberRequiresCancel = state.subscriberRequiresCancel,
|
||||
onPendingClick = {},
|
||||
onRowClick = {}
|
||||
)
|
||||
)
|
||||
|
||||
@@ -35,10 +35,10 @@ import org.signal.core.util.concurrent.LifecycleDisposable
|
||||
import org.signal.core.util.concurrent.addTo
|
||||
import org.signal.core.util.getParcelableArrayListExtraCompat
|
||||
import org.signal.core.util.orNull
|
||||
import org.signal.core.util.requireParcelableCompat
|
||||
import org.signal.donations.InAppPaymentType
|
||||
import org.thoughtcrime.securesms.AvatarPreviewActivity
|
||||
import org.thoughtcrime.securesms.BlockUnblockDialog
|
||||
import org.thoughtcrime.securesms.MuteDialog
|
||||
import org.thoughtcrime.securesms.PushContactSelectionActivity
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.badges.BadgeImageView
|
||||
@@ -69,9 +69,10 @@ import org.thoughtcrime.securesms.components.settings.conversation.preferences.R
|
||||
import org.thoughtcrime.securesms.components.settings.conversation.preferences.SharedMediaPreference
|
||||
import org.thoughtcrime.securesms.components.settings.conversation.preferences.Utils.formatMutedUntil
|
||||
import org.thoughtcrime.securesms.conversation.ConversationIntents
|
||||
import org.thoughtcrime.securesms.conversation.colors.Colorizer
|
||||
import org.thoughtcrime.securesms.conversation.colors.ColorizerV2
|
||||
import org.thoughtcrime.securesms.database.AttachmentTable
|
||||
import org.thoughtcrime.securesms.groups.GroupId
|
||||
import org.thoughtcrime.securesms.groups.memberlabel.MemberLabelEducationSheet
|
||||
import org.thoughtcrime.securesms.groups.memberlabel.StyledMemberLabel
|
||||
import org.thoughtcrime.securesms.groups.ui.GroupErrors
|
||||
import org.thoughtcrime.securesms.groups.ui.GroupLimitDialog
|
||||
@@ -119,15 +120,16 @@ private const val REQUEST_CODE_ADD_CONTACT = 2
|
||||
private const val REQUEST_CODE_ADD_MEMBERS_TO_GROUP = 3
|
||||
private const val REQUEST_CODE_RETURN_FROM_MEDIA = 4
|
||||
|
||||
class ConversationSettingsFragment : DSLSettingsFragment(
|
||||
layoutId = R.layout.conversation_settings_fragment,
|
||||
menuId = R.menu.conversation_settings
|
||||
) {
|
||||
class ConversationSettingsFragment :
|
||||
DSLSettingsFragment(
|
||||
layoutId = R.layout.conversation_settings_fragment,
|
||||
menuId = R.menu.conversation_settings
|
||||
) {
|
||||
|
||||
private val args: ConversationSettingsFragmentArgs by navArgs()
|
||||
private val alertTint by lazy { ContextCompat.getColor(requireContext(), R.color.signal_alert_primary) }
|
||||
private val alertDisabledTint by lazy { ContextCompat.getColor(requireContext(), R.color.signal_alert_primary_50) }
|
||||
private val colorizer = Colorizer()
|
||||
private val colorizer = ColorizerV2()
|
||||
private val blockIcon by lazy {
|
||||
ContextUtil.requireDrawable(requireContext(), R.drawable.symbol_block_24).apply {
|
||||
colorFilter = PorterDuffColorFilter(alertTint, PorterDuff.Mode.SRC_IN)
|
||||
@@ -189,6 +191,16 @@ class ConversationSettingsFragment : DSLSettingsFragment(
|
||||
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
parentFragmentManager.setFragmentResultListener(MemberLabelEducationSheet.RESULT_EDIT_MEMBER_LABEL, viewLifecycleOwner) { _, bundle ->
|
||||
val groupId = bundle.requireParcelableCompat(MemberLabelEducationSheet.KEY_GROUP_ID, GroupId.V2::class.java)
|
||||
navController.safeNavigate(ConversationSettingsFragmentDirections.actionConversationSettingsFragmentToMemberLabelFragment(groupId))
|
||||
}
|
||||
|
||||
parentFragmentManager.setFragmentResultListener(AboutSheet.RESULT_EDIT_MEMBER_LABEL, viewLifecycleOwner) { _, bundle ->
|
||||
val groupId = bundle.requireParcelableCompat(AboutSheet.RESULT_GROUP_ID, GroupId.V2::class.java)
|
||||
navController.safeNavigate(ConversationSettingsFragmentDirections.actionConversationSettingsFragmentToMemberLabelFragment(groupId))
|
||||
}
|
||||
|
||||
recyclerView?.addOnScrollListener(ConversationSettingsOnUserScrolledAnimationHelper(toolbarAvatarContainer, toolbarTitle, toolbarBackground))
|
||||
}
|
||||
|
||||
@@ -469,9 +481,11 @@ class ConversationSettingsFragment : DSLSettingsFragment(
|
||||
YouAreAlreadyInACallSnackbar.show(requireView())
|
||||
}
|
||||
},
|
||||
onMuteClick = {
|
||||
onMuteClick = { view ->
|
||||
if (!state.buttonStripState.isMuted) {
|
||||
MuteDialog.show(requireContext(), viewModel::setMuteUntil)
|
||||
MuteContextMenu.show(view, requireView() as ViewGroup, childFragmentManager, viewLifecycleOwner) { duration ->
|
||||
viewModel.setMuteUntil(duration)
|
||||
}
|
||||
} else {
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setMessage(state.recipient.muteUntil.formatMutedUntil(requireContext()))
|
||||
@@ -578,11 +592,19 @@ class ConversationSettingsFragment : DSLSettingsFragment(
|
||||
|
||||
if (!state.recipient.isSelf) {
|
||||
clickPref(
|
||||
title = DSLSettingsText.from(R.string.ConversationSettingsFragment__sounds_and_notifications),
|
||||
title = if (RemoteConfig.internalUser) {
|
||||
DSLSettingsText.from("${getString(R.string.ConversationSettingsFragment__sounds_and_notifications)} (Internal Only)")
|
||||
} else {
|
||||
DSLSettingsText.from(R.string.ConversationSettingsFragment__sounds_and_notifications)
|
||||
},
|
||||
icon = DSLSettingsIcon.from(R.drawable.symbol_speaker_24),
|
||||
isEnabled = !state.isDeprecatedOrUnregistered,
|
||||
onClick = {
|
||||
val action = ConversationSettingsFragmentDirections.actionConversationSettingsFragmentToSoundsAndNotificationsSettingsFragment(state.recipient.id)
|
||||
val action = if (RemoteConfig.internalUser) {
|
||||
ConversationSettingsFragmentDirections.actionConversationSettingsFragmentToSoundsAndNotificationsSettingsFragment2(state.recipient.id)
|
||||
} else {
|
||||
ConversationSettingsFragmentDirections.actionConversationSettingsFragmentToSoundsAndNotificationsSettingsFragment(state.recipient.id)
|
||||
}
|
||||
|
||||
navController.safeNavigate(action)
|
||||
}
|
||||
@@ -659,10 +681,11 @@ class ConversationSettingsFragment : DSLSettingsFragment(
|
||||
)
|
||||
)
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
clickPref(
|
||||
title = DSLSettingsText.from(R.string.ConversationSettingsFragment__see_all),
|
||||
onClick = {
|
||||
startActivity(MediaOverviewActivity.forThread(requireContext(), state.threadId))
|
||||
startActivityForResult(MediaOverviewActivity.forThread(requireContext(), state.threadId), REQUEST_CODE_RETURN_FROM_MEDIA)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -738,7 +761,7 @@ class ConversationSettingsFragment : DSLSettingsFragment(
|
||||
customPref(
|
||||
RecipientPreference.Model(
|
||||
recipient = group,
|
||||
onClick = {
|
||||
onRowClick = {
|
||||
CommunicationActions.startConversation(requireActivity(), group, null)
|
||||
requireActivity().finish()
|
||||
}
|
||||
@@ -786,13 +809,26 @@ class ConversationSettingsFragment : DSLSettingsFragment(
|
||||
)
|
||||
|
||||
for (member in groupState.members) {
|
||||
val canSetMemberLabel = member.member.isSelf && groupState.canSetOwnMemberLabel
|
||||
val memberLabel = member.getMemberLabel(groupState)
|
||||
|
||||
customPref(
|
||||
RecipientPreference.Model(
|
||||
recipient = member.member,
|
||||
isAdmin = member.isAdmin,
|
||||
memberLabel = member.getMemberLabel(groupState),
|
||||
memberLabel = memberLabel,
|
||||
canSetMemberLabel = canSetMemberLabel,
|
||||
lifecycleOwner = viewLifecycleOwner,
|
||||
onClick = {
|
||||
onRowClick = {
|
||||
if (canSetMemberLabel && memberLabel == null) {
|
||||
val action = ConversationSettingsFragmentDirections
|
||||
.actionConversationSettingsFragmentToMemberLabelFragment(groupState.groupId)
|
||||
navController.safeNavigate(action)
|
||||
} else {
|
||||
RecipientBottomSheetDialogFragment.show(parentFragmentManager, member.member.id, groupState.groupId)
|
||||
}
|
||||
},
|
||||
onAvatarClick = {
|
||||
RecipientBottomSheetDialogFragment.show(parentFragmentManager, member.member.id, groupState.groupId)
|
||||
}
|
||||
)
|
||||
@@ -828,7 +864,7 @@ class ConversationSettingsFragment : DSLSettingsFragment(
|
||||
clickPref(
|
||||
title = DSLSettingsText.from(R.string.ConversationSettingsFragment__group_member_label),
|
||||
icon = DSLSettingsIcon.from(R.drawable.symbol_tag_24),
|
||||
isEnabled = !state.isDeprecatedOrUnregistered,
|
||||
isEnabled = groupState.canSetOwnMemberLabel && !state.isDeprecatedOrUnregistered,
|
||||
onClick = {
|
||||
val action = ConversationSettingsFragmentDirections.actionConversationSettingsFragmentToMemberLabelFragment(groupState.groupId)
|
||||
navController.safeNavigate(action)
|
||||
@@ -1012,7 +1048,9 @@ class ConversationSettingsFragment : DSLSettingsFragment(
|
||||
}
|
||||
|
||||
private fun showGroupInvitesSentDialog(showGroupInvitesSentDialog: ConversationSettingsEvent.ShowGroupInvitesSentDialog) {
|
||||
GroupInviteSentDialog.showInvitesSent(requireContext(), viewLifecycleOwner, showGroupInvitesSentDialog.invitesSentTo)
|
||||
if (showGroupInvitesSentDialog.invitesSentTo.isNotEmpty()) {
|
||||
GroupInviteSentDialog.show(childFragmentManager, showGroupInvitesSentDialog.invitesSentTo)
|
||||
}
|
||||
}
|
||||
|
||||
private fun showMembersAdded(showMembersAdded: ConversationSettingsEvent.ShowMembersAdded) {
|
||||
|
||||
@@ -84,7 +84,8 @@ sealed class SpecificSettingsState {
|
||||
val membershipCountDescription: String = "",
|
||||
val legacyGroupState: LegacyGroupPreference.State = LegacyGroupPreference.State.NONE,
|
||||
val isAnnouncementGroup: Boolean = false,
|
||||
val memberLabelsByRecipientId: Map<RecipientId, MemberLabel> = emptyMap()
|
||||
val memberLabelsByRecipientId: Map<RecipientId, MemberLabel> = emptyMap(),
|
||||
val canSetOwnMemberLabel: Boolean = false
|
||||
) : SpecificSettingsState() {
|
||||
|
||||
override val isLoaded: Boolean = groupTitleLoaded && groupDescriptionLoaded
|
||||
|
||||
@@ -362,6 +362,7 @@ sealed class ConversationSettingsViewModel(
|
||||
|
||||
if (groupId.isV2) {
|
||||
loadMemberLabels(groupId.requireV2(), fullMembers)
|
||||
loadCanSetMemberLabel(groupId.requireV2())
|
||||
}
|
||||
|
||||
state.copy(
|
||||
@@ -520,6 +521,17 @@ sealed class ConversationSettingsViewModel(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadCanSetMemberLabel(groupId: GroupId.V2) = viewModelScope.launch(SignalDispatchers.IO) {
|
||||
val canSetLabel = MemberLabelRepository.instance.canSetLabel(groupId, Recipient.self())
|
||||
store.update {
|
||||
it.copy(
|
||||
specificSettingsState = it.requireGroupSettingsState().copy(
|
||||
canSetOwnMemberLabel = canSetLabel
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class Factory(
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package org.thoughtcrime.securesms.components.settings.conversation
|
||||
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import org.signal.core.util.dp
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.menu.ActionItem
|
||||
import org.thoughtcrime.securesms.components.menu.SignalContextMenu
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
object MuteContextMenu {
|
||||
|
||||
@JvmStatic
|
||||
fun show(anchor: View, container: ViewGroup, fragmentManager: FragmentManager, lifecycleOwner: LifecycleOwner, action: (Long) -> Unit): SignalContextMenu {
|
||||
fragmentManager.setFragmentResultListener(MuteUntilTimePickerBottomSheet.REQUEST_KEY, lifecycleOwner) { _, bundle ->
|
||||
action(bundle.getLong(MuteUntilTimePickerBottomSheet.RESULT_TIMESTAMP))
|
||||
}
|
||||
|
||||
val context = anchor.context
|
||||
val actionItems = listOf(
|
||||
ActionItem(R.drawable.ic_daytime_24, context.getString(R.string.arrays__mute_for_one_hour)) {
|
||||
action(System.currentTimeMillis() + TimeUnit.HOURS.toMillis(1))
|
||||
},
|
||||
ActionItem(R.drawable.ic_nighttime_26, context.getString(R.string.arrays__mute_for_eight_hours)) {
|
||||
action(System.currentTimeMillis() + TimeUnit.HOURS.toMillis(8))
|
||||
},
|
||||
ActionItem(R.drawable.symbol_calendar_one, context.getString(R.string.arrays__mute_for_one_day)) {
|
||||
action(System.currentTimeMillis() + TimeUnit.DAYS.toMillis(1))
|
||||
},
|
||||
ActionItem(R.drawable.symbol_calendar_week, context.getString(R.string.arrays__mute_for_seven_days)) {
|
||||
action(System.currentTimeMillis() + TimeUnit.DAYS.toMillis(7))
|
||||
},
|
||||
ActionItem(R.drawable.symbol_calendar_24, context.getString(R.string.MuteDialog__mute_until)) {
|
||||
MuteUntilTimePickerBottomSheet.show(fragmentManager)
|
||||
},
|
||||
ActionItem(R.drawable.symbol_bell_slash_24, context.getString(R.string.arrays__always)) {
|
||||
action(Long.MAX_VALUE)
|
||||
}
|
||||
)
|
||||
|
||||
return SignalContextMenu.Builder(anchor, container)
|
||||
.offsetX(12.dp)
|
||||
.offsetY(12.dp)
|
||||
.preferredVerticalPosition(SignalContextMenu.VerticalPosition.ABOVE)
|
||||
.show(actionItems)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
package org.thoughtcrime.securesms.components.settings.conversation
|
||||
|
||||
import android.text.format.DateFormat
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableLongStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.os.bundleOf
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import androidx.fragment.app.setFragmentResult
|
||||
import com.google.android.material.datepicker.CalendarConstraints
|
||||
import com.google.android.material.datepicker.DateValidatorPointForward
|
||||
import com.google.android.material.datepicker.MaterialDatePicker
|
||||
import com.google.android.material.timepicker.MaterialTimePicker
|
||||
import com.google.android.material.timepicker.TimeFormat
|
||||
import org.signal.core.ui.BottomSheetUtil
|
||||
import org.signal.core.ui.compose.BottomSheets
|
||||
import org.signal.core.ui.compose.Buttons
|
||||
import org.signal.core.ui.compose.ComposeBottomSheetDialogFragment
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.util.DateUtils
|
||||
import org.thoughtcrime.securesms.util.atMidnight
|
||||
import org.thoughtcrime.securesms.util.atUTC
|
||||
import org.thoughtcrime.securesms.util.formatHours
|
||||
import org.thoughtcrime.securesms.util.toLocalDateTime
|
||||
import org.thoughtcrime.securesms.util.toMillis
|
||||
import java.time.DayOfWeek
|
||||
import java.time.LocalDateTime
|
||||
import java.time.LocalTime
|
||||
import java.time.ZoneId
|
||||
import java.time.ZoneOffset
|
||||
import java.time.ZonedDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.temporal.TemporalAdjusters
|
||||
import java.util.Locale
|
||||
|
||||
class MuteUntilTimePickerBottomSheet : ComposeBottomSheetDialogFragment() {
|
||||
|
||||
override val peekHeightPercentage: Float = 0.66f
|
||||
|
||||
companion object {
|
||||
const val REQUEST_KEY = "mute_until_result"
|
||||
const val RESULT_TIMESTAMP = "timestamp"
|
||||
|
||||
@JvmStatic
|
||||
fun show(fragmentManager: FragmentManager) {
|
||||
val fragment = MuteUntilTimePickerBottomSheet()
|
||||
fragment.show(fragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun SheetContent() {
|
||||
val context = LocalContext.current
|
||||
val now = remember { LocalDateTime.now() }
|
||||
|
||||
val defaultDateTime = remember {
|
||||
if (now.hour < 17) {
|
||||
now.withHour(17).withMinute(0).withSecond(0).withNano(0)
|
||||
} else {
|
||||
val nextMorning = if (now.dayOfWeek == DayOfWeek.FRIDAY || now.dayOfWeek == DayOfWeek.SATURDAY || now.dayOfWeek == DayOfWeek.SUNDAY) {
|
||||
now.with(TemporalAdjusters.next(DayOfWeek.MONDAY))
|
||||
} else {
|
||||
now.plusDays(1)
|
||||
}
|
||||
nextMorning.withHour(8).withMinute(0).withSecond(0).withNano(0)
|
||||
}
|
||||
}
|
||||
|
||||
var selectedDate by remember { mutableLongStateOf(defaultDateTime.toMillis()) }
|
||||
var selectedHour by remember { mutableIntStateOf(defaultDateTime.hour) }
|
||||
var selectedMinute by remember { mutableIntStateOf(defaultDateTime.minute) }
|
||||
|
||||
val dateText = remember(selectedDate) {
|
||||
DateUtils.getDayPrecisionTimeString(context, Locale.getDefault(), selectedDate)
|
||||
}
|
||||
|
||||
val timeText = remember(selectedHour, selectedMinute) {
|
||||
LocalTime.of(selectedHour, selectedMinute).formatHours(context)
|
||||
}
|
||||
|
||||
val zonedDateTime = remember { ZonedDateTime.now() }
|
||||
val timezoneDisclaimer = remember {
|
||||
val zoneOffsetFormatter = DateTimeFormatter.ofPattern("OOOO")
|
||||
val zoneNameFormatter = DateTimeFormatter.ofPattern("zzzz")
|
||||
context.getString(
|
||||
R.string.MuteUntilTimePickerBottomSheet__timezone_disclaimer,
|
||||
zoneOffsetFormatter.format(zonedDateTime),
|
||||
zoneNameFormatter.format(zonedDateTime)
|
||||
)
|
||||
}
|
||||
|
||||
MuteUntilSheetContent(
|
||||
dateText = dateText,
|
||||
timeText = timeText,
|
||||
timezoneDisclaimer = timezoneDisclaimer,
|
||||
onDateClick = {
|
||||
val local = LocalDateTime.now().atMidnight().atUTC().toMillis()
|
||||
val datePicker = MaterialDatePicker.Builder.datePicker()
|
||||
.setTitleText(context.getString(R.string.MuteUntilTimePickerBottomSheet__select_date_title))
|
||||
.setSelection(selectedDate)
|
||||
.setCalendarConstraints(CalendarConstraints.Builder().setStart(local).setValidator(DateValidatorPointForward.now()).build())
|
||||
.build()
|
||||
|
||||
datePicker.addOnDismissListener {
|
||||
datePicker.clearOnDismissListeners()
|
||||
datePicker.clearOnPositiveButtonClickListeners()
|
||||
}
|
||||
|
||||
datePicker.addOnPositiveButtonClickListener {
|
||||
selectedDate = it.toLocalDateTime(ZoneOffset.UTC).atZone(ZoneId.systemDefault()).toMillis()
|
||||
}
|
||||
datePicker.show(childFragmentManager, "DATE_PICKER")
|
||||
},
|
||||
onTimeClick = {
|
||||
val timeFormat = if (DateFormat.is24HourFormat(context)) TimeFormat.CLOCK_24H else TimeFormat.CLOCK_12H
|
||||
val timePicker = MaterialTimePicker.Builder()
|
||||
.setTimeFormat(timeFormat)
|
||||
.setHour(selectedHour)
|
||||
.setMinute(selectedMinute)
|
||||
.setTitleText(context.getString(R.string.MuteUntilTimePickerBottomSheet__select_time_title))
|
||||
.build()
|
||||
|
||||
timePicker.addOnDismissListener {
|
||||
timePicker.clearOnDismissListeners()
|
||||
timePicker.clearOnPositiveButtonClickListeners()
|
||||
}
|
||||
|
||||
timePicker.addOnPositiveButtonClickListener {
|
||||
selectedHour = timePicker.hour
|
||||
selectedMinute = timePicker.minute
|
||||
}
|
||||
timePicker.show(childFragmentManager, "TIME_PICKER")
|
||||
},
|
||||
onMuteClick = {
|
||||
val timestamp = selectedDate.toLocalDateTime()
|
||||
.withHour(selectedHour)
|
||||
.withMinute(selectedMinute)
|
||||
.withSecond(0)
|
||||
.withNano(0)
|
||||
.toMillis()
|
||||
|
||||
if (timestamp > System.currentTimeMillis()) {
|
||||
setFragmentResult(REQUEST_KEY, bundleOf(RESULT_TIMESTAMP to timestamp))
|
||||
dismissAllowingStateLoss()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MuteUntilSheetContent(
|
||||
dateText: String,
|
||||
timeText: String,
|
||||
timezoneDisclaimer: String,
|
||||
onDateClick: () -> Unit,
|
||||
onTimeClick: () -> Unit,
|
||||
onMuteClick: () -> Unit
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
BottomSheets.Handle()
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.MuteUntilTimePickerBottomSheet__dialog_title),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
modifier = Modifier.padding(top = 18.dp, bottom = 24.dp)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = timezoneDisclaimer,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 56.dp)
|
||||
.align(Alignment.Start)
|
||||
)
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp, vertical = 16.dp)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.clickable(onClick = onDateClick)
|
||||
) {
|
||||
Text(
|
||||
text = dateText,
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_expand_down_24),
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.padding(start = 8.dp)
|
||||
.size(24.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.clickable(onClick = onTimeClick)
|
||||
) {
|
||||
Text(
|
||||
text = timeText,
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_expand_down_24),
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.padding(start = 8.dp)
|
||||
.size(24.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.End,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 18.dp, end = 18.dp, top = 14.dp, bottom = 24.dp)
|
||||
) {
|
||||
Buttons.MediumTonal(
|
||||
onClick = onMuteClick
|
||||
) {
|
||||
Text(stringResource(R.string.MuteUntilTimePickerBottomSheet__mute_notifications))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun MuteUntilSheetContentPreview() {
|
||||
Previews.BottomSheetContentPreview {
|
||||
MuteUntilSheetContent(
|
||||
dateText = "Today",
|
||||
timeText = "5:00 PM",
|
||||
timezoneDisclaimer = "All times in (GMT-05:00) Eastern Standard Time",
|
||||
onDateClick = {},
|
||||
onTimeClick = {},
|
||||
onMuteClick = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,7 @@ object ButtonStripPreference {
|
||||
val onMessageClick: () -> Unit = {},
|
||||
val onVideoClick: () -> Unit = {},
|
||||
val onAudioClick: () -> Unit = {},
|
||||
val onMuteClick: () -> Unit = {},
|
||||
val onMuteClick: (View) -> Unit = {},
|
||||
val onSearchClick: () -> Unit = {}
|
||||
) : PreferenceModel<Model>() {
|
||||
override fun areContentsTheSame(newItem: Model): Boolean {
|
||||
@@ -97,7 +97,7 @@ object ButtonStripPreference {
|
||||
message.setOnClickListener { model.onMessageClick() }
|
||||
videoCall.setOnClickListener { model.onVideoClick() }
|
||||
audioCall.setOnClickListener { model.onAudioClick() }
|
||||
mute.setOnClickListener { model.onMuteClick() }
|
||||
mute.setOnClickListener { model.onMuteClick(it) }
|
||||
search.setOnClickListener { model.onSearchClick() }
|
||||
addToStory.setOnClickListener { model.onAddToStoryClick() }
|
||||
}
|
||||
|
||||
@@ -3,8 +3,6 @@ package org.thoughtcrime.securesms.components.settings.conversation.preferences
|
||||
import android.text.SpannableStringBuilder
|
||||
import android.view.View
|
||||
import android.widget.TextView
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.Observer
|
||||
@@ -36,8 +34,10 @@ object RecipientPreference {
|
||||
val recipient: Recipient,
|
||||
val isAdmin: Boolean = false,
|
||||
val memberLabel: StyledMemberLabel? = null,
|
||||
val canSetMemberLabel: Boolean = false,
|
||||
val lifecycleOwner: LifecycleOwner? = null,
|
||||
val onClick: (() -> Unit)? = null
|
||||
val onRowClick: (() -> Unit)? = null,
|
||||
val onAvatarClick: (() -> Unit)? = null
|
||||
) : PreferenceModel<Model>() {
|
||||
override fun areItemsTheSame(newItem: Model): Boolean {
|
||||
return recipient.id == newItem.recipient.id
|
||||
@@ -47,7 +47,8 @@ object RecipientPreference {
|
||||
return super.areContentsTheSame(newItem) &&
|
||||
recipient.hasSameContent(newItem.recipient) &&
|
||||
isAdmin == newItem.isAdmin &&
|
||||
memberLabel == newItem.memberLabel
|
||||
memberLabel == newItem.memberLabel &&
|
||||
canSetMemberLabel == newItem.canSetMemberLabel
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,28 +57,38 @@ object RecipientPreference {
|
||||
private val name: TextView = itemView.findViewById(R.id.recipient_name)
|
||||
private val about: TextView? = itemView.findViewById(R.id.recipient_about)
|
||||
private val memberLabelView: MemberLabelPillView? = itemView.findViewById(R.id.recipient_member_label)
|
||||
private val addMemberLabelView: TextView? = itemView.findViewById(R.id.add_member_label)
|
||||
private val admin: View? = itemView.findViewById(R.id.admin)
|
||||
private val badge: BadgeImageView = itemView.findViewById(R.id.recipient_badge)
|
||||
|
||||
private var recipient: Recipient? = null
|
||||
private var canSetMemberLabel: Boolean = false
|
||||
private var memberLabel: StyledMemberLabel? = null
|
||||
|
||||
private val recipientObserver = Observer<Recipient> { recipient ->
|
||||
onRecipientChanged(recipient)
|
||||
onRecipientChanged(recipient = recipient, memberLabel = memberLabel, canSetMemberLabel = canSetMemberLabel)
|
||||
}
|
||||
|
||||
override fun bind(model: Model) {
|
||||
if (model.onClick != null) {
|
||||
itemView.setOnClickListener { model.onClick.invoke() }
|
||||
if (model.onRowClick != null) {
|
||||
itemView.setOnClickListener { model.onRowClick.invoke() }
|
||||
} else {
|
||||
itemView.setOnClickListener(null)
|
||||
}
|
||||
|
||||
if (model.onAvatarClick != null) {
|
||||
avatar.setOnClickListener { model.onAvatarClick.invoke() }
|
||||
} else {
|
||||
avatar.setOnClickListener(null)
|
||||
}
|
||||
|
||||
canSetMemberLabel = model.canSetMemberLabel
|
||||
memberLabel = model.memberLabel
|
||||
|
||||
if (model.lifecycleOwner != null) {
|
||||
observeRecipient(model.lifecycleOwner, model.recipient)
|
||||
model.memberLabel?.let(::showMemberLabel)
|
||||
} else {
|
||||
onRecipientChanged(model.recipient, model.memberLabel)
|
||||
}
|
||||
onRecipientChanged(model.recipient, model.memberLabel, model.canSetMemberLabel)
|
||||
|
||||
admin?.visible = model.isAdmin
|
||||
}
|
||||
@@ -86,7 +97,7 @@ object RecipientPreference {
|
||||
unbind()
|
||||
}
|
||||
|
||||
private fun onRecipientChanged(recipient: Recipient, memberLabel: StyledMemberLabel? = null) {
|
||||
private fun onRecipientChanged(recipient: Recipient, memberLabel: StyledMemberLabel? = null, canSetMemberLabel: Boolean = false) {
|
||||
avatar.setRecipient(recipient)
|
||||
badge.setBadgeFromRecipient(recipient)
|
||||
name.text = if (recipient.isSelf) {
|
||||
@@ -104,17 +115,17 @@ object RecipientPreference {
|
||||
}
|
||||
}
|
||||
|
||||
val aboutText = recipient.combinedAboutAndEmoji
|
||||
when {
|
||||
memberLabel != null -> showMemberLabel(memberLabel)
|
||||
|
||||
!recipient.combinedAboutAndEmoji.isNullOrEmpty() -> {
|
||||
about?.text = recipient.combinedAboutAndEmoji
|
||||
about?.visible = true
|
||||
memberLabelView?.visible = false
|
||||
}
|
||||
recipient.isSelf && canSetMemberLabel -> showAddMemberLabel()
|
||||
|
||||
!aboutText.isNullOrBlank() -> showAbout(aboutText)
|
||||
|
||||
else -> {
|
||||
memberLabelView?.visible = false
|
||||
addMemberLabelView?.visible = false
|
||||
about?.visible = false
|
||||
}
|
||||
}
|
||||
@@ -122,18 +133,29 @@ object RecipientPreference {
|
||||
|
||||
private fun showMemberLabel(styledLabel: StyledMemberLabel) {
|
||||
memberLabelView?.apply {
|
||||
style = MemberLabelPillView.Style(
|
||||
horizontalPadding = 8.dp,
|
||||
verticalPadding = 2.dp,
|
||||
textStyle = { MaterialTheme.typography.labelSmall }
|
||||
)
|
||||
style = MemberLabelPillView.Style.Compact
|
||||
setLabel(styledLabel.label, styledLabel.tintColor)
|
||||
visible = true
|
||||
}
|
||||
|
||||
addMemberLabelView?.visible = false
|
||||
about?.visible = false
|
||||
}
|
||||
|
||||
private fun showAddMemberLabel() {
|
||||
addMemberLabelView?.visible = true
|
||||
memberLabelView?.visible = false
|
||||
about?.visible = false
|
||||
}
|
||||
|
||||
private fun showAbout(text: String) {
|
||||
about?.text = text
|
||||
about?.visible = true
|
||||
|
||||
memberLabelView?.visible = false
|
||||
addMemberLabelView?.visible = false
|
||||
}
|
||||
|
||||
private fun observeRecipient(lifecycleOwner: LifecycleOwner?, recipient: Recipient?) {
|
||||
this.recipient?.live()?.liveData?.removeObserver(recipientObserver)
|
||||
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.settings.conversation.sounds
|
||||
|
||||
import org.thoughtcrime.securesms.database.RecipientTable.NotificationSetting
|
||||
|
||||
/**
|
||||
* Represents all user-driven actions that can occur on the Sounds & Notifications settings screen.
|
||||
*/
|
||||
sealed interface SoundsAndNotificationsEvent {
|
||||
|
||||
/**
|
||||
* Mutes notifications for this recipient until the given epoch-millisecond timestamp.
|
||||
*
|
||||
* @param muteUntil Epoch-millisecond timestamp after which notifications should resume.
|
||||
* Use [Long.MAX_VALUE] to mute indefinitely.
|
||||
*/
|
||||
data class SetMuteUntil(val muteUntil: Long) : SoundsAndNotificationsEvent
|
||||
|
||||
/**
|
||||
* Clears any active mute, immediately restoring notifications for this recipient.
|
||||
*/
|
||||
data object Unmute : SoundsAndNotificationsEvent
|
||||
|
||||
/**
|
||||
* Updates the mention notification setting for this recipient.
|
||||
* Only relevant for group conversations that support @mentions.
|
||||
*
|
||||
* @param setting The new [NotificationSetting] to apply for @mention notifications.
|
||||
*/
|
||||
data class SetMentionSetting(val setting: NotificationSetting) : SoundsAndNotificationsEvent
|
||||
|
||||
/**
|
||||
* Updates the call notification setting for this recipient.
|
||||
* Controls whether incoming calls still produce notifications while the conversation is muted.
|
||||
*
|
||||
* @param setting The new [NotificationSetting] to apply for call notifications.
|
||||
*/
|
||||
data class SetCallNotificationSetting(val setting: NotificationSetting) : SoundsAndNotificationsEvent
|
||||
|
||||
/**
|
||||
* Updates the reply notification setting for this recipient.
|
||||
* Controls whether replies directed at the current user still produce notifications while muted.
|
||||
*
|
||||
* @param setting The new [NotificationSetting] to apply for reply notifications.
|
||||
*/
|
||||
data class SetReplyNotificationSetting(val setting: NotificationSetting) : SoundsAndNotificationsEvent
|
||||
|
||||
/**
|
||||
* Signals that the user tapped the "Custom Notifications" row and wishes to navigate to the
|
||||
* [custom notifications settings screen][org.thoughtcrime.securesms.components.settings.conversation.sounds.custom.CustomNotificationsSettingsFragment].
|
||||
*/
|
||||
data object NavigateToCustomNotifications : SoundsAndNotificationsEvent
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user