mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-06-12 02:06:06 +01:00
Compare commits
145 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 13de1ede90 | |||
| b94f420393 | |||
| 4909f130cc | |||
| 0010386b9e | |||
| 02c760945d | |||
| a0247bb8cc | |||
| bcfec5de50 | |||
| b2215915ef | |||
| a0577cd8a2 | |||
| 9438646814 | |||
| 9dcf68581d | |||
| 4dd57460de | |||
| 6339b38dee | |||
| 1ce41edc7f | |||
| 3087116618 | |||
| 8fd2065253 | |||
| 09822d3ae9 | |||
| 10b0221e98 | |||
| db4def45f9 | |||
| 7dd6829bfa | |||
| 0e40acfdaa | |||
| c2e8cec042 | |||
| 04d2b3b0fe | |||
| 64cdff4638 | |||
| 59b42ac546 | |||
| 2e9fd87b06 | |||
| 0cf7705d4f | |||
| 5bcbbdf339 | |||
| a796316ad6 | |||
| 5655fcf973 | |||
| f4bd5fbe8b | |||
| fc448ecb59 | |||
| c4e7841ea3 | |||
| e248aee25c | |||
| 7c9268e326 | |||
| 8ffc2e7ab8 | |||
| b4404bb5b4 | |||
| 155bba2f81 | |||
| 639438b863 | |||
| b374a90ffe | |||
| d333503838 | |||
| 2efc115410 | |||
| 43a1c93961 | |||
| 39529af4e9 | |||
| d1e2fc0423 | |||
| 5e4865be73 | |||
| 5903c1bbf5 | |||
| 45da9fbfc0 | |||
| 0dacc4e8dc | |||
| 74935c963a | |||
| 02d245ac0c | |||
| e100ffbc14 | |||
| d4b3328151 | |||
| 0e82a43be7 | |||
| 0960c0dfea | |||
| 8503c49db0 | |||
| 88b95ce6a5 | |||
| 6a1d06486c | |||
| 824f0af00b | |||
| 03a6d8c12f | |||
| f1b231ca38 | |||
| dca4351b8b | |||
| b2a18f7202 | |||
| d68e541ee6 | |||
| 1c6f093b4c | |||
| b87486163d | |||
| 1ee341daac | |||
| 6baebfe140 | |||
| a6816df0e8 | |||
| 5b8c894512 | |||
| 439760e773 | |||
| 7560896e2d | |||
| fe18def67e | |||
| 413962a093 | |||
| e518eca9a1 | |||
| b70322b5a6 | |||
| 047516c80b | |||
| 0a45b9b5e3 | |||
| 99b0061127 | |||
| 7b11cc1676 | |||
| 663e0a616e | |||
| d05338cee0 | |||
| ce294dbc0b | |||
| d0efd8d4b0 | |||
| c8875b5ad1 | |||
| 188458f772 | |||
| ed7fd10749 | |||
| 2ffbf09b1b | |||
| 799e57dbe9 | |||
| 572c11ee6d | |||
| 4dd5a4ee53 | |||
| 370fca3c89 | |||
| d91f130238 | |||
| bb20432417 | |||
| 8138ea5f8f | |||
| f235aa0599 | |||
| c7d719e983 | |||
| cf71d43a2f | |||
| 1e70e825a3 | |||
| cce1979716 | |||
| ad7e9c0fd7 | |||
| bd3e1e8059 | |||
| adb9e2173f | |||
| 958c6f451f | |||
| ab090236a1 | |||
| 23698dbc28 | |||
| 0542262c49 | |||
| e2d4ca9a4c | |||
| e54f3f501a | |||
| 638d4997d1 | |||
| cbd05c4dff | |||
| ef396b5758 | |||
| 1d36ecafe1 | |||
| 07329c5b0d | |||
| 7fc4ec3006 | |||
| 9e7477bbeb | |||
| c83054906b | |||
| 011dc3495f | |||
| 41b833e788 | |||
| e11f7225d3 | |||
| bb261a3d85 | |||
| 116f702be6 | |||
| 4d09776277 | |||
| f32184c27e | |||
| 5fc037b324 | |||
| fc9d3e11e8 | |||
| a951c7edfe | |||
| 9d1714d452 | |||
| 9c2825f202 | |||
| a8969b34a4 | |||
| 1f59f3c2c4 | |||
| c6d91dce6e | |||
| 40c4633d41 | |||
| edfe89683b | |||
| cc3bedd154 | |||
| 56803a8850 | |||
| 2fdb712b38 | |||
| 3d39045d1b | |||
| 90385b4e1c | |||
| a02b66601c | |||
| a83c57ff73 | |||
| 3d063b38be | |||
| 03d20cb46a | |||
| 561186df90 | |||
| fdcd21132c |
+146
-103
@@ -1,7 +1,10 @@
|
||||
@file:Suppress("UnstableApiUsage")
|
||||
|
||||
import com.android.build.api.dsl.ManagedVirtualDevice
|
||||
import com.android.build.api.artifact.ArtifactTransformationRequest
|
||||
import com.android.build.api.artifact.SingleArtifact
|
||||
import org.gradle.api.tasks.testing.logging.TestExceptionFormat
|
||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||
import org.jetbrains.kotlin.gradle.dsl.KotlinAndroidProjectExtension
|
||||
import java.time.Instant
|
||||
import java.time.ZoneOffset
|
||||
import java.time.format.DateTimeFormatter
|
||||
@@ -10,7 +13,6 @@ import java.util.Properties
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.jetbrains.kotlin.android)
|
||||
alias(libs.plugins.ktlint)
|
||||
alias(libs.plugins.compose.compiler)
|
||||
alias(libs.plugins.kotlinx.serialization)
|
||||
@@ -22,21 +24,22 @@ plugins {
|
||||
id("licenses")
|
||||
}
|
||||
|
||||
apply(from = "static-ips.gradle.kts")
|
||||
val staticIps = Properties().apply { file("static-ips.properties").reader().use { load(it) } }
|
||||
staticIps.stringPropertyNames().forEach { rootProject.extra[it] = staticIps.getProperty(it) }
|
||||
|
||||
val canonicalVersionCode = 1684
|
||||
val canonicalVersionName = "8.9.1"
|
||||
val canonicalVersionCode = 1690
|
||||
val canonicalVersionName = "8.11.2"
|
||||
val currentHotfixVersion = 0
|
||||
val maxHotfixVersions = 100
|
||||
|
||||
// We don't want versions to ever end in 0 so that they don't conflict with nightly versions
|
||||
val possibleHotfixVersions = (0 until maxHotfixVersions).toList().filter { it % 10 != 0 }
|
||||
|
||||
val debugKeystorePropertiesProvider = providers.of(PropertiesFileValueSource::class.java) {
|
||||
val debugKeystorePropertiesProvider: Provider<Properties> = providers.of(PropertiesFileValueSource::class.java) {
|
||||
parameters.file.set(rootProject.layout.projectDirectory.file("keystore.debug.properties"))
|
||||
}
|
||||
|
||||
val languagesProvider = providers.of(LanguageListValueSource::class.java) {
|
||||
val languagesProvider: Provider<List<String>> = providers.of(LanguageListValueSource::class.java) {
|
||||
parameters.resDir.set(layout.projectDirectory.dir("src/main/res"))
|
||||
}
|
||||
|
||||
@@ -81,6 +84,24 @@ val selectableVariants = listOf(
|
||||
"githubProdRelease"
|
||||
)
|
||||
|
||||
// Wire 5.x iterates Android source sets and expects matching Kotlin source sets.
|
||||
// AGP 9.0's built-in Kotlin doesn't create all source sets automatically.
|
||||
val kotlinExt = extensions.getByName("kotlin") as KotlinAndroidProjectExtension
|
||||
android.sourceSets.all {
|
||||
kotlinExt.sourceSets.findByName(name) ?: kotlinExt.sourceSets.create(name)
|
||||
}
|
||||
// AGP 9.0's built-in Kotlin doesn't pick up extra java.srcDir entries from Android
|
||||
// source sets, so add shared dirs directly to the relevant Kotlin compile tasks.
|
||||
tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile::class.java).configureEach {
|
||||
val isTestTask = name.contains("UnitTest") || name.contains("AndroidTest")
|
||||
if (isTestTask) {
|
||||
source("$projectDir/src/testShared")
|
||||
}
|
||||
if (!isTestTask && (name.contains("Mocked") || name.contains("Benchmark"))) {
|
||||
source("$projectDir/src/benchmarkShared/java")
|
||||
}
|
||||
}
|
||||
|
||||
wire {
|
||||
kotlin {
|
||||
javaInterop = true
|
||||
@@ -94,8 +115,6 @@ wire {
|
||||
srcDir("${project.rootDir}/lib/libsignal-service/src/main/protowire")
|
||||
srcDir("${project.rootDir}/lib/archive/src/main/protowire")
|
||||
}
|
||||
// Handled by libsignal
|
||||
prune("signalservice.DecryptionErrorMessage")
|
||||
}
|
||||
|
||||
ktlint {
|
||||
@@ -106,7 +125,7 @@ android {
|
||||
namespace = "org.thoughtcrime.securesms"
|
||||
|
||||
buildToolsVersion = libs.versions.buildTools.get()
|
||||
compileSdkVersion = libs.versions.compileSdk.get()
|
||||
compileSdkVersion(libs.versions.compileSdk.get())
|
||||
ndkVersion = libs.versions.ndk.get()
|
||||
|
||||
flavorDimensions += listOf("distribution", "environment")
|
||||
@@ -114,13 +133,7 @@ android {
|
||||
|
||||
android.bundle.language.enableSplit = false
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = libs.versions.kotlinJvmTarget.get()
|
||||
freeCompilerArgs = listOf("-Xjvm-default=all")
|
||||
suppressWarnings = true
|
||||
}
|
||||
|
||||
debugKeystorePropertiesProvider.orNull?.let { properties ->
|
||||
debugKeystorePropertiesProvider.get().takeIf { it.isNotEmpty() }?.let { properties ->
|
||||
signingConfigs.getByName("debug").apply {
|
||||
storeFile = file("${project.rootDir}/${properties.getProperty("storeFile")}")
|
||||
storePassword = properties.getProperty("storePassword")
|
||||
@@ -137,8 +150,8 @@ android {
|
||||
}
|
||||
|
||||
managedDevices {
|
||||
devices {
|
||||
create<ManagedVirtualDevice>("pixel3api30") {
|
||||
localDevices {
|
||||
create("pixel3api30") {
|
||||
device = "Pixel 3"
|
||||
apiLevel = 30
|
||||
systemImageSource = "google-atd"
|
||||
@@ -195,10 +208,6 @@ android {
|
||||
compose = true
|
||||
}
|
||||
|
||||
composeOptions {
|
||||
kotlinCompilerExtensionVersion = "1.5.4"
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
if (currentHotfixVersion >= maxHotfixVersions) {
|
||||
throw AssertionError("Hotfix version offset is too large!")
|
||||
@@ -291,7 +300,7 @@ android {
|
||||
isDefault = true
|
||||
isMinifyEnabled = false
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android.txt"),
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard/proguard-firebase-messaging.pro",
|
||||
"proguard/proguard-google-play-services.pro",
|
||||
"proguard/proguard-jackson.pro",
|
||||
@@ -308,6 +317,7 @@ android {
|
||||
"proguard/proguard-retrolambda.pro",
|
||||
"proguard/proguard-okhttp.pro",
|
||||
"proguard/proguard-ez-vcard.pro",
|
||||
"proguard/proguard-dnsjava.pro",
|
||||
"proguard/proguard.cfg"
|
||||
)
|
||||
testProguardFiles(
|
||||
@@ -453,8 +463,8 @@ android {
|
||||
buildConfigField("String", "SIGNAL_CDN3_URL", "\"https://cdn3-staging.signal.org\"")
|
||||
buildConfigField("String", "SIGNAL_CDSI_URL", "\"https://cdsi.staging.signal.org\"")
|
||||
buildConfigField("String", "SIGNAL_SVR2_URL", "\"https://svr2.staging.signal.org\"")
|
||||
buildConfigField("String", "SVR2_MRENCLAVE_LEGACY", "\"a75542d82da9f6914a1e31f8a7407053b99cc99a0e7291d8fbd394253e19b036\"")
|
||||
buildConfigField("String", "SVR2_MRENCLAVE", "\"97f151f6ed078edbbfd72fa9cae694dcc08353f1f5e8d9ccd79a971b10ffc535\"")
|
||||
buildConfigField("String", "SVR2_MRENCLAVE_LEGACY", "\"97f151f6ed078edbbfd72fa9cae694dcc08353f1f5e8d9ccd79a971b10ffc535\"")
|
||||
buildConfigField("String", "SVR2_MRENCLAVE", "\"3c699f4975aaa3d172c0aad042f94f031b2b03e10b9c19a45116a01693d83302\"")
|
||||
buildConfigField("String[]", "UNIDENTIFIED_SENDER_TRUST_ROOTS", "new String[]{\"BbqY1DzohE4NUZoVF+L18oUPrK3kILllLEJh2UnPSsEx\", \"BYhU6tPjqP46KGZEzRs1OL4U39V5dlPJ/X09ha4rErkm\"}")
|
||||
buildConfigField("String", "ZKGROUP_SERVER_PUBLIC_PARAMS", "\"ABSY21VckQcbSXVNCGRYJcfWHiAMZmpTtTELcDmxgdFbtp/bWsSxZdMKzfCp8rvIs8ocCU3B37fT3r4Mi5qAemeGeR2X+/YmOGR5ofui7tD5mDQfstAI9i+4WpMtIe8KC3wU5w3Inq3uNWVmoGtpKndsNfwJrCg0Hd9zmObhypUnSkfYn2ooMOOnBpfdanRtrvetZUayDMSC5iSRcXKpdlukrpzzsCIvEwjwQlJYVPOQPj4V0F4UXXBdHSLK05uoPBCQG8G9rYIGedYsClJXnbrgGYG3eMTG5hnx4X4ntARBgELuMWWUEEfSK0mjXg+/2lPmWcTZWR9nkqgQQP0tbzuiPm74H2wMO4u1Wafe+UwyIlIT9L7KLS19Aw8r4sPrXZSSsOZ6s7M1+rTJN0bI5CKY2PX29y5Ok3jSWufIKcgKOnWoP67d5b2du2ZVJjpjfibNIHbT/cegy/sBLoFwtHogVYUewANUAXIaMPyCLRArsKhfJ5wBtTminG/PAvuBdJ70Z/bXVPf8TVsR292zQ65xwvWTejROW6AZX6aqucUjlENAErBme1YHmOSpU6tr6doJ66dPzVAWIanmO/5mgjNEDeK7DDqQdB1xd03HT2Qs2TxY3kCK8aAb/0iM0HQiXjxZ9HIgYhbtvGEnDKW5ILSUydqH/KBhW4Pb0jZWnqN/YgbWDKeJxnDbYcUob5ZY5Lt5ZCMKuaGUvCJRrCtuugSMaqjowCGRempsDdJEt+cMaalhZ6gczklJB/IbdwENW9KeVFPoFNFzhxWUIS5ML9riVYhAtE6JE5jX0xiHNVIIPthb458cfA8daR0nYfYAUKogQArm0iBezOO+mPk5vCNWI+wwkyFCqNDXz/qxl1gAntuCJtSfq9OC3NkdhQlgYQ==\"")
|
||||
buildConfigField("String", "GENERIC_SERVER_PUBLIC_PARAMS", "\"AHILOIrFPXX9laLbalbA9+L1CXpSbM/bTJXZGZiuyK1JaI6dK5FHHWL6tWxmHKYAZTSYmElmJ5z2A5YcirjO/yfoemE03FItyaf8W1fE4p14hzb5qnrmfXUSiAIVrhaXVwIwSzH6RL/+EO8jFIjJ/YfExfJ8aBl48CKHgu1+A6kWynhttonvWWx6h7924mIzW0Czj2ROuh4LwQyZypex4GuOPW8sgIT21KNZaafgg+KbV7XM1x1tF3XA17B4uGUaDbDw2O+nR1+U5p6qHPzmJ7ggFjSN6Utu+35dS1sS0P9N\"")
|
||||
@@ -480,70 +490,6 @@ android {
|
||||
lintConfig = rootProject.file("lint.xml")
|
||||
}
|
||||
|
||||
androidComponents {
|
||||
beforeVariants { variant ->
|
||||
variant.enable = variant.name in selectableVariants
|
||||
}
|
||||
onVariants(selector().all()) { variant: com.android.build.api.variant.ApplicationVariant ->
|
||||
// Include the test-only library on debug builds.
|
||||
if (variant.buildType != "instrumentation") {
|
||||
variant.packaging.jniLibs.excludes.add("**/libsignal_jni_testing.so")
|
||||
}
|
||||
|
||||
// Starting with minSdk 23, Android leaves native libraries uncompressed, which is fine for the Play Store, but not for our self-distributed APKs.
|
||||
// This reverts it to the legacy behavior, compressing the native libraries, and drastically reducing the APK file size.
|
||||
if (variant.name.contains("website", ignoreCase = true) || variant.name.contains("github", ignoreCase = true)) {
|
||||
variant.packaging.jniLibs.useLegacyPackaging.set(true)
|
||||
}
|
||||
|
||||
// Version overrides
|
||||
if (variant.name.contains("nightly", ignoreCase = true)) {
|
||||
var tag = getNightlyTagForCurrentCommit()
|
||||
if (!tag.isNullOrEmpty()) {
|
||||
if (tag.startsWith("v")) {
|
||||
tag = tag.substring(1)
|
||||
}
|
||||
|
||||
// We add a multiple of maxHotfixVersions to nightlies to ensure we're always at least that many versions ahead
|
||||
val nightlyBuffer = (5 * maxHotfixVersions)
|
||||
val nightlyVersionCode = (canonicalVersionCode * maxHotfixVersions) + (getNightlyBuildNumber(tag) * 10) + nightlyBuffer
|
||||
|
||||
variant.outputs.forEach { output ->
|
||||
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 }
|
||||
}
|
||||
|
||||
onVariants(selector().withBuildType("benchmark")) { variant ->
|
||||
val taskProvider = tasks.register<CopyBenchmarkBackupTask>("copyBenchmarkBackup${variant.name.capitalize()}") {
|
||||
if (benchmarkBackupFile != null) {
|
||||
inputFile.set(File(benchmarkBackupFile))
|
||||
}
|
||||
}
|
||||
variant.sources.assets?.addGeneratedSourceDirectory(taskProvider) { it.outputDir }
|
||||
}
|
||||
}
|
||||
|
||||
val releaseDir = "$projectDir/src/release/java"
|
||||
val debugDir = "$projectDir/src/debug/java"
|
||||
|
||||
@@ -565,15 +511,79 @@ android {
|
||||
manifest.srcFile("$projectDir/src/benchmarkShared/AndroidManifest.xml")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
applicationVariants.configureEach {
|
||||
outputs.configureEach {
|
||||
if (this is com.android.build.gradle.internal.api.BaseVariantOutputImpl) {
|
||||
val fileVersionName = versionName.substringBefore(" |")
|
||||
outputFileName = outputFileName.replace(".apk", "-$fileVersionName.apk")
|
||||
androidComponents {
|
||||
beforeVariants { variant ->
|
||||
variant.enable = variant.name in selectableVariants
|
||||
}
|
||||
onVariants(selector().all()) { variant: com.android.build.api.variant.ApplicationVariant ->
|
||||
// Rename APK to include version name
|
||||
val renameTask = tasks.register<RenameApkTask>("renameApk${variant.name.replaceFirstChar { it.uppercase() }}")
|
||||
val renameRequest = variant.artifacts.use(renameTask)
|
||||
.wiredWithDirectories(RenameApkTask::apkFolder, RenameApkTask::outFolder)
|
||||
.toTransformMany(SingleArtifact.APK)
|
||||
renameTask.configure {
|
||||
transformationRequest.set(renameRequest)
|
||||
}
|
||||
|
||||
// Include the test-only library on debug builds.
|
||||
if (variant.buildType != "instrumentation") {
|
||||
variant.packaging.jniLibs.excludes.add("**/libsignal_jni_testing.so")
|
||||
}
|
||||
|
||||
// Starting with minSdk 23, Android leaves native libraries uncompressed, which is fine for the Play Store, but not for our self-distributed APKs.
|
||||
// This reverts it to the legacy behavior, compressing the native libraries, and drastically reducing the APK file size.
|
||||
if (variant.name.contains("website", ignoreCase = true) || variant.name.contains("github", ignoreCase = true)) {
|
||||
variant.packaging.jniLibs.useLegacyPackaging.set(true)
|
||||
}
|
||||
|
||||
// Version overrides
|
||||
if (variant.name.contains("nightly", ignoreCase = true)) {
|
||||
var tag = getNightlyTagForCurrentCommit()
|
||||
if (!tag.isNullOrEmpty()) {
|
||||
if (tag.startsWith("v")) {
|
||||
tag = tag.substring(1)
|
||||
}
|
||||
|
||||
// We add a multiple of maxHotfixVersions to nightlies to ensure we're always at least that many versions ahead
|
||||
val nightlyBuffer = (5 * maxHotfixVersions)
|
||||
val nightlyVersionCode = (canonicalVersionCode * maxHotfixVersions) + (getNightlyBuildNumber(tag) * 10) + nightlyBuffer
|
||||
|
||||
variant.outputs.forEach { output ->
|
||||
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 }
|
||||
}
|
||||
|
||||
onVariants(selector().withBuildType("benchmark")) { variant ->
|
||||
val taskProvider = tasks.register<CopyBenchmarkBackupTask>("copyBenchmarkBackup${variant.name.capitalize()}") {
|
||||
if (benchmarkBackupFile != null) {
|
||||
inputFile.set(File(benchmarkBackupFile))
|
||||
}
|
||||
}
|
||||
variant.sources.assets?.addGeneratedSourceDirectory(taskProvider) { it.outputDir }
|
||||
}
|
||||
}
|
||||
|
||||
baselineProfile {
|
||||
@@ -590,6 +600,14 @@ baselineProfile {
|
||||
dexLayoutOptimization = false
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget = JvmTarget.fromTarget(libs.versions.kotlinJvmTarget.get())
|
||||
freeCompilerArgs.addAll("-Xjvm-default=all")
|
||||
suppressWarnings = true
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
lintChecks(project(":lintchecks"))
|
||||
ktlintRuleset(libs.ktlint.twitter.compose)
|
||||
@@ -619,11 +637,7 @@ dependencies {
|
||||
implementation(project(":lib:apng"))
|
||||
|
||||
implementation(libs.androidx.fragment.ktx)
|
||||
implementation(libs.androidx.appcompat) {
|
||||
version {
|
||||
strictly("1.6.1")
|
||||
}
|
||||
}
|
||||
implementation(libs.androidx.appcompat)
|
||||
implementation(libs.androidx.window.window)
|
||||
implementation(libs.androidx.window.java)
|
||||
implementation(libs.androidx.recyclerview)
|
||||
@@ -672,6 +686,7 @@ dependencies {
|
||||
implementation(libs.google.play.services.maps)
|
||||
implementation(libs.google.play.services.auth)
|
||||
implementation(libs.google.signin)
|
||||
implementation(libs.androidx.media)
|
||||
implementation(libs.bundles.media3)
|
||||
implementation(libs.conscrypt.android)
|
||||
implementation(libs.signal.aesgcmprovider)
|
||||
@@ -743,6 +758,7 @@ dependencies {
|
||||
}
|
||||
testImplementation(testLibs.conscrypt.openjdk.uber)
|
||||
testImplementation(testLibs.mockk)
|
||||
testImplementation(testFixtures(project(":core:ui")))
|
||||
testImplementation(testFixtures(project(":lib:libsignal-service")))
|
||||
testImplementation(testLibs.espresso.core)
|
||||
testImplementation(testLibs.kotlinx.coroutines.test)
|
||||
@@ -857,7 +873,7 @@ abstract class LanguageListValueSource : ValueSource<List<String>, LanguageListV
|
||||
}
|
||||
}
|
||||
|
||||
abstract class PropertiesFileValueSource : ValueSource<Properties?, PropertiesFileValueSource.Params> {
|
||||
abstract class PropertiesFileValueSource : ValueSource<Properties, PropertiesFileValueSource.Params> {
|
||||
interface Params : ValueSourceParameters {
|
||||
@get:InputFile
|
||||
@get:Optional
|
||||
@@ -865,9 +881,9 @@ abstract class PropertiesFileValueSource : ValueSource<Properties?, PropertiesFi
|
||||
val file: RegularFileProperty
|
||||
}
|
||||
|
||||
override fun obtain(): Properties? {
|
||||
override fun obtain(): Properties {
|
||||
val f: File = parameters.file.asFile.get()
|
||||
if (!f.exists()) return null
|
||||
if (!f.exists()) return Properties()
|
||||
|
||||
return Properties().apply {
|
||||
f.inputStream().use { load(it) }
|
||||
@@ -937,3 +953,30 @@ abstract class CopyBenchmarkBackupTask : DefaultTask() {
|
||||
backupFile.copyTo(dest.resolve("backup.binproto"), overwrite = true)
|
||||
}
|
||||
}
|
||||
|
||||
abstract class RenameApkTask : DefaultTask() {
|
||||
@get:InputFiles
|
||||
abstract val apkFolder: DirectoryProperty
|
||||
|
||||
@get:OutputDirectory
|
||||
abstract val outFolder: DirectoryProperty
|
||||
|
||||
@get:Internal
|
||||
abstract val transformationRequest: Property<ArtifactTransformationRequest<RenameApkTask>>
|
||||
|
||||
@TaskAction
|
||||
fun rename() {
|
||||
transformationRequest.get().submit(this) { artifact ->
|
||||
val originalFile = File(artifact.outputFile)
|
||||
val versionName = artifact.versionName?.substringBefore(" |")
|
||||
val newName = if (!versionName.isNullOrEmpty()) {
|
||||
originalFile.name.replace(".apk", "-$versionName.apk")
|
||||
} else {
|
||||
originalFile.name
|
||||
}
|
||||
val newFile = File(outFolder.get().asFile, newName)
|
||||
originalFile.copyTo(newFile, overwrite = true)
|
||||
newFile
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -513,6 +513,17 @@
|
||||
column="31"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="SoonBlockedPrivateApi"
|
||||
message="Reflective access to mAttachInfo will throw an exception when targeting API 35 and above"
|
||||
errorLine1=" Field attachInfoField = View.class.getDeclaredField("mAttachInfo");"
|
||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/java/org/thoughtcrime/securesms/components/KeyboardAwareLinearLayout.java"
|
||||
line="157"
|
||||
column="31"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="SimpleDateFormat"
|
||||
message="To get local formatting use `getDateInstance()`, `getDateTimeInstance()`, or `getTimeInstance()`, or use `new SimpleDateFormat(String template, Locale locale)` with for example `Locale.US` for ASCII dates."
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
# dnsjava references desktop/server-only classes that are absent on Android.
|
||||
-dontwarn com.sun.jna.**
|
||||
-dontwarn javax.naming.**
|
||||
-dontwarn lombok.Generated
|
||||
@@ -7,6 +7,7 @@
|
||||
-keep class org.signal.libsignal.usernames.** { *; }
|
||||
-keep class org.thoughtcrime.securesms.** { *; }
|
||||
-keep class org.signal.donations.json.** { *; }
|
||||
-keep class org.signal.network.** { *; }
|
||||
-keepclassmembers class ** {
|
||||
public void onEvent*(**);
|
||||
}
|
||||
@@ -16,6 +17,14 @@
|
||||
|
||||
-keep class androidx.window.** { *; }
|
||||
|
||||
# Workaround for R8 non-determinism in AGP 9.x. R8 inconsistently keeps or strips
|
||||
# the Signature attribute on this Kotlin lambda subclass of the generic
|
||||
# LottieValueCallback, causing intermittent dex byte differences. Explicitly
|
||||
# keeping the class stabilizes R8's attribute decisions.
|
||||
-keep class com.airbnb.lottie.compose.LottieDynamicPropertiesKt$toValueCallback$1 {
|
||||
*;
|
||||
}
|
||||
|
||||
-keepclassmembers class * extends androidx.constraintlayout.motion.widget.Key {
|
||||
public <init>();
|
||||
}
|
||||
|
||||
+1
-1
@@ -16,6 +16,7 @@ import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.signal.donations.InAppPaymentType
|
||||
import org.signal.network.NetworkResult
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository
|
||||
import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord
|
||||
@@ -26,7 +27,6 @@ import org.thoughtcrime.securesms.testing.InAppPaymentsRule
|
||||
import org.thoughtcrime.securesms.testing.RxTestSchedulerRule
|
||||
import org.thoughtcrime.securesms.testing.SignalActivityRule
|
||||
import org.thoughtcrime.securesms.testing.actions.RecyclerViewScrollToBottomAction
|
||||
import org.whispersystems.signalservice.api.NetworkResult
|
||||
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription
|
||||
import org.whispersystems.signalservice.api.subscriptions.SubscriberId
|
||||
import java.math.BigDecimal
|
||||
|
||||
@@ -19,6 +19,8 @@ import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.signal.network.NetworkResult
|
||||
import org.signal.network.exceptions.NonSuccessfulResponseCodeException
|
||||
import org.thoughtcrime.securesms.backup.DeletionState
|
||||
import org.thoughtcrime.securesms.backup.v2.BackupRepository
|
||||
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
|
||||
@@ -27,8 +29,6 @@ import org.thoughtcrime.securesms.jobs.protos.BackupDeleteJobData
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.testing.SignalActivityRule
|
||||
import org.thoughtcrime.securesms.util.RemoteConfig
|
||||
import org.whispersystems.signalservice.api.NetworkResult
|
||||
import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException
|
||||
|
||||
class BackupDeleteJobTest {
|
||||
|
||||
|
||||
+2
-2
@@ -27,6 +27,8 @@ import org.signal.core.util.billing.BillingPurchaseState
|
||||
import org.signal.core.util.billing.BillingResponseCode
|
||||
import org.signal.core.util.money.FiatMoney
|
||||
import org.signal.donations.InAppPaymentType
|
||||
import org.signal.network.NetworkResult
|
||||
import org.signal.network.exceptions.NonSuccessfulResponseCodeException
|
||||
import org.thoughtcrime.securesms.backup.DeletionState
|
||||
import org.thoughtcrime.securesms.backup.v2.BackupRepository
|
||||
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
|
||||
@@ -42,8 +44,6 @@ import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.net.SignalNetwork
|
||||
import org.thoughtcrime.securesms.testing.SignalActivityRule
|
||||
import org.thoughtcrime.securesms.util.RemoteConfig
|
||||
import org.whispersystems.signalservice.api.NetworkResult
|
||||
import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException
|
||||
import org.whispersystems.signalservice.api.storage.IAPSubscriptionId
|
||||
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription
|
||||
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription.ChargeFailure
|
||||
|
||||
@@ -0,0 +1,843 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.main
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.Bitmap
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.widget.TextView
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.signal.core.models.media.Media
|
||||
import org.thoughtcrime.securesms.MainActivity
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.calls.log.CallLogFragment
|
||||
import org.thoughtcrime.securesms.conversation.ConversationArgs
|
||||
import org.thoughtcrime.securesms.conversation.ConversationIntents
|
||||
import org.thoughtcrime.securesms.conversation.v2.ConversationFragment
|
||||
import org.thoughtcrime.securesms.conversationlist.ConversationListArchiveFragment
|
||||
import org.thoughtcrime.securesms.conversationlist.ConversationListFragment
|
||||
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionActivity
|
||||
import org.thoughtcrime.securesms.providers.BlobProvider
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.stories.landing.StoriesLandingFragment
|
||||
import org.thoughtcrime.securesms.testing.SignalActivityRule
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.util.Collections
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* End-to-end launch tests for [MainActivity], covering cold-launch and onNewIntent paths
|
||||
* through [MainNavigationViewModel].
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class MainNavigationLaunchTest {
|
||||
|
||||
@get:Rule
|
||||
val harness = SignalActivityRule(othersCount = 2)
|
||||
|
||||
private val context: Context get() = harness.context
|
||||
private val recipient: RecipientId get() = harness.others.first()
|
||||
|
||||
/**
|
||||
* Share-target cold-launch regression test. Pre-fix, wrapNavigator() re-routed the
|
||||
* early-staged Conversation through goTo(), whose async wallpaper-prefetch path emitted
|
||||
* a SECOND internalDetailLocation with a fresh ConversationArgs — recreating the
|
||||
* fragment and dropping share data.
|
||||
*/
|
||||
@Test
|
||||
fun coldLaunch_shareIntent_createsFragmentExactlyOnceWithShareData() {
|
||||
val timestamp = System.currentTimeMillis()
|
||||
val mimeType = "image/jpeg"
|
||||
val blob = realBlob(byteArrayOf(0x01, 0x02, 0x03), mimeType)
|
||||
val intent = shareToConversationIntent(
|
||||
recipient = recipient,
|
||||
blob = blob,
|
||||
mimeType = mimeType,
|
||||
shareDataTimestamp = timestamp
|
||||
)
|
||||
|
||||
launchSync(intent).use { launched ->
|
||||
val recorder = launched.recorder
|
||||
|
||||
try {
|
||||
await(timeoutMs = 10_000, description = "ConversationFragment to be added") {
|
||||
recorder.createdArgs.isNotEmpty()
|
||||
}
|
||||
} catch (e: IllegalStateException) {
|
||||
val vm = runOnMainSync { launched.activity.mainNavigationViewModel() }
|
||||
val state = runOnMainSync {
|
||||
buildString {
|
||||
appendLine("--- diagnostic dump ---")
|
||||
appendLine("fragments observed: ${recorder.allCreated}")
|
||||
appendLine("activity fragments: ${launched.activity.supportFragmentManager.fragments.map { it::class.simpleName }}")
|
||||
appendLine("vm.currentListLocation: ${vm.mainNavigationState.value.currentListLocation}")
|
||||
appendLine("vm.earlyNavigationDetailLocationRequested: ${vm.earlyNavigationDetailLocationRequested}")
|
||||
}
|
||||
}
|
||||
throw IllegalStateException("${e.message}\n$state", e)
|
||||
}
|
||||
|
||||
val expectedName = runOnMainSync { Recipient.resolved(recipient).getDisplayName(context) }
|
||||
awaitConversationTitle(launched, expectedName)
|
||||
|
||||
// Give the post-navigator wallpaper-prefetch path a chance to emit a (pre-fix)
|
||||
// duplicate second nav before we count fragments.
|
||||
Thread.sleep(750)
|
||||
|
||||
check(recorder.createdArgs.size == 1) {
|
||||
"Expected exactly one ConversationFragment, got ${recorder.createdArgs.size}"
|
||||
}
|
||||
val args = recorder.createdArgs.single()
|
||||
check(args.shareDataTimestamp == timestamp) {
|
||||
"Expected shareDataTimestamp=$timestamp, got ${args.shareDataTimestamp}"
|
||||
}
|
||||
check(args.recipientId == recipient) {
|
||||
"Expected recipient=$recipient, got ${args.recipientId}"
|
||||
}
|
||||
check(args.draftMedia == blob) {
|
||||
"Expected draftMedia=$blob, got ${args.draftMedia}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Image-share cold-launch: the dispatch path through `ShareOrDraftData.StartSendMedia`
|
||||
* that hops the user from the conversation into the media-send screen
|
||||
* ([MediaSelectionActivity]). Asserts that the secondary activity actually launches and
|
||||
* that its [MediaReviewFragment] surfaces the recipient's display name in the top
|
||||
* corner — i.e. it knows who the share is targeted at.
|
||||
*/
|
||||
@Test
|
||||
fun coldLaunch_shareImageIntent_opensMediaSendForRecipient() {
|
||||
val media = realJpegMedia()
|
||||
val intent = shareImageIntent(recipient = recipient, media = media)
|
||||
|
||||
launchSync(intent).use { launched ->
|
||||
val mediaSend = launched.awaitActivity(MediaSelectionActivity::class.java, timeoutMs = 20_000)
|
||||
val expectedName = runOnMainSync { Recipient.resolved(recipient).getDisplayName(context) }
|
||||
|
||||
await(timeoutMs = 15_000, description = "recipient label populated in MediaReviewFragment") {
|
||||
// await() already runs the predicate on the main thread; nesting another
|
||||
// runOnMainSync here would throw "can not be called from the main application thread".
|
||||
mediaSend.findViewById<TextView>(R.id.recipient)?.text?.toString() == expectedName
|
||||
}
|
||||
|
||||
// Exactly one ConversationFragment should have been created — the share dispatch
|
||||
// happens from inside it, then it stays put while the media editor sits on top.
|
||||
check(launched.recorder.createdArgs.size == 1) {
|
||||
"Expected exactly one ConversationFragment for image share, got ${launched.recorder.createdArgs.size}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Text-share cold-launch: the dispatch path through `ShareOrDraftData.SetText`. Asserts
|
||||
* the navigation boundary — one ConversationFragment, no secondary activity pushed on
|
||||
* top — *and* that the draft text actually shows up in the composer the user sees.
|
||||
*/
|
||||
@Test
|
||||
fun coldLaunch_shareTextIntent_opensConversationWithDraftText() {
|
||||
val draftText = "hello from share"
|
||||
val intent = shareTextIntent(recipient = recipient, text = draftText)
|
||||
|
||||
launchSync(intent).use { launched ->
|
||||
val recorder = launched.recorder
|
||||
await(timeoutMs = 10_000, description = "ConversationFragment to be added") {
|
||||
recorder.createdArgs.isNotEmpty()
|
||||
}
|
||||
|
||||
awaitComposerText(launched, draftText)
|
||||
|
||||
// Give a beat for any spurious second navigation to surface.
|
||||
Thread.sleep(750)
|
||||
|
||||
check(recorder.createdArgs.size == 1) {
|
||||
"Expected exactly one ConversationFragment, got ${recorder.createdArgs.size}"
|
||||
}
|
||||
val args = recorder.createdArgs.single()
|
||||
check(args.recipientId == recipient) {
|
||||
"Expected recipient=$recipient, got ${args.recipientId}"
|
||||
}
|
||||
check(args.draftMedia == null) {
|
||||
"Expected no draftMedia, got ${args.draftMedia}"
|
||||
}
|
||||
check(launched.nonMainActivities().isEmpty()) {
|
||||
"Text share should not launch a secondary activity, got ${launched.nonMainActivities().map { it::class.simpleName }}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun coldLaunch_notificationIntent_opensConversation() {
|
||||
val intent = notificationToConversationIntent(recipient)
|
||||
launchSync(intent).use { launched ->
|
||||
val recorder = launched.recorder
|
||||
await(timeoutMs = 10_000, description = "ConversationFragment to be added") {
|
||||
recorder.createdArgs.isNotEmpty()
|
||||
}
|
||||
|
||||
val expectedName = runOnMainSync { Recipient.resolved(recipient).getDisplayName(context) }
|
||||
awaitConversationTitle(launched, expectedName)
|
||||
|
||||
check(recorder.createdArgs.size == 1) {
|
||||
"Expected exactly one ConversationFragment, got ${recorder.createdArgs.size}"
|
||||
}
|
||||
val args = recorder.createdArgs.single()
|
||||
check(args.recipientId == recipient) {
|
||||
"Expected recipient=$recipient, got ${args.recipientId}"
|
||||
}
|
||||
check(args.threadId > 0) {
|
||||
"Expected threadId > 0, got ${args.threadId}"
|
||||
}
|
||||
check(args.draftMedia == null) {
|
||||
"Expected no draftMedia, got ${args.draftMedia}"
|
||||
}
|
||||
check(args.shareDataTimestamp == -1L) {
|
||||
"Expected shareDataTimestamp=-1 for notification path, got ${args.shareDataTimestamp}"
|
||||
}
|
||||
val vm = runOnMainSync { launched.activity.mainNavigationViewModel() }
|
||||
check(vm.mainNavigationState.value.currentListLocation == MainNavigationListLocation.CHATS) {
|
||||
"Expected currentListLocation=CHATS, got ${vm.mainNavigationState.value.currentListLocation}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun coldLaunch_tabIntent_setsListLocation() {
|
||||
val intent = tabIntent(MainNavigationListLocation.CALLS)
|
||||
launchSync(intent).use { launched ->
|
||||
val recorder = launched.recorder
|
||||
awaitListFragment(launched, MainNavigationListLocation.CALLS)
|
||||
|
||||
val vm = runOnMainSync { launched.activity.mainNavigationViewModel() }
|
||||
check(vm.mainNavigationState.value.currentListLocation == MainNavigationListLocation.CALLS) {
|
||||
"Expected VM CALLS, got ${vm.mainNavigationState.value.currentListLocation}"
|
||||
}
|
||||
Thread.sleep(750)
|
||||
check(recorder.createdArgs.isEmpty()) {
|
||||
"Expected no ConversationFragment for tab launch, got ${recorder.createdArgs.size}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Locks down present cold-launch behaviour for KEY_DETAIL_LOCATION: today it is only
|
||||
* consumed by onNewIntent. If a future change starts handling it on cold launch, this
|
||||
* test should fail and force a deliberate decision.
|
||||
*/
|
||||
@Test
|
||||
fun coldLaunch_detailLocationIntent_isNoOpToday() {
|
||||
val intent = detailLocationIntent(MainNavigationDetailLocation.Chats.ConversationSettings(recipient))
|
||||
launchSync(intent).use { launched ->
|
||||
val recorder = launched.recorder
|
||||
Thread.sleep(1500)
|
||||
check(recorder.createdArgs.isEmpty()) {
|
||||
"KEY_DETAIL_LOCATION is currently only handled by onNewIntent. If a future change " +
|
||||
"starts handling it on cold launch, update or delete this test. Got: ${recorder.allCreated}"
|
||||
}
|
||||
val vm = runOnMainSync { launched.activity.mainNavigationViewModel() }
|
||||
check(vm.earlyNavigationDetailLocationRequested == null) {
|
||||
"Expected no early detail to be staged, got ${vm.earlyNavigationDetailLocationRequested}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun coldLaunch_deepLinkIntent_reachesChatsList() {
|
||||
val intent = deepLinkIntent(Uri.parse("https://signal.org/test-not-a-real-deeplink"))
|
||||
launchSync(intent).use { launched ->
|
||||
val recorder = launched.recorder
|
||||
awaitListFragment(launched, MainNavigationListLocation.CHATS)
|
||||
|
||||
val vm = runOnMainSync { launched.activity.mainNavigationViewModel() }
|
||||
check(vm.mainNavigationState.value.currentListLocation == MainNavigationListLocation.CHATS) {
|
||||
"Expected CHATS for deep-link launch, got ${vm.mainNavigationState.value.currentListLocation}"
|
||||
}
|
||||
check(recorder.createdArgs.isEmpty()) {
|
||||
"Expected no ConversationFragment for deep-link launch, got ${recorder.createdArgs.size}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun coldLaunch_noExtras_defaultsToChats() {
|
||||
val intent = Intent(context, MainActivity::class.java)
|
||||
launchSync(intent).use { launched ->
|
||||
val recorder = launched.recorder
|
||||
awaitListFragment(launched, MainNavigationListLocation.CHATS)
|
||||
|
||||
val vm = runOnMainSync { launched.activity.mainNavigationViewModel() }
|
||||
check(vm.mainNavigationState.value.currentListLocation == MainNavigationListLocation.CHATS) {
|
||||
"Expected default CHATS, got ${vm.mainNavigationState.value.currentListLocation}"
|
||||
}
|
||||
Thread.sleep(750)
|
||||
check(vm.earlyNavigationDetailLocationRequested == null) {
|
||||
"Expected no early detail, got ${vm.earlyNavigationDetailLocationRequested}"
|
||||
}
|
||||
check(recorder.createdArgs.isEmpty()) {
|
||||
"Expected no ConversationFragment for bare launch, got ${recorder.createdArgs.size}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun warmStart_onNewIntent_conversationIntent_opensConversation() {
|
||||
launchSync(Intent(context, MainActivity::class.java)).use { launched ->
|
||||
val recorder = launched.recorder
|
||||
// Let the bare list settle so we know any further fragment adds came from onNewIntent.
|
||||
Thread.sleep(1000)
|
||||
val baseline = recorder.createdArgs.size
|
||||
|
||||
val warmIntent = notificationToConversationIntent(recipient)
|
||||
runOnMainSync {
|
||||
InstrumentationRegistry.getInstrumentation().callActivityOnNewIntent(launched.activity, warmIntent)
|
||||
}
|
||||
|
||||
await(timeoutMs = 10_000, description = "ConversationFragment after onNewIntent") {
|
||||
recorder.createdArgs.size > baseline
|
||||
}
|
||||
|
||||
val expectedName = runOnMainSync { Recipient.resolved(recipient).getDisplayName(context) }
|
||||
awaitConversationTitle(launched, expectedName)
|
||||
|
||||
val newArgs = recorder.createdArgs.drop(baseline)
|
||||
check(newArgs.size == 1) { "Expected one new ConversationFragment, got ${newArgs.size}" }
|
||||
check(newArgs.single().recipientId == recipient) {
|
||||
"Expected recipient=$recipient, got ${newArgs.single().recipientId}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mid-conversation onNewIntent with `KEY_DETAIL_LOCATION = Empty` — the contract used
|
||||
* by [ConversationSettingsFragment.goToConversationList] to drop back to the chat list
|
||||
* on phones. No new ConversationFragment should be added.
|
||||
*/
|
||||
@Test
|
||||
fun warmStart_onNewIntent_emptyDetailIntent_returnsToList() {
|
||||
launchSync(notificationToConversationIntent(recipient)).use { launched ->
|
||||
val recorder = launched.recorder
|
||||
await(timeoutMs = 10_000, description = "initial ConversationFragment") {
|
||||
recorder.createdArgs.isNotEmpty()
|
||||
}
|
||||
val baseline = recorder.createdArgs.size
|
||||
|
||||
val warmIntent = detailLocationIntent(MainNavigationDetailLocation.Empty)
|
||||
runOnMainSync {
|
||||
InstrumentationRegistry.getInstrumentation().callActivityOnNewIntent(launched.activity, warmIntent)
|
||||
}
|
||||
|
||||
await(description = "no new ConversationFragment after Empty detail intent") {
|
||||
recorder.createdArgs.size == baseline
|
||||
}
|
||||
// The user-visible signal that we're "back on the list" is the chat list fragment
|
||||
// being attached, not just the VM saying CHATS.
|
||||
awaitListFragment(launched, MainNavigationListLocation.CHATS)
|
||||
|
||||
val vm = runOnMainSync { launched.activity.mainNavigationViewModel() }
|
||||
check(vm.mainNavigationState.value.currentListLocation == MainNavigationListLocation.CHATS) {
|
||||
"Expected CHATS, got ${vm.mainNavigationState.value.currentListLocation}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun warmStart_onNewIntent_tabIntent_switchesList() {
|
||||
launchSync(Intent(context, MainActivity::class.java)).use { launched ->
|
||||
awaitListFragment(launched, MainNavigationListLocation.CHATS)
|
||||
|
||||
val warmIntent = tabIntent(MainNavigationListLocation.CALLS)
|
||||
runOnMainSync {
|
||||
InstrumentationRegistry.getInstrumentation().callActivityOnNewIntent(launched.activity, warmIntent)
|
||||
}
|
||||
|
||||
awaitListFragment(launched, MainNavigationListLocation.CALLS)
|
||||
|
||||
val vm = runOnMainSync { launched.activity.mainNavigationViewModel() }
|
||||
check(vm.mainNavigationState.value.currentListLocation == MainNavigationListLocation.CALLS) {
|
||||
"Expected VM CALLS, got ${vm.mainNavigationState.value.currentListLocation}"
|
||||
}
|
||||
check(launched.recorder.createdArgs.isEmpty()) {
|
||||
"Expected no ConversationFragment for tab switch, got ${launched.recorder.createdArgs.size}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun recreate_midConversation_restoresState() {
|
||||
launchSync(notificationToConversationIntent(recipient)).use { launched ->
|
||||
val recorder = launched.recorder
|
||||
await(timeoutMs = 10_000, description = "initial ConversationFragment") {
|
||||
recorder.createdArgs.isNotEmpty()
|
||||
}
|
||||
val expectedName = runOnMainSync { Recipient.resolved(recipient).getDisplayName(context) }
|
||||
awaitConversationTitle(launched, expectedName)
|
||||
val initial = recorder.createdArgs.first()
|
||||
|
||||
runOnMainSync { launched.activity.recreate() }
|
||||
|
||||
await(timeoutMs = 15_000, description = "ConversationFragment after recreate") {
|
||||
recorder.createdArgs.size >= 2
|
||||
}
|
||||
// Verify the user-visible title rebinds after recreate, not just the args.
|
||||
awaitConversationTitle(launched, expectedName)
|
||||
|
||||
val recreated = recorder.createdArgs[1]
|
||||
check(recreated.recipientId == initial.recipientId) {
|
||||
"Recipient changed across recreate: ${initial.recipientId} -> ${recreated.recipientId}"
|
||||
}
|
||||
check(recreated.threadId == initial.threadId) {
|
||||
"Thread changed across recreate: ${initial.threadId} -> ${recreated.threadId}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun recreate_midTab_restoresTab() {
|
||||
launchSync(tabIntent(MainNavigationListLocation.CALLS)).use { launched ->
|
||||
awaitListFragment(launched, MainNavigationListLocation.CALLS)
|
||||
|
||||
runOnMainSync { launched.activity.recreate() }
|
||||
|
||||
// Verify the user-visible tab content rebinds after recreate, not just the VM. The
|
||||
// recorder removes destroyed fragments, so this only passes once the post-recreate
|
||||
// CallLogFragment instance is attached.
|
||||
awaitListFragment(launched, MainNavigationListLocation.CALLS)
|
||||
|
||||
// launched.activity returns the *latest* MainActivity (the holder updates in
|
||||
// onActivityCreated), so this reads the post-recreate VM instance.
|
||||
val location = runOnMainSync {
|
||||
launched.activity.mainNavigationViewModel().mainNavigationState.value.currentListLocation
|
||||
}
|
||||
check(location == MainNavigationListLocation.CALLS) {
|
||||
"Expected VM CALLS post-recreate, got $location"
|
||||
}
|
||||
check(launched.recorder.createdArgs.isEmpty()) {
|
||||
"Expected no ConversationFragment across tab recreate, got ${launched.recorder.createdArgs.size}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun recreate_midShareConversation_preservesShareData() {
|
||||
val timestamp = System.currentTimeMillis()
|
||||
val mimeType = "image/jpeg"
|
||||
val blob = realBlob(byteArrayOf(0x01, 0x02, 0x03), mimeType)
|
||||
val intent = shareToConversationIntent(
|
||||
recipient = recipient,
|
||||
blob = blob,
|
||||
mimeType = mimeType,
|
||||
shareDataTimestamp = timestamp
|
||||
)
|
||||
|
||||
launchSync(intent).use { launched ->
|
||||
val recorder = launched.recorder
|
||||
await(timeoutMs = 10_000, description = "initial ConversationFragment") {
|
||||
recorder.createdArgs.isNotEmpty()
|
||||
}
|
||||
val expectedName = runOnMainSync { Recipient.resolved(recipient).getDisplayName(context) }
|
||||
awaitConversationTitle(launched, expectedName)
|
||||
val initialCount = recorder.createdArgs.size
|
||||
|
||||
runOnMainSync { launched.activity.recreate() }
|
||||
|
||||
await(timeoutMs = 15_000, description = "ConversationFragment after recreate") {
|
||||
recorder.createdArgs.size > initialCount
|
||||
}
|
||||
awaitConversationTitle(launched, expectedName)
|
||||
|
||||
val recreated = recorder.createdArgs.last()
|
||||
check(recreated.shareDataTimestamp == timestamp) {
|
||||
"shareDataTimestamp not preserved across recreate: $timestamp -> ${recreated.shareDataTimestamp}"
|
||||
}
|
||||
check(recreated.draftMedia == blob) {
|
||||
"draftMedia not preserved across recreate: $blob -> ${recreated.draftMedia}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// region Helpers
|
||||
|
||||
/**
|
||||
* Mirrors [org.thoughtcrime.securesms.sharing.v2.ShareActivity.openConversation]. We
|
||||
* deliberately drop the producer's `clearTop` flags (NEW_TASK | CLEAR_TOP | SINGLE_TOP)
|
||||
* — they are launch-routing concerns that are incompatible with our lifecycle monitor.
|
||||
*/
|
||||
private fun shareToConversationIntent(
|
||||
recipient: RecipientId,
|
||||
blob: Uri,
|
||||
mimeType: String,
|
||||
draftText: String? = null,
|
||||
shareDataTimestamp: Long = System.currentTimeMillis()
|
||||
): Intent {
|
||||
val builder = ConversationIntents.createBuilder(context, recipient, -1L).blockingGet()
|
||||
val conversationIntent = builder
|
||||
.withDataUri(blob)
|
||||
.withDataType(mimeType)
|
||||
.withMedia(emptyList())
|
||||
.withDraftText(draftText)
|
||||
.withStickerLocator(null)
|
||||
.asBorderless(false)
|
||||
.withShareDataTimestamp(shareDataTimestamp)
|
||||
.build()
|
||||
|
||||
return Intent(context, MainActivity::class.java).apply {
|
||||
action = ConversationIntents.ACTION
|
||||
putExtras(conversationIntent)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors the image-share path through [org.thoughtcrime.securesms.sharing.v2.ShareActivity.openConversation]:
|
||||
* a non-empty `media` list is what flips dispatch to `ShareOrDraftData.StartSendMedia`,
|
||||
* which is what triggers the hop to the media-send screen.
|
||||
*/
|
||||
private fun shareImageIntent(recipient: RecipientId, media: Media): Intent {
|
||||
val builder = ConversationIntents.createBuilder(context, recipient, -1L).blockingGet()
|
||||
val conversationIntent = builder
|
||||
.withDataUri(media.uri)
|
||||
.withDataType(media.contentType)
|
||||
.withMedia(listOf(media))
|
||||
.withStickerLocator(null)
|
||||
.asBorderless(false)
|
||||
.withShareDataTimestamp(System.currentTimeMillis())
|
||||
.build()
|
||||
|
||||
return Intent(context, MainActivity::class.java).apply {
|
||||
action = ConversationIntents.ACTION
|
||||
putExtras(conversationIntent)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors a text-only share. Empty media list + non-null draft text routes dispatch to
|
||||
* `ShareOrDraftData.SetText`.
|
||||
*/
|
||||
private fun shareTextIntent(recipient: RecipientId, text: String): Intent {
|
||||
val builder = ConversationIntents.createBuilder(context, recipient, -1L).blockingGet()
|
||||
val conversationIntent = builder
|
||||
.withMedia(emptyList())
|
||||
.withDraftText(text)
|
||||
.withStickerLocator(null)
|
||||
.asBorderless(false)
|
||||
.withShareDataTimestamp(System.currentTimeMillis())
|
||||
.build()
|
||||
|
||||
return Intent(context, MainActivity::class.java).apply {
|
||||
action = ConversationIntents.ACTION
|
||||
putExtras(conversationIntent)
|
||||
}
|
||||
}
|
||||
|
||||
private fun notificationToConversationIntent(recipient: RecipientId): Intent {
|
||||
val conversationIntent = ConversationIntents.createBuilder(context, recipient, -1L)
|
||||
.blockingGet()
|
||||
.build()
|
||||
|
||||
return Intent(context, MainActivity::class.java).apply {
|
||||
action = ConversationIntents.ACTION
|
||||
putExtras(conversationIntent)
|
||||
}
|
||||
}
|
||||
|
||||
private fun tabIntent(tab: MainNavigationListLocation): Intent {
|
||||
return Intent(context, MainActivity::class.java)
|
||||
.putExtra("STARTING_TAB", tab)
|
||||
}
|
||||
|
||||
private fun detailLocationIntent(location: MainNavigationDetailLocation): Intent {
|
||||
return Intent(context, MainActivity::class.java)
|
||||
.putExtra("DETAIL_LOCATION", location)
|
||||
}
|
||||
|
||||
private fun realBlob(bytes: ByteArray, mimeType: String): Uri {
|
||||
return BlobProvider.getInstance()
|
||||
.forData(bytes)
|
||||
.withMimeType(mimeType)
|
||||
.createForSingleSessionInMemory()
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a [Media] backed by a real 1×1 JPEG. The media-send screen attempts to decode
|
||||
* the image during MediaReviewFragment setup, so a fake byte array won't survive — we
|
||||
* need genuine JPEG bytes for the fragment to reach the state where `R.id.recipient`
|
||||
* is populated.
|
||||
*/
|
||||
private fun realJpegMedia(): Media {
|
||||
val bitmap = Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888)
|
||||
val bytes = ByteArrayOutputStream().use { stream ->
|
||||
bitmap.compress(Bitmap.CompressFormat.JPEG, 90, stream)
|
||||
stream.toByteArray()
|
||||
}
|
||||
bitmap.recycle()
|
||||
val uri = realBlob(bytes, "image/jpeg")
|
||||
return Media(
|
||||
uri = uri,
|
||||
contentType = "image/jpeg",
|
||||
date = 0L,
|
||||
width = 1,
|
||||
height = 1,
|
||||
size = bytes.size.toLong(),
|
||||
duration = 0L,
|
||||
isBorderless = false,
|
||||
isVideoGif = false,
|
||||
bucketId = null,
|
||||
caption = null,
|
||||
transformProperties = null,
|
||||
fileName = null
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors [org.thoughtcrime.securesms.deeplinks.DeepLinkEntryActivity]: bare clearTop
|
||||
* plus a [Uri] in the data field.
|
||||
*/
|
||||
private fun deepLinkIntent(data: Uri): Intent {
|
||||
return Intent(context, MainActivity::class.java).setData(data)
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronously launch [MainActivity] and return the running instance plus a fragment
|
||||
* recorder wired up *before* the activity is created.
|
||||
*
|
||||
* We bypass [androidx.test.core.app.ActivityScenario] and
|
||||
* [android.app.Instrumentation.startActivitySync] because both fail for our case:
|
||||
* ActivityScenario's lifecycle tracker misses CREATED/STARTED/RESUMED for activities
|
||||
* launched with a custom-action intent, and `startActivitySync` waits for main-thread
|
||||
* idle which never arrives while MainActivity's composition + ConversationFragment
|
||||
* setup keeps the looper busy.
|
||||
*/
|
||||
private fun launchSync(intent: Intent): LaunchedActivity {
|
||||
val recorder = ConversationFragmentRecorder()
|
||||
val app = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as Application
|
||||
val resumed = CountDownLatch(1)
|
||||
val activityHolder = arrayOfNulls<MainActivity>(1)
|
||||
val allActivities: MutableList<Activity> = Collections.synchronizedList(mutableListOf())
|
||||
val callbacks = object : Application.ActivityLifecycleCallbacks {
|
||||
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {
|
||||
allActivities += activity
|
||||
if (activity is MainActivity) {
|
||||
activityHolder[0] = activity
|
||||
activity.supportFragmentManager.registerFragmentLifecycleCallbacks(recorder, true)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onActivityStarted(activity: Activity) = Unit
|
||||
override fun onActivityResumed(activity: Activity) {
|
||||
if (activity is MainActivity) resumed.countDown()
|
||||
}
|
||||
|
||||
override fun onActivityPaused(activity: Activity) = Unit
|
||||
override fun onActivityStopped(activity: Activity) = Unit
|
||||
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) = Unit
|
||||
override fun onActivityDestroyed(activity: Activity) {
|
||||
allActivities.remove(activity)
|
||||
}
|
||||
}
|
||||
app.registerActivityLifecycleCallbacks(callbacks)
|
||||
|
||||
// Application.startActivity from a non-Activity context requires FLAG_ACTIVITY_NEW_TASK.
|
||||
val launchIntent = Intent(intent).apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) }
|
||||
try {
|
||||
app.startActivity(launchIntent)
|
||||
} catch (t: Throwable) {
|
||||
app.unregisterActivityLifecycleCallbacks(callbacks)
|
||||
throw t
|
||||
}
|
||||
|
||||
if (!resumed.await(15, TimeUnit.SECONDS)) {
|
||||
app.unregisterActivityLifecycleCallbacks(callbacks)
|
||||
error("MainActivity did not reach RESUMED within 15s")
|
||||
}
|
||||
return LaunchedActivity(activityHolder, recorder, app, callbacks, allActivities)
|
||||
}
|
||||
|
||||
private fun <T> runOnMainSync(block: () -> T): T {
|
||||
var result: Result<T> = Result.failure(IllegalStateException("runOnMainSync did not produce a result"))
|
||||
InstrumentationRegistry.getInstrumentation().runOnMainSync {
|
||||
result = runCatching(block)
|
||||
}
|
||||
return result.getOrThrow()
|
||||
}
|
||||
|
||||
private fun await(
|
||||
timeoutMs: Long = 5_000,
|
||||
pollMs: Long = 50,
|
||||
description: String = "condition",
|
||||
predicate: () -> Boolean
|
||||
) {
|
||||
val deadline = System.currentTimeMillis() + timeoutMs
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
if (runOnMainSync(predicate)) return
|
||||
Thread.sleep(pollMs)
|
||||
}
|
||||
error("Timed out after ${timeoutMs}ms waiting for $description")
|
||||
}
|
||||
|
||||
private fun MainActivity.mainNavigationViewModel(): MainNavigationViewModel {
|
||||
return ViewModelProvider(this as FragmentActivity, MainNavigationViewModel.Factory())[MainNavigationViewModel::class.java]
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait until the latest [ConversationFragment]'s composer EditText shows [expected].
|
||||
* setDraftText is invoked off the InputReadyState/ShareOrDraftData reactive chain, so the
|
||||
* text won't be present at fragment-create time — we have to poll the rendered view.
|
||||
*/
|
||||
private fun awaitComposerText(launched: LaunchedActivity, expected: String) {
|
||||
await(timeoutMs = 15_000, description = "composer shows \"$expected\"") {
|
||||
val frag = launched.recorder.latestActive() ?: return@await false
|
||||
val view = frag.view ?: return@await false
|
||||
view.findViewById<TextView>(R.id.embedded_text_editor)?.text?.toString() == expected
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait until the latest [ConversationFragment]'s toolbar shows [expected]. Scoped through
|
||||
* R.id.conversation_title_view to avoid colliding with other R.id.title uses.
|
||||
*/
|
||||
private fun awaitConversationTitle(launched: LaunchedActivity, expected: String) {
|
||||
await(timeoutMs = 15_000, description = "conversation title shows \"$expected\"") {
|
||||
val frag = launched.recorder.latestActive() ?: return@await false
|
||||
val view = frag.view ?: return@await false
|
||||
val titleHost = view.findViewById<View>(R.id.conversation_title_view) ?: return@await false
|
||||
titleHost.findViewById<TextView>(R.id.title)?.text?.toString() == expected
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* MainActivity hosts each tab as a different [Fragment] via Compose's `AndroidFragment`
|
||||
* (see MainActivity.kt:662-698). The user sees the content of whichever one is currently
|
||||
* attached, so a tab assertion that reads the FragmentManager is a real user-visible
|
||||
* signal — strictly stronger than reading the VM's `currentListLocation`.
|
||||
*/
|
||||
private fun listFragmentClass(location: MainNavigationListLocation): Class<out Fragment> = when (location) {
|
||||
MainNavigationListLocation.CHATS -> ConversationListFragment::class.java
|
||||
MainNavigationListLocation.ARCHIVE -> ConversationListArchiveFragment::class.java
|
||||
MainNavigationListLocation.CALLS -> CallLogFragment::class.java
|
||||
MainNavigationListLocation.STORIES -> StoriesLandingFragment::class.java
|
||||
}
|
||||
|
||||
private fun awaitListFragment(launched: LaunchedActivity, location: MainNavigationListLocation) {
|
||||
val expected = listFragmentClass(location)
|
||||
try {
|
||||
await(timeoutMs = 10_000, description = "${expected.simpleName} attached for $location") {
|
||||
launched.recorder.isAttached(expected)
|
||||
}
|
||||
} catch (e: IllegalStateException) {
|
||||
throw IllegalStateException("${e.message}; currently attached: ${launched.recorder.attachedNames()}", e)
|
||||
}
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region Types
|
||||
|
||||
/**
|
||||
* Records every [ConversationFragment] added under an activity's fragment manager,
|
||||
* capturing each fragment's arguments at create-time.
|
||||
*/
|
||||
private class ConversationFragmentRecorder : FragmentManager.FragmentLifecycleCallbacks() {
|
||||
val createdArgs: MutableList<ConversationArgs> = mutableListOf()
|
||||
val allCreated: MutableList<String> = mutableListOf()
|
||||
private val active: MutableList<ConversationFragment> = mutableListOf()
|
||||
private val attached: MutableList<Fragment> = mutableListOf()
|
||||
var destroyedCount: Int = 0
|
||||
private set
|
||||
|
||||
/** Most-recently-added still-attached ConversationFragment, or null. Main-thread read. */
|
||||
fun latestActive(): ConversationFragment? = active.lastOrNull()
|
||||
|
||||
/**
|
||||
* Exact class match (not [Class.isInstance]) — `ConversationListArchiveFragment`
|
||||
* extends `ConversationListFragment`, so an `isInstance` check for CHATS would falsely
|
||||
* pass when the archive list is attached.
|
||||
*/
|
||||
fun isAttached(clazz: Class<out Fragment>): Boolean = attached.any { it::class.java == clazz }
|
||||
|
||||
fun attachedNames(): List<String> = attached.map { it::class.simpleName ?: it::class.java.name }
|
||||
|
||||
override fun onFragmentCreated(fm: FragmentManager, f: Fragment, savedInstanceState: android.os.Bundle?) {
|
||||
allCreated += f::class.simpleName ?: f::class.java.name
|
||||
attached += f
|
||||
if (f is ConversationFragment) {
|
||||
createdArgs += ConversationIntents.readArgsFromBundle(f.requireArguments())
|
||||
active += f
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFragmentDestroyed(fm: FragmentManager, f: Fragment) {
|
||||
attached.remove(f)
|
||||
if (f is ConversationFragment) {
|
||||
active.remove(f)
|
||||
destroyedCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class LaunchedActivity(
|
||||
private val activityHolder: Array<MainActivity?>,
|
||||
val recorder: ConversationFragmentRecorder,
|
||||
private val app: Application,
|
||||
private val callbacks: Application.ActivityLifecycleCallbacks,
|
||||
private val allActivities: MutableList<Activity>
|
||||
) : AutoCloseable {
|
||||
/**
|
||||
* Always returns the *latest* MainActivity instance so reads follow `recreate()`.
|
||||
*/
|
||||
val activity: MainActivity get() = checkNotNull(activityHolder[0]) { "No active MainActivity" }
|
||||
|
||||
/**
|
||||
* Poll until an activity of [clazz] has been created, then return it. Used to assert
|
||||
* the share-image flow's hop into MediaSelectionActivity.
|
||||
*/
|
||||
fun <T : Activity> awaitActivity(clazz: Class<T>, timeoutMs: Long = 10_000): T {
|
||||
val deadline = System.currentTimeMillis() + timeoutMs
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
val match = synchronized(allActivities) {
|
||||
allActivities.firstOrNull { clazz.isInstance(it) }
|
||||
}
|
||||
if (match != null) return clazz.cast(match)!!
|
||||
Thread.sleep(50)
|
||||
}
|
||||
val seen = synchronized(allActivities) { allActivities.map { it::class.simpleName } }
|
||||
error("Timed out after ${timeoutMs}ms waiting for ${clazz.simpleName}; saw $seen")
|
||||
}
|
||||
|
||||
fun nonMainActivities(): List<Activity> = synchronized(allActivities) {
|
||||
allActivities.filter { it !is MainActivity }.toList()
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
// Don't wait for looper idle — secondary activities (e.g. MediaSelectionActivity
|
||||
// opened by share processing) can keep it busy indefinitely. Finish every tracked
|
||||
// activity so subsequent tests start from a clean slate.
|
||||
val toFinish = synchronized(allActivities) { allActivities.toList() }
|
||||
if (toFinish.isNotEmpty()) {
|
||||
InstrumentationRegistry.getInstrumentation().runOnMainSync {
|
||||
toFinish.forEach { it.finish() }
|
||||
}
|
||||
}
|
||||
app.unregisterActivityLifecycleCallbacks(callbacks)
|
||||
}
|
||||
}
|
||||
|
||||
// endregion
|
||||
}
|
||||
@@ -8,9 +8,9 @@ package org.thoughtcrime.securesms.testing
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import io.mockk.every
|
||||
import org.junit.rules.ExternalResource
|
||||
import org.signal.network.NetworkResult
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.util.JsonUtils
|
||||
import org.whispersystems.signalservice.api.NetworkResult
|
||||
import org.whispersystems.signalservice.internal.push.SubscriptionsConfiguration
|
||||
|
||||
/**
|
||||
|
||||
@@ -17,7 +17,6 @@ import org.whispersystems.signalservice.internal.push.PreKeyResponse
|
||||
import org.whispersystems.signalservice.internal.push.PreKeyResponseItem
|
||||
import org.whispersystems.signalservice.internal.push.PushServiceSocket
|
||||
import org.whispersystems.signalservice.internal.push.RegistrationSessionMetadataJson
|
||||
import org.whispersystems.signalservice.internal.push.SenderCertificate
|
||||
import org.whispersystems.signalservice.internal.push.VerifyAccountResponse
|
||||
import org.whispersystems.signalservice.internal.push.WhoAmIResponse
|
||||
import java.security.SecureRandom
|
||||
@@ -27,8 +26,6 @@ import java.security.SecureRandom
|
||||
*/
|
||||
object MockProvider {
|
||||
|
||||
val senderCertificate = SenderCertificate().apply { certificate = ByteArray(0) }
|
||||
|
||||
val lockedFailure = PushServiceSocket.RegistrationLockFailure().apply {
|
||||
svr1Credentials = AuthCredentials.create("username", "password")
|
||||
svr2Credentials = AuthCredentials.create("username", "password")
|
||||
|
||||
@@ -23,7 +23,7 @@ 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 org.signal.network.websocket.WebSocketRequestMessage
|
||||
import kotlin.random.Random
|
||||
|
||||
/**
|
||||
|
||||
@@ -74,7 +74,7 @@ class OtherClient(val serviceId: ServiceId, val e164: String, val identityKeyPai
|
||||
val cipher = SignalServiceCipher(serviceAddress, 1, aciStore, sessionLock, null)
|
||||
|
||||
if (!aciStore.containsSession(getAliceProtocolAddress())) {
|
||||
val sessionBuilder = SignalSessionBuilder(sessionLock, SessionBuilder(aciStore, getAliceProtocolAddress()))
|
||||
val sessionBuilder = SignalSessionBuilder(sessionLock, SessionBuilder(aciStore, getAliceProtocolAddress(), SignalProtocolAddress(serviceAddress.identifier, 1)))
|
||||
sessionBuilder.process(getAlicePreKeyBundle())
|
||||
}
|
||||
|
||||
|
||||
+3
@@ -8,6 +8,9 @@ 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.signal.network.websocket.WebSocketRequestMessage
|
||||
import org.signal.network.websocket.WebSocketResponseMessage
|
||||
import org.signal.network.websocket.WebsocketResponse
|
||||
import org.thoughtcrime.securesms.util.JsonUtils
|
||||
import org.thoughtcrime.securesms.util.SignalTrace
|
||||
import org.whispersystems.signalservice.api.websocket.WebSocketConnectionState
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-sdk tools:overrideLibrary="androidx.camera.core,androidx.camera.camera2,androidx.camera.lifecycle,androidx.camera.view" />
|
||||
|
||||
<!-- ======================================= -->
|
||||
<!-- Features -->
|
||||
<!-- ======================================= -->
|
||||
@@ -40,6 +38,7 @@
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_PHONE_CALL" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_REMOTE_MESSAGING" />
|
||||
<uses-permission android:name="android.permission.GET_ACCOUNTS" />
|
||||
@@ -1405,7 +1404,7 @@
|
||||
<service
|
||||
android:name="org.thoughtcrime.securesms.service.webrtc.ActiveCallManager$ActiveCallForegroundService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="dataSync|microphone|camera|phoneCall" />
|
||||
android:foregroundServiceType="dataSync|microphone|camera|phoneCall|mediaProjection" />
|
||||
|
||||
<service
|
||||
android:name="com.google.android.datatransport.runtime.scheduling.jobscheduling.JobInfoSchedulerService"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -77,6 +77,7 @@ import org.thoughtcrime.securesms.jobs.LinkedDeviceInactiveCheckJob;
|
||||
import org.thoughtcrime.securesms.jobs.MultiDeviceContactUpdateJob;
|
||||
import org.thoughtcrime.securesms.jobs.PreKeysSyncJob;
|
||||
import org.thoughtcrime.securesms.jobs.ProfileUploadJob;
|
||||
import org.thoughtcrime.securesms.jobs.RefreshAttributesJob;
|
||||
import org.thoughtcrime.securesms.jobs.RefreshSvrCredentialsJob;
|
||||
import org.thoughtcrime.securesms.jobs.RestoreOptimizedMediaJob;
|
||||
import org.thoughtcrime.securesms.jobs.RetrieveProfileJob;
|
||||
@@ -111,6 +112,7 @@ import org.thoughtcrime.securesms.util.AppStartup;
|
||||
import org.thoughtcrime.securesms.util.DeviceProperties;
|
||||
import org.thoughtcrime.securesms.util.DynamicTheme;
|
||||
import org.thoughtcrime.securesms.util.Environment;
|
||||
import org.thoughtcrime.securesms.util.PlayServicesUtil;
|
||||
import org.thoughtcrime.securesms.util.RemoteConfig;
|
||||
import org.thoughtcrime.securesms.util.SignalLocalMetrics;
|
||||
import org.thoughtcrime.securesms.util.SignalUncaughtExceptionHandler;
|
||||
@@ -321,7 +323,7 @@ public class ApplicationContext extends Application implements AppForegroundObse
|
||||
* This is so we can capture ANR's that happen on boot before the foreground event.
|
||||
*/
|
||||
private void startAnrDetector() {
|
||||
AnrDetector.start(TimeUnit.SECONDS.toMillis(5), RemoteConfig::internalUser, (dumps) -> {
|
||||
AnrDetector.start(TimeUnit.SECONDS.toMillis(5), () -> RemoteConfig.internalUser() && SignalStore.internal().getAnrDetectionCrashes(), (dumps) -> {
|
||||
LogDatabase.getInstance(this).anrs().save(System.currentTimeMillis(), dumps);
|
||||
return Unit.INSTANCE;
|
||||
});
|
||||
@@ -439,7 +441,24 @@ public class ApplicationContext extends Application implements AppForegroundObse
|
||||
}
|
||||
|
||||
private void initializeFcmCheck() {
|
||||
if (SignalStore.account().isRegistered()) {
|
||||
if (!SignalStore.account().isRegistered()) {
|
||||
return;
|
||||
}
|
||||
|
||||
PlayServicesUtil.PlayServicesStatus playServicesStatus = PlayServicesUtil.getPlayServicesStatus(this);
|
||||
|
||||
if (playServicesStatus == PlayServicesUtil.PlayServicesStatus.SUCCESS && !SignalStore.account().isFcmEnabled()) {
|
||||
Log.i(TAG, "Play Services are newly-available. Enabling FCM and updating server.");
|
||||
SignalStore.account().setFcmEnabled(true);
|
||||
AppDependencies.getJobManager().startChain(new FcmRefreshJob())
|
||||
.then(new RefreshAttributesJob())
|
||||
.enqueue();
|
||||
} else if (playServicesStatus == PlayServicesUtil.PlayServicesStatus.MISSING && SignalStore.account().isFcmEnabled()) {
|
||||
Log.w(TAG, "Play Services are no longer available. Disabling FCM and updating server.");
|
||||
SignalStore.account().setFcmEnabled(false);
|
||||
SignalStore.account().setFcmToken(null);
|
||||
AppDependencies.getJobManager().add(new RefreshAttributesJob());
|
||||
} else if (SignalStore.account().isFcmEnabled()) {
|
||||
long lastSetTime = SignalStore.account().getFcmTokenLastSetTime();
|
||||
long nextSetTime = lastSetTime + TimeUnit.HOURS.toMillis(6);
|
||||
long now = System.currentTimeMillis();
|
||||
@@ -447,6 +466,8 @@ public class ApplicationContext extends Application implements AppForegroundObse
|
||||
if (SignalStore.account().getFcmToken() == null || nextSetTime <= now || lastSetTime > now) {
|
||||
AppDependencies.getJobManager().add(new FcmRefreshJob());
|
||||
}
|
||||
} else {
|
||||
Log.d(TAG, "Play Services status: " + playServicesStatus + ", fcmEnabled: false. Skipping FCM check.");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -85,7 +85,6 @@ import kotlinx.coroutines.withContext
|
||||
import org.signal.core.ui.BottomSheetUtil
|
||||
import org.signal.core.ui.compose.Snackbars
|
||||
import org.signal.core.ui.compose.theme.SignalTheme
|
||||
import org.signal.core.ui.isSplitPane
|
||||
import org.signal.core.ui.permissions.Permissions
|
||||
import org.signal.core.ui.rememberIsSplitPane
|
||||
import org.signal.core.util.Util
|
||||
@@ -521,15 +520,14 @@ class MainActivity :
|
||||
}.navigateToDetailLocation(location)
|
||||
}
|
||||
|
||||
is MainNavigationDetailLocation.Chats -> {
|
||||
if (location is MainNavigationDetailLocation.Chats.Conversation) {
|
||||
chatNavGraphState.writeGraphicsLayerToBitmap()
|
||||
}
|
||||
is MainNavigationDetailLocation.Conversation -> {
|
||||
chatNavGraphState.writeGraphicsLayerToBitmap()
|
||||
chatsNavHostController.navigateToDetailLocation(location)
|
||||
}
|
||||
|
||||
is MainNavigationDetailLocation.Chats -> chatsNavHostController.navigateToDetailLocation(location)
|
||||
is MainNavigationDetailLocation.CallLinkDetails -> callsNavHostController.navigateToDetailLocation(location)
|
||||
is MainNavigationDetailLocation.Calls -> callsNavHostController.navigateToDetailLocation(location)
|
||||
|
||||
is MainNavigationDetailLocation.Stories -> storiesNavHostController.navigateToDetailLocation(location)
|
||||
}
|
||||
}
|
||||
@@ -848,7 +846,7 @@ class MainActivity :
|
||||
|
||||
val detailLocation = extras.getParcelableCompat(KEY_DETAIL_LOCATION, MainNavigationDetailLocation::class.java)
|
||||
if (detailLocation != null) {
|
||||
mainNavigationViewModel.goTo(detailLocation)
|
||||
goTo(detailLocation)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1034,7 +1032,7 @@ class MainActivity :
|
||||
private fun handleConversationIntent(intent: Intent) {
|
||||
if (ConversationIntents.isConversationIntent(intent)) {
|
||||
mainNavigationViewModel.goTo(MainNavigationListLocation.CHATS)
|
||||
mainNavigationViewModel.goTo(MainNavigationDetailLocation.Chats.Conversation(ConversationIntents.readArgsFromBundle(intent.extras!!)))
|
||||
mainNavigationViewModel.goTo(MainNavigationDetailLocation.Conversation(ConversationIntents.readArgsFromBundle(intent.extras!!)))
|
||||
intent.action = null
|
||||
setIntent(intent)
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ public class MainNavigator {
|
||||
.withStartingPosition(startingPosition)
|
||||
.asIncognito(incognito)
|
||||
.toConversationArgs())
|
||||
.subscribe(args -> viewModel.goTo(new MainNavigationDetailLocation.Chats.Conversation(args)));
|
||||
.subscribe(args -> viewModel.goTo(new MainNavigationDetailLocation.Conversation(args)));
|
||||
|
||||
lifecycleDisposable.add(disposable);
|
||||
}
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
*/
|
||||
package org.thoughtcrime.securesms;
|
||||
|
||||
import android.animation.Animator;
|
||||
import android.app.KeyguardManager;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
@@ -49,7 +48,6 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder;
|
||||
|
||||
import org.signal.core.util.ThreadUtil;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.animation.AnimationCompleteListener;
|
||||
import org.thoughtcrime.securesms.components.AnimatingToggle;
|
||||
import org.thoughtcrime.securesms.crypto.InvalidPassphraseException;
|
||||
import org.thoughtcrime.securesms.crypto.MasterSecret;
|
||||
@@ -389,13 +387,7 @@ public class PassphrasePromptActivity extends PassphraseActivity {
|
||||
Log.i(TAG, "onAuthenticationSucceeded");
|
||||
|
||||
lockScreenButton.setOnClickListener(null);
|
||||
unlockView.addAnimatorListener(new AnimationCompleteListener() {
|
||||
@Override
|
||||
public void onAnimationEnd(Animator animation) {
|
||||
handleAuthenticated();
|
||||
}
|
||||
});
|
||||
unlockView.playAnimation();
|
||||
handleAuthenticated();
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -10,12 +10,15 @@ import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
import androidx.core.app.TaskStackBuilder;
|
||||
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.util.CommunicationActions;
|
||||
|
||||
public class ShortcutLauncherActivity extends AppCompatActivity {
|
||||
|
||||
private static final String TAG = Log.tag(ShortcutLauncherActivity.class);
|
||||
|
||||
private static final String KEY_RECIPIENT = "recipient_id";
|
||||
|
||||
public static Intent createIntent(@NonNull Context context, @NonNull RecipientId recipientId) {
|
||||
@@ -30,9 +33,18 @@ public class ShortcutLauncherActivity extends AppCompatActivity {
|
||||
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
|
||||
String rawId = getIntent().getStringExtra(KEY_RECIPIENT);
|
||||
String rawId = getIntent().getStringExtra(KEY_RECIPIENT);
|
||||
RecipientId recipientId = null;
|
||||
|
||||
if (rawId == null) {
|
||||
if (rawId != null) {
|
||||
try {
|
||||
recipientId = RecipientId.from(rawId);
|
||||
} catch (Throwable t) {
|
||||
Log.w(TAG, "Failed to parse recipientId from intent.", t);
|
||||
}
|
||||
}
|
||||
|
||||
if (recipientId == null) {
|
||||
Toast.makeText(this, R.string.ShortcutLauncherActivity_invalid_shortcut, Toast.LENGTH_SHORT).show();
|
||||
// TODO [greyson] Navigation
|
||||
startActivity(MainActivity.clearTop(this));
|
||||
@@ -40,7 +52,7 @@ public class ShortcutLauncherActivity extends AppCompatActivity {
|
||||
return;
|
||||
}
|
||||
|
||||
Recipient recipient = Recipient.live(RecipientId.from(rawId)).get();
|
||||
Recipient recipient = Recipient.live(recipientId).get();
|
||||
// TODO [greyson] Navigation
|
||||
TaskStackBuilder backStack = TaskStackBuilder.create(this)
|
||||
.addNextIntent(MainActivity.clearTop(this));
|
||||
|
||||
@@ -16,8 +16,8 @@ import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.media.AudioManager
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.core.util.safeUnregisterReceiver
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.util.safeUnregisterReceiver
|
||||
import org.thoughtcrime.securesms.webrtc.audio.SignalAudioHandler
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ import kotlinx.coroutines.flow.update
|
||||
import org.signal.core.util.bytes
|
||||
import org.signal.core.util.concurrent.SignalExecutors
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.core.util.safeUnregisterReceiver
|
||||
import org.signal.core.util.throttleLatest
|
||||
import org.thoughtcrime.securesms.BuildConfig
|
||||
import org.thoughtcrime.securesms.attachments.AttachmentId
|
||||
@@ -31,7 +32,6 @@ import org.thoughtcrime.securesms.jobmanager.impl.DiskSpaceNotLowConstraint
|
||||
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint
|
||||
import org.thoughtcrime.securesms.jobmanager.impl.WifiConstraint
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.util.safeUnregisterReceiver
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
@@ -203,6 +203,7 @@ object ArchiveRestoreProgress {
|
||||
state.hasActivelyRestoredThisRun -> ArchiveRestoreProgressState.RestoreStatus.FINISHED
|
||||
else -> ArchiveRestoreProgressState.RestoreStatus.NONE
|
||||
}
|
||||
|
||||
else -> {
|
||||
val availableBytes = SignalStore.backup.spaceAvailableOnDiskBytes
|
||||
|
||||
|
||||
@@ -66,7 +66,11 @@ import org.signal.libsignal.messagebackup.BackupForwardSecrecyToken
|
||||
import org.signal.libsignal.zkgroup.VerificationFailedException
|
||||
import org.signal.libsignal.zkgroup.backups.BackupLevel
|
||||
import org.signal.libsignal.zkgroup.profiles.ProfileKey
|
||||
import org.signal.network.ApplicationErrorAction
|
||||
import org.signal.network.NetworkResult
|
||||
import org.signal.network.StatusCodeErrorAction
|
||||
import org.signal.network.api.SvrBApi
|
||||
import org.signal.network.exceptions.NonSuccessfulResponseCodeException
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.attachments.AttachmentId
|
||||
import org.thoughtcrime.securesms.attachments.Cdn
|
||||
@@ -145,9 +149,6 @@ import org.thoughtcrime.securesms.util.RemoteConfig
|
||||
import org.thoughtcrime.securesms.util.ServiceUtil
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences
|
||||
import org.thoughtcrime.securesms.util.toMillis
|
||||
import org.whispersystems.signalservice.api.ApplicationErrorAction
|
||||
import org.whispersystems.signalservice.api.NetworkResult
|
||||
import org.whispersystems.signalservice.api.StatusCodeErrorAction
|
||||
import org.whispersystems.signalservice.api.archive.ArchiveGetMediaItemsResponse
|
||||
import org.whispersystems.signalservice.api.archive.ArchiveKeyRotationLimitResponse
|
||||
import org.whispersystems.signalservice.api.archive.ArchiveMediaRequest
|
||||
@@ -161,7 +162,6 @@ import org.whispersystems.signalservice.api.crypto.AttachmentCipherStreamUtil
|
||||
import org.whispersystems.signalservice.api.link.TransferArchiveResponse
|
||||
import org.whispersystems.signalservice.api.messages.AttachmentTransferProgress
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceAttachment.ProgressListener
|
||||
import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException
|
||||
import org.whispersystems.signalservice.internal.crypto.PaddingInputStream
|
||||
import org.whispersystems.signalservice.internal.push.AttachmentUploadForm
|
||||
import org.whispersystems.signalservice.internal.push.AuthCredentials
|
||||
|
||||
+1
-1
@@ -322,7 +322,7 @@ class ChatItemArchiveExporter(
|
||||
}
|
||||
|
||||
MessageTypes.isSessionSwitchoverType(record.type) -> {
|
||||
builder.updateMessage = record.toRemoteSessionSwitchoverUpdate(record.dateSent) ?: continue
|
||||
builder.updateMessage = record.toRemoteSessionSwitchoverUpdate(record.dateSent)?.takeIf { builder.authorIsAciContact(exportState) } ?: continue
|
||||
transformTimer.emit("sse")
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -886,8 +886,8 @@ class ChatItemArchiveImporter(
|
||||
SimpleChatUpdate.Type.UNKNOWN -> typeWithoutBase
|
||||
SimpleChatUpdate.Type.JOINED_SIGNAL -> MessageTypes.JOINED_TYPE or typeWithoutBase
|
||||
SimpleChatUpdate.Type.IDENTITY_UPDATE -> MessageTypes.KEY_EXCHANGE_IDENTITY_UPDATE_BIT or typeWithoutBase
|
||||
SimpleChatUpdate.Type.IDENTITY_VERIFIED -> MessageTypes.KEY_EXCHANGE_IDENTITY_VERIFIED_BIT or typeWithoutBase
|
||||
SimpleChatUpdate.Type.IDENTITY_DEFAULT -> MessageTypes.KEY_EXCHANGE_IDENTITY_DEFAULT_BIT or typeWithoutBase
|
||||
SimpleChatUpdate.Type.IDENTITY_VERIFIED -> MessageTypes.KEY_EXCHANGE_IDENTITY_VERIFIED_BIT or typeWithoutBase or MessageTypes.BASE_SENT_TYPE
|
||||
SimpleChatUpdate.Type.IDENTITY_DEFAULT -> MessageTypes.KEY_EXCHANGE_IDENTITY_DEFAULT_BIT or typeWithoutBase or MessageTypes.BASE_SENT_TYPE
|
||||
SimpleChatUpdate.Type.CHANGE_NUMBER -> MessageTypes.CHANGE_NUMBER_TYPE
|
||||
SimpleChatUpdate.Type.RELEASE_CHANNEL_DONATION_REQUEST -> MessageTypes.RELEASE_CHANNEL_DONATION_REQUEST_TYPE
|
||||
SimpleChatUpdate.Type.END_SESSION -> MessageTypes.END_SESSION_BIT or typeWithoutBase
|
||||
|
||||
@@ -333,9 +333,10 @@ private fun BackupFailedBody() {
|
||||
append(stringResource(id = R.string.BackupAlertBottomSheet__an_error_occurred))
|
||||
append(" ")
|
||||
|
||||
val link = stringResource(R.string.remote_backup_support_url)
|
||||
withLink(
|
||||
LinkAnnotation.Clickable(tag = "learn-more") {
|
||||
CommunicationActions.openBrowserLink(context, context.getString(R.string.remote_backup_support_url))
|
||||
CommunicationActions.openBrowserLink(context, link)
|
||||
}
|
||||
) {
|
||||
withStyle(SpanStyle(color = MaterialTheme.colorScheme.primary)) {
|
||||
|
||||
+2
-1
@@ -31,10 +31,11 @@ class NoRemoteStorageSpaceAvailableBottomSheet : ComposeBottomSheetDialogFragmen
|
||||
@Composable
|
||||
override fun SheetContent() {
|
||||
val context = LocalContext.current
|
||||
val supportUrl = stringResource(R.string.remote_backup_support_url)
|
||||
|
||||
NoRemoteStorageSpaceAvailableBottomSheetContent(
|
||||
onLearnMoreClick = {
|
||||
CommunicationActions.openBrowserLink(context, context.getString(R.string.remote_backup_support_url))
|
||||
CommunicationActions.openBrowserLink(context, supportUrl)
|
||||
},
|
||||
onContactSupportClick = {
|
||||
ContactSupportDialogFragment.create(
|
||||
|
||||
+4
-4
@@ -20,6 +20,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalLocale
|
||||
import androidx.compose.ui.res.dimensionResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.AnnotatedString.Builder
|
||||
@@ -37,7 +38,6 @@ import org.signal.core.ui.compose.Previews
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.keyvalue.BackupValues
|
||||
import org.thoughtcrime.securesms.util.DateUtils
|
||||
import java.util.Locale
|
||||
import kotlin.time.Duration.Companion.days
|
||||
import org.signal.core.ui.R as CoreUiR
|
||||
|
||||
@@ -53,7 +53,7 @@ fun BackupCreateErrorRow(
|
||||
onLearnMoreClick: () -> Unit = {}
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val locale = Locale.getDefault()
|
||||
val locale = LocalLocale.current
|
||||
|
||||
when (error) {
|
||||
BackupValues.BackupCreationError.TRANSIENT -> {
|
||||
@@ -82,7 +82,7 @@ fun BackupCreateErrorRow(
|
||||
BackupValues.BackupCreationError.BACKUP_FILE_TOO_LARGE -> {
|
||||
BackupAlertText {
|
||||
if (lastMessageCutoffTime > 0) {
|
||||
append(stringResource(R.string.BackupStatusRow__not_backing_up_old_messages, DateUtils.getDayPrecisionTimeString(context, locale, lastMessageCutoffTime)))
|
||||
append(stringResource(R.string.BackupStatusRow__not_backing_up_old_messages, DateUtils.getDayPrecisionTimeString(context, locale.platformLocale, lastMessageCutoffTime)))
|
||||
} else {
|
||||
append(stringResource(R.string.BackupStatusRow__backup_file_too_large))
|
||||
}
|
||||
@@ -91,7 +91,7 @@ fun BackupCreateErrorRow(
|
||||
|
||||
BackupValues.BackupCreationError.NOT_ENOUGH_DISK_SPACE -> {
|
||||
BackupAlertText {
|
||||
append(stringResource(R.string.BackupStatusRow__not_enough_disk_space, DateUtils.getDayPrecisionTimeString(context, locale, lastMessageCutoffTime)))
|
||||
append(stringResource(R.string.BackupStatusRow__not_enough_disk_space, DateUtils.getDayPrecisionTimeString(context, locale.platformLocale, lastMessageCutoffTime)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+5
-1
@@ -43,6 +43,7 @@ import org.signal.core.models.AccountEntropyPool
|
||||
import org.signal.core.ui.compose.Buttons
|
||||
import org.signal.core.ui.compose.horizontalGutters
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.TemporaryScreenshotSecurity
|
||||
import org.thoughtcrime.securesms.fonts.MonoTypeface
|
||||
import org.thoughtcrime.securesms.registration.ui.restore.BackupKeyVisualTransformation
|
||||
import org.thoughtcrime.securesms.registration.ui.restore.attachBackupKeyAutoFillHelper
|
||||
@@ -59,6 +60,8 @@ fun EnterKeyScreen(
|
||||
captionContent: @Composable () -> Unit,
|
||||
seeKeyButton: @Composable () -> Unit
|
||||
) {
|
||||
TemporaryScreenshotSecurity.bind()
|
||||
|
||||
Column(
|
||||
verticalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier
|
||||
@@ -90,7 +93,8 @@ fun EnterKeyScreen(
|
||||
|
||||
val updateEnteredBackupKey = { input: String ->
|
||||
enteredBackupKey = AccountEntropyPool.removeIllegalCharacters(input).uppercase()
|
||||
isBackupKeyValid = enteredBackupKey == backupKey
|
||||
val normalized = AccountEntropyPool.formatForStorage(enteredBackupKey)
|
||||
isBackupKeyValid = normalized.equals(AccountEntropyPool.formatForStorage(backupKey), ignoreCase = true)
|
||||
showError = !isBackupKeyValid && enteredBackupKey.length >= backupKey.length
|
||||
}
|
||||
|
||||
|
||||
+3
@@ -59,6 +59,7 @@ import org.signal.core.ui.compose.horizontalGutters
|
||||
import org.signal.core.ui.compose.theme.SignalTheme
|
||||
import org.signal.core.util.Util
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.TemporaryScreenshotSecurity
|
||||
import org.thoughtcrime.securesms.components.settings.app.backups.remote.BackupKeyCredentialManagerHandler
|
||||
import org.thoughtcrime.securesms.components.settings.app.backups.remote.BackupKeySaveState
|
||||
import org.thoughtcrime.securesms.fonts.MonoTypeface
|
||||
@@ -133,6 +134,8 @@ fun MessageBackupsKeyRecordScreen(
|
||||
mode: MessageBackupsKeyRecordMode = MessageBackupsKeyRecordMode.Next(onNextClick = {}),
|
||||
notifyKeyIsSameAsOnDeviceBackupKey: Boolean = false
|
||||
) {
|
||||
TemporaryScreenshotSecurity.bind()
|
||||
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
val backupKeyString = remember(backupKey) {
|
||||
backupKey.chunked(4).joinToString(" ")
|
||||
|
||||
+2
-1
@@ -108,9 +108,10 @@ fun VerifyBackupPinScreen(
|
||||
append(stringResource(id = R.string.VerifyBackupPinScreen__enter_the_backup_key_that_you_recorded))
|
||||
append(" ")
|
||||
|
||||
val supportUrl = stringResource(R.string.remote_backup_support_url)
|
||||
withLink(
|
||||
LinkAnnotation.Clickable(tag = "learn-more") {
|
||||
CommunicationActions.openBrowserLink(context, context.getString(R.string.remote_backup_support_url))
|
||||
CommunicationActions.openBrowserLink(context, supportUrl)
|
||||
}
|
||||
) {
|
||||
withStyle(SpanStyle(color = MaterialTheme.colorScheme.primary)) {
|
||||
|
||||
+2
-2
@@ -10,8 +10,8 @@ import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.models.CurrencySelection
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.models.NetworkFailure
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.ui.CurrencySelection
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.ui.NetworkFailure
|
||||
import org.thoughtcrime.securesms.components.settings.configure
|
||||
import org.thoughtcrime.securesms.components.settings.models.IndeterminateLoadingCircle
|
||||
import org.thoughtcrime.securesms.components.settings.models.SplashImage
|
||||
|
||||
+4
-11
@@ -16,9 +16,8 @@ import androidx.fragment.app.FragmentActivity
|
||||
import org.signal.core.ui.compose.theme.SignalTheme
|
||||
import org.signal.core.util.getParcelableExtraCompat
|
||||
import org.thoughtcrime.securesms.calls.links.EditCallLinkNameDialogFragment
|
||||
import org.thoughtcrime.securesms.main.MainNavigationCallDetailRouter
|
||||
import org.thoughtcrime.securesms.main.MainNavigationDetailLocation
|
||||
import org.thoughtcrime.securesms.main.MainNavigationListLocation
|
||||
import org.thoughtcrime.securesms.main.MainNavigationRouter
|
||||
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId
|
||||
import org.thoughtcrime.securesms.util.viewModel
|
||||
|
||||
@@ -56,23 +55,17 @@ class CallLinkDetailsActivity : FragmentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
private inner class Router : MainNavigationRouter {
|
||||
override fun goTo(location: MainNavigationDetailLocation) {
|
||||
private inner class Router : MainNavigationCallDetailRouter {
|
||||
override fun goToCallDetail(location: MainNavigationDetailLocation.Calls) {
|
||||
when (location) {
|
||||
is MainNavigationDetailLocation.Calls.CallLinks.EditCallLinkName -> {
|
||||
EditCallLinkNameDialogFragment().apply {
|
||||
arguments = bundleOf(EditCallLinkNameDialogFragment.ARG_NAME to viewModel.nameSnapshot)
|
||||
}.show(supportFragmentManager, null)
|
||||
}
|
||||
|
||||
is MainNavigationDetailLocation.Empty -> {
|
||||
finishAfterTransition()
|
||||
}
|
||||
|
||||
else -> error("Unsupported route $location")
|
||||
}
|
||||
}
|
||||
|
||||
override fun goTo(location: MainNavigationListLocation) = Unit
|
||||
override fun exitDetailLocation() = finishAfterTransition()
|
||||
}
|
||||
}
|
||||
|
||||
+5
-5
@@ -46,8 +46,8 @@ import org.thoughtcrime.securesms.calls.YouAreAlreadyInACallSnackbar.YouAreAlrea
|
||||
import org.thoughtcrime.securesms.calls.links.CallLinks
|
||||
import org.thoughtcrime.securesms.calls.links.SignalCallRow
|
||||
import org.thoughtcrime.securesms.database.CallLinkTable
|
||||
import org.thoughtcrime.securesms.main.MainNavigationCallDetailRouter
|
||||
import org.thoughtcrime.securesms.main.MainNavigationDetailLocation
|
||||
import org.thoughtcrime.securesms.main.MainNavigationRouter
|
||||
import org.thoughtcrime.securesms.main.MainNavigationViewModel
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkCredentials
|
||||
@@ -63,7 +63,7 @@ fun CallLinkDetailsScreen(
|
||||
viewModel: CallLinkDetailsViewModel = viewModel {
|
||||
CallLinkDetailsViewModel(roomId)
|
||||
},
|
||||
router: MainNavigationRouter = viewModel<MainNavigationViewModel>(viewModelStoreOwner = LocalActivity.current as ComponentActivity) {
|
||||
router: MainNavigationCallDetailRouter = viewModel<MainNavigationViewModel>(viewModelStoreOwner = LocalActivity.current as ComponentActivity) {
|
||||
error("Should already be created.")
|
||||
}
|
||||
) {
|
||||
@@ -90,7 +90,7 @@ fun CallLinkDetailsScreen(
|
||||
class DefaultCallLinkDetailsCallback(
|
||||
private val activity: FragmentActivity,
|
||||
private val viewModel: CallLinkDetailsViewModel,
|
||||
private val router: MainNavigationRouter
|
||||
private val router: MainNavigationCallDetailRouter
|
||||
) : CallLinkDetailsCallback {
|
||||
|
||||
private val lifecycleDisposable = LifecycleDisposable()
|
||||
@@ -113,7 +113,7 @@ class DefaultCallLinkDetailsCallback(
|
||||
}
|
||||
|
||||
override fun onEditNameClicked() {
|
||||
router.goTo(MainNavigationDetailLocation.Calls.CallLinks.EditCallLinkName(callLinkRoomId = viewModel.recipientSnapshot!!.requireCallLinkRoomId()))
|
||||
router.goToCallDetail(MainNavigationDetailLocation.Calls.CallLinks.EditCallLinkName(callLinkRoomId = viewModel.recipientSnapshot!!.requireCallLinkRoomId()))
|
||||
}
|
||||
|
||||
override fun onShareClicked() {
|
||||
@@ -152,7 +152,7 @@ class DefaultCallLinkDetailsCallback(
|
||||
viewModel.setDisplayRevocationDialog(false)
|
||||
activity.lifecycleScope.launch {
|
||||
if (viewModel.delete()) {
|
||||
router.goTo(MainNavigationDetailLocation.Empty)
|
||||
router.exitDetailLocation()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -324,13 +324,16 @@ class CallLogAdapter(
|
||||
return
|
||||
}
|
||||
|
||||
presentRecipientDetails(model.call.peer, model.call.searchQuery)
|
||||
presentRecipientDetails(model.call)
|
||||
presentCallInfo(model.call, model.call.date)
|
||||
presentCallType(model)
|
||||
}
|
||||
|
||||
private fun presentRecipientDetails(recipient: Recipient, searchQuery: String?) {
|
||||
binding.callRecipientAvatar.setAvatar(Glide.with(binding.callRecipientAvatar), recipient, true)
|
||||
private fun presentRecipientDetails(call: CallLogRow.Call) {
|
||||
val recipient = call.peer
|
||||
val searchQuery = call.searchQuery
|
||||
binding.callRecipientAvatar.setAvatar(Glide.with(binding.callRecipientAvatar), recipient, false)
|
||||
binding.callRecipientAvatar.setOnClickListener { onCallClicked(call) }
|
||||
binding.callRecipientBadge.setBadgeFromRecipient(recipient)
|
||||
binding.callRecipientName.text = if (searchQuery != null) {
|
||||
SearchUtil.getHighlightedSpan(
|
||||
|
||||
@@ -333,7 +333,7 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
|
||||
if (viewModel.selectionStateSnapshot.isNotEmpty(binding.recycler.adapter!!.itemCount)) {
|
||||
viewModel.toggleSelected(callLogRow.id)
|
||||
} else {
|
||||
mainNavigationViewModel.goTo(MainNavigationDetailLocation.Calls.CallLinks.CallLinkDetails(callLogRow.record.roomId))
|
||||
mainNavigationViewModel.goTo(MainNavigationDetailLocation.CallLinkDetails(callLogRow.record.roomId))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -25,7 +25,6 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -214,7 +213,8 @@ private fun UserMessagesHost(
|
||||
onDismiss: (UserMessage) -> Unit,
|
||||
snackbarHostState: SnackbarHostState
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val youAreAlreadyInACall = stringResource(R.string.CommunicationActions__you_are_already_in_a_call)
|
||||
val errorRetrievingContacts = stringResource(R.string.ContactSelectionListFragment_error_retrieving_contacts_check_your_network_connection)
|
||||
|
||||
when (userMessage) {
|
||||
null -> {}
|
||||
@@ -228,14 +228,14 @@ private fun UserMessagesHost(
|
||||
|
||||
is UserMessage.UserAlreadyInAnotherCall -> LaunchedEffect(userMessage) {
|
||||
snackbarHostState.showSnackbar(
|
||||
message = context.getString(R.string.CommunicationActions__you_are_already_in_a_call)
|
||||
message = youAreAlreadyInACall
|
||||
)
|
||||
onDismiss(userMessage)
|
||||
}
|
||||
|
||||
is UserMessage.ContactsRefreshFailed -> LaunchedEffect(userMessage) {
|
||||
snackbarHostState.showSnackbar(
|
||||
message = context.getString(R.string.ContactSelectionListFragment_error_retrieving_contacts_check_your_network_connection)
|
||||
message = errorRetrievingContacts
|
||||
)
|
||||
onDismiss(userMessage)
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.network.NetworkResult
|
||||
import org.thoughtcrime.securesms.calls.new.NewCallUiState.CallType
|
||||
import org.thoughtcrime.securesms.calls.new.NewCallUiState.UserMessage
|
||||
import org.thoughtcrime.securesms.contacts.sync.ContactDiscovery
|
||||
@@ -25,7 +26,6 @@ import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.recipients.RecipientRepository
|
||||
import org.thoughtcrime.securesms.recipients.ui.RecipientSelection
|
||||
import org.whispersystems.signalservice.api.NetworkResult
|
||||
|
||||
class NewCallViewModel : ViewModel() {
|
||||
companion object {
|
||||
|
||||
@@ -63,6 +63,7 @@ public final class AudioView extends FrameLayout {
|
||||
@NonNull private final AnimatingToggle controlToggle;
|
||||
@NonNull private final View progressAndPlay;
|
||||
@NonNull private final LottieAnimationView playPauseButton;
|
||||
@NonNull private final View downloadContainer;
|
||||
@NonNull private final ImageView downloadButton;
|
||||
@Nullable private final ProgressWheel circleProgress;
|
||||
@NonNull private final SeekBar seekBar;
|
||||
@@ -121,13 +122,14 @@ public final class AudioView extends FrameLayout {
|
||||
throw new IllegalStateException("Unsupported mode: " + mode);
|
||||
}
|
||||
|
||||
this.controlToggle = findViewById(R.id.control_toggle);
|
||||
this.playPauseButton = findViewById(R.id.play);
|
||||
this.progressAndPlay = findViewById(R.id.progress_and_play);
|
||||
this.downloadButton = findViewById(R.id.download);
|
||||
this.circleProgress = findViewById(R.id.circle_progress);
|
||||
this.seekBar = findViewById(R.id.seek);
|
||||
this.duration = findViewById(R.id.duration);
|
||||
this.controlToggle = findViewById(R.id.control_toggle);
|
||||
this.playPauseButton = findViewById(R.id.play);
|
||||
this.progressAndPlay = findViewById(R.id.progress_and_play);
|
||||
this.downloadContainer = findViewById(R.id.download_container);
|
||||
this.downloadButton = findViewById(R.id.download);
|
||||
this.circleProgress = findViewById(R.id.circle_progress);
|
||||
this.seekBar = findViewById(R.id.seek);
|
||||
this.duration = findViewById(R.id.duration);
|
||||
|
||||
lottieDirection = REVERSE;
|
||||
this.playPauseButton.setOnClickListener(new PlayPauseClickedListener());
|
||||
@@ -168,6 +170,7 @@ public final class AudioView extends FrameLayout {
|
||||
|
||||
public void setProgressAndPlayBackgroundTint(@ColorInt int color) {
|
||||
progressAndPlay.getBackground().setColorFilter(color, PorterDuff.Mode.SRC_IN);
|
||||
downloadContainer.getBackground().setColorFilter(color, PorterDuff.Mode.SRC_IN);
|
||||
}
|
||||
|
||||
public Observer<VoiceNotePlaybackState> getPlaybackStateObserver() {
|
||||
@@ -195,7 +198,7 @@ public final class AudioView extends FrameLayout {
|
||||
}
|
||||
|
||||
if (showControls && audio.isPendingDownload()) {
|
||||
controlToggle.displayQuick(downloadButton);
|
||||
controlToggle.displayQuick(downloadContainer);
|
||||
seekBar.setEnabled(false);
|
||||
downloadButton.setOnClickListener(new DownloadClickedListener(audio));
|
||||
if (circleProgress != null) {
|
||||
|
||||
@@ -30,11 +30,12 @@ import com.bumptech.glide.request.target.SimpleTarget;
|
||||
import com.bumptech.glide.request.target.Target;
|
||||
import com.bumptech.glide.request.transition.Transition;
|
||||
|
||||
import org.signal.core.util.ContextUtil;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.avatar.fallback.FallbackAvatar;
|
||||
import org.thoughtcrime.securesms.avatar.fallback.FallbackAvatarDrawable;
|
||||
import org.thoughtcrime.securesms.components.settings.conversation.ConversationSettingsActivity;
|
||||
import org.thoughtcrime.securesms.components.settings.conversation.ConversationSettingsNavigator;
|
||||
import org.thoughtcrime.securesms.contacts.avatars.ContactPhoto;
|
||||
import org.thoughtcrime.securesms.contacts.avatars.ProfileContactPhoto;
|
||||
import org.thoughtcrime.securesms.contacts.avatars.SystemContactPhoto;
|
||||
@@ -53,9 +54,7 @@ import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
public final class AvatarImageView extends AppCompatImageView {
|
||||
|
||||
private static final int SIZE_LARGE = 1;
|
||||
private static final int SIZE_SMALL = 2;
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
private static final String TAG = Log.tag(AvatarImageView.class);
|
||||
@@ -156,10 +155,10 @@ public final class AvatarImageView extends AppCompatImageView {
|
||||
|
||||
public void setAvatar(@NonNull RequestManager requestManager, @Nullable Recipient recipient, boolean quickContactEnabled, boolean useSelfProfileAvatar, boolean useBlurGradient) {
|
||||
setAvatar(requestManager, recipient, new AvatarOptions.Builder(this)
|
||||
.withUseSelfProfileAvatar(useSelfProfileAvatar)
|
||||
.withQuickContactEnabled(quickContactEnabled)
|
||||
.withUseBlurGradient(useBlurGradient)
|
||||
.build());
|
||||
.withUseSelfProfileAvatar(useSelfProfileAvatar)
|
||||
.withQuickContactEnabled(quickContactEnabled)
|
||||
.withUseBlurGradient(useBlurGradient)
|
||||
.build());
|
||||
}
|
||||
|
||||
private void setAvatar(@Nullable Recipient recipient, @NonNull AvatarOptions avatarOptions) {
|
||||
@@ -182,8 +181,8 @@ public final class AvatarImageView extends AppCompatImageView {
|
||||
|
||||
if (!photo.equals(recipientContactPhoto) || shouldBlur != blurred || !Objects.equals(chatColors, this.chatColors) || !Objects.equals(initials, this.initials)) {
|
||||
requestManager.clear(this);
|
||||
this.chatColors = chatColors;
|
||||
this.initials = initials;
|
||||
this.chatColors = chatColors;
|
||||
this.initials = initials;
|
||||
recipientContactPhoto = photo;
|
||||
|
||||
FallbackAvatarProvider activeFallbackPhotoProvider = this.fallbackAvatarProvider;
|
||||
@@ -215,12 +214,12 @@ public final class AvatarImageView extends AppCompatImageView {
|
||||
List<Transformation<Bitmap>> transforms = Collections.singletonList(new CircleCrop());
|
||||
|
||||
RequestBuilder<Drawable> request = requestManager.load(photo.contactPhoto)
|
||||
.fallback(fallback)
|
||||
.error(fallback)
|
||||
.diskCacheStrategy(DiskCacheStrategy.ALL)
|
||||
.downsample(DownsampleStrategy.CENTER_INSIDE)
|
||||
.transform(new MultiTransformation<>(transforms))
|
||||
.addListener(redownloadRequestListener);
|
||||
.fallback(fallback)
|
||||
.error(fallback)
|
||||
.diskCacheStrategy(DiskCacheStrategy.ALL)
|
||||
.downsample(DownsampleStrategy.CENTER_INSIDE)
|
||||
.transform(new MultiTransformation<>(transforms))
|
||||
.addListener(redownloadRequestListener);
|
||||
|
||||
if (wasUnblurred) {
|
||||
blurred = shouldBlur;
|
||||
@@ -260,17 +259,12 @@ public final class AvatarImageView extends AppCompatImageView {
|
||||
private void setAvatarClickHandler(@NonNull final Recipient recipient, boolean quickContactEnabled) {
|
||||
if (quickContactEnabled) {
|
||||
super.setOnClickListener(v -> {
|
||||
Context context = getContext();
|
||||
FragmentActivity activity = ContextUtil.requireFragmentActivity(getContext());
|
||||
|
||||
if (recipient.isPushGroup()) {
|
||||
context.startActivity(ConversationSettingsActivity.forGroup(context, recipient.requireGroupId().requirePush()),
|
||||
ConversationSettingsActivity.createTransitionBundle(context, this));
|
||||
ConversationSettingsNavigator.navigate(activity, recipient);
|
||||
} else {
|
||||
if (context instanceof FragmentActivity) {
|
||||
RecipientBottomSheetDialogFragment.show(((FragmentActivity) context).getSupportFragmentManager(), recipient.getId(), null);
|
||||
} else {
|
||||
context.startActivity(ConversationSettingsActivity.forRecipient(context, recipient.getId()),
|
||||
ConversationSettingsActivity.createTransitionBundle(context, this));
|
||||
}
|
||||
RecipientBottomSheetDialogFragment.show(activity.getSupportFragmentManager(), recipient.getId(), null);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
@@ -283,13 +277,13 @@ public final class AvatarImageView extends AppCompatImageView {
|
||||
Drawable fallback = new FallbackAvatarDrawable(getContext(), new FallbackAvatar.Resource.Group(color)).circleCrop();
|
||||
|
||||
Glide.with(this)
|
||||
.load(avatarBytes)
|
||||
.dontAnimate()
|
||||
.fallback(fallback)
|
||||
.error(fallback)
|
||||
.diskCacheStrategy(DiskCacheStrategy.ALL)
|
||||
.circleCrop()
|
||||
.into(this);
|
||||
.load(avatarBytes)
|
||||
.dontAnimate()
|
||||
.fallback(fallback)
|
||||
.error(fallback)
|
||||
.diskCacheStrategy(DiskCacheStrategy.ALL)
|
||||
.circleCrop()
|
||||
.into(this);
|
||||
}
|
||||
|
||||
public void setNonAvatarImageResource(@DrawableRes int imageResource) {
|
||||
@@ -308,7 +302,8 @@ public final class AvatarImageView extends AppCompatImageView {
|
||||
}
|
||||
}
|
||||
|
||||
private static class DefaultFallbackAvatarProvider implements FallbackAvatarProvider {}
|
||||
private static class DefaultFallbackAvatarProvider implements FallbackAvatarProvider {
|
||||
}
|
||||
|
||||
private static class RecipientContactPhoto {
|
||||
|
||||
|
||||
+10
-2
@@ -254,7 +254,11 @@ public class ConversationItemFooter extends ConstraintLayout {
|
||||
}
|
||||
});
|
||||
|
||||
dateView.setMaxWidth(ViewUtil.dpToPx(32));
|
||||
ConstraintLayout.LayoutParams params = (ConstraintLayout.LayoutParams) dateView.getLayoutParams();
|
||||
params.startToEnd = R.id.footer_audio_playback_speed_toggle;
|
||||
params.constrainedWidth = true;
|
||||
params.horizontalBias = 1f;
|
||||
dateView.setLayoutParams(params);
|
||||
}
|
||||
|
||||
private void hidePlaybackSpeedToggle() {
|
||||
@@ -276,7 +280,11 @@ public class ConversationItemFooter extends ConstraintLayout {
|
||||
}
|
||||
});
|
||||
|
||||
dateView.setMaxWidth(Integer.MAX_VALUE);
|
||||
ConstraintLayout.LayoutParams params = (ConstraintLayout.LayoutParams) dateView.getLayoutParams();
|
||||
params.startToEnd = ConstraintLayout.LayoutParams.UNSET;
|
||||
params.constrainedWidth = false;
|
||||
params.horizontalBias = 0.5f;
|
||||
dateView.setLayoutParams(params);
|
||||
}
|
||||
|
||||
private @NonNull Rect getPlaybackSpeedToggleTouchDelegateRect() {
|
||||
|
||||
@@ -9,10 +9,10 @@ import androidx.annotation.DrawableRes;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.core.content.ContextCompat;
|
||||
|
||||
import org.signal.core.util.ContextUtil;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.components.emoji.SimpleEmojiTextView;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.util.ContextUtil;
|
||||
import org.thoughtcrime.securesms.util.DrawableUtil;
|
||||
import org.thoughtcrime.securesms.util.RemoteConfig;
|
||||
import org.thoughtcrime.securesms.util.SpanUtil;
|
||||
|
||||
+47
-8
@@ -5,18 +5,41 @@
|
||||
|
||||
package org.thoughtcrime.securesms.components
|
||||
|
||||
import android.view.Window
|
||||
import android.view.WindowManager
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.LocalActivity
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.DefaultLifecycleObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences
|
||||
import java.util.WeakHashMap
|
||||
|
||||
/**
|
||||
* Applies temporary screenshot security for the given component lifecycle.
|
||||
*
|
||||
* Multiple callers can request security on the same window concurrently; the
|
||||
* flag is only cleared once every caller has released its hold.
|
||||
*/
|
||||
object TemporaryScreenshotSecurity {
|
||||
|
||||
private val activeHolds = WeakHashMap<Window, Int>()
|
||||
|
||||
@Composable
|
||||
fun bind() {
|
||||
val activity = LocalActivity.current as? ComponentActivity ?: return
|
||||
|
||||
DisposableEffect(activity) {
|
||||
acquire(activity)
|
||||
|
||||
onDispose {
|
||||
release(activity)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun bindToViewLifecycleOwner(fragment: Fragment) {
|
||||
val observer = LifecycleObserver { fragment.requireActivity() }
|
||||
@@ -31,21 +54,37 @@ object TemporaryScreenshotSecurity {
|
||||
activity.lifecycle.addObserver(observer)
|
||||
}
|
||||
|
||||
private fun acquire(activity: ComponentActivity) {
|
||||
val window = activity.window
|
||||
val previous = activeHolds[window] ?: 0
|
||||
activeHolds[window] = previous + 1
|
||||
if (previous == 0 && !TextSecurePreferences.isScreenSecurityEnabled(activity)) {
|
||||
window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
|
||||
}
|
||||
}
|
||||
|
||||
private fun release(activity: ComponentActivity) {
|
||||
val window = activity.window
|
||||
val next = ((activeHolds[window] ?: 0) - 1).coerceAtLeast(0)
|
||||
if (next == 0) {
|
||||
activeHolds.remove(window)
|
||||
if (!TextSecurePreferences.isScreenSecurityEnabled(activity)) {
|
||||
window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
|
||||
}
|
||||
} else {
|
||||
activeHolds[window] = next
|
||||
}
|
||||
}
|
||||
|
||||
private class LifecycleObserver(
|
||||
private val activityProvider: () -> ComponentActivity
|
||||
) : DefaultLifecycleObserver {
|
||||
override fun onResume(owner: LifecycleOwner) {
|
||||
val activity = activityProvider()
|
||||
if (!TextSecurePreferences.isScreenSecurityEnabled(activity)) {
|
||||
activity.window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
|
||||
}
|
||||
acquire(activityProvider())
|
||||
}
|
||||
|
||||
override fun onPause(owner: LifecycleOwner) {
|
||||
val activity = activityProvider()
|
||||
if (!TextSecurePreferences.isScreenSecurityEnabled(activity)) {
|
||||
activity.window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
|
||||
}
|
||||
release(activityProvider())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ import com.bumptech.glide.request.Request;
|
||||
import com.bumptech.glide.request.RequestListener;
|
||||
import com.bumptech.glide.request.RequestOptions;
|
||||
|
||||
import org.signal.core.models.media.TransformProperties;
|
||||
import org.signal.core.util.concurrent.ListenableFuture;
|
||||
import org.signal.core.util.concurrent.SettableFuture;
|
||||
import org.signal.core.util.logging.Log;
|
||||
@@ -608,7 +609,14 @@ public class ThumbnailView extends FrameLayout {
|
||||
}
|
||||
|
||||
private RequestBuilder<Drawable> buildThumbnailRequestBuilder(@NonNull RequestManager requestManager, @NonNull Slide slide) {
|
||||
RequestBuilder<Drawable> requestBuilder = applySizing(requestManager.load(new DecryptableUri(Objects.requireNonNull(slide.getDisplayUri())))
|
||||
long videoTrimStartTimeUs = 0;
|
||||
TransformProperties transformProperties = slide.asAttachment().transformProperties;
|
||||
|
||||
if (transformProperties != null && !transformProperties.shouldSkipTransform()) {
|
||||
videoTrimStartTimeUs = transformProperties.videoTrimStartTimeUs;
|
||||
}
|
||||
|
||||
RequestBuilder<Drawable> requestBuilder = applySizing(requestManager.load(new DecryptableUri(Objects.requireNonNull(slide.getDisplayUri()), videoTrimStartTimeUs))
|
||||
.diskCacheStrategy(DiskCacheStrategy.RESOURCE)
|
||||
.downsample(SignalDownsampleStrategy.CENTER_OUTSIDE_NO_UPSCALE)
|
||||
.transition(withCrossFade()));
|
||||
|
||||
+4
-2
@@ -69,12 +69,14 @@ fun <Reason> SendSupportEmailEffect(
|
||||
filterRes: ContactSupportCallbacks.StringForReason<Reason>,
|
||||
hide: () -> Unit
|
||||
) {
|
||||
val subject = stringResource(subjectRes(contactSupportState.reason))
|
||||
val helpDebugLog = stringResource(R.string.HelpFragment__debug_log)
|
||||
|
||||
val context = LocalContext.current
|
||||
LaunchedEffect(contactSupportState.sendEmail) {
|
||||
if (contactSupportState.sendEmail) {
|
||||
val subject = context.getString(subjectRes(contactSupportState.reason))
|
||||
val prefix = if (contactSupportState.debugLogUrl != null) {
|
||||
"\n${context.getString(R.string.HelpFragment__debug_log)} ${contactSupportState.debugLogUrl}\n\n"
|
||||
"\n$helpDebugLog ${contactSupportState.debugLogUrl}\n\n"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
|
||||
@@ -15,11 +15,11 @@ import androidx.recyclerview.widget.LinearLayoutManager;
|
||||
import androidx.recyclerview.widget.LinearSmoothScroller;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import org.signal.core.util.ContextUtil;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiPageViewGridAdapter.EmojiHeader;
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiPageViewGridAdapter.EmojiNoResultsModel;
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiPageViewGridAdapter.VariationSelectorListener;
|
||||
import org.thoughtcrime.securesms.util.ContextUtil;
|
||||
import org.thoughtcrime.securesms.util.DrawableUtil;
|
||||
import org.thoughtcrime.securesms.util.ViewUtil;
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel;
|
||||
|
||||
@@ -243,7 +243,11 @@ public class EmojiTextView extends AppCompatTextView {
|
||||
return;
|
||||
}
|
||||
|
||||
textView.setPrecomputedText(precomputedTextCompat);
|
||||
try {
|
||||
textView.setPrecomputedText(precomputedTextCompat);
|
||||
} catch (IllegalArgumentException e) {
|
||||
textView.setText(text, type);
|
||||
}
|
||||
|
||||
if (textView.sizeChangeInProgress) {
|
||||
textView.sizeChangeInProgress = false;
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
package org.thoughtcrime.securesms.components.emoji;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.res.TypedArray;
|
||||
import android.graphics.drawable.Drawable;
|
||||
import android.util.AttributeSet;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.widget.AppCompatImageButton;
|
||||
|
||||
import org.signal.core.util.ContextUtil;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.keyboard.KeyboardPage;
|
||||
import org.thoughtcrime.securesms.util.ContextUtil;
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
|
||||
public class EmojiToggle extends AppCompatImageButton implements MediaKeyboard.MediaKeyboardListener {
|
||||
|
||||
@@ -3,7 +3,6 @@ package org.thoughtcrime.securesms.components.emoji;
|
||||
import android.content.Context;
|
||||
import android.content.res.TypedArray;
|
||||
import android.util.AttributeSet;
|
||||
import android.view.ContextThemeWrapper;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.FrameLayout;
|
||||
@@ -15,6 +14,7 @@ import androidx.fragment.app.FragmentActivity;
|
||||
import androidx.fragment.app.FragmentManager;
|
||||
import androidx.fragment.app.FragmentTransaction;
|
||||
|
||||
import org.signal.core.util.ContextUtil;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.components.InputAwareLayout.InputView;
|
||||
@@ -29,12 +29,12 @@ public class MediaKeyboard extends FrameLayout implements InputView {
|
||||
private static final String EMOJI_SEARCH = "emoji_search_fragment";
|
||||
|
||||
@Nullable private MediaKeyboardListener keyboardListener;
|
||||
private boolean isInitialised;
|
||||
private int latestKeyboardHeight;
|
||||
private State keyboardState;
|
||||
private KeyboardPagerFragment keyboardPagerFragment;
|
||||
private FragmentManager fragmentManager;
|
||||
private int mediaKeyboardTheme;
|
||||
private boolean isInitialised;
|
||||
private int latestKeyboardHeight;
|
||||
private State keyboardState;
|
||||
private KeyboardPagerFragment keyboardPagerFragment;
|
||||
private FragmentManager fragmentManager;
|
||||
private int mediaKeyboardTheme;
|
||||
|
||||
public MediaKeyboard(Context context) {
|
||||
this(context, null);
|
||||
@@ -175,7 +175,7 @@ public class MediaKeyboard extends FrameLayout implements InputView {
|
||||
LayoutInflater.from(getContext()).inflate(R.layout.media_keyboard, this, true);
|
||||
|
||||
if (fragmentManager == null) {
|
||||
FragmentActivity activity = resolveActivity(getContext());
|
||||
FragmentActivity activity = ContextUtil.requireFragmentActivity(getContext());
|
||||
fragmentManager = activity.getSupportFragmentManager();
|
||||
}
|
||||
|
||||
@@ -188,25 +188,17 @@ public class MediaKeyboard extends FrameLayout implements InputView {
|
||||
.replace(R.id.media_keyboard_fragment_container, keyboardPagerFragment, TAG)
|
||||
.commitNowAllowingStateLoss();
|
||||
|
||||
keyboardState = State.NORMAL;
|
||||
latestKeyboardHeight = -1;
|
||||
isInitialised = true;
|
||||
}
|
||||
}
|
||||
|
||||
private static FragmentActivity resolveActivity(@Nullable Context context) {
|
||||
if (context instanceof FragmentActivity) {
|
||||
return (FragmentActivity) context;
|
||||
} else if (context instanceof ContextThemeWrapper) {
|
||||
return resolveActivity(((ContextThemeWrapper) context).getBaseContext());
|
||||
} else {
|
||||
throw new IllegalStateException("Could not locate FragmentActivity");
|
||||
keyboardState = State.NORMAL;
|
||||
latestKeyboardHeight = -1;
|
||||
isInitialised = true;
|
||||
}
|
||||
}
|
||||
|
||||
public interface MediaKeyboardListener {
|
||||
void onShown();
|
||||
|
||||
void onHidden();
|
||||
|
||||
void onKeyboardChanged(@NonNull KeyboardPage page);
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -11,9 +11,9 @@ import androidx.annotation.ColorInt;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.core.graphics.drawable.DrawableCompat;
|
||||
|
||||
import org.signal.core.util.ContextUtil;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.components.spoiler.SpoilerAnnotation;
|
||||
import org.thoughtcrime.securesms.util.ContextUtil;
|
||||
import org.thoughtcrime.securesms.util.DrawableUtil;
|
||||
import org.signal.core.util.Util;
|
||||
import org.thoughtcrime.securesms.util.ViewUtil;
|
||||
|
||||
+36
-36
@@ -312,49 +312,49 @@ private fun AppSettingsContent(
|
||||
enabled = isRegisteredAndUpToDate
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
val context = LocalContext.current
|
||||
val donateUrl = stringResource(R.string.donate_url)
|
||||
item {
|
||||
val context = LocalContext.current
|
||||
val donateUrl = stringResource(R.string.donate_url)
|
||||
|
||||
Rows.TextRow(
|
||||
text = {
|
||||
Text(
|
||||
text = stringResource(R.string.preferences__donate_to_signal),
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Rows.TextRow(
|
||||
text = {
|
||||
Text(
|
||||
text = stringResource(R.string.preferences__donate_to_signal),
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
|
||||
if (state.hasExpiredGiftBadge) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.symbol_info_fill_24),
|
||||
tint = colorResource(R.color.signal_accent_primary),
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
},
|
||||
icon = {
|
||||
if (state.hasExpiredGiftBadge) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.symbol_heart_24),
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurface
|
||||
painter = painterResource(R.drawable.symbol_info_fill_24),
|
||||
tint = colorResource(R.color.signal_accent_primary),
|
||||
contentDescription = null
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
if (state.allowUserToGoToDonationManagementScreen) {
|
||||
callbacks.navigate(AppSettingsRoute.DonationsRoute.Donations())
|
||||
} else {
|
||||
CommunicationActions.openBrowserLink(context, donateUrl)
|
||||
}
|
||||
},
|
||||
onLongClick = {
|
||||
callbacks.copyDonorBadgeSubscriberIdToClipboard()
|
||||
}
|
||||
)
|
||||
}
|
||||
},
|
||||
icon = {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.symbol_heart_24),
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
if (state.allowUserToGoToDonationManagementScreen) {
|
||||
callbacks.navigate(AppSettingsRoute.DonationsRoute.Donations())
|
||||
} else {
|
||||
CommunicationActions.openBrowserLink(context, donateUrl)
|
||||
}
|
||||
},
|
||||
onLongClick = {
|
||||
callbacks.copyDonorBadgeSubscriberIdToClipboard()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Dividers.Default()
|
||||
}
|
||||
item {
|
||||
Dividers.Default()
|
||||
}
|
||||
|
||||
item {
|
||||
|
||||
+1
-1
@@ -5,10 +5,10 @@ import com.fasterxml.jackson.databind.JsonNode
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import org.signal.network.NetworkResult
|
||||
import org.thoughtcrime.securesms.net.SignalNetwork
|
||||
import org.thoughtcrime.securesms.providers.BlobProvider
|
||||
import org.thoughtcrime.securesms.util.JsonUtils
|
||||
import org.whispersystems.signalservice.api.NetworkResult
|
||||
|
||||
class ExportAccountDataRepository {
|
||||
|
||||
|
||||
+1
-1
@@ -21,6 +21,7 @@ import org.signal.core.util.logging.Log
|
||||
import org.signal.core.util.money.FiatMoney
|
||||
import org.signal.core.util.throttleLatest
|
||||
import org.signal.donations.InAppPaymentType
|
||||
import org.signal.network.NetworkResult
|
||||
import org.thoughtcrime.securesms.backup.v2.BackupRepository
|
||||
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
|
||||
import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsType
|
||||
@@ -33,7 +34,6 @@ import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.util.InternetConnectionObserver
|
||||
import org.whispersystems.signalservice.api.NetworkResult
|
||||
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription
|
||||
import java.math.BigDecimal
|
||||
import java.util.Currency
|
||||
|
||||
+4
-2
@@ -31,6 +31,7 @@ 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.platform.LocalLocale
|
||||
import androidx.compose.ui.res.dimensionResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
@@ -507,6 +508,7 @@ private fun ActiveBackupsRow(
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
|
||||
val locale = LocalLocale.current.platformLocale
|
||||
when (val type = backupState.messageBackupsType) {
|
||||
is MessageBackupsType.Paid -> {
|
||||
val body = if (backupState is BackupState.Canceled) {
|
||||
@@ -514,13 +516,13 @@ private fun ActiveBackupsRow(
|
||||
} else if (type.pricePerMonth.amount == BigDecimal.ZERO) {
|
||||
stringResource(
|
||||
R.string.BackupsSettingsFragment_renews_s,
|
||||
DateUtils.formatDateWithYear(Locale.getDefault(), backupState.renewalTime.inWholeMilliseconds)
|
||||
DateUtils.formatDateWithYear(locale, backupState.renewalTime.inWholeMilliseconds)
|
||||
)
|
||||
} else {
|
||||
stringResource(
|
||||
R.string.BackupsSettingsFragment_s_month_renews_s,
|
||||
FiatMoneyUtil.format(LocalContext.current.resources, type.pricePerMonth),
|
||||
DateUtils.formatDateWithYear(Locale.getDefault(), backupState.renewalTime.inWholeMilliseconds)
|
||||
DateUtils.formatDateWithYear(locale, backupState.renewalTime.inWholeMilliseconds)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+2
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
package org.thoughtcrime.securesms.components.settings.app.backups.local
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.widget.Toast
|
||||
@@ -176,6 +177,7 @@ class LocalBackupsFragment : ComposeFragment() {
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("LocalContextGetResourceValueCall")
|
||||
@Composable
|
||||
private fun rememberChooseBackupLocationLauncher(backStack: NavBackStack<NavKey>): ActivityResultLauncher<Uri?> {
|
||||
val context = LocalContext.current
|
||||
|
||||
+1
-1
@@ -16,12 +16,12 @@ import kotlinx.coroutines.withContext
|
||||
import org.signal.core.models.AccountEntropyPool
|
||||
import org.signal.core.util.concurrent.SignalDispatchers
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.network.NetworkResult
|
||||
import org.thoughtcrime.securesms.backup.v2.BackupRepository
|
||||
import org.thoughtcrime.securesms.backup.v2.StagedBackupKeyRotations
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.jobs.RestoreOptimizedMediaJob
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.whispersystems.signalservice.api.NetworkResult
|
||||
|
||||
class BackupKeyDisplayViewModel : ViewModel(), BackupKeyCredentialManagerHandler {
|
||||
|
||||
|
||||
+6
-2
@@ -54,6 +54,7 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalLocale
|
||||
import androidx.compose.ui.res.colorResource
|
||||
import androidx.compose.ui.res.dimensionResource
|
||||
import androidx.compose.ui.res.painterResource
|
||||
@@ -1051,9 +1052,10 @@ private fun BackupCard(
|
||||
else -> error("Not supported here.")
|
||||
}
|
||||
|
||||
val locale = LocalLocale.current.platformLocale
|
||||
if (backupState.renewalTime > 0.seconds) {
|
||||
Text(
|
||||
text = stringResource(resource, DateUtils.formatDateWithYear(Locale.getDefault(), backupState.renewalTime.inWholeMilliseconds))
|
||||
text = stringResource(resource, DateUtils.formatDateWithYear(locale, backupState.renewalTime.inWholeMilliseconds))
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1873,8 +1875,10 @@ private fun ErrorCardPreview() {
|
||||
@Composable
|
||||
private fun PendingCardPreview() {
|
||||
Previews.Preview {
|
||||
val locale = LocalLocale.current.platformLocale
|
||||
|
||||
PendingCard(
|
||||
price = FiatMoney(BigDecimal.TEN, Currency.getInstance(Locale.getDefault()))
|
||||
price = FiatMoney(BigDecimal.TEN, Currency.getInstance(locale))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -29,6 +29,7 @@ import org.signal.core.util.logging.Log
|
||||
import org.signal.core.util.mebiBytes
|
||||
import org.signal.core.util.throttleLatest
|
||||
import org.signal.donations.InAppPaymentType
|
||||
import org.signal.network.NetworkResult
|
||||
import org.thoughtcrime.securesms.backup.ArchiveUploadProgress
|
||||
import org.thoughtcrime.securesms.backup.DeletionState
|
||||
import org.thoughtcrime.securesms.backup.v2.ArchiveRestoreProgress
|
||||
@@ -51,7 +52,6 @@ import org.thoughtcrime.securesms.keyvalue.protos.ArchiveUploadProgressState
|
||||
import org.thoughtcrime.securesms.util.Environment
|
||||
import org.thoughtcrime.securesms.util.RemoteConfig
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences
|
||||
import org.whispersystems.signalservice.api.NetworkResult
|
||||
import kotlin.time.Duration.Companion.days
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
+2
-2
@@ -21,6 +21,7 @@ import org.signal.libsignal.protocol.state.SignalProtocolStore
|
||||
import org.signal.libsignal.protocol.state.SignedPreKeyRecord
|
||||
import org.signal.libsignal.protocol.util.KeyHelper
|
||||
import org.signal.libsignal.protocol.util.Medium
|
||||
import org.signal.network.NetworkResult
|
||||
import org.thoughtcrime.securesms.crypto.PreKeyUtil
|
||||
import org.thoughtcrime.securesms.database.IdentityTable
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
@@ -36,7 +37,6 @@ import org.thoughtcrime.securesms.pin.SvrWrongPinException
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.registration.viewmodel.SvrAuthCredentialSet
|
||||
import org.thoughtcrime.securesms.storage.StorageSyncHelper
|
||||
import org.whispersystems.signalservice.api.NetworkResult
|
||||
import org.whispersystems.signalservice.api.SignalServiceAccountManager
|
||||
import org.whispersystems.signalservice.api.SignalServiceMessageSender
|
||||
import org.whispersystems.signalservice.api.SvrNoDataException
|
||||
@@ -147,7 +147,7 @@ class ChangeNumberRepository(
|
||||
Log.i(TAG, "Submitting prekeys with PNI identity key: ${pniIdentityKeyPair.publicKey.fingerprint}")
|
||||
|
||||
retryChangeLocalNumberNetworkOperation {
|
||||
SignalNetwork.keys.setPreKeys(
|
||||
SignalNetwork.keys.setPreKeysSync(
|
||||
PreKeyUpload(
|
||||
serviceIdType = ServiceIdType.PNI,
|
||||
signedPreKey = signedPreKey,
|
||||
|
||||
+2
-2
@@ -5,11 +5,11 @@
|
||||
|
||||
package org.thoughtcrime.securesms.components.settings.app.changenumber
|
||||
|
||||
import org.signal.network.NetworkResult
|
||||
import org.signal.network.exceptions.NonSuccessfulResponseCodeException
|
||||
import org.thoughtcrime.securesms.pin.SvrWrongPinException
|
||||
import org.thoughtcrime.securesms.registration.data.network.RegistrationResult
|
||||
import org.whispersystems.signalservice.api.NetworkResult
|
||||
import org.whispersystems.signalservice.api.SvrNoDataException
|
||||
import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException
|
||||
import org.whispersystems.signalservice.api.svr.Svr3Credentials
|
||||
import org.whispersystems.signalservice.internal.push.AuthCredentials
|
||||
import org.whispersystems.signalservice.internal.push.PushServiceSocket.RegistrationLockFailure
|
||||
|
||||
+4
@@ -103,6 +103,10 @@ class ChangeNumberVerifyFragment : LoggingFragment(R.layout.fragment_change_phon
|
||||
|
||||
is VerificationCodeRequestResult.ChallengeRequired -> {
|
||||
Log.i(TAG, "Unable to request sms code due to challenges required: ${castResult.challenges.joinToString { it.key }}")
|
||||
if (castResult.challenges.isEmpty()) {
|
||||
Log.w(TAG, "Challenge required but no challenges listed, showing error.")
|
||||
showErrorDialog(R.string.RegistrationActivity_sms_provider_error)
|
||||
}
|
||||
}
|
||||
|
||||
is VerificationCodeRequestResult.RateLimited -> {
|
||||
|
||||
+16
@@ -27,6 +27,7 @@ import org.signal.core.util.bytes
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.compose.rememberStatusBarColorNestedScrollModifier
|
||||
import org.thoughtcrime.securesms.mms.SentMediaQuality
|
||||
import org.thoughtcrime.securesms.util.AttachmentUtil
|
||||
import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
||||
import org.thoughtcrime.securesms.webrtc.CallDataMode
|
||||
import kotlin.math.abs
|
||||
@@ -169,6 +170,21 @@ private fun DataAndStorageSettingsScreen(
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Rows.TextRow(
|
||||
text = {
|
||||
Text(
|
||||
text = stringResource(
|
||||
R.string.DataAndStorageSettingsFragment__voice_messages_and_stickers_under_size_are_always_auto_downloaded,
|
||||
AttachmentUtil.SMALL_ATTACHMENT_SIZE.toUnitString()
|
||||
),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Dividers.Default()
|
||||
}
|
||||
|
||||
+17
@@ -207,12 +207,21 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
|
||||
|
||||
switchPref(
|
||||
title = DSLSettingsText.from("Force split pane UI on phones."),
|
||||
isEnabled = !state.forceSinglePane,
|
||||
isChecked = state.forceSplitPane,
|
||||
onClick = {
|
||||
viewModel.setForceSplitPane(!state.forceSplitPane)
|
||||
}
|
||||
)
|
||||
|
||||
switchPref(
|
||||
title = DSLSettingsText.from("Force single-pane on newer devices."),
|
||||
isChecked = state.forceSinglePane,
|
||||
onClick = {
|
||||
viewModel.setForceSinglePane(!state.forceSinglePane)
|
||||
}
|
||||
)
|
||||
|
||||
clickPref(
|
||||
title = DSLSettingsText.from("Display enable permission sheet"),
|
||||
onClick = {
|
||||
@@ -370,6 +379,14 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
|
||||
}
|
||||
)
|
||||
|
||||
switchPref(
|
||||
title = DSLSettingsText.from("Enable ANR-induced crashing"),
|
||||
isChecked = SignalStore.internal.anrDetectionCrashes,
|
||||
onClick = {
|
||||
SignalStore.internal.anrDetectionCrashes = !SignalStore.internal.anrDetectionCrashes
|
||||
}
|
||||
)
|
||||
|
||||
dividerPref()
|
||||
|
||||
sectionHeaderPref(DSLSettingsText.from("Logging"))
|
||||
|
||||
+1
@@ -31,6 +31,7 @@ data class InternalSettingsState(
|
||||
val hasPendingOneTimeDonation: Boolean,
|
||||
val hevcEncoding: Boolean,
|
||||
val forceSplitPane: Boolean,
|
||||
val forceSinglePane: Boolean,
|
||||
val useNewMediaActivity: Boolean,
|
||||
val disableInternalUser: Boolean
|
||||
)
|
||||
|
||||
+6
@@ -203,6 +203,7 @@ class InternalSettingsViewModel(private val repository: InternalSettingsReposito
|
||||
hasPendingOneTimeDonation = SignalStore.inAppPayments.getPendingOneTimeDonation() != null,
|
||||
hevcEncoding = SignalStore.internal.hevcEncoding,
|
||||
forceSplitPane = SignalStore.internal.forceSplitPane,
|
||||
forceSinglePane = SignalStore.internal.forceSinglePane,
|
||||
useNewMediaActivity = SignalStore.internal.useNewMediaActivity,
|
||||
disableInternalUser = RemoteConfig.internalUserDisabled
|
||||
)
|
||||
@@ -225,6 +226,11 @@ class InternalSettingsViewModel(private val repository: InternalSettingsReposito
|
||||
refresh()
|
||||
}
|
||||
|
||||
fun setForceSinglePane(forceSinglePane: Boolean) {
|
||||
SignalStore.internal.forceSinglePane = forceSinglePane
|
||||
refresh()
|
||||
}
|
||||
|
||||
class Factory(private val repository: InternalSettingsRepository) : ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
return requireNotNull(modelClass.cast(InternalSettingsViewModel(repository)))
|
||||
|
||||
+1
-1
@@ -503,7 +503,7 @@ fun Screen(
|
||||
text = "Copy Account Entropy Pool (AEP)",
|
||||
label = "Copies the Account Entropy Pool (AEP) to the clipboard, which is labeled as the \"Backup Key\" in the designs.",
|
||||
onClick = {
|
||||
Util.copyToClipboard(context, SignalStore.account.accountEntropyPool.value)
|
||||
Util.copyToClipboard(context, SignalStore.account.accountEntropyPool.displayValue)
|
||||
Toast.makeText(context, "Copied!", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
)
|
||||
|
||||
+1
-1
@@ -37,6 +37,7 @@ import org.signal.core.util.readNBytesOrThrow
|
||||
import org.signal.core.util.roundedString
|
||||
import org.signal.core.util.stream.LimitedInputStream
|
||||
import org.signal.libsignal.zkgroup.profiles.ProfileKey
|
||||
import org.signal.network.NetworkResult
|
||||
import org.signal.network.api.SvrBApi
|
||||
import org.thoughtcrime.securesms.attachments.AttachmentId
|
||||
import org.thoughtcrime.securesms.attachments.DatabaseAttachment
|
||||
@@ -58,7 +59,6 @@ import org.thoughtcrime.securesms.keyvalue.protos.LocalBackupCreationProgress
|
||||
import org.thoughtcrime.securesms.net.SignalNetwork
|
||||
import org.thoughtcrime.securesms.providers.BlobProvider
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.whispersystems.signalservice.api.NetworkResult
|
||||
import java.io.FileOutputStream
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
|
||||
+1
-1
@@ -50,8 +50,8 @@ 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.SignalIcons
|
||||
import org.signal.core.util.Hex
|
||||
import org.signal.core.util.Util
|
||||
import org.signal.libsignal.protocol.util.Hex
|
||||
import org.thoughtcrime.securesms.components.settings.app.internal.sqlite.InternalSqlitePlaygroundViewModel.QueryResult
|
||||
|
||||
class InternalSqlitePlaygroundFragment : ComposeFragment() {
|
||||
|
||||
-1
@@ -12,6 +12,5 @@ sealed interface LabsSettingsEvents {
|
||||
data class ToggleGroupSuggestionsForMembers(val enabled: Boolean) : LabsSettingsEvents
|
||||
data class ToggleBetterSearch(val enabled: Boolean) : LabsSettingsEvents
|
||||
data class ToggleAutoLowerHand(val enabled: Boolean) : LabsSettingsEvents
|
||||
|
||||
data class ToggleStarredMessages(val enabled: Boolean) : LabsSettingsEvents
|
||||
}
|
||||
|
||||
-1
@@ -15,6 +15,5 @@ data class LabsSettingsState(
|
||||
val groupSuggestionsForMembers: Boolean = false,
|
||||
val betterSearch: Boolean = false,
|
||||
val autoLowerHand: Boolean = false,
|
||||
|
||||
val starredMessages: Boolean = false
|
||||
)
|
||||
|
||||
-1
@@ -57,7 +57,6 @@ class LabsSettingsViewModel : ViewModel() {
|
||||
groupSuggestionsForMembers = SignalStore.labs.groupSuggestionsForMembers,
|
||||
betterSearch = SignalStore.labs.betterSearch,
|
||||
autoLowerHand = SignalStore.labs.autoLowerHand,
|
||||
|
||||
starredMessages = SignalStore.labs.starredMessages
|
||||
)
|
||||
}
|
||||
|
||||
+20
-23
@@ -45,7 +45,6 @@ import org.signal.core.ui.compose.Texts
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.compose.rememberStatusBarColorNestedScrollModifier
|
||||
import org.thoughtcrime.securesms.util.CommunicationActions
|
||||
import org.thoughtcrime.securesms.util.RemoteConfig
|
||||
import org.thoughtcrime.securesms.util.viewModel
|
||||
|
||||
/**
|
||||
@@ -299,31 +298,29 @@ private fun AdvancedPrivacySettingsScreen(
|
||||
)
|
||||
}
|
||||
|
||||
if (RemoteConfig.internalUser) {
|
||||
item {
|
||||
Dividers.Default()
|
||||
}
|
||||
item {
|
||||
Dividers.Default()
|
||||
}
|
||||
|
||||
item {
|
||||
val label = buildAnnotatedString {
|
||||
append(stringResource(R.string.preferences_automatic_key_verification_body))
|
||||
append(" ")
|
||||
withLink(
|
||||
LinkAnnotation.Clickable("learn-more", linkInteractionListener = {
|
||||
callbacks.onAutomaticVerificationLearnMoreClick()
|
||||
})
|
||||
) {
|
||||
append(stringResource(R.string.LearnMoreTextView_learn_more))
|
||||
}
|
||||
item {
|
||||
val label = buildAnnotatedString {
|
||||
append(stringResource(R.string.preferences_automatic_key_verification_body))
|
||||
append(" ")
|
||||
withLink(
|
||||
LinkAnnotation.Clickable("learn-more", linkInteractionListener = {
|
||||
callbacks.onAutomaticVerificationLearnMoreClick()
|
||||
})
|
||||
) {
|
||||
append(stringResource(R.string.LearnMoreTextView_learn_more))
|
||||
}
|
||||
|
||||
Rows.ToggleRow(
|
||||
checked = state.allowAutomaticKeyVerification,
|
||||
text = AnnotatedString(stringResource(R.string.preferences_automatic_key_verification)),
|
||||
label = label,
|
||||
onCheckChanged = callbacks::onAllowAutomaticVerificationChanged
|
||||
)
|
||||
}
|
||||
|
||||
Rows.ToggleRow(
|
||||
checked = state.allowAutomaticKeyVerification,
|
||||
text = AnnotatedString(stringResource(R.string.preferences_automatic_key_verification)),
|
||||
label = label,
|
||||
onCheckChanged = callbacks::onAllowAutomaticVerificationChanged
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -7,6 +7,7 @@ import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.donations.PaymentSourceType
|
||||
import org.signal.network.NetworkResult
|
||||
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
|
||||
import org.thoughtcrime.securesms.badges.Badges
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository.requireSubscriberType
|
||||
@@ -29,7 +30,6 @@ import org.thoughtcrime.securesms.storage.StorageSyncHelper
|
||||
import org.thoughtcrime.securesms.subscription.LevelUpdate
|
||||
import org.thoughtcrime.securesms.subscription.LevelUpdateOperation
|
||||
import org.thoughtcrime.securesms.subscription.Subscription
|
||||
import org.whispersystems.signalservice.api.NetworkResult
|
||||
import org.whispersystems.signalservice.api.storage.IAPSubscriptionId
|
||||
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription
|
||||
import org.whispersystems.signalservice.api.subscriptions.IdempotencyKey
|
||||
@@ -297,6 +297,7 @@ object RecurringInAppPaymentRepository {
|
||||
if (response.status == 200 || response.status == 204) {
|
||||
Log.d(TAG, "Successfully set user subscription to level $subscriptionLevel with response code ${response.status}", true)
|
||||
SignalStore.inAppPayments.updateLocalStateForLocalSubscribe(subscriberType)
|
||||
MultiDeviceSubscriptionSyncRequestJob.enqueue()
|
||||
syncAccountRecord().subscribe()
|
||||
} else {
|
||||
if (response.applicationError.isPresent) {
|
||||
|
||||
+2
-2
@@ -34,9 +34,9 @@ import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.boost.Boost
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewaySelectorBottomSheet
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.models.CurrencySelection
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.models.NetworkFailure
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.thanks.ThanksForYourSupportBottomSheetDialogFragment
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.ui.CurrencySelection
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.ui.NetworkFailure
|
||||
import org.thoughtcrime.securesms.components.settings.configure
|
||||
import org.thoughtcrime.securesms.database.InAppPaymentTable
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
|
||||
|
||||
+1
-1
@@ -2,8 +2,8 @@ package org.thoughtcrime.securesms.components.settings.app.subscription.donate
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.network.util.Preconditions
|
||||
import org.thoughtcrime.securesms.database.InAppPaymentTable
|
||||
import org.whispersystems.signalservice.api.util.Preconditions
|
||||
|
||||
/**
|
||||
* State holder for the checkout flow when utilizing Google Pay.
|
||||
|
||||
+1
-2
@@ -46,8 +46,7 @@ sealed interface GatewayOrderStrategy {
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun getStrategy(): GatewayOrderStrategy {
|
||||
val self = Recipient.self()
|
||||
fun getStrategy(self: Recipient = Recipient.self()): GatewayOrderStrategy {
|
||||
val e164 = self.e164.orNull() ?: return Default
|
||||
|
||||
return if (PhoneNumberUtil.getInstance().parse(e164, "").countryCode == 1) {
|
||||
|
||||
+39
-161
@@ -1,34 +1,28 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.os.bundleOf
|
||||
import androidx.fragment.app.setFragmentResult
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.navigation.fragment.navArgs
|
||||
import io.reactivex.rxjava3.kotlin.subscribeBy
|
||||
import org.signal.core.util.concurrent.LifecycleDisposable
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import org.signal.core.ui.compose.ComposeBottomSheetDialogFragment
|
||||
import org.signal.core.util.dp
|
||||
import org.signal.donations.InAppPaymentType
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.badges.Badges
|
||||
import org.thoughtcrime.securesms.badges.models.BadgeDisplay112
|
||||
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsBottomSheetFragment
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsIcon
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationSerializationHelper.toFiatMoney
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.GooglePayComponent
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.models.GooglePayButton
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.models.IdealWeroButton
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.models.PayPalButton
|
||||
import org.thoughtcrime.securesms.components.settings.configure
|
||||
import org.thoughtcrime.securesms.components.settings.models.IndeterminateLoadingCircle
|
||||
import org.thoughtcrime.securesms.database.InAppPaymentTable
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
|
||||
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
|
||||
import org.thoughtcrime.securesms.payments.currency.CurrencyUtil
|
||||
import org.thoughtcrime.securesms.util.fragments.requireListener
|
||||
import org.thoughtcrime.securesms.util.viewModel
|
||||
import org.signal.core.ui.R as CoreUiR
|
||||
@@ -36,172 +30,56 @@ import org.signal.core.ui.R as CoreUiR
|
||||
/**
|
||||
* Entry point to capturing the necessary payment token to pay for a donation
|
||||
*/
|
||||
class GatewaySelectorBottomSheet : DSLSettingsBottomSheetFragment() {
|
||||
|
||||
private val lifecycleDisposable = LifecycleDisposable()
|
||||
class GatewaySelectorBottomSheet : ComposeBottomSheetDialogFragment() {
|
||||
|
||||
private val args: GatewaySelectorBottomSheetArgs by navArgs()
|
||||
override val peekHeightPercentage: Float = 1f
|
||||
|
||||
private val viewModel: GatewaySelectorViewModel by viewModel {
|
||||
GatewaySelectorViewModel(args, requireListener<GooglePayComponent>().googlePayRepository)
|
||||
}
|
||||
|
||||
override fun bindAdapter(adapter: DSLSettingsAdapter) {
|
||||
BadgeDisplay112.register(adapter)
|
||||
GooglePayButton.register(adapter)
|
||||
PayPalButton.register(adapter)
|
||||
IndeterminateLoadingCircle.register(adapter)
|
||||
IdealWeroButton.register(adapter)
|
||||
@Composable
|
||||
override fun SheetContent() {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
lifecycleDisposable.bindTo(viewLifecycleOwner)
|
||||
|
||||
lifecycleDisposable += viewModel.state.subscribe { state ->
|
||||
adapter.submitList(getConfiguration(state).toMappingModelList())
|
||||
}
|
||||
GatewaySelectorBottomSheetContent(state, onEvent = this::onEvent)
|
||||
}
|
||||
|
||||
private fun getConfiguration(state: GatewaySelectorState): DSLConfiguration {
|
||||
return when (state) {
|
||||
GatewaySelectorState.Loading -> {
|
||||
configure {
|
||||
space(16.dp)
|
||||
customPref(IndeterminateLoadingCircle)
|
||||
space(16.dp)
|
||||
private fun onEvent(event: GatewaySelectorBottomSheetEvent) {
|
||||
when (event) {
|
||||
GatewaySelectorBottomSheetEvent.GOOGLE_PAY_SELECTED -> {
|
||||
setPaymentMethodAndDismiss(InAppPaymentData.PaymentMethodType.GOOGLE_PAY)
|
||||
}
|
||||
|
||||
GatewaySelectorBottomSheetEvent.PAYPAL_SELECTED -> {
|
||||
setPaymentMethodAndDismiss(InAppPaymentData.PaymentMethodType.PAYPAL)
|
||||
}
|
||||
|
||||
GatewaySelectorBottomSheetEvent.SEPA_SELECTED -> {
|
||||
if (viewModel.checkIsSepaPaymentValidAmount()) {
|
||||
setPaymentMethodAndDismiss(InAppPaymentData.PaymentMethodType.SEPA_DEBIT)
|
||||
} else {
|
||||
findNavController().popBackStack()
|
||||
setFragmentResult(REQUEST_KEY, bundleOf(FAILURE_KEY to true, SEPA_EURO_MAX to viewModel.getSepaMaximum()))
|
||||
}
|
||||
}
|
||||
is GatewaySelectorState.Ready -> {
|
||||
configure {
|
||||
customPref(
|
||||
BadgeDisplay112.Model(
|
||||
badge = state.inAppPayment.data.badge!!.let { Badges.fromDatabaseBadge(it) },
|
||||
withDisplayText = false
|
||||
)
|
||||
)
|
||||
|
||||
space(12.dp)
|
||||
GatewaySelectorBottomSheetEvent.IDEAL_SELECTED -> {
|
||||
setPaymentMethodAndDismiss(InAppPaymentData.PaymentMethodType.IDEAL)
|
||||
}
|
||||
|
||||
presentTitleAndSubtitle(requireContext(), state.inAppPayment)
|
||||
|
||||
space(16.dp)
|
||||
|
||||
state.gatewayOrderStrategy.orderedGateways.forEach { gateway ->
|
||||
when (gateway) {
|
||||
InAppPaymentData.PaymentMethodType.GOOGLE_PLAY_BILLING -> error("Unsupported payment method.")
|
||||
InAppPaymentData.PaymentMethodType.GOOGLE_PAY -> renderGooglePayButton(state)
|
||||
InAppPaymentData.PaymentMethodType.PAYPAL -> renderPayPalButton(state)
|
||||
InAppPaymentData.PaymentMethodType.CARD -> renderCreditCardButton(state)
|
||||
InAppPaymentData.PaymentMethodType.SEPA_DEBIT -> renderSEPADebitButton(state)
|
||||
InAppPaymentData.PaymentMethodType.IDEAL -> renderIDEALButton(state)
|
||||
InAppPaymentData.PaymentMethodType.UNKNOWN -> error("Unsupported payment method.")
|
||||
}
|
||||
}
|
||||
|
||||
space(16.dp)
|
||||
}
|
||||
GatewaySelectorBottomSheetEvent.CREDIT_CARD_SELECTED -> {
|
||||
setPaymentMethodAndDismiss(InAppPaymentData.PaymentMethodType.CARD)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun DSLConfiguration.renderGooglePayButton(state: GatewaySelectorState.Ready) {
|
||||
if (state.isGooglePayAvailable) {
|
||||
space(16.dp)
|
||||
|
||||
customPref(
|
||||
GooglePayButton.Model(
|
||||
isEnabled = true,
|
||||
onClick = {
|
||||
lifecycleDisposable += viewModel.updateInAppPaymentMethod(InAppPaymentData.PaymentMethodType.GOOGLE_PAY)
|
||||
.subscribeBy {
|
||||
findNavController().popBackStack()
|
||||
setFragmentResult(REQUEST_KEY, bundleOf(REQUEST_KEY to it))
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun DSLConfiguration.renderPayPalButton(state: GatewaySelectorState.Ready) {
|
||||
if (state.isPayPalAvailable) {
|
||||
space(16.dp)
|
||||
|
||||
customPref(
|
||||
PayPalButton.Model(
|
||||
onClick = {
|
||||
lifecycleDisposable += viewModel.updateInAppPaymentMethod(InAppPaymentData.PaymentMethodType.PAYPAL)
|
||||
.subscribeBy {
|
||||
findNavController().popBackStack()
|
||||
setFragmentResult(REQUEST_KEY, bundleOf(REQUEST_KEY to it))
|
||||
}
|
||||
},
|
||||
isEnabled = true
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun DSLConfiguration.renderCreditCardButton(state: GatewaySelectorState.Ready) {
|
||||
if (state.isCreditCardAvailable) {
|
||||
space(16.dp)
|
||||
|
||||
primaryButton(
|
||||
text = DSLSettingsText.from(R.string.GatewaySelectorBottomSheet__credit_or_debit_card),
|
||||
icon = DSLSettingsIcon.from(R.drawable.credit_card, CoreUiR.color.signal_colorOnCustom),
|
||||
disableOnClick = true,
|
||||
onClick = {
|
||||
lifecycleDisposable += viewModel.updateInAppPaymentMethod(InAppPaymentData.PaymentMethodType.CARD)
|
||||
.subscribeBy {
|
||||
findNavController().popBackStack()
|
||||
setFragmentResult(REQUEST_KEY, bundleOf(REQUEST_KEY to it))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun DSLConfiguration.renderSEPADebitButton(state: GatewaySelectorState.Ready) {
|
||||
if (state.isSEPADebitAvailable) {
|
||||
space(16.dp)
|
||||
|
||||
tonalButton(
|
||||
text = DSLSettingsText.from(R.string.GatewaySelectorBottomSheet__bank_transfer),
|
||||
icon = DSLSettingsIcon.from(R.drawable.bank_transfer),
|
||||
disableOnClick = true,
|
||||
onClick = {
|
||||
val price = state.inAppPayment.data.amount!!.toFiatMoney()
|
||||
if (state.sepaEuroMaximum != null &&
|
||||
price.currency == CurrencyUtil.EURO &&
|
||||
price.amount > state.sepaEuroMaximum.amount
|
||||
) {
|
||||
findNavController().popBackStack()
|
||||
setFragmentResult(REQUEST_KEY, bundleOf(FAILURE_KEY to true, SEPA_EURO_MAX to state.sepaEuroMaximum.amount))
|
||||
} else {
|
||||
lifecycleDisposable += viewModel.updateInAppPaymentMethod(InAppPaymentData.PaymentMethodType.SEPA_DEBIT)
|
||||
.subscribeBy {
|
||||
findNavController().popBackStack()
|
||||
setFragmentResult(REQUEST_KEY, bundleOf(REQUEST_KEY to it))
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun DSLConfiguration.renderIDEALButton(state: GatewaySelectorState.Ready) {
|
||||
if (state.isIDEALAvailable) {
|
||||
space(16.dp)
|
||||
|
||||
customPref(
|
||||
IdealWeroButton.Model(
|
||||
onClick = {
|
||||
lifecycleDisposable += viewModel.updateInAppPaymentMethod(InAppPaymentData.PaymentMethodType.IDEAL)
|
||||
.subscribeBy {
|
||||
findNavController().popBackStack()
|
||||
setFragmentResult(REQUEST_KEY, bundleOf(REQUEST_KEY to it))
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
private fun setPaymentMethodAndDismiss(type: InAppPaymentData.PaymentMethodType) {
|
||||
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.Main) {
|
||||
val inAppPayment = viewModel.updateInAppPaymentMethod(type)
|
||||
findNavController().popBackStack()
|
||||
setFragmentResult(REQUEST_KEY, bundleOf(REQUEST_KEY to inAppPayment))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+366
@@ -0,0 +1,366 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway
|
||||
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
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.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalResources
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.signal.core.ui.compose.BottomSheets
|
||||
import org.signal.core.ui.compose.Buttons
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.horizontalGutters
|
||||
import org.signal.core.util.money.FiatMoney
|
||||
import org.signal.donations.DonateWithGooglePayButton
|
||||
import org.signal.donations.InAppPaymentType
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.badges.Badges
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.BadgeImage112
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationSerializationHelper.toDecimalValue
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationSerializationHelper.toFiatMoney
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.ui.IdealWeroButton
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.ui.PayPalButton
|
||||
import org.thoughtcrime.securesms.database.InAppPaymentTable
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.BadgeList
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.FiatValue
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
|
||||
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import java.math.BigDecimal
|
||||
import java.util.Currency
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
@Composable
|
||||
fun GatewaySelectorBottomSheetContent(
|
||||
state: GatewaySelectorState,
|
||||
onEvent: (GatewaySelectorBottomSheetEvent) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val scrollState = rememberScrollState()
|
||||
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = modifier
|
||||
.testTag(GatewaySelectorTestTags.CONTAINER)
|
||||
.verticalScroll(scrollState)
|
||||
.horizontalGutters()
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
BottomSheets.Handle()
|
||||
|
||||
when (state) {
|
||||
GatewaySelectorState.Loading -> Loading()
|
||||
is GatewaySelectorState.Ready -> Ready(state, onEvent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Loading() {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.padding(vertical = 16.dp)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Ready(state: GatewaySelectorState.Ready, onEvent: (GatewaySelectorBottomSheetEvent) -> Unit) {
|
||||
BadgeImage112(
|
||||
badge = state.inAppPayment.data.badge!!.let { Badges.fromDatabaseBadge(it) },
|
||||
modifier = Modifier.size(112.dp)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.size(12.dp))
|
||||
|
||||
TitleAndSubtitle(state.inAppPayment)
|
||||
|
||||
Spacer(modifier = Modifier.size(16.dp))
|
||||
|
||||
var isGatewaySelected by remember { mutableStateOf(false) }
|
||||
val onGatewaySelected: (GatewaySelectorBottomSheetEvent) -> Unit = remember(onEvent) {
|
||||
{
|
||||
if (!isGatewaySelected) {
|
||||
isGatewaySelected = true
|
||||
onEvent(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
state.gatewayOrderStrategy.orderedGateways.forEach {
|
||||
when (it) {
|
||||
InAppPaymentData.PaymentMethodType.UNKNOWN -> error("Unsupported payment method.")
|
||||
InAppPaymentData.PaymentMethodType.GOOGLE_PAY -> {
|
||||
if (state.isGooglePayAvailable) {
|
||||
DonateWithGooglePayButton(
|
||||
onClick = { onGatewaySelected(GatewaySelectorBottomSheetEvent.GOOGLE_PAY_SELECTED) },
|
||||
enabled = !isGatewaySelected,
|
||||
modifier = Modifier
|
||||
.testTag(GatewaySelectorTestTags.GOOGLE_PAY_BUTTON)
|
||||
.padding(top = 16.dp)
|
||||
.fillMaxWidth()
|
||||
.height(44.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
InAppPaymentData.PaymentMethodType.CARD -> {
|
||||
if (state.isCreditCardAvailable) {
|
||||
Buttons.LargePrimary(
|
||||
onClick = { onGatewaySelected(GatewaySelectorBottomSheetEvent.CREDIT_CARD_SELECTED) },
|
||||
enabled = !isGatewaySelected,
|
||||
modifier = Modifier
|
||||
.testTag(GatewaySelectorTestTags.CREDIT_CARD_BUTTON)
|
||||
.padding(top = 16.dp)
|
||||
.fillMaxWidth()
|
||||
.height(44.dp)
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = spacedBy(8.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = ImageVector.vectorResource(R.drawable.credit_card),
|
||||
contentDescription = null
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.GatewaySelectorBottomSheet__credit_or_debit_card)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
InAppPaymentData.PaymentMethodType.SEPA_DEBIT -> {
|
||||
if (state.isSEPADebitAvailable) {
|
||||
Buttons.LargeTonal(
|
||||
onClick = { onGatewaySelected(GatewaySelectorBottomSheetEvent.SEPA_SELECTED) },
|
||||
enabled = !isGatewaySelected,
|
||||
modifier = Modifier
|
||||
.testTag(GatewaySelectorTestTags.SEPA_BUTTON)
|
||||
.padding(top = 16.dp)
|
||||
.fillMaxWidth()
|
||||
.height(44.dp)
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = spacedBy(8.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = ImageVector.vectorResource(R.drawable.bank_transfer),
|
||||
contentDescription = null
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.GatewaySelectorBottomSheet__bank_transfer)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
InAppPaymentData.PaymentMethodType.IDEAL -> {
|
||||
if (state.isIDEALAvailable) {
|
||||
IdealWeroButton(
|
||||
onClick = { onGatewaySelected(GatewaySelectorBottomSheetEvent.IDEAL_SELECTED) },
|
||||
enabled = !isGatewaySelected,
|
||||
modifier = Modifier
|
||||
.testTag(GatewaySelectorTestTags.IDEAL_BUTTON)
|
||||
.padding(top = 16.dp)
|
||||
.height(44.dp)
|
||||
.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
InAppPaymentData.PaymentMethodType.PAYPAL -> {
|
||||
if (state.isPayPalAvailable) {
|
||||
PayPalButton(
|
||||
enabled = !isGatewaySelected,
|
||||
onClick = { onGatewaySelected(GatewaySelectorBottomSheetEvent.PAYPAL_SELECTED) },
|
||||
modifier = Modifier
|
||||
.testTag(GatewaySelectorTestTags.PAYPAL_BUTTON)
|
||||
.padding(top = 16.dp)
|
||||
.height(44.dp)
|
||||
.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
InAppPaymentData.PaymentMethodType.GOOGLE_PLAY_BILLING -> error("Unsupported payment method.")
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.size(16.dp))
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun GatewaySelectorBottomSheetContentLoadingPreview() {
|
||||
Previews.BottomSheetContentPreview {
|
||||
GatewaySelectorBottomSheetContent(
|
||||
state = GatewaySelectorState.Loading,
|
||||
onEvent = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TitleAndSubtitle(inAppPayment: InAppPaymentTable.InAppPayment) {
|
||||
when (inAppPayment.type) {
|
||||
InAppPaymentType.UNKNOWN -> error("Unsupported type UNKNOWN")
|
||||
InAppPaymentType.ONE_TIME_GIFT -> OneTimeGiftTitleAndSubtitle(inAppPayment)
|
||||
InAppPaymentType.ONE_TIME_DONATION -> RecurringDonationTitleAndSubtitle(inAppPayment)
|
||||
InAppPaymentType.RECURRING_DONATION -> OneTimeDonationTitleAndSubtitle(inAppPayment)
|
||||
InAppPaymentType.RECURRING_BACKUP -> error("This type is not supported")
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RecurringDonationTitleAndSubtitle(inAppPayment: InAppPaymentTable.InAppPayment) {
|
||||
Text(
|
||||
text = stringResource(R.string.GatewaySelectorBottomSheet__donate_s_month_to_signal, rememberFormattedAmount(inAppPayment)),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
modifier = Modifier.padding(bottom = 6.dp)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.GatewaySelectorBottomSheet__get_a_s_badge, inAppPayment.data.badge!!.name),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun OneTimeDonationTitleAndSubtitle(inAppPayment: InAppPaymentTable.InAppPayment) {
|
||||
Text(
|
||||
text = stringResource(R.string.GatewaySelectorBottomSheet__donate_s_to_signal, rememberFormattedAmount(inAppPayment)),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
modifier = Modifier.padding(bottom = 6.dp)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = pluralStringResource(R.plurals.GatewaySelectorBottomSheet__get_a_s_badge_for_d_days, 30, inAppPayment.data.badge!!.name, 30),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun OneTimeGiftTitleAndSubtitle(inAppPayment: InAppPaymentTable.InAppPayment) {
|
||||
Text(
|
||||
text = stringResource(R.string.GatewaySelectorBottomSheet__donate_s_to_signal, rememberFormattedAmount(inAppPayment)),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
modifier = Modifier.padding(bottom = 6.dp)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.GatewaySelectorBottomSheet__donate_for_a_friend),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun rememberFormattedAmount(inAppPayment: InAppPaymentTable.InAppPayment): String {
|
||||
val resources = LocalResources.current
|
||||
return remember(inAppPayment.data.amount) {
|
||||
FiatMoneyUtil.format(resources, inAppPayment.data.amount!!.toFiatMoney())
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun GatewaySelectorBottomSheetContentReadyOneTimeDonationPreview() {
|
||||
Previews.BottomSheetContentPreview {
|
||||
GatewaySelectorBottomSheetContent(
|
||||
state = rememberGatewaySelectorBottomSheetContentPreviewState(InAppPaymentType.ONE_TIME_DONATION),
|
||||
onEvent = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun GatewaySelectorBottomSheetContentReadyRecurringDonationPreview() {
|
||||
Previews.BottomSheetContentPreview {
|
||||
GatewaySelectorBottomSheetContent(
|
||||
state = rememberGatewaySelectorBottomSheetContentPreviewState(InAppPaymentType.RECURRING_DONATION),
|
||||
onEvent = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun GatewaySelectorBottomSheetContentReadyOneTimeGiftDonationPreview() {
|
||||
Previews.BottomSheetContentPreview {
|
||||
GatewaySelectorBottomSheetContent(
|
||||
state = rememberGatewaySelectorBottomSheetContentPreviewState(InAppPaymentType.ONE_TIME_GIFT),
|
||||
onEvent = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@VisibleForTesting
|
||||
fun rememberGatewaySelectorBottomSheetContentPreviewState(type: InAppPaymentType): GatewaySelectorState.Ready {
|
||||
return remember {
|
||||
GatewaySelectorState.Ready(
|
||||
inAppPayment = InAppPaymentTable.InAppPayment(
|
||||
id = InAppPaymentTable.InAppPaymentId(1),
|
||||
type = type,
|
||||
state = InAppPaymentTable.State.CREATED,
|
||||
insertedAt = 1.milliseconds,
|
||||
updatedAt = 1.milliseconds,
|
||||
notified = true,
|
||||
subscriberId = null,
|
||||
endOfPeriod = 0.milliseconds,
|
||||
data = InAppPaymentData(
|
||||
badge = BadgeList.Badge(
|
||||
name = type.name.lowercase()
|
||||
),
|
||||
amount = FiatValue(currencyCode = "USD", amount = BigDecimal.TEN.toDecimalValue())
|
||||
)
|
||||
),
|
||||
gatewayOrderStrategy = GatewayOrderStrategy.getStrategy(
|
||||
self = Recipient(
|
||||
isResolving = false,
|
||||
e164Value = "+15555555555"
|
||||
)
|
||||
),
|
||||
isGooglePayAvailable = true,
|
||||
isPayPalAvailable = true,
|
||||
isCreditCardAvailable = true,
|
||||
isSEPADebitAvailable = true,
|
||||
isIDEALAvailable = true,
|
||||
sepaEuroMaximum = FiatMoney(BigDecimal.ONE, Currency.getInstance("USD"))
|
||||
)
|
||||
}
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway
|
||||
|
||||
enum class GatewaySelectorBottomSheetEvent {
|
||||
GOOGLE_PAY_SELECTED,
|
||||
PAYPAL_SELECTED,
|
||||
SEPA_SELECTED,
|
||||
IDEAL_SELECTED,
|
||||
CREDIT_CARD_SELECTED
|
||||
}
|
||||
+7
-4
@@ -1,8 +1,9 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway
|
||||
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.signal.core.util.money.FiatMoney
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.getAvailablePaymentMethods
|
||||
import org.thoughtcrime.securesms.database.InAppPaymentTable
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
@@ -35,8 +36,8 @@ object GatewaySelectorRepository {
|
||||
}
|
||||
}
|
||||
|
||||
fun setInAppPaymentMethodType(inAppPayment: InAppPaymentTable.InAppPayment, paymentMethodType: InAppPaymentData.PaymentMethodType): Single<InAppPaymentTable.InAppPayment> {
|
||||
return Single.fromCallable {
|
||||
suspend fun setInAppPaymentMethodType(inAppPayment: InAppPaymentTable.InAppPayment, paymentMethodType: InAppPaymentData.PaymentMethodType): InAppPaymentTable.InAppPayment {
|
||||
return withContext(Dispatchers.IO) {
|
||||
SignalDatabase.inAppPayments.update(
|
||||
inAppPayment.copy(
|
||||
data = inAppPayment.data.copy(
|
||||
@@ -44,7 +45,9 @@ object GatewaySelectorRepository {
|
||||
)
|
||||
)
|
||||
)
|
||||
}.flatMap { InAppPaymentsRepository.requireInAppPayment(inAppPayment.id) }
|
||||
|
||||
SignalDatabase.inAppPayments.getById(inAppPayment.id) ?: throw Exception("Not found.")
|
||||
}
|
||||
}
|
||||
|
||||
data class GatewayConfiguration(
|
||||
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway
|
||||
|
||||
object GatewaySelectorTestTags {
|
||||
const val CONTAINER = "container"
|
||||
const val GOOGLE_PAY_BUTTON = "google_pay_button"
|
||||
const val PAYPAL_BUTTON = "paypal_button"
|
||||
const val CREDIT_CARD_BUTTON = "credit_card_button"
|
||||
const val SEPA_BUTTON = "sepa_button"
|
||||
const val IDEAL_BUTTON = "ideal_button"
|
||||
}
|
||||
+27
-8
@@ -1,29 +1,33 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
import io.reactivex.rxjava3.kotlin.plusAssign
|
||||
import io.reactivex.rxjava3.kotlin.subscribeBy
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import org.signal.donations.PaymentSourceType
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationSerializationHelper.toFiatMoney
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.GooglePayRepository
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppDonations
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository
|
||||
import org.thoughtcrime.securesms.database.InAppPaymentTable
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.util.rx.RxStore
|
||||
import org.thoughtcrime.securesms.payments.currency.CurrencyUtil
|
||||
import java.math.BigDecimal
|
||||
|
||||
class GatewaySelectorViewModel(
|
||||
args: GatewaySelectorBottomSheetArgs,
|
||||
repository: GooglePayRepository
|
||||
) : ViewModel() {
|
||||
|
||||
private val store = RxStore<GatewaySelectorState>(GatewaySelectorState.Loading)
|
||||
private val store = MutableStateFlow<GatewaySelectorState>(GatewaySelectorState.Loading)
|
||||
private val disposables = CompositeDisposable()
|
||||
|
||||
val state = store.stateFlowable
|
||||
val state = store.asStateFlow()
|
||||
|
||||
init {
|
||||
val inAppPayment = InAppPaymentsRepository.requireInAppPayment(args.inAppPaymentId)
|
||||
@@ -48,13 +52,28 @@ class GatewaySelectorViewModel(
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
store.dispose()
|
||||
disposables.clear()
|
||||
}
|
||||
|
||||
fun updateInAppPaymentMethod(inAppPaymentMethodType: InAppPaymentData.PaymentMethodType): Single<InAppPaymentTable.InAppPayment> {
|
||||
val state = store.state as GatewaySelectorState.Ready
|
||||
fun getSepaMaximum(): BigDecimal {
|
||||
val state = store.value as GatewaySelectorState.Ready
|
||||
return state.sepaEuroMaximum!!.amount
|
||||
}
|
||||
|
||||
return GatewaySelectorRepository.setInAppPaymentMethodType(state.inAppPayment, inAppPaymentMethodType).observeOn(AndroidSchedulers.mainThread())
|
||||
fun checkIsSepaPaymentValidAmount(): Boolean {
|
||||
val state = store.value as GatewaySelectorState.Ready
|
||||
|
||||
val price = state.inAppPayment.data.amount!!.toFiatMoney()
|
||||
return !(
|
||||
state.sepaEuroMaximum != null &&
|
||||
price.currency == CurrencyUtil.EURO &&
|
||||
price.amount > state.sepaEuroMaximum.amount
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun updateInAppPaymentMethod(inAppPaymentMethodType: InAppPaymentData.PaymentMethodType): InAppPaymentTable.InAppPayment {
|
||||
val state = store.value as GatewaySelectorState.Ready
|
||||
|
||||
return GatewaySelectorRepository.setInAppPaymentMethodType(state.inAppPayment, inAppPaymentMethodType)
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -14,6 +14,7 @@ import org.signal.core.util.logging.Log
|
||||
import org.signal.donations.InAppPaymentType
|
||||
import org.signal.donations.PayPalPaymentSource
|
||||
import org.signal.donations.PaymentSourceType
|
||||
import org.signal.network.util.Preconditions
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository.requireSubscriberType
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.RecurringInAppPaymentRepository
|
||||
@@ -26,7 +27,6 @@ import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord
|
||||
import org.thoughtcrime.securesms.jobs.MultiDeviceSubscriptionSyncRequestJob
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.util.rx.RxStore
|
||||
import org.whispersystems.signalservice.api.util.Preconditions
|
||||
|
||||
class PayPalPaymentInProgressViewModel : ViewModel() {
|
||||
|
||||
|
||||
+1
-1
@@ -16,6 +16,7 @@ import org.signal.donations.InAppPaymentType
|
||||
import org.signal.donations.PaymentSource
|
||||
import org.signal.donations.PaymentSourceType
|
||||
import org.signal.donations.StripeApi
|
||||
import org.signal.network.util.Preconditions
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository.requireSubscriberType
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository.toErrorSource
|
||||
@@ -29,7 +30,6 @@ import org.thoughtcrime.securesms.database.InAppPaymentTable
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord
|
||||
import org.thoughtcrime.securesms.util.rx.RxStore
|
||||
import org.whispersystems.signalservice.api.util.Preconditions
|
||||
|
||||
class StripePaymentInProgressViewModel : ViewModel() {
|
||||
|
||||
|
||||
+1
-1
@@ -29,8 +29,8 @@ import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationSerializationHelper.toFiatMoney
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.completed.InAppPaymentsBottomSheetDelegate
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.CheckoutFlowActivity
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.models.NetworkFailure
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.thanks.ThanksForYourSupportBottomSheetDialogFragment
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.ui.NetworkFailure
|
||||
import org.thoughtcrime.securesms.components.settings.configure
|
||||
import org.thoughtcrime.securesms.components.settings.models.IndeterminateLoadingCircle
|
||||
import org.thoughtcrime.securesms.database.InAppPaymentTable
|
||||
|
||||
-32
@@ -1,32 +0,0 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.models
|
||||
|
||||
import android.view.View
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.PreferenceModel
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder
|
||||
|
||||
object GooglePayButton {
|
||||
|
||||
class Model(val onClick: () -> Unit, override val isEnabled: Boolean) : PreferenceModel<Model>(isEnabled = isEnabled) {
|
||||
override fun areItemsTheSame(newItem: Model): Boolean = true
|
||||
}
|
||||
|
||||
class ViewHolder(itemView: View) : MappingViewHolder<Model>(itemView) {
|
||||
|
||||
private val googlePayButton: View = findViewById(R.id.googlepay_button)
|
||||
|
||||
override fun bind(model: Model) {
|
||||
googlePayButton.isEnabled = model.isEnabled
|
||||
googlePayButton.setOnClickListener {
|
||||
googlePayButton.isEnabled = false
|
||||
model.onClick()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun register(adapter: MappingAdapter) {
|
||||
adapter.registerFactory(Model::class.java, LayoutFactory({ ViewHolder(it) }, R.layout.google_pay_button_pref))
|
||||
}
|
||||
}
|
||||
-28
@@ -1,28 +0,0 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.models
|
||||
|
||||
import org.thoughtcrime.securesms.databinding.PaypalButtonBinding
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.BindingFactory
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.BindingViewHolder
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel
|
||||
|
||||
object PayPalButton {
|
||||
fun register(mappingAdapter: MappingAdapter) {
|
||||
mappingAdapter.registerFactory(Model::class.java, BindingFactory(::ViewHolder, PaypalButtonBinding::inflate))
|
||||
}
|
||||
|
||||
class Model(val onClick: () -> Unit, val isEnabled: Boolean) : MappingModel<Model> {
|
||||
override fun areItemsTheSame(newItem: Model): Boolean = true
|
||||
override fun areContentsTheSame(newItem: Model): Boolean = isEnabled == newItem.isEnabled
|
||||
}
|
||||
|
||||
class ViewHolder(binding: PaypalButtonBinding) : BindingViewHolder<Model, PaypalButtonBinding>(binding) {
|
||||
override fun bind(model: Model) {
|
||||
binding.paypalButton.isEnabled = model.isEnabled
|
||||
binding.paypalButton.setOnClickListener {
|
||||
binding.paypalButton.isEnabled = false
|
||||
model.onClick()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.models
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.ui
|
||||
|
||||
import android.view.View
|
||||
import android.widget.TextView
|
||||
+9
-47
@@ -1,19 +1,11 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.models
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.ui
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.material3.ButtonColors
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Stable
|
||||
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.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.ComposeView
|
||||
import androidx.compose.ui.res.colorResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
@@ -21,49 +13,19 @@ import androidx.compose.ui.unit.dp
|
||||
import org.signal.core.ui.compose.Buttons
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.horizontalGutters
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.PreferenceModel
|
||||
import org.thoughtcrime.securesms.components.settings.models.DSLComposePreference
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
|
||||
|
||||
/**
|
||||
* DSL Ideal | Wero button for the payments gateway.
|
||||
*/
|
||||
object IdealWeroButton {
|
||||
|
||||
@Stable
|
||||
class Model(val onClick: () -> Unit) : PreferenceModel<Model>() {
|
||||
override fun areItemsTheSame(newItem: Model): Boolean = true
|
||||
}
|
||||
|
||||
class ViewHolder(itemView: ComposeView) : DSLComposePreference.ViewHolder<Model>(itemView) {
|
||||
@Composable
|
||||
override fun Content(model: Model) {
|
||||
IdealWeroButton(model)
|
||||
}
|
||||
}
|
||||
|
||||
fun register(adapter: MappingAdapter) {
|
||||
DSLComposePreference.register(adapter) { ViewHolder(it) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun IdealWeroButton(model: IdealWeroButton.Model) {
|
||||
var enabled by remember { mutableStateOf(true) }
|
||||
|
||||
fun IdealWeroButton(
|
||||
onClick: () -> Unit,
|
||||
enabled: Boolean,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Buttons.LargeTonal(
|
||||
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
|
||||
onClick = {
|
||||
enabled = false
|
||||
model.onClick()
|
||||
},
|
||||
onClick = onClick,
|
||||
enabled = enabled,
|
||||
modifier = Modifier
|
||||
.height(44.dp)
|
||||
.horizontalGutters()
|
||||
.fillMaxWidth(),
|
||||
modifier = modifier,
|
||||
colors = ButtonColors(
|
||||
containerColor = colorResource(org.signal.core.ui.R.color.signal_light_colorPrimaryContainer),
|
||||
contentColor = colorResource(org.signal.core.ui.R.color.signal_light_colorOnPrimaryContainer),
|
||||
@@ -82,6 +44,6 @@ private fun IdealWeroButton(model: IdealWeroButton.Model) {
|
||||
@Composable
|
||||
private fun IdealWeroButtonPreview() {
|
||||
Previews.Preview {
|
||||
IdealWeroButton(model = remember { IdealWeroButton.Model(onClick = {}) })
|
||||
IdealWeroButton(onClick = {}, enabled = true)
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.models
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.ui
|
||||
|
||||
import android.view.View
|
||||
import com.google.android.material.button.MaterialButton
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.ui
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawWithContent
|
||||
import androidx.compose.ui.geometry.CornerRadius
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.colorResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.signal.core.ui.compose.Buttons
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.horizontalGutters
|
||||
import org.thoughtcrime.securesms.R
|
||||
|
||||
@Composable
|
||||
fun PayPalButton(
|
||||
enabled: Boolean,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val overlayColor = colorResource(org.signal.core.ui.R.color.signal_light_colorTransparent3)
|
||||
Buttons.LargeTonal(
|
||||
onClick = onClick,
|
||||
enabled = enabled,
|
||||
contentPadding = PaddingValues.Zero,
|
||||
modifier = modifier.drawWithContent {
|
||||
drawContent()
|
||||
|
||||
if (!enabled) {
|
||||
drawRoundRect(
|
||||
color = overlayColor,
|
||||
cornerRadius = CornerRadius(500f, 500f)
|
||||
)
|
||||
}
|
||||
},
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = Color(0xFFF6C757),
|
||||
disabledContainerColor = Color(0xFFF6C757)
|
||||
)
|
||||
) {
|
||||
Image(
|
||||
imageVector = ImageVector.vectorResource(R.drawable.paypal),
|
||||
contentDescription = stringResource(R.string.BackupsTypeSettingsFragment__paypal)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
fun PayPalButtonPreview() {
|
||||
Previews.Preview {
|
||||
PayPalButton(
|
||||
enabled = true,
|
||||
onClick = {},
|
||||
modifier = Modifier
|
||||
.horizontalGutters()
|
||||
.fillMaxWidth()
|
||||
.height(44.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
fun PayPalButtonDisabledPreview() {
|
||||
Previews.Preview {
|
||||
PayPalButton(
|
||||
enabled = false,
|
||||
onClick = {},
|
||||
modifier = Modifier
|
||||
.horizontalGutters()
|
||||
.fillMaxWidth()
|
||||
.height(44.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
+19
-24
@@ -7,7 +7,6 @@ import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.graphics.Bitmap
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.widget.Toast
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
@@ -49,8 +48,7 @@ import androidx.core.app.ShareCompat
|
||||
import androidx.core.app.TaskStackBuilder
|
||||
import androidx.fragment.app.setFragmentResultListener
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||
@@ -58,8 +56,10 @@ import com.google.accompanist.permissions.PermissionState
|
||||
import com.google.accompanist.permissions.PermissionStatus
|
||||
import com.google.accompanist.permissions.isGranted
|
||||
import com.google.accompanist.permissions.rememberPermissionState
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import org.signal.camera.CameraScreenEvents
|
||||
import org.signal.camera.CameraScreenState
|
||||
import org.signal.camera.CameraScreenViewModel
|
||||
import org.signal.core.ui.compose.Buttons
|
||||
import org.signal.core.ui.compose.ComposeFragment
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
@@ -69,7 +69,6 @@ import org.signal.core.ui.compose.SignalIcons
|
||||
import org.signal.core.ui.compose.Snackbars
|
||||
import org.signal.core.ui.compose.theme.SignalTheme
|
||||
import org.signal.core.ui.permissions.Permissions
|
||||
import org.signal.core.util.concurrent.LifecycleDisposable
|
||||
import org.thoughtcrime.securesms.MainActivity
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.QrCodeData
|
||||
@@ -85,7 +84,6 @@ import java.util.UUID
|
||||
class UsernameLinkSettingsFragment : ComposeFragment() {
|
||||
|
||||
private val viewModel: UsernameLinkSettingsViewModel by viewModels()
|
||||
private val disposables: LifecycleDisposable = LifecycleDisposable()
|
||||
|
||||
private lateinit var galleryLauncher: ActivityResultLauncher<Unit>
|
||||
|
||||
@@ -115,21 +113,29 @@ class UsernameLinkSettingsFragment : ComposeFragment() {
|
||||
val linkCopiedEvent: UUID? by viewModel.linkCopiedEvent
|
||||
val helpText = stringResource(id = R.string.UsernameLinkSettings_scan_this_qr_code)
|
||||
|
||||
val cameraViewModel: CameraScreenViewModel = viewModel { CameraScreenViewModel() }
|
||||
val cameraState by cameraViewModel.state
|
||||
|
||||
val cameraPermissionState: PermissionState = rememberPermissionState(permission = Manifest.permission.CAMERA) {
|
||||
viewModel.onTabSelected(ActiveTab.Scan)
|
||||
}
|
||||
|
||||
LaunchedEffect(cameraViewModel) {
|
||||
cameraViewModel.qrCodeDetected.collect { data ->
|
||||
viewModel.onQrCodeScanned(data)
|
||||
}
|
||||
}
|
||||
|
||||
MainScreen(
|
||||
state = state,
|
||||
navController = navController,
|
||||
lifecycleOwner = viewLifecycleOwner,
|
||||
disposables = disposables.disposables,
|
||||
cameraState = cameraState,
|
||||
cameraEmitter = cameraViewModel::onEvent,
|
||||
cameraPermissionState = cameraPermissionState,
|
||||
onCodeTabSelected = { viewModel.onTabSelected(ActiveTab.Code) },
|
||||
onScanTabSelected = { viewModel.onTabSelected(ActiveTab.Scan) },
|
||||
onUsernameLinkResetResultHandled = { viewModel.onUsernameLinkResetResultHandled() },
|
||||
onShareBadge = { shareQrBadge(requireActivity(), viewModel.generateQrCodeImage(helpText)) },
|
||||
onQrCodeScanned = { data -> viewModel.onQrCodeScanned(data) },
|
||||
onQrResultHandled = { viewModel.onQrResultHandled() },
|
||||
onOpenCameraClicked = { askCameraPermissions() },
|
||||
onOpenGalleryClicked = { galleryLauncher.launch(Unit) },
|
||||
@@ -143,10 +149,6 @@ class UsernameLinkSettingsFragment : ComposeFragment() {
|
||||
Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults)
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
disposables.bindTo(viewLifecycleOwner)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
viewModel.onResume()
|
||||
@@ -167,14 +169,13 @@ class UsernameLinkSettingsFragment : ComposeFragment() {
|
||||
private fun MainScreen(
|
||||
state: UsernameLinkSettingsState,
|
||||
navController: NavController? = null,
|
||||
lifecycleOwner: LifecycleOwner = previewLifecycleOwner,
|
||||
disposables: CompositeDisposable = CompositeDisposable(),
|
||||
cameraState: CameraScreenState = CameraScreenState(),
|
||||
cameraEmitter: (CameraScreenEvents) -> Unit = {},
|
||||
cameraPermissionState: PermissionState = previewPermissionState(),
|
||||
onCodeTabSelected: () -> Unit = {},
|
||||
onScanTabSelected: () -> Unit = {},
|
||||
onUsernameLinkResetResultHandled: () -> Unit = {},
|
||||
onShareBadge: () -> Unit = {},
|
||||
onQrCodeScanned: (String) -> Unit = {},
|
||||
onQrResultHandled: () -> Unit = {},
|
||||
onOpenCameraClicked: () -> Unit = {},
|
||||
onOpenGalleryClicked: () -> Unit = {},
|
||||
@@ -238,10 +239,9 @@ private fun MainScreen(
|
||||
exit = slideOutHorizontally(targetOffsetX = { fullWidth -> fullWidth })
|
||||
) {
|
||||
UsernameQrScanScreen(
|
||||
lifecycleOwner = lifecycleOwner,
|
||||
disposables = disposables,
|
||||
qrScanResult = state.qrScanResult,
|
||||
onQrCodeScanned = onQrCodeScanned,
|
||||
cameraState = cameraState,
|
||||
cameraEmitter = cameraEmitter,
|
||||
onQrResultHandled = onQrResultHandled,
|
||||
onOpenCameraClicked = onOpenCameraClicked,
|
||||
onOpenGalleryClicked = onOpenGalleryClicked,
|
||||
@@ -394,11 +394,6 @@ private fun previewPermissionState(): PermissionState {
|
||||
}
|
||||
}
|
||||
|
||||
private val previewLifecycleOwner: LifecycleOwner = object : LifecycleOwner {
|
||||
override val lifecycle: Lifecycle
|
||||
get() = throw UnsupportedOperationException("Only for tests")
|
||||
}
|
||||
|
||||
private fun shareQrBadge(activity: Activity, badge: Bitmap?) {
|
||||
if (badge == null) {
|
||||
return
|
||||
|
||||
+87
-25
@@ -1,45 +1,50 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.usernamelinks.main
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
import io.reactivex.rxjava3.kotlin.plusAssign
|
||||
import org.signal.camera.CameraCaptureMode
|
||||
import org.signal.camera.CameraScreen
|
||||
import org.signal.camera.CameraScreenEvents
|
||||
import org.signal.camera.CameraScreenState
|
||||
import org.signal.core.ui.compose.Buttons
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Dialogs
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.theme.SignalTheme
|
||||
import org.signal.qr.QrScannerView
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.mediasend.camerax.CameraXRemoteConfig
|
||||
import org.thoughtcrime.securesms.qr.QrScanScreens
|
||||
import org.thoughtcrime.securesms.qr.QrCrosshair
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* A screen that allows you to scan a QR code to start a chat.
|
||||
*/
|
||||
@Composable
|
||||
fun UsernameQrScanScreen(
|
||||
lifecycleOwner: LifecycleOwner,
|
||||
disposables: CompositeDisposable,
|
||||
qrScanResult: QrScanResult?,
|
||||
onQrCodeScanned: (String) -> Unit,
|
||||
cameraState: CameraScreenState,
|
||||
cameraEmitter: (CameraScreenEvents) -> Unit,
|
||||
onQrResultHandled: () -> Unit,
|
||||
onOpenCameraClicked: () -> Unit,
|
||||
onOpenGalleryClicked: () -> Unit,
|
||||
@@ -88,26 +93,49 @@ fun UsernameQrScanScreen(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f, true)
|
||||
.background(Color.Black)
|
||||
) {
|
||||
QrScanScreens.QrScanScreen(
|
||||
factory = { context ->
|
||||
val view = QrScannerView(context)
|
||||
disposables += view.qrData.throttleFirst(3000, TimeUnit.MILLISECONDS).subscribe { data ->
|
||||
onQrCodeScanned(data)
|
||||
if (hasCameraPermission) {
|
||||
CameraScreen(
|
||||
state = cameraState,
|
||||
emitter = cameraEmitter,
|
||||
enableQrScanning = true,
|
||||
captureMode = CameraCaptureMode.ImageOnly,
|
||||
roundCorners = false,
|
||||
fillViewport = true,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
QrCrosshair(modifier = Modifier.fillMaxSize())
|
||||
}
|
||||
} else {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.align(Alignment.Center)
|
||||
.padding(48.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.CameraXFragment_to_scan_qr_code_allow_camera),
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = Color.White
|
||||
)
|
||||
Buttons.MediumTonal(
|
||||
colors = ButtonDefaults.filledTonalButtonColors(),
|
||||
onClick = onOpenCameraClicked
|
||||
) {
|
||||
Text(stringResource(R.string.CameraXFragment_allow_access))
|
||||
}
|
||||
view
|
||||
},
|
||||
update = { view ->
|
||||
view.start(lifecycleOwner = lifecycleOwner, forceLegacy = CameraXRemoteConfig.isBlocklisted())
|
||||
},
|
||||
hasPermission = hasCameraPermission,
|
||||
onRequestPermissions = onOpenCameraClicked,
|
||||
qrHeaderLabelString = ""
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
FloatingActionButton(
|
||||
shape = CircleShape,
|
||||
containerColor = SignalTheme.colors.colorSurface1,
|
||||
modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 24.dp),
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(bottom = 24.dp),
|
||||
onClick = onOpenGalleryClicked
|
||||
) {
|
||||
Image(
|
||||
@@ -143,3 +171,37 @@ private fun QrScanResultDialog(title: String? = null, message: String, onDismiss
|
||||
onDismiss = onDismiss
|
||||
)
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun UsernameQrScanScreenPreview() {
|
||||
Previews.Preview {
|
||||
UsernameQrScanScreen(
|
||||
qrScanResult = null,
|
||||
cameraState = CameraScreenState(),
|
||||
cameraEmitter = {},
|
||||
onQrResultHandled = {},
|
||||
onOpenCameraClicked = {},
|
||||
onOpenGalleryClicked = {},
|
||||
onRecipientFound = {},
|
||||
hasCameraPermission = true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun UsernameQrScanScreenNoPermissionPreview() {
|
||||
Previews.Preview {
|
||||
UsernameQrScanScreen(
|
||||
qrScanResult = null,
|
||||
cameraState = CameraScreenState(),
|
||||
cameraEmitter = {},
|
||||
onQrResultHandled = {},
|
||||
onOpenCameraClicked = {},
|
||||
onOpenGalleryClicked = {},
|
||||
onRecipientFound = {},
|
||||
hasCameraPermission = false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+20
-14
@@ -24,22 +24,24 @@ import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||
import com.google.accompanist.permissions.MultiplePermissionsState
|
||||
import com.google.accompanist.permissions.PermissionState
|
||||
import com.google.accompanist.permissions.isGranted
|
||||
import com.google.accompanist.permissions.rememberMultiplePermissionsState
|
||||
import com.google.accompanist.permissions.rememberPermissionState
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
import org.signal.camera.CameraScreenEvents
|
||||
import org.signal.camera.CameraScreenState
|
||||
import org.signal.camera.CameraScreenViewModel
|
||||
import org.signal.core.ui.compose.Dialogs
|
||||
import org.signal.core.ui.compose.SignalIcons
|
||||
import org.signal.core.ui.compose.theme.SignalTheme
|
||||
import org.signal.core.ui.permissions.Permissions
|
||||
import org.signal.core.util.concurrent.LifecycleDisposable
|
||||
import org.signal.core.util.getParcelableExtraCompat
|
||||
import org.signal.core.util.permissions.PermissionCompat
|
||||
import org.thoughtcrime.securesms.R
|
||||
@@ -57,7 +59,6 @@ class UsernameQrScannerActivity : AppCompatActivity() {
|
||||
}
|
||||
|
||||
private val viewModel: UsernameQrScannerViewModel by viewModels()
|
||||
private val disposables = LifecycleDisposable()
|
||||
|
||||
@SuppressLint("MissingSuperCall")
|
||||
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
|
||||
@@ -66,7 +67,6 @@ class UsernameQrScannerActivity : AppCompatActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
disposables.bindTo(this)
|
||||
|
||||
val galleryLauncher = registerForActivityResult(QrImageSelectionActivity.Contract()) { uri ->
|
||||
if (uri != null) {
|
||||
@@ -86,14 +86,22 @@ class UsernameQrScannerActivity : AppCompatActivity() {
|
||||
val cameraPermissionState: PermissionState = rememberPermissionState(permission = Manifest.permission.CAMERA)
|
||||
val state by viewModel.state
|
||||
|
||||
val cameraViewModel: CameraScreenViewModel = viewModel { CameraScreenViewModel() }
|
||||
val cameraState by cameraViewModel.state
|
||||
|
||||
LaunchedEffect(cameraViewModel) {
|
||||
cameraViewModel.qrCodeDetected.collect { url ->
|
||||
viewModel.onQrScanned(url)
|
||||
}
|
||||
}
|
||||
|
||||
SignalTheme {
|
||||
Content(
|
||||
lifecycleOwner = this,
|
||||
diposables = disposables.disposables,
|
||||
state = state,
|
||||
cameraState = cameraState,
|
||||
cameraEmitter = cameraViewModel::onEvent,
|
||||
galleryPermissionsState = galleryPermissionState,
|
||||
cameraPermissionState = cameraPermissionState,
|
||||
onQrScanned = { url -> viewModel.onQrScanned(url) },
|
||||
onQrResultHandled = {
|
||||
finish()
|
||||
},
|
||||
@@ -143,12 +151,11 @@ class UsernameQrScannerActivity : AppCompatActivity() {
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun Content(
|
||||
lifecycleOwner: LifecycleOwner,
|
||||
diposables: CompositeDisposable,
|
||||
state: UsernameQrScannerViewModel.ScannerState,
|
||||
cameraState: CameraScreenState,
|
||||
cameraEmitter: (CameraScreenEvents) -> Unit,
|
||||
galleryPermissionsState: MultiplePermissionsState,
|
||||
cameraPermissionState: PermissionState,
|
||||
onQrScanned: (String) -> Unit,
|
||||
onQrResultHandled: () -> Unit,
|
||||
onOpenCameraClicked: () -> Unit,
|
||||
onOpenGalleryClicked: () -> Unit,
|
||||
@@ -173,10 +180,9 @@ fun Content(
|
||||
}
|
||||
) { contentPadding ->
|
||||
UsernameQrScanScreen(
|
||||
lifecycleOwner = lifecycleOwner,
|
||||
disposables = diposables,
|
||||
qrScanResult = state.qrScanResult,
|
||||
onQrCodeScanned = onQrScanned,
|
||||
cameraState = cameraState,
|
||||
cameraEmitter = cameraEmitter,
|
||||
onQrResultHandled = onQrResultHandled,
|
||||
onOpenCameraClicked = onOpenCameraClicked,
|
||||
onOpenGalleryClicked = onOpenGalleryClicked,
|
||||
|
||||
-32
@@ -1,12 +1,9 @@
|
||||
package org.thoughtcrime.securesms.components.settings.conversation
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.app.ActivityOptionsCompat
|
||||
import com.google.android.material.transition.platform.MaterialContainerTransformSharedElementCallback
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsActivity
|
||||
@@ -36,35 +33,6 @@ open class ConversationSettingsActivity : DSLSettingsActivity(), ConversationSet
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
@JvmStatic
|
||||
fun createTransitionBundle(context: Context, avatar: View, windowContent: View): Bundle? {
|
||||
return if (context is Activity) {
|
||||
ActivityOptionsCompat.makeSceneTransitionAnimation(
|
||||
context,
|
||||
*arrayOf(
|
||||
androidx.core.util.Pair.create(avatar, "avatar"),
|
||||
androidx.core.util.Pair.create(windowContent, "window_content")
|
||||
)
|
||||
).toBundle()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun createTransitionBundle(context: Context, avatar: View): Bundle? {
|
||||
return if (context is Activity) {
|
||||
ActivityOptionsCompat.makeSceneTransitionAnimation(
|
||||
context,
|
||||
avatar,
|
||||
"avatar"
|
||||
).toBundle()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun forGroup(context: Context, groupId: GroupId): Intent {
|
||||
val startBundle = ConversationSettingsFragmentArgs.Builder(null, groupId, null)
|
||||
|
||||
+18
-18
@@ -38,6 +38,7 @@ 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.requireDrawable
|
||||
import org.signal.core.util.requireParcelableCompat
|
||||
import org.signal.donations.InAppPaymentType
|
||||
import org.thoughtcrime.securesms.AvatarPreviewActivity
|
||||
@@ -91,8 +92,8 @@ import org.thoughtcrime.securesms.groups.ui.managegroup.dialogs.GroupDescription
|
||||
import org.thoughtcrime.securesms.groups.ui.managegroup.dialogs.GroupInviteSentDialog
|
||||
import org.thoughtcrime.securesms.groups.ui.managegroup.dialogs.GroupsLearnMoreBottomSheetDialogFragment
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.main.MainNavigationChatDetailRouter
|
||||
import org.thoughtcrime.securesms.main.MainNavigationDetailLocation
|
||||
import org.thoughtcrime.securesms.main.MainNavigationRouter
|
||||
import org.thoughtcrime.securesms.mediaoverview.MediaOverviewActivity
|
||||
import org.thoughtcrime.securesms.mediapreview.MediaIntentFactory
|
||||
import org.thoughtcrime.securesms.mediasend.camerax.CameraXRemoteConfig
|
||||
@@ -111,7 +112,6 @@ import org.thoughtcrime.securesms.stories.dialogs.StoryDialogs
|
||||
import org.thoughtcrime.securesms.stories.viewer.AddToGroupStoryDelegate
|
||||
import org.thoughtcrime.securesms.stories.viewer.StoryViewerActivity
|
||||
import org.thoughtcrime.securesms.util.CommunicationActions
|
||||
import org.thoughtcrime.securesms.util.ContextUtil
|
||||
import org.thoughtcrime.securesms.util.DateUtils
|
||||
import org.thoughtcrime.securesms.util.ExpirationUtil
|
||||
import org.thoughtcrime.securesms.util.Material3OnScrollHelper
|
||||
@@ -147,30 +147,28 @@ class ConversationSettingsFragment :
|
||||
private val alertDisabledTint by lazy { ContextCompat.getColor(requireContext(), R.color.signal_alert_primary_50) }
|
||||
private val colorizer = ColorizerV2()
|
||||
private val blockIcon by lazy {
|
||||
ContextUtil.requireDrawable(requireContext(), R.drawable.symbol_block_24).apply {
|
||||
requireContext().requireDrawable(R.drawable.symbol_block_24).apply {
|
||||
colorFilter = PorterDuffColorFilter(alertTint, PorterDuff.Mode.SRC_IN)
|
||||
}
|
||||
}
|
||||
|
||||
private val leaveIcon by lazy {
|
||||
ContextUtil.requireDrawable(requireContext(), R.drawable.symbol_leave_24).apply {
|
||||
requireContext().requireDrawable(R.drawable.symbol_leave_24).apply {
|
||||
colorFilter = PorterDuffColorFilter(alertTint, PorterDuff.Mode.SRC_IN)
|
||||
}
|
||||
}
|
||||
|
||||
private val endGroupIcon by lazy {
|
||||
ContextUtil.requireDrawable(requireContext(), R.drawable.symbol_x_circle_24).apply {
|
||||
requireContext().requireDrawable(R.drawable.symbol_x_circle_24).apply {
|
||||
colorFilter = PorterDuffColorFilter(alertTint, PorterDuff.Mode.SRC_IN)
|
||||
}
|
||||
}
|
||||
|
||||
private val viewModel by viewModels<ConversationSettingsViewModel>(
|
||||
factoryProducer = {
|
||||
val groupId = args.groupId as? GroupId
|
||||
|
||||
ConversationSettingsViewModel.Factory(
|
||||
recipientId = args.recipientId,
|
||||
groupId = groupId,
|
||||
groupId = args.groupId,
|
||||
callMessageIds = args.callMessageIds ?: longArrayOf(),
|
||||
repository = ConversationSettingsRepository(requireContext()),
|
||||
messageRequestRepository = MessageRequestRepository(requireContext())
|
||||
@@ -179,7 +177,7 @@ class ConversationSettingsFragment :
|
||||
)
|
||||
|
||||
private var transitionCallback: TransitionCallback? = null
|
||||
private var mainNavRouter: MainNavigationRouter? = null
|
||||
private var chatRouter: MainNavigationChatDetailRouter? = null
|
||||
|
||||
private lateinit var toolbar: Toolbar
|
||||
private lateinit var toolbarAvatarContainer: FrameLayout
|
||||
@@ -196,7 +194,7 @@ class ConversationSettingsFragment :
|
||||
override fun onAttach(context: Context) {
|
||||
super.onAttach(context)
|
||||
transitionCallback = context as? TransitionCallback
|
||||
mainNavRouter = context as? MainNavigationRouter
|
||||
chatRouter = context as? MainNavigationChatDetailRouter
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
@@ -253,9 +251,7 @@ class ConversationSettingsFragment :
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||
return if (item.itemId == R.id.action_edit) {
|
||||
val args = ConversationSettingsFragmentArgs.fromBundle(requireArguments())
|
||||
val groupId = args.groupId as GroupId
|
||||
|
||||
startActivity(CreateProfileActivity.getIntentForGroupProfile(requireActivity(), requireNotNull(groupId)))
|
||||
startActivity(CreateProfileActivity.getIntentForGroupProfile(requireActivity(), requireNotNull(args.groupId)))
|
||||
true
|
||||
} else {
|
||||
super.onOptionsItemSelected(item)
|
||||
@@ -263,8 +259,8 @@ class ConversationSettingsFragment :
|
||||
}
|
||||
|
||||
private fun goToConversationList() {
|
||||
if (mainNavRouter != null) {
|
||||
mainNavRouter?.goTo(MainNavigationDetailLocation.Empty)
|
||||
if (chatRouter != null) {
|
||||
chatRouter?.exitDetailLocation()
|
||||
} else {
|
||||
startActivity(MainActivity.clearTopAndOpenDetail(requireContext(), MainNavigationDetailLocation.Empty))
|
||||
}
|
||||
@@ -546,7 +542,9 @@ class ConversationSettingsFragment :
|
||||
.build()
|
||||
|
||||
startActivity(intent)
|
||||
requireActivity().finish()
|
||||
if (requireActivity() !is MainNavigationChatDetailRouter) {
|
||||
requireActivity().finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -816,7 +814,9 @@ class ConversationSettingsFragment :
|
||||
recipient = group,
|
||||
onRowClick = {
|
||||
CommunicationActions.startConversation(requireActivity(), group, null)
|
||||
requireActivity().finish()
|
||||
if (requireActivity() !is MainNavigationChatDetailRouter) {
|
||||
requireActivity().finish()
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
@@ -914,7 +914,7 @@ class ConversationSettingsFragment :
|
||||
icon = DSLSettingsIcon.from(R.drawable.ic_link_16),
|
||||
isEnabled = state.recipient.isActiveGroup && !state.isDeprecatedOrUnregistered,
|
||||
onClick = {
|
||||
navController.safeNavigate(ConversationSettingsFragmentDirections.actionConversationSettingsFragmentToShareableGroupLinkFragment(groupState.groupId.requireV2().toString()))
|
||||
navController.safeNavigate(ConversationSettingsFragmentDirections.actionConversationSettingsFragmentToShareableGroupLinkFragment(groupState.groupId))
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.settings.conversation
|
||||
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import org.thoughtcrime.securesms.main.MainNavigationChatDetailRouter
|
||||
import org.thoughtcrime.securesms.main.MainNavigationDetailLocation
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
|
||||
/**
|
||||
* Routes to the conversation settings screen, handling split-pane vs. standalone activity automatically.
|
||||
*/
|
||||
object ConversationSettingsNavigator {
|
||||
@JvmStatic
|
||||
fun navigate(
|
||||
activity: FragmentActivity,
|
||||
recipient: Recipient
|
||||
) {
|
||||
if (activity is MainNavigationChatDetailRouter) {
|
||||
activity.goToChatDetail(MainNavigationDetailLocation.Chats.ConversationSettings(recipient.id))
|
||||
return
|
||||
}
|
||||
|
||||
val intent = if (recipient.isPushGroup) {
|
||||
ConversationSettingsActivity.forGroup(activity, recipient.requireGroupId())
|
||||
} else {
|
||||
ConversationSettingsActivity.forRecipient(activity, recipient.id)
|
||||
}
|
||||
activity.startActivity(intent)
|
||||
}
|
||||
}
|
||||
+31
-7
@@ -28,6 +28,7 @@ import org.signal.libsignal.zkgroup.profiles.ProfileKey
|
||||
import org.thoughtcrime.securesms.MainActivity
|
||||
import org.thoughtcrime.securesms.attachments.Attachment
|
||||
import org.thoughtcrime.securesms.attachments.UriAttachment
|
||||
import org.thoughtcrime.securesms.components.SignalProgressDialog
|
||||
import org.thoughtcrime.securesms.database.AttachmentTable
|
||||
import org.thoughtcrime.securesms.database.MessageType
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
@@ -297,15 +298,38 @@ class InternalConversationSettingsFragment : ComposeFragment(), InternalConversa
|
||||
}
|
||||
|
||||
override fun clearSenderKeyAndArchiveSessions(recipientId: RecipientId) {
|
||||
clearSenderKey(recipientId)
|
||||
lifecycleScope.launch {
|
||||
val dialog = withContext(Dispatchers.Main) {
|
||||
SignalProgressDialog.show(requireContext(), "Clearing...", cancelable = false, indeterminate = true)
|
||||
}
|
||||
|
||||
val group = SignalDatabase.groups.getGroup(recipientId).orNull()
|
||||
if (group == null) {
|
||||
Log.w(TAG, "Couldn't find group for recipientId: $recipientId")
|
||||
return
|
||||
withContext(Dispatchers.Default) {
|
||||
clearSenderKey(recipientId)
|
||||
|
||||
val group = SignalDatabase.groups.getGroup(recipientId).orNull()
|
||||
if (group == null) {
|
||||
Log.w(TAG, "Couldn't find group for recipientId: $recipientId")
|
||||
return@withContext
|
||||
}
|
||||
|
||||
group.members.forEach { memberId ->
|
||||
archiveSessions(memberId)
|
||||
|
||||
val member = Recipient.resolved(memberId)
|
||||
if (member.hasAci) {
|
||||
AppDependencies.protocolStore.aci().identities().delete(member.requireAci().toString())
|
||||
}
|
||||
|
||||
if (member.hasPni) {
|
||||
AppDependencies.protocolStore.aci().identities().delete(member.requirePni().toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
dialog.dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
group.members.forEach { archiveSessions(it) }
|
||||
}
|
||||
|
||||
class InternalViewModel(
|
||||
|
||||
+1
-1
@@ -212,7 +212,7 @@ fun InternalConversationSettingsScreen(
|
||||
item {
|
||||
Rows.TextRow(
|
||||
text = "Clear sender key and archive sessions",
|
||||
label = "Resets any sender key state and archives all sessions for group members, will force creating new sessions and re-distributing sender key material.",
|
||||
label = "Resets any sender key state, archives all sessions, and removes identity keys for group members, will force creating new sessions and re-distributing sender key material.",
|
||||
onClick = {
|
||||
dialog = Dialog.CLEAR_SENDER_KEY_AND_ARCHIVE_SESSIONS
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user