mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-07-19 04:02:02 +01:00
Compare commits
229 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 81926d1bc4 | |||
| b327d92b82 | |||
| 9a8e0dd64f | |||
| c50757880e | |||
| 99b1fa0ebb | |||
| 74f08927f2 | |||
| 39598a8da0 | |||
| 5949d96cfd | |||
| c9ebc1c12e | |||
| 99ed40580c | |||
| c5b48be263 | |||
| 1742b2ec50 | |||
| 49ef205821 | |||
| 6f81a08669 | |||
| 97a8795100 | |||
| b8508af636 | |||
| c0f861d0b8 | |||
| 59d61184f5 | |||
| 622208831f | |||
| 67b50b499e | |||
| 4cafe16e25 | |||
| 2f70d780d0 | |||
| fd83ad9625 | |||
| b3857e4b35 | |||
| eff0e973aa | |||
| 34f336c2a2 | |||
| b0f71c8613 | |||
| 95019407c2 | |||
| 0101d41cf9 | |||
| 75c18806d5 | |||
| 7a07a96fba | |||
| 4f6c1c4647 | |||
| df4afe4e92 | |||
| 6a18bcc915 | |||
| 06e7b79e3d | |||
| 31f0945db4 | |||
| edc629e7fa | |||
| bdfe06ed74 | |||
| 1d11e29e94 | |||
| f70ae10067 | |||
| 7ca57e5531 | |||
| adad1425d8 | |||
| 693b208fe2 | |||
| e975084b8e | |||
| 3bc693961d | |||
| 2dde0b43b8 | |||
| 88366cd416 | |||
| 4ee2b71b13 | |||
| 86aae39141 | |||
| e71ec7c6d9 | |||
| 20d24d177d | |||
| cfc1ed8d5c | |||
| 25f7c31e75 | |||
| 929a391d50 | |||
| 76f2543308 | |||
| 5eebaa3528 | |||
| 673324f46a | |||
| ea9cd4251e | |||
| ac7d54331a | |||
| 5e3a34e1bc | |||
| fe6a74eb56 | |||
| 6a6b7a1924 | |||
| f50e970b5d | |||
| 32961106e1 | |||
| 8d3728b32a | |||
| 2f3b25c787 | |||
| 5cc7266a9e | |||
| 90cba056f1 | |||
| 07ca23ceae | |||
| 0d0b9d345c | |||
| 7f63af659d | |||
| 66390b4fe4 | |||
| b60f53f4df | |||
| 8bc31386a5 | |||
| 8f4b4aa160 | |||
| 12af41174a | |||
| e7c5af7ea5 | |||
| 51b1dbd470 | |||
| dd38f72f60 | |||
| db8a705704 | |||
| ba43403009 | |||
| 417b9eb50a | |||
| 76c9b4a7dc | |||
| d1b916d61c | |||
| 908bb8dbe6 | |||
| 31216592de | |||
| bcc9f17e41 | |||
| d9dd15eff3 | |||
| 1a12eb992d | |||
| 7c1e010482 | |||
| 8a5826555c | |||
| 8ffbfb4000 | |||
| 16fe25a38a | |||
| c47a74ba09 | |||
| 24287a15e2 | |||
| 77bef0b101 | |||
| e2ef8c2805 | |||
| a52799e069 | |||
| 2cabdd0978 | |||
| 02e134c16f | |||
| fe87ae203a | |||
| 272754384d | |||
| 1bad283af2 | |||
| 573013be5c | |||
| ca7762c3d0 | |||
| e45a4476d3 | |||
| 636247f83a | |||
| cc72a2232e | |||
| 80f6795665 | |||
| b55a8dc98e | |||
| 4ab4bb458a | |||
| 566a72ecf7 | |||
| 355332d604 | |||
| d8e77d8827 | |||
| f5e48a3ff9 | |||
| 46006cbfde | |||
| d695cd8b5a | |||
| debed5f537 | |||
| ecbdde592e | |||
| 384344c91b | |||
| e687718ec3 | |||
| cf394aeee8 | |||
| 8b4a38a2c6 | |||
| 1358fefeee | |||
| 958236467f | |||
| c638af6671 | |||
| bdbe69c7b0 | |||
| 184e0bc3c2 | |||
| 6893a0abc0 | |||
| 74b431d988 | |||
| f6c7824050 | |||
| b3904982a5 | |||
| 5f1a7397e1 | |||
| 55941d3ccc | |||
| 0b92bc41fe | |||
| 2373f567d8 | |||
| 7e81240d4d | |||
| 5cf61665d4 | |||
| 9814c520e2 | |||
| 6664716930 | |||
| 6194acfdba | |||
| 8db4a5238c | |||
| 60192c4b46 | |||
| 512df1ec1b | |||
| a849af6880 | |||
| 3d0a743fe3 | |||
| f5e065d050 | |||
| 7e26badb4c | |||
| da8af40e4f | |||
| 364f2baf4b | |||
| 97a0f7b659 | |||
| 310e90be84 | |||
| b462ef2149 | |||
| cd5ead4981 | |||
| 9be6f488c4 | |||
| 5a6b0cbfef | |||
| 86169b7368 | |||
| d4d8b54232 | |||
| e74ed9dbf3 | |||
| e1687715d3 | |||
| 447f29521b | |||
| c029d12b22 | |||
| 417c0f98ad | |||
| be41a24c0c | |||
| b95815c963 | |||
| 15d86c9f13 | |||
| 5c233daf38 | |||
| b91ee27620 | |||
| c03ae68ef2 | |||
| b6f6d1e42f | |||
| db899e92f5 | |||
| 99b89743a7 | |||
| 2bc2ae2867 | |||
| 300e5bf6f6 | |||
| 17d6fe918d | |||
| e71c4734bd | |||
| 7d39ad04e3 | |||
| 1ee4c590b7 | |||
| 4a0325ea55 | |||
| c4faafa9ae | |||
| ee11d1fd3b | |||
| c1c6db2b3b | |||
| 5372814468 | |||
| e5ed84f08c | |||
| ab90969b0e | |||
| ebd64305bb | |||
| f3b7368043 | |||
| a7de53333d | |||
| 36898a1e3a | |||
| ec18fb4317 | |||
| dd671a8489 | |||
| bcb5c37ed6 | |||
| a4a6117788 | |||
| bd42444e00 | |||
| b10cd9c5d3 | |||
| b87abdabfa | |||
| 91965d205a | |||
| 55b5997a4e | |||
| bdb6b7d5f1 | |||
| 97740b34eb | |||
| 5ed1bdc26a | |||
| 7505ef1834 | |||
| 07afbb5a8f | |||
| 613a07eb2c | |||
| 0943030efa | |||
| 5b266081db | |||
| 8536504318 | |||
| 495b5ef9be | |||
| bfdc628dd2 | |||
| 80aa521e42 | |||
| 9f1c8961f0 | |||
| 8eeffff923 | |||
| e07a3e05bf | |||
| 2c35a6a3fe | |||
| 248a787d74 | |||
| e76a584bb7 | |||
| 5a9cf128a0 | |||
| c7ac7087f7 | |||
| 9e2be2796b | |||
| 3f850375c1 | |||
| c91b739c1b | |||
| 600acb82bd | |||
| 7d6a31b254 | |||
| 56378c7166 | |||
| 987f5158d0 | |||
| 76a1d89954 | |||
| 72fd5d30df | |||
| f74f0e5109 | |||
| 6e03627c47 |
@@ -22,12 +22,12 @@ jobs:
|
||||
submodules: true
|
||||
lfs: true
|
||||
|
||||
- name: set up JDK 17
|
||||
uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5
|
||||
- name: set up JDK 21
|
||||
uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5
|
||||
# gh api repos/actions/setup-java/commits/v5 --jq '.sha'
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 17
|
||||
java-version: 21
|
||||
|
||||
- name: Validate Gradle Wrapper
|
||||
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # v6
|
||||
@@ -42,15 +42,16 @@ jobs:
|
||||
# Required to persist the Gradle configuration cache across runs.
|
||||
cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
|
||||
|
||||
# Pull requests run the fast custom linter (ci); pushes to main / 8.x branches run the full
|
||||
# Android lint (qa).
|
||||
# Pull requests run the fast custom linter (ciRemote); pushes to main / 8.x branches run the
|
||||
# full Android lint (qaRemote). Both include screenshot validation, which is deliberately kept
|
||||
# out of the local qa/ci tasks because screenshot rendering is host-OS dependent.
|
||||
- name: Build with Gradle
|
||||
env:
|
||||
SIGNAL_BUILD_CACHE_URL: ${{ secrets.SIGNAL_BUILD_CACHE_URL }}
|
||||
SIGNAL_BUILD_CACHE_USER: ${{ secrets.SIGNAL_BUILD_CACHE_USER }}
|
||||
SIGNAL_BUILD_CACHE_PASSWORD: ${{ secrets.SIGNAL_BUILD_CACHE_PASSWORD }}
|
||||
SIGNAL_BUILD_CACHE_PUSH: ${{ startsWith(github.ref, 'refs/heads/8.') }}
|
||||
run: ./gradlew ${{ github.event_name == 'pull_request' && 'ci' || 'qa' }}
|
||||
run: ./gradlew ${{ github.event_name == 'pull_request' && 'ciRemote' || 'qaRemote' }}
|
||||
|
||||
- name: Archive reports for failed build
|
||||
if: ${{ failure() }}
|
||||
@@ -58,4 +59,7 @@ jobs:
|
||||
# gh api repos/actions/upload-artifact/commits/v7 --jq '.sha'
|
||||
with:
|
||||
name: reports
|
||||
path: '*/build/reports'
|
||||
path: |
|
||||
**/build/reports
|
||||
**/build/test-results
|
||||
if-no-files-found: ignore
|
||||
|
||||
@@ -22,12 +22,12 @@ jobs:
|
||||
submodules: true
|
||||
ref: ${{ github.event.pull_request.base.sha }}
|
||||
|
||||
- name: set up JDK 17
|
||||
uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5
|
||||
- name: set up JDK 21
|
||||
uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5
|
||||
# gh api repos/actions/setup-java/commits/v5 --jq '.sha'
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 17
|
||||
java-version: 21
|
||||
|
||||
- name: Set up Gradle
|
||||
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6
|
||||
@@ -47,7 +47,7 @@ jobs:
|
||||
|
||||
- name: Cache base apk
|
||||
id: cache-base
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v5
|
||||
# gh api repos/actions/cache/commits/v5 --jq '.sha'
|
||||
with:
|
||||
path: diffuse-base.apk
|
||||
|
||||
@@ -13,9 +13,15 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
# gh api repos/actions/checkout/commits/v6 --jq '.sha'
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
- name: Build image
|
||||
run: |
|
||||
cd reproducible-builds
|
||||
docker build -t signal-android .
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
with:
|
||||
context: reproducible-builds
|
||||
tags: signal-android
|
||||
load: true
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
- name: Test build
|
||||
run: docker run --memory=12g --rm -v "$(pwd)":/project -w /project signal-android ./gradlew --no-daemon --max-workers=1 -Dorg.gradle.jvmargs="-Xmx4g -XX:MaxMetaspaceSize=512m" -Dkotlin.compiler.execution.strategy=in-process bundlePlayProdRelease
|
||||
|
||||
@@ -14,7 +14,7 @@ jobs:
|
||||
actions: write
|
||||
|
||||
steps:
|
||||
- uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10
|
||||
- uses: actions/stale@1e223db275d687790206a7acac4d1a11bd6fe629 # v10
|
||||
# gh api repos/actions/stale/commits/v10 --jq '.sha'
|
||||
with:
|
||||
days-before-stale: 60
|
||||
|
||||
+1
-1
@@ -1,2 +1,2 @@
|
||||
java openjdk-17.0.2
|
||||
java openjdk-21.0.2
|
||||
uv latest
|
||||
|
||||
+169
-2
@@ -2,7 +2,10 @@
|
||||
|
||||
import com.android.build.api.artifact.ArtifactTransformationRequest
|
||||
import com.android.build.api.artifact.SingleArtifact
|
||||
import com.android.build.api.variant.HasAndroidTest
|
||||
import org.gradle.api.tasks.testing.logging.TestExceptionFormat
|
||||
import org.gradle.process.ExecOperations
|
||||
import org.gradle.work.DisableCachingByDefault
|
||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||
import org.jetbrains.kotlin.gradle.dsl.KotlinAndroidProjectExtension
|
||||
import java.time.Instant
|
||||
@@ -10,6 +13,7 @@ import java.time.ZoneOffset
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.Locale
|
||||
import java.util.Properties
|
||||
import javax.inject.Inject
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
@@ -28,8 +32,8 @@ plugins {
|
||||
val staticIps = Properties().apply { file("static-ips.properties").reader().use { load(it) } }
|
||||
staticIps.stringPropertyNames().forEach { rootProject.extra[it] = staticIps.getProperty(it) }
|
||||
|
||||
val canonicalVersionCode = 1713
|
||||
val canonicalVersionName = "8.17.3"
|
||||
val canonicalVersionCode = 1723
|
||||
val canonicalVersionName = "8.20.3"
|
||||
val currentHotfixVersion = 0
|
||||
val maxHotfixVersions = 100
|
||||
|
||||
@@ -128,6 +132,17 @@ ktlint {
|
||||
version.set("1.5.0")
|
||||
}
|
||||
|
||||
// ktlint only scans convention source dirs, so the shared dirs added to the compile tasks are
|
||||
// otherwise skipped. Add them to the base test/androidTest ktlint tasks so ktlintCheck/format cover them.
|
||||
tasks.withType(org.jlleitschuh.gradle.ktlint.tasks.BaseKtLintCheckTask::class.java).configureEach {
|
||||
if (name.endsWith("OverTestSourceSet") || name.endsWith("OverAndroidTestSourceSet")) {
|
||||
source("$projectDir/src/testShared")
|
||||
}
|
||||
if (name.endsWith("OverAndroidTestSourceSet")) {
|
||||
source("$projectDir/src/benchmarkShared/java")
|
||||
}
|
||||
}
|
||||
|
||||
screenshotTests {
|
||||
// Fraction of differing pixels tolerated before a screenshot test fails (0.0001 = 0.01%).
|
||||
imageDifferenceThreshold = 0.0001f
|
||||
@@ -598,6 +613,43 @@ androidComponents {
|
||||
}
|
||||
variant.sources.assets?.addGeneratedSourceDirectory(taskProvider) { it.outputDir }
|
||||
}
|
||||
|
||||
onVariants(selector().withName("playProdDebug")) { variant ->
|
||||
val androidTest = (variant as? HasAndroidTest)?.androidTest ?: return@onVariants
|
||||
|
||||
tasks.register<FirebaseTestLabTask>("firebaseTestLab") {
|
||||
group = "Verification"
|
||||
description = "Runs the ${variant.name} instrumentation tests on Firebase Test Lab via the gcloud CLI. Run a single class with -Pftl.class=<fqcn>[#method]; override other defaults with -Pftl.* properties."
|
||||
|
||||
appApkDirectory.set(variant.artifacts.get(SingleArtifact.APK))
|
||||
testApkDirectory.set(androidTest.artifacts.get(SingleArtifact.APK))
|
||||
|
||||
val deviceOverride = project.providers.gradleProperty("ftl.devices").orNull
|
||||
devices.set(
|
||||
deviceOverride?.split(";")?.map { it.trim() }?.filter { it.isNotEmpty() }
|
||||
?: listOf("model=Pixel2.arm,version=31,locale=en,orientation=portrait")
|
||||
)
|
||||
|
||||
useOrchestrator.set(true)
|
||||
environmentVariables.set(mapOf("clearPackageData" to "true"))
|
||||
testTimeout.set(project.providers.gradleProperty("ftl.timeout").getOrElse("30m"))
|
||||
numFlakyTestAttempts.set(project.providers.gradleProperty("ftl.numFlakyTestAttempts").map { it.toInt() }.getOrElse(1))
|
||||
gcloudProject.set(project.providers.gradleProperty("ftl.project"))
|
||||
resultsBucket.set(project.providers.gradleProperty("ftl.resultsBucket"))
|
||||
resultsDir.set(project.providers.gradleProperty("ftl.resultsDir"))
|
||||
|
||||
val testClass = project.providers.gradleProperty("ftl.class").orNull?.takeIf { it.isNotBlank() }
|
||||
testTargets.set(
|
||||
if (testClass != null) "class $testClass" else project.providers.gradleProperty("ftl.testTargets").orNull
|
||||
)
|
||||
gcloudExecutable.set(project.providers.gradleProperty("ftl.gcloud").getOrElse("gcloud"))
|
||||
extraArgs.set(
|
||||
project.providers.gradleProperty("ftl.extraArgs").orNull
|
||||
?.split(" ")?.map { it.trim() }?.filter { it.isNotEmpty() }
|
||||
?: emptyList()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
baselineProfile {
|
||||
@@ -670,6 +722,7 @@ dependencies {
|
||||
implementation(libs.androidx.navigation.compose)
|
||||
implementation(libs.androidx.navigation3.runtime)
|
||||
implementation(libs.androidx.navigation3.ui)
|
||||
implementation(libs.androidx.lifecycle.viewmodel.navigation3)
|
||||
implementation(libs.androidx.lifecycle.viewmodel.ktx)
|
||||
implementation(libs.androidx.lifecycle.livedata.ktx)
|
||||
implementation(libs.androidx.lifecycle.process)
|
||||
@@ -789,8 +842,18 @@ dependencies {
|
||||
|
||||
androidTestImplementation(platform(libs.androidx.compose.bom))
|
||||
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
|
||||
androidTestImplementation(libs.androidx.compose.ui.test.manifest)
|
||||
androidTestImplementation(testLibs.androidx.test.ext.junit)
|
||||
androidTestImplementation(testLibs.espresso.core)
|
||||
androidTestImplementation(testLibs.espresso.contrib) {
|
||||
// espresso-contrib transitively pulls the full checkerframework jar (only its annotations are needed),
|
||||
// whose MANIFEST.MF collides with other test dependencies during androidTest resource merging.
|
||||
exclude(group = "org.checkerframework", module = "checker")
|
||||
// accessibility-test-framework drags in an ancient com.google.protobuf:protobuf-lite:3.0.1 whose
|
||||
// GeneratedMessageLite wins the merged dex and lacks registerDefaultInstance(Class, GeneratedMessageLite),
|
||||
// crashing tests at runtime. We only use RecyclerViewActions from contrib, not the accessibility checks.
|
||||
exclude(group = "com.google.android.apps.common.testing.accessibility.framework")
|
||||
}
|
||||
androidTestImplementation(testLibs.androidx.test.core)
|
||||
androidTestImplementation(testLibs.androidx.test.core.ktx)
|
||||
androidTestImplementation(testLibs.androidx.test.ext.junit.ktx)
|
||||
@@ -974,6 +1037,110 @@ abstract class CopyBenchmarkBackupTask : DefaultTask() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs an instrumentation test suite on Firebase Test Lab by shelling out to `gcloud firebase test android run`.
|
||||
*
|
||||
* The `gcloud` CLI must be installed and authenticated (`gcloud auth login` and a configured project, or an
|
||||
* activated service account) before invoking this task.
|
||||
*/
|
||||
@DisableCachingByDefault(because = "Executes tests on remote devices; results must never be served from the build cache")
|
||||
abstract class FirebaseTestLabTask
|
||||
@Inject
|
||||
constructor(
|
||||
private val execOperations: ExecOperations
|
||||
) : DefaultTask() {
|
||||
@get:InputFiles
|
||||
@get:PathSensitive(PathSensitivity.RELATIVE)
|
||||
abstract val appApkDirectory: DirectoryProperty
|
||||
|
||||
@get:InputFiles
|
||||
@get:PathSensitive(PathSensitivity.RELATIVE)
|
||||
abstract val testApkDirectory: DirectoryProperty
|
||||
|
||||
@get:Input
|
||||
abstract val devices: ListProperty<String>
|
||||
|
||||
@get:Input
|
||||
abstract val useOrchestrator: Property<Boolean>
|
||||
|
||||
@get:Input
|
||||
abstract val environmentVariables: MapProperty<String, String>
|
||||
|
||||
@get:Input
|
||||
abstract val testTimeout: Property<String>
|
||||
|
||||
@get:Input
|
||||
abstract val numFlakyTestAttempts: Property<Int>
|
||||
|
||||
@get:Input
|
||||
@get:Optional
|
||||
abstract val gcloudProject: Property<String>
|
||||
|
||||
@get:Input
|
||||
@get:Optional
|
||||
abstract val resultsBucket: Property<String>
|
||||
|
||||
@get:Input
|
||||
@get:Optional
|
||||
abstract val resultsDir: Property<String>
|
||||
|
||||
@get:Input
|
||||
@get:Optional
|
||||
abstract val testTargets: Property<String>
|
||||
|
||||
@get:Input
|
||||
abstract val gcloudExecutable: Property<String>
|
||||
|
||||
@get:Input
|
||||
abstract val extraArgs: ListProperty<String>
|
||||
|
||||
@TaskAction
|
||||
fun run() {
|
||||
val appApk = findApk(appApkDirectory.get().asFile, "app")
|
||||
val testApk = findApk(testApkDirectory.get().asFile, "instrumentation test")
|
||||
|
||||
val arguments = mutableListOf(
|
||||
gcloudExecutable.get(),
|
||||
"firebase", "test", "android", "run",
|
||||
"--type", "instrumentation",
|
||||
"--app", appApk.absolutePath,
|
||||
"--test", testApk.absolutePath,
|
||||
"--timeout", testTimeout.get(),
|
||||
"--num-flaky-test-attempts", numFlakyTestAttempts.get().toString()
|
||||
)
|
||||
|
||||
devices.get().forEach { device ->
|
||||
arguments += listOf("--device", device)
|
||||
}
|
||||
|
||||
if (useOrchestrator.get()) {
|
||||
arguments += "--use-orchestrator"
|
||||
}
|
||||
|
||||
val environment = environmentVariables.get()
|
||||
if (environment.isNotEmpty()) {
|
||||
arguments += "--environment-variables"
|
||||
arguments += environment.entries.joinToString(",") { "${it.key}=${it.value}" }
|
||||
}
|
||||
|
||||
gcloudProject.orNull?.takeIf { it.isNotBlank() }?.let { arguments += listOf("--project", it) }
|
||||
resultsBucket.orNull?.takeIf { it.isNotBlank() }?.let { arguments += listOf("--results-bucket", it) }
|
||||
resultsDir.orNull?.takeIf { it.isNotBlank() }?.let { arguments += listOf("--results-dir", it) }
|
||||
testTargets.orNull?.takeIf { it.isNotBlank() }?.let { arguments += listOf("--test-targets", it) }
|
||||
arguments += extraArgs.get()
|
||||
|
||||
logger.lifecycle("Running Firebase Test Lab:\n ${arguments.joinToString(" ")}")
|
||||
execOperations.exec {
|
||||
commandLine(arguments)
|
||||
}
|
||||
}
|
||||
|
||||
private fun findApk(directory: File, label: String): File {
|
||||
return directory.walkTopDown().firstOrNull { it.isFile && it.extension == "apk" }
|
||||
?: throw GradleException("No $label APK found under ${directory.absolutePath}. Was the assemble task run?")
|
||||
}
|
||||
}
|
||||
|
||||
abstract class RenameApkTask : DefaultTask() {
|
||||
@get:InputFiles
|
||||
abstract val apkFolder: DirectoryProperty
|
||||
|
||||
+191
@@ -0,0 +1,191 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.donate
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.navigation.fragment.NavHostFragment
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.test.core.app.ActivityScenario
|
||||
import androidx.test.espresso.Espresso.onView
|
||||
import androidx.test.espresso.action.ViewActions.click
|
||||
import androidx.test.espresso.action.ViewActions.scrollTo
|
||||
import androidx.test.espresso.contrib.RecyclerViewActions
|
||||
import androidx.test.espresso.matcher.ViewMatchers.hasDescendant
|
||||
import androidx.test.espresso.matcher.ViewMatchers.withId
|
||||
import androidx.test.espresso.matcher.ViewMatchers.withText
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.isEqualTo
|
||||
import assertk.assertions.isNotNull
|
||||
import assertk.assertions.isNull
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.signal.core.util.getParcelableExtraCompat
|
||||
import org.signal.donations.InAppPaymentType
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.badges.models.Badge
|
||||
import org.thoughtcrime.securesms.badges.self.none.BecomeASustainerFragment
|
||||
import org.thoughtcrime.securesms.badges.self.overview.BadgesOverviewFragment
|
||||
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
|
||||
import org.thoughtcrime.securesms.components.settings.app.routes.AppSettingsRoute
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.profiles.manage.EditProfileActivity
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.testing.InAppPaymentsRule
|
||||
import org.thoughtcrime.securesms.testing.SignalActivityRule
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Entry-path coverage for the "become a subscriber" upsell.
|
||||
*
|
||||
* The upsell surface is [BecomeASustainerFragment], reached from the profile screen's badges row. The
|
||||
* handoff test asserts the sheet's button launches the recurring-donation checkout route; the gating
|
||||
* tests assert the "user might be a sustainer" rule in [org.thoughtcrime.securesms.profiles.manage.EditProfileFragment]:
|
||||
* a non-donor sees the upsell, an existing donor is routed to badge management instead.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class BecomeASustainerUpsellTest {
|
||||
|
||||
@get:Rule
|
||||
val harness = SignalActivityRule()
|
||||
|
||||
@get:Rule
|
||||
val iapRule = InAppPaymentsRule()
|
||||
|
||||
private val context: Context get() = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
setSelfBadges(emptyList())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nonDonor_badgeTap_showsUpsellSheet() {
|
||||
ActivityScenario.launch(EditProfileActivity::class.java).use { scenario ->
|
||||
onView(withId(R.id.manage_profile_badges_container)).perform(scrollTo(), click())
|
||||
|
||||
val sheet = awaitNavHostFragment(scenario) { it.filterIsInstance<BecomeASustainerFragment>().firstOrNull() }
|
||||
assertThat(sheet).isNotNull()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun becomeASustainer_launchesRecurringCheckoutRoute() {
|
||||
LaunchedActivityRecorder(AppSettingsActivity::class.java).use { recorder ->
|
||||
ActivityScenario.launch(EditProfileActivity::class.java).use { scenario ->
|
||||
onView(withId(R.id.manage_profile_badges_container)).perform(scrollTo(), click())
|
||||
awaitNavHostFragment(scenario) { it.filterIsInstance<BecomeASustainerFragment>().firstOrNull() }
|
||||
|
||||
val becomeASustainer = withText(R.string.BecomeASustainerMegaphone__become_a_sustainer)
|
||||
onView(withId(R.id.recycler)).perform(RecyclerViewActions.scrollTo<RecyclerView.ViewHolder>(hasDescendant(becomeASustainer)))
|
||||
onView(becomeASustainer).perform(click())
|
||||
|
||||
val intent = recorder.awaitLaunch()
|
||||
val route = intent.extras!!.keySet().firstNotNullOfOrNull { intent.getParcelableExtraCompat(it, AppSettingsRoute::class.java) }
|
||||
assertThat(route).isEqualTo(AppSettingsRoute.DonationsRoute.Donations(directToCheckoutType = InAppPaymentType.RECURRING_DONATION))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun donor_badgeTap_opensBadgeManagement() {
|
||||
setSelfBadges(listOf(donorBadge()))
|
||||
|
||||
ActivityScenario.launch(EditProfileActivity::class.java).use { scenario ->
|
||||
onView(withId(R.id.manage_profile_badges_container)).perform(scrollTo(), click())
|
||||
|
||||
val badgeManagement = awaitNavHostFragment(scenario) { it.filterIsInstance<BadgesOverviewFragment>().firstOrNull() }
|
||||
assertThat(badgeManagement).isNotNull()
|
||||
|
||||
val upsell = navHostFragments(scenario).filterIsInstance<BecomeASustainerFragment>().firstOrNull()
|
||||
assertThat(upsell).isNull()
|
||||
}
|
||||
}
|
||||
|
||||
private fun awaitNavHostFragment(scenario: ActivityScenario<EditProfileActivity>, selector: (List<Fragment>) -> Fragment?): Fragment {
|
||||
return await(description = "nav host fragment") { selector(navHostFragments(scenario)) }
|
||||
}
|
||||
|
||||
private fun navHostFragments(scenario: ActivityScenario<EditProfileActivity>): List<Fragment> {
|
||||
var fragments: List<Fragment> = emptyList()
|
||||
scenario.onActivity { activity ->
|
||||
val navHost = activity.supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as? NavHostFragment
|
||||
fragments = navHost?.childFragmentManager?.fragments ?: emptyList()
|
||||
}
|
||||
return fragments
|
||||
}
|
||||
|
||||
private fun setSelfBadges(badges: List<Badge>) {
|
||||
SignalDatabase.recipients.setBadges(Recipient.self().id, badges)
|
||||
Recipient.self().fresh()
|
||||
}
|
||||
|
||||
private fun donorBadge(): Badge {
|
||||
return Badge(
|
||||
id = "test-donor-badge",
|
||||
category = Badge.Category.Donor,
|
||||
name = "Signal Sustainer",
|
||||
description = "",
|
||||
imageUrl = Uri.EMPTY,
|
||||
imageDensity = "xxhdpi",
|
||||
expirationTimestamp = System.currentTimeMillis() + TimeUnit.DAYS.toMillis(30),
|
||||
visible = true,
|
||||
duration = TimeUnit.DAYS.toMillis(30)
|
||||
)
|
||||
}
|
||||
|
||||
private fun <T> await(timeoutMs: Long = 10_000, pollMs: Long = 50, description: String, supplier: () -> T?): T {
|
||||
val deadline = System.currentTimeMillis() + timeoutMs
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
supplier()?.let { return it }
|
||||
Thread.sleep(pollMs)
|
||||
}
|
||||
error("Timed out after ${timeoutMs}ms waiting for $description")
|
||||
}
|
||||
|
||||
/**
|
||||
* Captures the first launched activity of [activityClass] and finishes it immediately so its own
|
||||
* downstream launches (e.g. the auto-launched checkout) don't cascade during the test.
|
||||
*/
|
||||
private inner class LaunchedActivityRecorder(private val activityClass: Class<out Activity>) : AutoCloseable {
|
||||
@Volatile
|
||||
private var launched: Intent? = null
|
||||
|
||||
private val app = context.applicationContext as Application
|
||||
private val callbacks = object : Application.ActivityLifecycleCallbacks {
|
||||
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {
|
||||
if (activityClass.isInstance(activity) && launched == null) {
|
||||
launched = activity.intent
|
||||
activity.finish()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onActivityStarted(activity: Activity) = Unit
|
||||
override fun onActivityResumed(activity: Activity) = Unit
|
||||
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) = Unit
|
||||
}
|
||||
|
||||
init {
|
||||
app.registerActivityLifecycleCallbacks(callbacks)
|
||||
}
|
||||
|
||||
fun awaitLaunch(): Intent = await(description = "${activityClass.simpleName} launch") { launched }
|
||||
|
||||
override fun close() = app.unregisterActivityLifecycleCallbacks(callbacks)
|
||||
}
|
||||
}
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.donate
|
||||
|
||||
import androidx.compose.ui.test.junit4.v2.createEmptyComposeRule
|
||||
import androidx.test.core.app.ActivityScenario
|
||||
import androidx.test.espresso.Espresso.onView
|
||||
import androidx.test.espresso.action.ViewActions
|
||||
import androidx.test.espresso.assertion.ViewAssertions.matches
|
||||
import androidx.test.espresso.matcher.RootMatchers.isDialog
|
||||
import androidx.test.espresso.matcher.ViewMatchers.isClickable
|
||||
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
|
||||
import androidx.test.espresso.matcher.ViewMatchers.withId
|
||||
import androidx.test.espresso.matcher.ViewMatchers.withText
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import io.mockk.unmockkObject
|
||||
import org.hamcrest.Matchers.allOf
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.signal.core.util.deleteAll
|
||||
import org.signal.donations.InAppPaymentType
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.permits.DonationPermits
|
||||
import org.thoughtcrime.securesms.database.InAppPaymentTable
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.testing.GooglePayTestRule
|
||||
import org.thoughtcrime.securesms.testing.InAppPaymentsRule
|
||||
import org.thoughtcrime.securesms.testing.RxTestSchedulerRule
|
||||
import org.thoughtcrime.securesms.testing.SignalActivityRule
|
||||
import org.thoughtcrime.securesms.testing.actions.scrollToDescendant
|
||||
|
||||
@Suppress("ClassName")
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class CheckoutFlowActivityTest__OneTimeDonations {
|
||||
@get:Rule
|
||||
val harness = SignalActivityRule(othersCount = 10)
|
||||
|
||||
@get:Rule
|
||||
val iapRule = InAppPaymentsRule()
|
||||
|
||||
@get:Rule
|
||||
val rxRule = RxTestSchedulerRule()
|
||||
|
||||
@get:Rule
|
||||
val googlePayRule = GooglePayTestRule()
|
||||
|
||||
@get:Rule
|
||||
val composeRule = createEmptyComposeRule()
|
||||
|
||||
private val intent = CheckoutFlowActivity.createIntent(InstrumentationRegistry.getInstrumentation().targetContext, InAppPaymentType.ONE_TIME_DONATION)
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
SignalDatabase.inAppPayments.writableDatabase.deleteAll(InAppPaymentTable.TABLE_NAME)
|
||||
startJobLoopForTests()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
unmockkObject(DonationPermits)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenPermitAcquisitionFails_whenIDonateOnce_thenIExpectPaymentSetupErrorDialog() {
|
||||
failDonationPermitAcquisition()
|
||||
|
||||
val scenario = ActivityScenario.launch<CheckoutFlowActivity>(intent)
|
||||
rxRule.defaultTestScheduler.triggerActions()
|
||||
|
||||
scrollToDescendant(R.id.recycler, withId(R.id.boost_1), rxRule.defaultTestScheduler)
|
||||
onView(allOf(withId(R.id.boost_1), isClickable())).perform(ViewActions.click())
|
||||
rxRule.defaultTestScheduler.triggerActions()
|
||||
|
||||
scrollToDescendant(R.id.recycler, withText(R.string.DonateToSignalFragment__continue), rxRule.defaultTestScheduler)
|
||||
onView(withText(R.string.DonateToSignalFragment__continue)).perform(ViewActions.click())
|
||||
rxRule.defaultTestScheduler.triggerActions()
|
||||
|
||||
scenario.selectGooglePay(composeRule, rxRule.defaultTestScheduler, InAppPaymentType.ONE_TIME_DONATION)
|
||||
|
||||
awaitDialog(rxRule.defaultTestScheduler, R.string.DonationsErrors__error_processing_payment)
|
||||
onView(withText(R.string.DonationsErrors__your_payment)).inRoot(isDialog()).check(matches(isDisplayed()))
|
||||
}
|
||||
}
|
||||
+89
-67
@@ -1,9 +1,11 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.donate
|
||||
|
||||
import androidx.compose.ui.test.junit4.v2.createEmptyComposeRule
|
||||
import androidx.test.core.app.ActivityScenario
|
||||
import androidx.test.espresso.Espresso.onView
|
||||
import androidx.test.espresso.action.ViewActions
|
||||
import androidx.test.espresso.assertion.ViewAssertions.matches
|
||||
import androidx.test.espresso.matcher.RootMatchers.isDialog
|
||||
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
|
||||
import androidx.test.espresso.matcher.ViewMatchers.isNotEnabled
|
||||
import androidx.test.espresso.matcher.ViewMatchers.isSelected
|
||||
@@ -11,28 +13,33 @@ import androidx.test.espresso.matcher.ViewMatchers.withId
|
||||
import androidx.test.espresso.matcher.ViewMatchers.withText
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import io.mockk.every
|
||||
import io.mockk.unmockkObject
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.signal.core.util.deleteAll
|
||||
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.components.settings.app.subscription.permits.DonationPermits
|
||||
import org.thoughtcrime.securesms.database.InAppPaymentTable
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.testing.GooglePayTestRule
|
||||
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.subscriptions.ActiveSubscription
|
||||
import org.thoughtcrime.securesms.testing.actions.scrollToDescendant
|
||||
import org.thoughtcrime.securesms.testing.endpoints.DonationResponses
|
||||
import org.thoughtcrime.securesms.testing.endpoints.MockEndpoints
|
||||
import org.thoughtcrime.securesms.testing.endpoints.failure
|
||||
import org.thoughtcrime.securesms.testing.endpoints.ok
|
||||
import org.whispersystems.signalservice.api.subscriptions.SubscriberId
|
||||
import java.math.BigDecimal
|
||||
import java.util.Currency
|
||||
import kotlin.time.Duration.Companion.days
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
@Suppress("ClassName")
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
@@ -46,19 +53,37 @@ class CheckoutFlowActivityTest__RecurringDonations {
|
||||
@get:Rule
|
||||
val rxRule = RxTestSchedulerRule()
|
||||
|
||||
@get:Rule
|
||||
val googlePayRule = GooglePayTestRule()
|
||||
|
||||
@get:Rule
|
||||
val composeRule = createEmptyComposeRule()
|
||||
|
||||
private val intent = CheckoutFlowActivity.createIntent(InstrumentationRegistry.getInstrumentation().targetContext, InAppPaymentType.RECURRING_DONATION)
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
SignalDatabase.inAppPayments.writableDatabase.deleteAll(InAppPaymentTable.TABLE_NAME)
|
||||
startJobLoopForTests()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
unmockkObject(DonationPermits)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenRecurringDonations_whenILoadScreen_thenIExpectMonthlySelected() {
|
||||
ActivityScenario.launch<CheckoutFlowActivity>(intent)
|
||||
scrollToDescendant(R.id.recycler, withId(R.id.monthly), rxRule.defaultTestScheduler)
|
||||
onView(withId(R.id.monthly)).check(matches(isSelected()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenNoCurrentDonation_whenILoadScreen_thenIExpectContinueButton() {
|
||||
ActivityScenario.launch<CheckoutFlowActivity>(intent)
|
||||
onView(withId(R.id.recycler)).perform(RecyclerViewScrollToBottomAction)
|
||||
onView(withText("Continue")).check(matches(isDisplayed()))
|
||||
scrollToDescendant(R.id.recycler, withText(R.string.DonateToSignalFragment__continue), rxRule.defaultTestScheduler)
|
||||
onView(withText(R.string.DonateToSignalFragment__continue)).check(matches(isDisplayed()))
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -69,8 +94,9 @@ class CheckoutFlowActivityTest__RecurringDonations {
|
||||
|
||||
rxRule.defaultTestScheduler.triggerActions()
|
||||
|
||||
onView(withId(R.id.recycler)).perform(RecyclerViewScrollToBottomAction)
|
||||
scrollToDescendant(R.id.recycler, withText(R.string.SubscribeFragment__update_subscription), rxRule.defaultTestScheduler)
|
||||
onView(withText(R.string.SubscribeFragment__update_subscription)).check(matches(isDisplayed()))
|
||||
scrollToDescendant(R.id.recycler, withText(R.string.SubscribeFragment__cancel_subscription), rxRule.defaultTestScheduler)
|
||||
onView(withText(R.string.SubscribeFragment__cancel_subscription)).check(matches(isDisplayed()))
|
||||
}
|
||||
|
||||
@@ -82,7 +108,7 @@ class CheckoutFlowActivityTest__RecurringDonations {
|
||||
|
||||
rxRule.defaultTestScheduler.triggerActions()
|
||||
|
||||
onView(withId(R.id.recycler)).perform(RecyclerViewScrollToBottomAction)
|
||||
scrollToDescendant(R.id.recycler, withText(R.string.SubscribeFragment__cancel_subscription), rxRule.defaultTestScheduler)
|
||||
onView(withText(R.string.SubscribeFragment__cancel_subscription)).check(matches(isDisplayed()))
|
||||
onView(withText(R.string.SubscribeFragment__cancel_subscription)).perform(ViewActions.click())
|
||||
onView(withText(R.string.SubscribeFragment__confirm_cancellation)).check(matches(isDisplayed()))
|
||||
@@ -97,50 +123,65 @@ class CheckoutFlowActivityTest__RecurringDonations {
|
||||
|
||||
rxRule.defaultTestScheduler.triggerActions()
|
||||
|
||||
onView(withId(R.id.recycler)).perform(RecyclerViewScrollToBottomAction)
|
||||
scrollToDescendant(R.id.recycler, withText(R.string.SubscribeFragment__update_subscription), rxRule.defaultTestScheduler)
|
||||
onView(withText(R.string.SubscribeFragment__update_subscription)).check(matches(isDisplayed()))
|
||||
onView(withText(R.string.SubscribeFragment__update_subscription)).check(matches(isNotEnabled()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenSubscriberPermitAcquisitionFails_whenISubscribe_thenIExpectPaymentSetupErrorDialog() {
|
||||
failDonationPermitAcquisition()
|
||||
|
||||
val scenario = ActivityScenario.launch<CheckoutFlowActivity>(intent)
|
||||
rxRule.defaultTestScheduler.triggerActions()
|
||||
|
||||
scrollToDescendant(R.id.recycler, withText(R.string.DonateToSignalFragment__continue), rxRule.defaultTestScheduler)
|
||||
onView(withText(R.string.DonateToSignalFragment__continue)).perform(ViewActions.click())
|
||||
rxRule.defaultTestScheduler.triggerActions()
|
||||
|
||||
scenario.selectGooglePay(composeRule, rxRule.defaultTestScheduler, InAppPaymentType.RECURRING_DONATION)
|
||||
|
||||
awaitDialog(rxRule.defaultTestScheduler, R.string.DonationsErrors__error_processing_payment)
|
||||
onView(withText(R.string.DonationsErrors__your_payment)).inRoot(isDialog()).check(matches(isDisplayed()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenSubscriptionPaymentMethodPermitRejected_whenISubscribe_thenIExpectPaymentSetupErrorDialog() {
|
||||
succeedDonationPermitAcquisition()
|
||||
MockEndpoints.responder.register({ it.method == "POST" && it.path.contains("/create_payment_method") }) {
|
||||
failure(402)
|
||||
}
|
||||
|
||||
val scenario = ActivityScenario.launch<CheckoutFlowActivity>(intent)
|
||||
rxRule.defaultTestScheduler.triggerActions()
|
||||
|
||||
scrollToDescendant(R.id.recycler, withText(R.string.DonateToSignalFragment__continue), rxRule.defaultTestScheduler)
|
||||
onView(withText(R.string.DonateToSignalFragment__continue)).perform(ViewActions.click())
|
||||
rxRule.defaultTestScheduler.triggerActions()
|
||||
|
||||
scenario.selectGooglePay(composeRule, rxRule.defaultTestScheduler, InAppPaymentType.RECURRING_DONATION)
|
||||
|
||||
awaitDialog(rxRule.defaultTestScheduler, R.string.DonationsErrors__error_processing_payment)
|
||||
onView(withText(R.string.DonationsErrors__your_payment)).inRoot(isDialog()).check(matches(isDisplayed()))
|
||||
}
|
||||
|
||||
private fun initialiseActiveSubscription() {
|
||||
val currency = Currency.getInstance("USD")
|
||||
val subscriber = InAppPaymentSubscriberRecord(
|
||||
subscriberId = SubscriberId.generate(),
|
||||
currency = currency,
|
||||
type = InAppPaymentSubscriberRecord.Type.DONATION,
|
||||
requiresCancel = false,
|
||||
paymentMethodType = InAppPaymentData.PaymentMethodType.CARD,
|
||||
iapSubscriptionId = null
|
||||
)
|
||||
|
||||
InAppPaymentsRepository.setSubscriber(subscriber)
|
||||
SignalStore.inAppPayments.setRecurringDonationCurrency(currency)
|
||||
|
||||
AppDependencies.donationsApi.apply {
|
||||
every { getSubscription(subscriber.subscriberId) } returns NetworkResult.Success(
|
||||
ActiveSubscription(
|
||||
ActiveSubscription.Subscription(
|
||||
200,
|
||||
currency.currencyCode,
|
||||
BigDecimal.ONE,
|
||||
System.currentTimeMillis().milliseconds.inWholeSeconds + 30.days.inWholeSeconds,
|
||||
true,
|
||||
System.currentTimeMillis().milliseconds.inWholeSeconds + 30.days.inWholeSeconds,
|
||||
false,
|
||||
"active",
|
||||
"STRIPE",
|
||||
"CARD",
|
||||
false
|
||||
),
|
||||
null
|
||||
)
|
||||
)
|
||||
|
||||
every { deleteSubscription(subscriber.subscriberId) } returns NetworkResult.Success(Unit)
|
||||
val subscriber = registerSubscriber()
|
||||
val serialized = subscriber.subscriberId.serialize()
|
||||
MockEndpoints.responder.register({ it.method == "GET" && it.path.contains(serialized) }) {
|
||||
ok(DonationResponses.activeSubscription(status = "active", active = true))
|
||||
}
|
||||
}
|
||||
|
||||
private fun initialisePendingSubscription() {
|
||||
val subscriber = registerSubscriber()
|
||||
val serialized = subscriber.subscriberId.serialize()
|
||||
MockEndpoints.responder.register({ it.method == "GET" && it.path.contains(serialized) }) {
|
||||
ok(DonationResponses.activeSubscription(status = "incomplete", active = false))
|
||||
}
|
||||
}
|
||||
|
||||
private fun registerSubscriber(): InAppPaymentSubscriberRecord {
|
||||
val currency = Currency.getInstance("USD")
|
||||
val subscriber = InAppPaymentSubscriberRecord(
|
||||
subscriberId = SubscriberId.generate(),
|
||||
@@ -154,25 +195,6 @@ class CheckoutFlowActivityTest__RecurringDonations {
|
||||
InAppPaymentsRepository.setSubscriber(subscriber)
|
||||
SignalStore.inAppPayments.setRecurringDonationCurrency(currency)
|
||||
|
||||
AppDependencies.donationsApi.apply {
|
||||
every { getSubscription(subscriber.subscriberId) } returns NetworkResult.Success(
|
||||
ActiveSubscription(
|
||||
ActiveSubscription.Subscription(
|
||||
200,
|
||||
currency.currencyCode,
|
||||
BigDecimal.ONE,
|
||||
System.currentTimeMillis().milliseconds.inWholeSeconds + 30.days.inWholeSeconds,
|
||||
false,
|
||||
System.currentTimeMillis().milliseconds.inWholeSeconds + 30.days.inWholeSeconds,
|
||||
false,
|
||||
"incomplete",
|
||||
"STRIPE",
|
||||
"CARD",
|
||||
false
|
||||
),
|
||||
null
|
||||
)
|
||||
)
|
||||
}
|
||||
return subscriber
|
||||
}
|
||||
}
|
||||
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.donate
|
||||
|
||||
import androidx.compose.ui.test.junit4.v2.createEmptyComposeRule
|
||||
import androidx.test.core.app.ActivityScenario
|
||||
import androidx.test.espresso.Espresso.onView
|
||||
import androidx.test.espresso.action.ViewActions
|
||||
import androidx.test.espresso.matcher.ViewMatchers.isClickable
|
||||
import androidx.test.espresso.matcher.ViewMatchers.withId
|
||||
import androidx.test.espresso.matcher.ViewMatchers.withText
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.isNotEmpty
|
||||
import io.mockk.unmockkObject
|
||||
import org.hamcrest.Matchers.allOf
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.signal.core.util.deleteAll
|
||||
import org.signal.donations.InAppPaymentType
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.permits.DonationPermits
|
||||
import org.thoughtcrime.securesms.database.DonationReceiptTable
|
||||
import org.thoughtcrime.securesms.database.InAppPaymentTable
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.model.InAppPaymentReceiptRecord
|
||||
import org.thoughtcrime.securesms.testing.GooglePayTestRule
|
||||
import org.thoughtcrime.securesms.testing.InAppPaymentsRule
|
||||
import org.thoughtcrime.securesms.testing.RxTestSchedulerRule
|
||||
import org.thoughtcrime.securesms.testing.SignalActivityRule
|
||||
import org.thoughtcrime.securesms.testing.actions.scrollToDescendant
|
||||
import org.thoughtcrime.securesms.testing.endpoints.MockEndpoints
|
||||
import org.thoughtcrime.securesms.testing.endpoints.registerStripeHappyPath
|
||||
import org.thoughtcrime.securesms.testing.flushUntil
|
||||
|
||||
/**
|
||||
* Milestone integration test: drives a Google Pay one-time donation through the entire lifecycle to
|
||||
* receipt redemption, exercising all three outer boundaries at once — the Signal-service websocket
|
||||
* responder (boost intent + receipt credential + redeem), the Stripe HTTP interceptor (payment
|
||||
* method + confirm + intent status), and the test zk server (real client-side credential validation).
|
||||
*/
|
||||
@Suppress("ClassName")
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class CheckoutFlowActivityTest__Redemption {
|
||||
@get:Rule
|
||||
val harness = SignalActivityRule(othersCount = 10)
|
||||
|
||||
@get:Rule
|
||||
val iapRule = InAppPaymentsRule()
|
||||
|
||||
@get:Rule
|
||||
val rxRule = RxTestSchedulerRule()
|
||||
|
||||
@get:Rule
|
||||
val googlePayRule = GooglePayTestRule()
|
||||
|
||||
@get:Rule
|
||||
val composeRule = createEmptyComposeRule()
|
||||
|
||||
private val intent = CheckoutFlowActivity.createIntent(InstrumentationRegistry.getInstrumentation().targetContext, InAppPaymentType.ONE_TIME_DONATION)
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
SignalDatabase.inAppPayments.writableDatabase.deleteAll(InAppPaymentTable.TABLE_NAME)
|
||||
SignalDatabase.donationReceipts.writableDatabase.deleteAll(DonationReceiptTable.TABLE_NAME)
|
||||
startJobLoopForTests()
|
||||
succeedDonationPermitAcquisition()
|
||||
MockEndpoints.responder.registerStripeHappyPath()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
unmockkObject(DonationPermits)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenAllEndpointsSucceed_whenIDonateOnceWithGooglePay_thenIExpectAReceipt() {
|
||||
val scenario = ActivityScenario.launch<CheckoutFlowActivity>(intent)
|
||||
rxRule.defaultTestScheduler.triggerActions()
|
||||
|
||||
scrollToDescendant(R.id.recycler, withId(R.id.boost_1), rxRule.defaultTestScheduler)
|
||||
onView(allOf(withId(R.id.boost_1), isClickable())).perform(ViewActions.click())
|
||||
rxRule.defaultTestScheduler.triggerActions()
|
||||
|
||||
scrollToDescendant(R.id.recycler, withText(R.string.DonateToSignalFragment__continue), rxRule.defaultTestScheduler)
|
||||
onView(withText(R.string.DonateToSignalFragment__continue)).perform(ViewActions.click())
|
||||
rxRule.defaultTestScheduler.triggerActions()
|
||||
|
||||
scenario.selectGooglePay(composeRule, rxRule.defaultTestScheduler, InAppPaymentType.ONE_TIME_DONATION)
|
||||
|
||||
rxRule.defaultTestScheduler.flushUntil {
|
||||
SignalDatabase.donationReceipts.getReceipts(InAppPaymentReceiptRecord.Type.ONE_TIME_DONATION).isNotEmpty()
|
||||
}
|
||||
|
||||
assertThat(SignalDatabase.donationReceipts.getReceipts(InAppPaymentReceiptRecord.Type.ONE_TIME_DONATION)).isNotEmpty()
|
||||
}
|
||||
}
|
||||
+280
@@ -0,0 +1,280 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.donate
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.compose.ui.test.hasTestTag
|
||||
import androidx.compose.ui.test.junit4.ComposeTestRule
|
||||
import androidx.compose.ui.test.onAllNodesWithTag
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performScrollToNode
|
||||
import androidx.compose.ui.test.performTextInput
|
||||
import androidx.core.os.bundleOf
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import androidx.navigation.fragment.NavHostFragment
|
||||
import androidx.test.core.app.ActivityScenario
|
||||
import androidx.test.espresso.Espresso.onView
|
||||
import androidx.test.espresso.action.ViewActions
|
||||
import androidx.test.espresso.assertion.ViewAssertions.matches
|
||||
import androidx.test.espresso.matcher.ViewMatchers.isClickable
|
||||
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
|
||||
import androidx.test.espresso.matcher.ViewMatchers.isEnabled
|
||||
import androidx.test.espresso.matcher.ViewMatchers.withId
|
||||
import androidx.test.espresso.matcher.ViewMatchers.withText
|
||||
import io.reactivex.rxjava3.schedulers.TestScheduler
|
||||
import org.hamcrest.Matchers.allOf
|
||||
import org.signal.donations.InAppPaymentType
|
||||
import org.signal.donations.StripeIntentAccessor
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewaySelectorTestTags
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.paypal.PayPalConfirmationDialogFragment
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.paypal.PayPalConfirmationResult
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.stripe.Stripe3DSDialogFragment
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.transfer.DonationTransferTestTags
|
||||
import org.thoughtcrime.securesms.testing.actions.scrollToDescendant
|
||||
import org.thoughtcrime.securesms.testing.endpoints.StripeResponses
|
||||
import org.thoughtcrime.securesms.testing.flushUntil
|
||||
|
||||
/**
|
||||
* Drives [CheckoutFlowActivity] through the UI "as a user" for a [DonationsCheckoutTestSpec]: selects
|
||||
* an amount/tier, continues to the gateway selector, and completes the chosen payment method. Each
|
||||
* payment method is a strategy dispatched on [DonationsCheckoutTestSpec.paymentMethod].
|
||||
*
|
||||
* Webview approval steps (PayPal, Stripe 3DS) can't complete against a mocked network, so instead of
|
||||
* loading the approval URL we inject the fragment result the webview would have posted — the same
|
||||
* mechanism the production dialog uses on return.
|
||||
*/
|
||||
class CheckoutFlowDriver(
|
||||
private val composeRule: ComposeTestRule,
|
||||
private val scheduler: TestScheduler
|
||||
) {
|
||||
|
||||
companion object {
|
||||
private const val TEST_NAME = "Test McTesterson"
|
||||
private const val TEST_EMAIL = "test@signal.org"
|
||||
|
||||
/** A structurally valid German IBAN (passes mod-97), for the SEPA form. */
|
||||
private const val TEST_IBAN = "DE89370400440532013000"
|
||||
}
|
||||
|
||||
fun drive(scenario: ActivityScenario<CheckoutFlowActivity>, spec: DonationsCheckoutTestSpec) {
|
||||
scheduler.triggerActions()
|
||||
|
||||
selectAmountOrTier(spec.inAppPaymentType)
|
||||
proceedToGateway()
|
||||
|
||||
when (spec.paymentMethod) {
|
||||
TestPaymentMethod.GOOGLE_PAY -> scenario.selectGooglePay(composeRule, scheduler, spec.inAppPaymentType)
|
||||
TestPaymentMethod.CREDIT_CARD -> completeCreditCard()
|
||||
TestPaymentMethod.PAYPAL -> completePayPal(scenario, spec.inAppPaymentType)
|
||||
TestPaymentMethod.SEPA_DEBIT -> completeSepa()
|
||||
TestPaymentMethod.IDEAL -> completeIdeal(spec.inAppPaymentType)
|
||||
}
|
||||
|
||||
if (spec.world.stripe == StripeOutcome.REQUIRES_3DS && spec.paymentMethod.usesStripe()) {
|
||||
complete3ds(scenario, spec.inAppPaymentType)
|
||||
}
|
||||
}
|
||||
|
||||
private fun selectAmountOrTier(type: InAppPaymentType) {
|
||||
if (type == InAppPaymentType.ONE_TIME_DONATION) {
|
||||
scrollToDescendant(R.id.recycler, withId(R.id.boost_1), scheduler)
|
||||
onView(allOf(withId(R.id.boost_1), isClickable())).perform(ViewActions.click())
|
||||
scheduler.triggerActions()
|
||||
}
|
||||
// Recurring: the monthly tier is selected by default, so nothing to pick here.
|
||||
}
|
||||
|
||||
private fun proceedToGateway() {
|
||||
scrollToDescendant(R.id.recycler, withText(R.string.DonateToSignalFragment__continue), scheduler)
|
||||
onView(withText(R.string.DonateToSignalFragment__continue)).perform(ViewActions.click())
|
||||
scheduler.triggerActions()
|
||||
}
|
||||
|
||||
/**
|
||||
* Fills the [org.thoughtcrime.securesms.components.settings.app.subscription.donate.card.CreditCardFragment]
|
||||
* with a valid test card and continues, driving the flow into the Stripe payment pipeline.
|
||||
*/
|
||||
private fun completeCreditCard() {
|
||||
clickGatewayButton(GatewaySelectorTestTags.CREDIT_CARD_BUTTON)
|
||||
|
||||
scheduler.flushUntil {
|
||||
onView(withId(R.id.card_number)).check(matches(isDisplayed()))
|
||||
true
|
||||
}
|
||||
|
||||
onView(withId(R.id.card_number)).perform(ViewActions.typeText("4242424242424242"), ViewActions.closeSoftKeyboard())
|
||||
onView(withId(R.id.card_expiry)).perform(ViewActions.typeText("1228"), ViewActions.closeSoftKeyboard())
|
||||
onView(withId(R.id.card_cvv)).perform(ViewActions.typeText("123"), ViewActions.closeSoftKeyboard())
|
||||
|
||||
scheduler.flushUntil {
|
||||
onView(withId(R.id.continue_button)).check(matches(isEnabled()))
|
||||
true
|
||||
}
|
||||
onView(withId(R.id.continue_button)).perform(ViewActions.click())
|
||||
scheduler.triggerActions()
|
||||
}
|
||||
|
||||
/**
|
||||
* Selects PayPal, then injects the approval result the webview would have returned. One-time uses a
|
||||
* [PayPalConfirmationResult]; recurring posts `true`.
|
||||
*/
|
||||
private fun completePayPal(scenario: ActivityScenario<CheckoutFlowActivity>, type: InAppPaymentType) {
|
||||
clickGatewayButton(GatewaySelectorTestTags.PAYPAL_BUTTON)
|
||||
|
||||
val result: Any = if (type == InAppPaymentType.ONE_TIME_DONATION) {
|
||||
PayPalConfirmationResult(payerId = "test-payer", paymentId = null, paymentToken = "test-token")
|
||||
} else {
|
||||
true
|
||||
}
|
||||
|
||||
awaitDialogAndSetResult(
|
||||
scenario = scenario,
|
||||
dialogClass = PayPalConfirmationDialogFragment::class.java,
|
||||
resultKey = PayPalConfirmationDialogFragment.REQUEST_KEY,
|
||||
bundle = bundleOf(PayPalConfirmationDialogFragment.REQUEST_KEY to result)
|
||||
)
|
||||
}
|
||||
|
||||
/** Injects the 3DS return the Stripe webview would have posted, resuming the Stripe pipeline. */
|
||||
private fun complete3ds(scenario: ActivityScenario<CheckoutFlowActivity>, type: InAppPaymentType) {
|
||||
val accessor = if (type == InAppPaymentType.ONE_TIME_DONATION) {
|
||||
StripeIntentAccessor(StripeIntentAccessor.ObjectType.PAYMENT_INTENT, StripeResponses.DEFAULT_PAYMENT_INTENT_ID, "${StripeResponses.DEFAULT_PAYMENT_INTENT_ID}_secret_test")
|
||||
} else {
|
||||
StripeIntentAccessor(StripeIntentAccessor.ObjectType.SETUP_INTENT, StripeResponses.DEFAULT_SETUP_INTENT_ID, "${StripeResponses.DEFAULT_SETUP_INTENT_ID}_secret_test")
|
||||
}
|
||||
|
||||
awaitDialogAndSetResult(
|
||||
scenario = scenario,
|
||||
dialogClass = Stripe3DSDialogFragment::class.java,
|
||||
resultKey = Stripe3DSDialogFragment.REQUEST_KEY,
|
||||
bundle = bundleOf(Stripe3DSDialogFragment.REQUEST_KEY to accessor)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Selects SEPA, accepts the mandate (its button scrolls the text to the bottom before it agrees, so
|
||||
* it is clicked until the details form appears), fills the bank details, and donates. SEPA requires
|
||||
* a EUR donation currency (see [WorldState.currencyCode]).
|
||||
*/
|
||||
private fun completeSepa() {
|
||||
clickGatewayButton(GatewaySelectorTestTags.SEPA_BUTTON)
|
||||
|
||||
scheduler.flushUntil {
|
||||
val ibanPresent = runCatching {
|
||||
composeRule.onAllNodesWithTag(DonationTransferTestTags.SEPA_IBAN_FIELD).fetchSemanticsNodes().isNotEmpty()
|
||||
}.getOrDefault(false)
|
||||
if (!ibanPresent) {
|
||||
runCatching { composeRule.onNodeWithTag(DonationTransferTestTags.SEPA_MANDATE_CONTINUE_BUTTON).performClick() }
|
||||
}
|
||||
ibanPresent
|
||||
}
|
||||
|
||||
typeIntoField(DonationTransferTestTags.SEPA_DETAILS_LIST, DonationTransferTestTags.SEPA_IBAN_FIELD, TEST_IBAN)
|
||||
typeIntoField(DonationTransferTestTags.SEPA_DETAILS_LIST, DonationTransferTestTags.SEPA_NAME_FIELD, TEST_NAME)
|
||||
typeIntoField(DonationTransferTestTags.SEPA_DETAILS_LIST, DonationTransferTestTags.SEPA_EMAIL_FIELD, TEST_EMAIL)
|
||||
|
||||
clickWhenReady(DonationTransferTestTags.SEPA_DONATE_BUTTON)
|
||||
}
|
||||
|
||||
/**
|
||||
* Selects iDEAL, fills the bank details (email is recurring-only), and donates. iDEAL requires a EUR
|
||||
* donation currency and, on success, lands in an awaiting-authorization state.
|
||||
*/
|
||||
private fun completeIdeal(type: InAppPaymentType) {
|
||||
clickGatewayButton(GatewaySelectorTestTags.IDEAL_BUTTON)
|
||||
|
||||
scheduler.flushUntil {
|
||||
runCatching {
|
||||
composeRule.onAllNodesWithTag(DonationTransferTestTags.IDEAL_NAME_FIELD).fetchSemanticsNodes().isNotEmpty()
|
||||
}.getOrDefault(false)
|
||||
}
|
||||
|
||||
typeIntoField(DonationTransferTestTags.IDEAL_DETAILS_LIST, DonationTransferTestTags.IDEAL_NAME_FIELD, TEST_NAME)
|
||||
if (type.recurring) {
|
||||
typeIntoField(DonationTransferTestTags.IDEAL_DETAILS_LIST, DonationTransferTestTags.IDEAL_EMAIL_FIELD, TEST_EMAIL)
|
||||
}
|
||||
|
||||
clickWhenReady(DonationTransferTestTags.IDEAL_DONATE_BUTTON)
|
||||
}
|
||||
|
||||
/**
|
||||
* Scrolls the [listTag] lazy list until the field tagged [fieldTag] is composed and on-screen, then types
|
||||
* [text] into it. The transfer forms lay their fields out in a [androidx.compose.foundation.lazy.LazyColumn],
|
||||
* so a lower field is not composed on a short screen until scrolled to — without this, the input silently
|
||||
* matches nothing on Firebase's smaller devices.
|
||||
*/
|
||||
private fun typeIntoField(listTag: String, fieldTag: String, text: String) {
|
||||
composeRule.onNodeWithTag(listTag).performScrollToNode(hasTestTag(fieldTag))
|
||||
composeRule.onNodeWithTag(fieldTag).performTextInput(text)
|
||||
}
|
||||
|
||||
/** Retries clicking a Compose node until the click lands (e.g. once a submit button becomes enabled). */
|
||||
private fun clickWhenReady(tag: String) {
|
||||
scheduler.flushUntil {
|
||||
runCatching {
|
||||
composeRule.onNodeWithTag(tag).performClick()
|
||||
true
|
||||
}.getOrDefault(false)
|
||||
}
|
||||
scheduler.triggerActions()
|
||||
}
|
||||
|
||||
/**
|
||||
* Clicks a Compose gateway-selector button and waits for the bottom sheet to dismiss. For methods
|
||||
* other than Google Pay the checkout then navigates to the method's fragment; Google Pay instead
|
||||
* requires a result injection (see `selectGooglePay`).
|
||||
*/
|
||||
private fun clickGatewayButton(tag: String) {
|
||||
scheduler.flushUntil {
|
||||
composeRule.onAllNodesWithTag(tag).fetchSemanticsNodes().isNotEmpty()
|
||||
}
|
||||
|
||||
// The selector is a vertically scrolling bottom sheet; on a short screen the lower gateway buttons are
|
||||
// off-screen, so bring the target on-screen before clicking (mirrors GatewaySelectorBottomSheetContentTest).
|
||||
composeRule.onNodeWithTag(GatewaySelectorTestTags.CONTAINER).performScrollToNode(hasTestTag(tag))
|
||||
composeRule.onNodeWithTag(tag).performClick()
|
||||
|
||||
scheduler.flushUntil {
|
||||
runCatching {
|
||||
composeRule.onAllNodesWithTag(tag).fetchSemanticsNodes().isEmpty()
|
||||
}.getOrDefault(true)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits for a navigated dialog of [dialogClass] to be present, then posts [bundle] as its fragment
|
||||
* result on the nav host's fragment manager (where the in-progress fragment registered its listener).
|
||||
*/
|
||||
private fun <T : Fragment> awaitDialogAndSetResult(
|
||||
scenario: ActivityScenario<CheckoutFlowActivity>,
|
||||
dialogClass: Class<T>,
|
||||
resultKey: String,
|
||||
bundle: Bundle
|
||||
) {
|
||||
scheduler.flushUntil {
|
||||
var delivered = false
|
||||
scenario.onActivity { activity ->
|
||||
val fragmentManager = navHostChildFragmentManager(activity)
|
||||
if (fragmentManager != null && fragmentManager.fragments.any { dialogClass.isInstance(it) }) {
|
||||
fragmentManager.setFragmentResult(resultKey, bundle)
|
||||
delivered = true
|
||||
}
|
||||
}
|
||||
delivered
|
||||
}
|
||||
scheduler.triggerActions()
|
||||
}
|
||||
|
||||
private fun navHostChildFragmentManager(activity: FragmentActivity): FragmentManager? {
|
||||
return activity.supportFragmentManager.fragments
|
||||
.filterIsInstance<NavHostFragment>()
|
||||
.firstOrNull()
|
||||
?.childFragmentManager
|
||||
}
|
||||
|
||||
private fun TestPaymentMethod.usesStripe(): Boolean {
|
||||
return this != TestPaymentMethod.PAYPAL
|
||||
}
|
||||
}
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.donate
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import androidx.compose.ui.test.hasTestTag
|
||||
import androidx.compose.ui.test.junit4.ComposeTestRule
|
||||
import androidx.compose.ui.test.onAllNodesWithTag
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performScrollToNode
|
||||
import androidx.test.core.app.ActivityScenario
|
||||
import androidx.test.espresso.Espresso.onView
|
||||
import androidx.test.espresso.assertion.ViewAssertions.matches
|
||||
import androidx.test.espresso.matcher.RootMatchers.isDialog
|
||||
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
|
||||
import androidx.test.espresso.matcher.ViewMatchers.withText
|
||||
import io.mockk.every
|
||||
import io.mockk.mockkObject
|
||||
import io.reactivex.rxjava3.schedulers.TestScheduler
|
||||
import org.signal.donations.InAppPaymentType
|
||||
import org.signal.libsignal.net.RequestResult
|
||||
import org.signal.network.rest.RestStatusCodeError
|
||||
import org.thoughtcrime.securesms.SignalInstrumentationApplicationContext
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.GooglePayComponent
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewaySelectorTestTags
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.permits.DonationPermits
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.testing.flushUntil
|
||||
|
||||
/**
|
||||
* Forces donation-permit acquisition to fail, so the checkout surfaces a payment-setup error before
|
||||
* reaching the payment method. Stubs the [DonationPermits] object directly (the underlying
|
||||
* `createDonationPermits` call rides the websocket, which the responder does not mint valid permits
|
||||
* for). Call `unmockkObject(DonationPermits)` in teardown.
|
||||
*/
|
||||
fun failDonationPermitAcquisition(statusCode: Int = 500) {
|
||||
AppDependencies.donationPermitsRepository.clearPermits()
|
||||
mockkObject(DonationPermits)
|
||||
every { DonationPermits.getDonationPermit() } returns RequestResult.NonSuccess(RestStatusCodeError(statusCode, emptyMap(), null))
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes donation-permit acquisition succeed with a placeholder permit, letting the checkout proceed
|
||||
* past the permit gate to the payment method. Call `unmockkObject(DonationPermits)` in teardown.
|
||||
*/
|
||||
fun succeedDonationPermitAcquisition() {
|
||||
AppDependencies.donationPermitsRepository.clearPermits()
|
||||
mockkObject(DonationPermits)
|
||||
every { DonationPermits.getDonationPermit() } returns RequestResult.Success("permit")
|
||||
}
|
||||
|
||||
/**
|
||||
* The instrumentation app stubs [org.thoughtcrime.securesms.ApplicationContext.beginJobLoop] to a no-op, so enqueued
|
||||
* jobs never run. The checkout pipeline drives a real setup job through the JobManager, so the loop must be started.
|
||||
*
|
||||
* Before starting it, cancel any leftover in-app-payment jobs. A recurring setup job retries indefinitely over a
|
||||
* one-day lifespan, so a spec that fails setup leaves one persisted in the JobManager. On Firebase Test Lab the
|
||||
* orchestrator's `clearPackageData` does not reliably wipe the JobManager between parameterized specs (it does on a
|
||||
* local emulator, which is why this only surfaces on-device), so that job survives into the next spec and runs after
|
||||
* its InAppPayment row was deleted in setup — `InAppPaymentsRepository.resolveLock` then throws "Not found" from the
|
||||
* job thread and crashes the whole app. Clearing the queues here makes each test start from a clean job state.
|
||||
*/
|
||||
fun startJobLoopForTests() {
|
||||
AppDependencies.jobManager.cancelAllInQueues(
|
||||
setOf(
|
||||
InAppPaymentsRepository.getRecurringJobQueueKey(InAppPaymentType.RECURRING_DONATION),
|
||||
InAppPaymentsRepository.getRecurringJobQueueKey(InAppPaymentType.RECURRING_BACKUP)
|
||||
)
|
||||
)
|
||||
AppDependencies.jobManager.flush()
|
||||
|
||||
(AppDependencies.application as SignalInstrumentationApplicationContext).beginJobLoopForTests()
|
||||
}
|
||||
|
||||
/**
|
||||
* Selects Google Pay in the Compose gateway selector and feeds the stubbed Google Pay result back into the
|
||||
* checkout, navigating to the payment-in-progress screen where the pipeline runs.
|
||||
*
|
||||
* The gateway selector is populated by Rx work on [scheduler], so we [flushUntil] the button is present
|
||||
* rather than sleeping. Selecting Google Pay dismisses the gateway sheet and hands a fragment result back to
|
||||
* the checkout, which runs `launchGooglePay` -> `provideGatewayRequestForGooglePay` on [scheduler]; only then
|
||||
* does the checkout's subscriber consume a [GooglePayComponent.googlePayResultPublisher] emission
|
||||
* (`consumeGatewayRequestForGooglePay` returns null until then, and the publisher is a hot PublishSubject that
|
||||
* drops earlier emissions). So we [flushUntil] the sheet has dismissed — a real signal that the click was
|
||||
* fully processed — before dispatching the result, rather than pumping a fixed number of times and racing.
|
||||
*/
|
||||
fun ActivityScenario<CheckoutFlowActivity>.selectGooglePay(
|
||||
composeRule: ComposeTestRule,
|
||||
scheduler: TestScheduler,
|
||||
inAppPaymentType: InAppPaymentType
|
||||
) {
|
||||
scheduler.flushUntil {
|
||||
composeRule.onAllNodesWithTag(GatewaySelectorTestTags.GOOGLE_PAY_BUTTON).fetchSemanticsNodes().isNotEmpty()
|
||||
}
|
||||
|
||||
composeRule.onNodeWithTag(GatewaySelectorTestTags.CONTAINER).performScrollToNode(hasTestTag(GatewaySelectorTestTags.GOOGLE_PAY_BUTTON))
|
||||
composeRule.onNodeWithTag(GatewaySelectorTestTags.GOOGLE_PAY_BUTTON).performClick()
|
||||
|
||||
scheduler.flushUntil {
|
||||
// Once the gateway sheet dismisses there is no Compose hierarchy left, so fetchSemanticsNodes throws
|
||||
// rather than returning empty; treat both the empty list and the missing hierarchy as "sheet gone".
|
||||
runCatching {
|
||||
composeRule.onAllNodesWithTag(GatewaySelectorTestTags.GOOGLE_PAY_BUTTON).fetchSemanticsNodes().isEmpty()
|
||||
}.getOrDefault(true)
|
||||
}
|
||||
|
||||
onActivity { activity ->
|
||||
(activity as GooglePayComponent).googlePayResultPublisher.onNext(
|
||||
GooglePayComponent.GooglePayResult(
|
||||
requestCode = InAppPaymentsRepository.getGooglePayRequestCode(inAppPaymentType),
|
||||
resultCode = Activity.RESULT_OK,
|
||||
data = Intent()
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits for a dialog whose title is [titleResId] to be displayed. Matches any dialog by title (error,
|
||||
* confirmation, thanks, etc.) — it is not specific to error dialogs.
|
||||
*
|
||||
* [flushUntil] advances the Rx pipeline (which may create the payment, enqueue the real setup job, and react
|
||||
* to its committed state) while yielding real time for any job to run, until the dialog renders. A single
|
||||
* condition-driven pump rather than a fixed-duration wall-clock poll. Letting the Espresso check throw (rather
|
||||
* than collapsing it to a boolean) lets [flushUntil] chain the last matcher failure as the timeout cause.
|
||||
*/
|
||||
fun awaitDialog(
|
||||
scheduler: TestScheduler,
|
||||
titleResId: Int,
|
||||
timeoutMillis: Long = 15_000
|
||||
) {
|
||||
scheduler.flushUntil(timeoutMillis) {
|
||||
onView(withText(titleResId)).inRoot(isDialog()).check(matches(isDisplayed()))
|
||||
true
|
||||
}
|
||||
}
|
||||
+282
@@ -0,0 +1,282 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.donate
|
||||
|
||||
import androidx.compose.ui.test.junit4.v2.createEmptyComposeRule
|
||||
import androidx.test.core.app.ActivityScenario
|
||||
import androidx.test.espresso.Espresso.onView
|
||||
import androidx.test.espresso.assertion.ViewAssertions.matches
|
||||
import androidx.test.espresso.matcher.RootMatchers.isDialog
|
||||
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
|
||||
import androidx.test.espresso.matcher.ViewMatchers.withText
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.isNotEmpty
|
||||
import assertk.assertions.isNotNull
|
||||
import assertk.assertions.isNull
|
||||
import io.mockk.unmockkObject
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.junit.runners.Parameterized
|
||||
import org.signal.core.util.deleteAll
|
||||
import org.signal.donations.InAppPaymentType
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.permits.DonationPermits
|
||||
import org.thoughtcrime.securesms.database.DonationReceiptTable
|
||||
import org.thoughtcrime.securesms.database.InAppPaymentTable
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.model.InAppPaymentReceiptRecord
|
||||
import org.thoughtcrime.securesms.testing.GooglePayTestRule
|
||||
import org.thoughtcrime.securesms.testing.InAppPaymentsRule
|
||||
import org.thoughtcrime.securesms.testing.RawFlag
|
||||
import org.thoughtcrime.securesms.testing.RemoteConfigForTest
|
||||
import org.thoughtcrime.securesms.testing.RxTestSchedulerRule
|
||||
import org.thoughtcrime.securesms.testing.SignalActivityRule
|
||||
import org.thoughtcrime.securesms.testing.flushUntil
|
||||
|
||||
/**
|
||||
* Spec-driven integration test for donation checkout. Each [DonationsCheckoutTestSpec] declares a
|
||||
* "world" (what each endpoint returns) and an expected outcome; this runner applies the world, drives
|
||||
* [CheckoutFlowActivity] through the real UI via [CheckoutFlowDriver], and asserts the outcome.
|
||||
*
|
||||
* Adding coverage is data: append a spec to [DonationsCheckoutSpecs.ALL].
|
||||
*/
|
||||
@RunWith(Parameterized::class)
|
||||
@RemoteConfigForTest(
|
||||
rawFlags = [
|
||||
RawFlag("android.sepa.debit.donations.5", "true"),
|
||||
RawFlag("android.ideal.donations.5", "true"),
|
||||
// Region codes are matched against the digit-only self E164 ("15555550101"), so no leading "+".
|
||||
RawFlag("global.donations.sepaEnabledRegions", "1"),
|
||||
RawFlag("global.donations.idealEnabledRegions", "1")
|
||||
]
|
||||
)
|
||||
class DonationsCheckoutSpecTest(private val spec: DonationsCheckoutTestSpec) {
|
||||
|
||||
@get:Rule
|
||||
val harness = SignalActivityRule(othersCount = 10)
|
||||
|
||||
@get:Rule
|
||||
val iapRule = InAppPaymentsRule()
|
||||
|
||||
@get:Rule
|
||||
val rxRule = RxTestSchedulerRule()
|
||||
|
||||
@get:Rule
|
||||
val googlePayRule = GooglePayTestRule()
|
||||
|
||||
@get:Rule
|
||||
val composeRule = createEmptyComposeRule()
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
SignalDatabase.inAppPayments.writableDatabase.deleteAll(InAppPaymentTable.TABLE_NAME)
|
||||
SignalDatabase.donationReceipts.writableDatabase.deleteAll(DonationReceiptTable.TABLE_NAME)
|
||||
startJobLoopForTests()
|
||||
spec.world.apply()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
unmockkObject(DonationPermits)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun runSpec() {
|
||||
val intent = CheckoutFlowActivity.createIntent(InstrumentationRegistry.getInstrumentation().targetContext, spec.inAppPaymentType)
|
||||
val scenario = ActivityScenario.launch<CheckoutFlowActivity>(intent)
|
||||
|
||||
CheckoutFlowDriver(composeRule, rxRule.defaultTestScheduler).drive(scenario, spec)
|
||||
|
||||
when (val expected = spec.expected) {
|
||||
is ExpectedOutcome.Redeemed -> {
|
||||
rxRule.defaultTestScheduler.flushUntil { receipts().isNotEmpty() }
|
||||
assertThat(receipts()).isNotEmpty()
|
||||
}
|
||||
|
||||
is ExpectedOutcome.ErrorDialog -> {
|
||||
awaitDialog(rxRule.defaultTestScheduler, expected.titleRes)
|
||||
if (expected.messageRes != null) {
|
||||
onView(withText(expected.messageRes)).inRoot(isDialog()).check(matches(isDisplayed()))
|
||||
}
|
||||
}
|
||||
|
||||
is ExpectedOutcome.PaymentSubmitted -> {
|
||||
rxRule.defaultTestScheduler.flushUntil { submittedPayment() != null }
|
||||
val payment = submittedPayment()
|
||||
assertThat(payment).isNotNull()
|
||||
assertThat(payment!!.data.error).isNull()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The latest payment once it has moved past CREATED with no error, or null if not yet there. */
|
||||
private fun submittedPayment(): InAppPaymentTable.InAppPayment? {
|
||||
return SignalDatabase.inAppPayments.getLatestInAppPaymentByType(spec.inAppPaymentType)
|
||||
?.takeIf { it.state != InAppPaymentTable.State.CREATED && it.data.error == null }
|
||||
}
|
||||
|
||||
private fun receipts(): List<InAppPaymentReceiptRecord> {
|
||||
val type = if (spec.inAppPaymentType == InAppPaymentType.ONE_TIME_DONATION) {
|
||||
InAppPaymentReceiptRecord.Type.ONE_TIME_DONATION
|
||||
} else {
|
||||
InAppPaymentReceiptRecord.Type.RECURRING_DONATION
|
||||
}
|
||||
return SignalDatabase.donationReceipts.getReceipts(type)
|
||||
}
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
@Parameterized.Parameters(name = "{0}")
|
||||
fun specs(): Collection<DonationsCheckoutTestSpec> = DonationsCheckoutSpecs.ALL
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The enumerated donation checkout scenarios. Currently seeded with Google Pay coverage; the other
|
||||
* payment-method drivers (card, SEPA, iDEAL, PayPal) and their scenarios are added alongside their
|
||||
* [CheckoutFlowDriver] strategies.
|
||||
*/
|
||||
object DonationsCheckoutSpecs {
|
||||
|
||||
private val paymentErrorDialog = ExpectedOutcome.ErrorDialog(
|
||||
titleRes = R.string.DonationsErrors__error_processing_payment,
|
||||
messageRes = R.string.DonationsErrors__your_payment
|
||||
)
|
||||
|
||||
val ALL: List<DonationsCheckoutTestSpec> = listOf(
|
||||
DonationsCheckoutTestSpec(
|
||||
name = "googlePay_oneTime_happyPath_isRedeemed",
|
||||
inAppPaymentType = InAppPaymentType.ONE_TIME_DONATION,
|
||||
paymentMethod = TestPaymentMethod.GOOGLE_PAY,
|
||||
world = WorldState(),
|
||||
expected = ExpectedOutcome.Redeemed
|
||||
),
|
||||
DonationsCheckoutTestSpec(
|
||||
name = "googlePay_oneTime_permitFailure_showsError",
|
||||
inAppPaymentType = InAppPaymentType.ONE_TIME_DONATION,
|
||||
paymentMethod = TestPaymentMethod.GOOGLE_PAY,
|
||||
world = WorldState(permit = PermitOutcome.FAILURE),
|
||||
expected = paymentErrorDialog
|
||||
),
|
||||
DonationsCheckoutTestSpec(
|
||||
name = "googlePay_oneTime_stripeDecline_showsError",
|
||||
inAppPaymentType = InAppPaymentType.ONE_TIME_DONATION,
|
||||
paymentMethod = TestPaymentMethod.GOOGLE_PAY,
|
||||
world = WorldState(stripe = StripeOutcome.DECLINE),
|
||||
expected = paymentErrorDialog
|
||||
),
|
||||
DonationsCheckoutTestSpec(
|
||||
name = "googlePay_oneTime_signalCreateIntent402_showsError",
|
||||
inAppPaymentType = InAppPaymentType.ONE_TIME_DONATION,
|
||||
paymentMethod = TestPaymentMethod.GOOGLE_PAY,
|
||||
world = WorldState(signalCreatePaymentStatus = 402),
|
||||
expected = paymentErrorDialog
|
||||
),
|
||||
DonationsCheckoutTestSpec(
|
||||
name = "googlePay_recurring_permitFailure_showsError",
|
||||
inAppPaymentType = InAppPaymentType.RECURRING_DONATION,
|
||||
paymentMethod = TestPaymentMethod.GOOGLE_PAY,
|
||||
world = WorldState(permit = PermitOutcome.FAILURE),
|
||||
expected = paymentErrorDialog
|
||||
),
|
||||
DonationsCheckoutTestSpec(
|
||||
name = "creditCard_oneTime_happyPath_isRedeemed",
|
||||
inAppPaymentType = InAppPaymentType.ONE_TIME_DONATION,
|
||||
paymentMethod = TestPaymentMethod.CREDIT_CARD,
|
||||
world = WorldState(),
|
||||
expected = ExpectedOutcome.Redeemed
|
||||
),
|
||||
DonationsCheckoutTestSpec(
|
||||
name = "creditCard_oneTime_stripeDecline_showsError",
|
||||
inAppPaymentType = InAppPaymentType.ONE_TIME_DONATION,
|
||||
paymentMethod = TestPaymentMethod.CREDIT_CARD,
|
||||
world = WorldState(stripe = StripeOutcome.DECLINE),
|
||||
expected = paymentErrorDialog
|
||||
),
|
||||
DonationsCheckoutTestSpec(
|
||||
name = "creditCard_oneTime_3dsApproved_isRedeemed",
|
||||
inAppPaymentType = InAppPaymentType.ONE_TIME_DONATION,
|
||||
paymentMethod = TestPaymentMethod.CREDIT_CARD,
|
||||
world = WorldState(stripe = StripeOutcome.REQUIRES_3DS),
|
||||
expected = ExpectedOutcome.Redeemed
|
||||
),
|
||||
DonationsCheckoutTestSpec(
|
||||
name = "payPal_oneTime_happyPath_isRedeemed",
|
||||
inAppPaymentType = InAppPaymentType.ONE_TIME_DONATION,
|
||||
paymentMethod = TestPaymentMethod.PAYPAL,
|
||||
world = WorldState(),
|
||||
expected = ExpectedOutcome.Redeemed
|
||||
),
|
||||
DonationsCheckoutTestSpec(
|
||||
name = "sepa_oneTime_happyPath_isSubmitted",
|
||||
inAppPaymentType = InAppPaymentType.ONE_TIME_DONATION,
|
||||
paymentMethod = TestPaymentMethod.SEPA_DEBIT,
|
||||
world = WorldState(currencyCode = "EUR"),
|
||||
expected = ExpectedOutcome.PaymentSubmitted
|
||||
),
|
||||
DonationsCheckoutTestSpec(
|
||||
name = "ideal_oneTime_happyPath_isSubmitted",
|
||||
inAppPaymentType = InAppPaymentType.ONE_TIME_DONATION,
|
||||
paymentMethod = TestPaymentMethod.IDEAL,
|
||||
world = WorldState(currencyCode = "EUR"),
|
||||
expected = ExpectedOutcome.PaymentSubmitted
|
||||
),
|
||||
|
||||
// Recurring happy paths. Asserted as "submitted": the recurring-specific work is the setup
|
||||
// pipeline (create subscriber, setup intent, set default, set level); receipt redemption is
|
||||
// shared with the one-time path already covered above.
|
||||
DonationsCheckoutTestSpec(
|
||||
name = "googlePay_recurring_happyPath_isSubmitted",
|
||||
inAppPaymentType = InAppPaymentType.RECURRING_DONATION,
|
||||
paymentMethod = TestPaymentMethod.GOOGLE_PAY,
|
||||
world = WorldState(recurringActivatesAfterSetup = true),
|
||||
expected = ExpectedOutcome.PaymentSubmitted
|
||||
),
|
||||
DonationsCheckoutTestSpec(
|
||||
name = "creditCard_recurring_happyPath_isSubmitted",
|
||||
inAppPaymentType = InAppPaymentType.RECURRING_DONATION,
|
||||
paymentMethod = TestPaymentMethod.CREDIT_CARD,
|
||||
world = WorldState(recurringActivatesAfterSetup = true),
|
||||
expected = ExpectedOutcome.PaymentSubmitted
|
||||
),
|
||||
DonationsCheckoutTestSpec(
|
||||
name = "payPal_recurring_happyPath_isSubmitted",
|
||||
inAppPaymentType = InAppPaymentType.RECURRING_DONATION,
|
||||
paymentMethod = TestPaymentMethod.PAYPAL,
|
||||
world = WorldState(recurringActivatesAfterSetup = true),
|
||||
expected = ExpectedOutcome.PaymentSubmitted
|
||||
),
|
||||
DonationsCheckoutTestSpec(
|
||||
name = "sepa_recurring_happyPath_isSubmitted",
|
||||
inAppPaymentType = InAppPaymentType.RECURRING_DONATION,
|
||||
paymentMethod = TestPaymentMethod.SEPA_DEBIT,
|
||||
world = WorldState(currencyCode = "EUR"),
|
||||
expected = ExpectedOutcome.PaymentSubmitted
|
||||
),
|
||||
|
||||
// Additional error cases.
|
||||
DonationsCheckoutTestSpec(
|
||||
name = "creditCard_oneTime_permitFailure_showsError",
|
||||
inAppPaymentType = InAppPaymentType.ONE_TIME_DONATION,
|
||||
paymentMethod = TestPaymentMethod.CREDIT_CARD,
|
||||
world = WorldState(permit = PermitOutcome.FAILURE),
|
||||
expected = paymentErrorDialog
|
||||
),
|
||||
DonationsCheckoutTestSpec(
|
||||
name = "creditCard_recurring_stripeDecline_showsError",
|
||||
inAppPaymentType = InAppPaymentType.RECURRING_DONATION,
|
||||
paymentMethod = TestPaymentMethod.CREDIT_CARD,
|
||||
world = WorldState(stripe = StripeOutcome.DECLINE),
|
||||
expected = paymentErrorDialog
|
||||
),
|
||||
DonationsCheckoutTestSpec(
|
||||
name = "googlePay_recurring_signalCreatePaymentMethod402_showsError",
|
||||
inAppPaymentType = InAppPaymentType.RECURRING_DONATION,
|
||||
paymentMethod = TestPaymentMethod.GOOGLE_PAY,
|
||||
world = WorldState(signalCreatePaymentStatus = 402),
|
||||
expected = paymentErrorDialog
|
||||
)
|
||||
)
|
||||
}
|
||||
+159
@@ -0,0 +1,159 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.donate
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import org.signal.donations.InAppPaymentType
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.testing.endpoints.DonationResponses
|
||||
import org.thoughtcrime.securesms.testing.endpoints.EndpointResponse
|
||||
import org.thoughtcrime.securesms.testing.endpoints.MockEndpoints
|
||||
import org.thoughtcrime.securesms.testing.endpoints.StripeResponses
|
||||
import org.thoughtcrime.securesms.testing.endpoints.failure
|
||||
import org.thoughtcrime.securesms.testing.endpoints.ok
|
||||
import org.thoughtcrime.securesms.testing.endpoints.registerPayPalHappyPath
|
||||
import org.thoughtcrime.securesms.testing.endpoints.registerStripeHappyPath
|
||||
import java.util.Currency
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
/**
|
||||
* A declarative "state of the world" + expected outcome for one donation checkout scenario. Specs
|
||||
* are enumerated by [DonationsCheckoutSpecTest] and each one drives [CheckoutFlowActivity] end to end
|
||||
* for its [paymentMethod].
|
||||
*/
|
||||
data class DonationsCheckoutTestSpec(
|
||||
val name: String,
|
||||
val inAppPaymentType: InAppPaymentType,
|
||||
val paymentMethod: TestPaymentMethod,
|
||||
val world: WorldState = WorldState(),
|
||||
val expected: ExpectedOutcome
|
||||
) {
|
||||
/** Used as the parameterized test label. */
|
||||
override fun toString(): String = name
|
||||
}
|
||||
|
||||
enum class TestPaymentMethod {
|
||||
GOOGLE_PAY,
|
||||
CREDIT_CARD,
|
||||
SEPA_DEBIT,
|
||||
IDEAL,
|
||||
PAYPAL
|
||||
}
|
||||
|
||||
/** Whether donation-permit acquisition succeeds or fails. */
|
||||
enum class PermitOutcome { SUCCESS, FAILURE }
|
||||
|
||||
/** The Stripe HTTP edge's behavior for a scenario. */
|
||||
enum class StripeOutcome {
|
||||
SUCCESS,
|
||||
DECLINE,
|
||||
FAILURE,
|
||||
REQUIRES_3DS
|
||||
}
|
||||
|
||||
/** Pre-existing recurring subscription state (recurring scenarios only). */
|
||||
enum class SubscriptionState { NONE, ACTIVE, PENDING }
|
||||
|
||||
/**
|
||||
* The knobs that define what each endpoint returns. [apply] registers the corresponding handlers on
|
||||
* the shared responder (overriding the [org.thoughtcrime.securesms.testing.InAppPaymentsRule]
|
||||
* defaults, since later registrations win) and sets the permit outcome.
|
||||
*/
|
||||
data class WorldState(
|
||||
val permit: PermitOutcome = PermitOutcome.SUCCESS,
|
||||
val stripe: StripeOutcome = StripeOutcome.SUCCESS,
|
||||
val declineCode: String = "card_declined",
|
||||
val signalCreatePaymentStatus: Int = 200,
|
||||
val receiptCredentialStatus: Int = 200,
|
||||
val redeemStatus: Int = 200,
|
||||
val existingSubscription: SubscriptionState = SubscriptionState.NONE,
|
||||
/** Donation currency to use. SEPA/iDEAL require EUR. */
|
||||
val currencyCode: String = "USD",
|
||||
/**
|
||||
* For a new recurring donation: report no active subscription until the subscription level is set
|
||||
* during setup, then report an active one. This lets the checkout show "Continue" (new subscriber)
|
||||
* while still letting the recurring receipt-credential context job see an active subscription to redeem.
|
||||
*/
|
||||
val recurringActivatesAfterSetup: Boolean = false
|
||||
) {
|
||||
fun apply() {
|
||||
val currency = Currency.getInstance(currencyCode)
|
||||
SignalStore.inAppPayments.setOneTimeCurrency(currency)
|
||||
SignalStore.inAppPayments.setRecurringDonationCurrency(currency)
|
||||
|
||||
when (permit) {
|
||||
PermitOutcome.SUCCESS -> succeedDonationPermitAcquisition()
|
||||
PermitOutcome.FAILURE -> failDonationPermitAcquisition()
|
||||
}
|
||||
|
||||
val responder = MockEndpoints.responder
|
||||
|
||||
// Signal-service PayPal endpoints (harmless for non-PayPal specs; distinct paths).
|
||||
responder.registerPayPalHappyPath()
|
||||
|
||||
// Stripe HTTP edge.
|
||||
responder.registerStripeHappyPath()
|
||||
when (stripe) {
|
||||
StripeOutcome.SUCCESS -> Unit
|
||||
StripeOutcome.DECLINE -> responder.register({ it.method == "POST" && (it.path.endsWith("/confirm") || it.path.contains("/payment_methods")) }) {
|
||||
failure(402, StripeResponses.declineError(declineCode))
|
||||
}
|
||||
StripeOutcome.FAILURE -> responder.register({ it.method == "POST" && it.path.endsWith("/confirm") }) {
|
||||
failure(402, StripeResponses.failureError("card_not_supported"))
|
||||
}
|
||||
StripeOutcome.REQUIRES_3DS -> responder.register({ it.method == "POST" && it.path.endsWith("/confirm") }) {
|
||||
ok(StripeResponses.confirm3dsRequired())
|
||||
}
|
||||
}
|
||||
|
||||
// Signal-service edge overrides.
|
||||
if (signalCreatePaymentStatus != 200) {
|
||||
responder.register({ it.method == "POST" && (it.path.contains("/create_payment_method") || it.path.endsWith("/boost/create")) }) {
|
||||
failure(signalCreatePaymentStatus)
|
||||
}
|
||||
}
|
||||
if (receiptCredentialStatus != 200) {
|
||||
responder.register({ it.method == "POST" && it.path.contains("/receipt_credentials") }) { failure(receiptCredentialStatus) }
|
||||
}
|
||||
if (redeemStatus != 200) {
|
||||
responder.register({ it.method == "POST" && it.path.contains("/redeem-receipt") }) { failure(redeemStatus) }
|
||||
}
|
||||
|
||||
when (existingSubscription) {
|
||||
SubscriptionState.NONE -> Unit
|
||||
SubscriptionState.ACTIVE -> registerSubscription(status = "active", active = true)
|
||||
SubscriptionState.PENDING -> registerSubscription(status = "incomplete", active = false)
|
||||
}
|
||||
|
||||
if (recurringActivatesAfterSetup) {
|
||||
val activated = AtomicBoolean(false)
|
||||
responder.register({ it.method == "PUT" && it.path.contains("/level/") }) {
|
||||
activated.set(true)
|
||||
ok()
|
||||
}
|
||||
responder.register({ it.method == "GET" && it.path.contains("/v1/subscription/") && !it.path.contains("configuration") && !it.path.contains("bank_mandate") }) {
|
||||
if (activated.get()) ok(DonationResponses.activeSubscription()) else ok(DonationResponses.emptySubscription())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun registerSubscription(status: String, active: Boolean) {
|
||||
MockEndpoints.responder.register({ it.method == "GET" && it.path.contains("/v1/subscription/") && !it.path.contains("configuration") }) {
|
||||
EndpointResponse(status = 200, body = DonationResponses.activeSubscription(status = status, active = active))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The expected terminal result of driving a spec. */
|
||||
sealed interface ExpectedOutcome {
|
||||
/** An error dialog with [titleRes] (and optionally [messageRes]) is shown. */
|
||||
data class ErrorDialog(@StringRes val titleRes: Int, @StringRes val messageRes: Int? = null) : ExpectedOutcome
|
||||
|
||||
/** The full lifecycle completes and a donation receipt is recorded. */
|
||||
data object Redeemed : ExpectedOutcome
|
||||
|
||||
/**
|
||||
* Payment setup succeeded and the payment moved past [org.thoughtcrime.securesms.database.InAppPaymentTable.State.CREATED]
|
||||
* with no error. Used for bank-transfer methods (SEPA/iDEAL), which don't redeem immediately —
|
||||
* they land in a pending / awaiting-authorization state.
|
||||
*/
|
||||
data object PaymentSubmitted : ExpectedOutcome
|
||||
}
|
||||
+200
@@ -0,0 +1,200 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.conversation.v2
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.isEqualTo
|
||||
import assertk.assertions.isInstanceOf
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.signal.core.util.getSerializableCompat
|
||||
import org.signal.donations.InAppPaymentType
|
||||
import org.thoughtcrime.securesms.MainActivity
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.badges.models.Badge
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonateToSignalFragment
|
||||
import org.thoughtcrime.securesms.conversation.ConversationIntents
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.testing.InAppPaymentsRule
|
||||
import org.thoughtcrime.securesms.testing.SignalActivityRule
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Entry-path coverage for the inline chat donation prompt: the Release-Channel "donation request"
|
||||
* conversation item that renders a "Donate" action button and opens the one-time checkout.
|
||||
*
|
||||
* The conversation is hosted by [MainActivity], so this drives it the same way
|
||||
* [org.thoughtcrime.securesms.main.MainNavigationLaunchTest] does — a manual lifecycle-callback launch
|
||||
* plus direct view interaction, since ActivityScenario/Espresso misbehave for the conversation
|
||||
* custom-action intent.
|
||||
*
|
||||
* Unlike the megaphone and subscriber-upsell paths, this prompt is intentionally NOT gated by sustainer
|
||||
* status; [sustainer_donatePromptStillShown] documents that.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class InlineDonationPromptTest {
|
||||
|
||||
@get:Rule
|
||||
val harness = SignalActivityRule()
|
||||
|
||||
@get:Rule
|
||||
val iapRule = InAppPaymentsRule()
|
||||
|
||||
private val context: Context get() = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
SignalStore.inAppPayments.setLastEndOfPeriod(0L)
|
||||
SignalDatabase.recipients.setBadges(Recipient.self().id, emptyList())
|
||||
Recipient.self().fresh()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nonSustainer_donatePromptOpensOneTimeCheckout() {
|
||||
seedDonationRequest()
|
||||
|
||||
assertDonatePromptOpensCheckout()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sustainer_donatePromptStillShown() {
|
||||
SignalStore.inAppPayments.setLastEndOfPeriod(TimeUnit.MILLISECONDS.toSeconds(System.currentTimeMillis()) + TimeUnit.DAYS.toSeconds(30))
|
||||
SignalDatabase.recipients.setBadges(Recipient.self().id, listOf(donorBadge()))
|
||||
Recipient.self().fresh()
|
||||
|
||||
seedDonationRequest()
|
||||
|
||||
assertDonatePromptOpensCheckout()
|
||||
}
|
||||
|
||||
private fun assertDonatePromptOpensCheckout() {
|
||||
val activity = launchConversation()
|
||||
|
||||
val donateButton = awaitView(activity, R.id.conversation_update_action)
|
||||
runOnMainSync { donateButton.performClick() }
|
||||
|
||||
val dialog = await(description = "one-time checkout dialog") {
|
||||
activity.supportFragmentManager.findFragmentByTag(ONE_TIME_NAV_TAG)
|
||||
}
|
||||
|
||||
assertThat(dialog).isInstanceOf(DonateToSignalFragment.Dialog::class)
|
||||
val type = dialog.requireArguments().getSerializableCompat(DonateToSignalFragment.Dialog.ARG, InAppPaymentType::class.java)
|
||||
assertThat(type).isEqualTo(InAppPaymentType.ONE_TIME_DONATION)
|
||||
}
|
||||
|
||||
private fun seedDonationRequest() {
|
||||
val recipientId: RecipientId = SignalDatabase.recipients.insertReleaseChannelRecipient()
|
||||
SignalStore.releaseChannel.setReleaseChannelRecipientId(recipientId)
|
||||
|
||||
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(Recipient.resolved(recipientId))
|
||||
SignalDatabase.messages.insertBoostRequestMessage(recipientId, threadId)
|
||||
}
|
||||
|
||||
private fun launchConversation(): MainActivity {
|
||||
val recipientId = SignalStore.releaseChannel.releaseChannelRecipientId!!
|
||||
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(Recipient.resolved(recipientId))
|
||||
|
||||
val conversationIntent = ConversationIntents.createBuilder(context, recipientId, threadId).blockingGet().build()
|
||||
val launchIntent = Intent(context, MainActivity::class.java).apply {
|
||||
action = ConversationIntents.ACTION
|
||||
putExtras(conversationIntent)
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
|
||||
val app = context.applicationContext as Application
|
||||
val resumed = CountDownLatch(1)
|
||||
val holder = arrayOfNulls<MainActivity>(1)
|
||||
val callbacks = object : Application.ActivityLifecycleCallbacks {
|
||||
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) = Unit
|
||||
|
||||
// Capture on resume rather than create: a CLEAR_TOP|SINGLE_TOP conversation intent may be
|
||||
// delivered to an existing MainActivity via onNewIntent, which never fires onActivityCreated.
|
||||
override fun onActivityResumed(activity: Activity) {
|
||||
if (activity is MainActivity) {
|
||||
holder[0] = activity
|
||||
resumed.countDown()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onActivityStarted(activity: Activity) = Unit
|
||||
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) = Unit
|
||||
}
|
||||
app.registerActivityLifecycleCallbacks(callbacks)
|
||||
|
||||
app.startActivity(launchIntent)
|
||||
if (!resumed.await(15, TimeUnit.SECONDS)) {
|
||||
app.unregisterActivityLifecycleCallbacks(callbacks)
|
||||
error("MainActivity did not reach RESUMED within 15s")
|
||||
}
|
||||
app.unregisterActivityLifecycleCallbacks(callbacks)
|
||||
|
||||
return holder[0] ?: error("MainActivity was not captured")
|
||||
}
|
||||
|
||||
private fun awaitView(activity: FragmentActivity, viewId: Int): View {
|
||||
return await(description = "view $viewId") {
|
||||
activity.findViewById<View>(viewId)?.takeIf { it.isShown }
|
||||
}
|
||||
}
|
||||
|
||||
private fun <T> await(
|
||||
timeoutMs: Long = 15_000,
|
||||
pollMs: Long = 50,
|
||||
description: String,
|
||||
supplier: () -> T?
|
||||
): T {
|
||||
val deadline = System.currentTimeMillis() + timeoutMs
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
val result = runOnMainSync(supplier)
|
||||
if (result != null) return result
|
||||
Thread.sleep(pollMs)
|
||||
}
|
||||
error("Timed out after ${timeoutMs}ms waiting for $description")
|
||||
}
|
||||
|
||||
private fun <T> runOnMainSync(block: () -> T): T {
|
||||
var result: Result<T> = Result.failure(IllegalStateException("runOnMainSync produced no result"))
|
||||
InstrumentationRegistry.getInstrumentation().runOnMainSync { result = runCatching(block) }
|
||||
return result.getOrThrow()
|
||||
}
|
||||
|
||||
private fun donorBadge(): Badge {
|
||||
return Badge(
|
||||
id = "test-donor-badge",
|
||||
category = Badge.Category.Donor,
|
||||
name = "Signal Sustainer",
|
||||
description = "",
|
||||
imageUrl = Uri.EMPTY,
|
||||
imageDensity = "xxhdpi",
|
||||
expirationTimestamp = System.currentTimeMillis() + TimeUnit.DAYS.toMillis(30),
|
||||
visible = true,
|
||||
duration = TimeUnit.DAYS.toMillis(30)
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val ONE_TIME_NAV_TAG = "one_time_nav"
|
||||
}
|
||||
}
|
||||
+56
-2
@@ -11,6 +11,7 @@ import assertk.assertions.isEmpty
|
||||
import assertk.assertions.isEqualTo
|
||||
import assertk.assertions.isNotEmpty
|
||||
import assertk.assertions.isNotEqualTo
|
||||
import assertk.assertions.isNull
|
||||
import assertk.assertions.isTrue
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotEquals
|
||||
@@ -219,6 +220,26 @@ class AttachmentTableTest {
|
||||
assertThat(SignalDatabase.attachments.getAttachment(attachmentId)!!.archiveTransferState).isEqualTo(AttachmentTable.ArchiveTransferState.NONE)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resetArchiveTransferStateForLocalBackupMedia_onlyResetsLocalBackupMedia() {
|
||||
// Given one archive-finished attachment restored from a local backup, and one that wasn't
|
||||
val localBackupMessageId = SignalDatabase.messages.insertMessageInbox(createIncomingMessage(serverTime = 0.days, attachment = createArchivedAttachment(localBackupKey = Random.nextBytes(32)))).map { it.messageId }.get()
|
||||
val localBackupAttachmentId = SignalDatabase.attachments.getAttachmentsForMessage(localBackupMessageId).first().attachmentId
|
||||
|
||||
val nonLocalBackupMessageId = SignalDatabase.messages.insertMessageInbox(createIncomingMessage(serverTime = 1.days, attachment = createArchivedAttachment())).map { it.messageId }.get()
|
||||
val nonLocalBackupAttachmentId = SignalDatabase.attachments.getAttachmentsForMessage(nonLocalBackupMessageId).first().attachmentId
|
||||
|
||||
SignalDatabase.attachments.setArchiveTransferState(localBackupAttachmentId, AttachmentTable.ArchiveTransferState.FINISHED)
|
||||
SignalDatabase.attachments.setArchiveTransferState(nonLocalBackupAttachmentId, AttachmentTable.ArchiveTransferState.FINISHED)
|
||||
|
||||
val resetCount = SignalDatabase.attachments.resetArchiveTransferStateForLocalBackupMedia()
|
||||
|
||||
// Only the local-backup attachment is reset
|
||||
assertThat(resetCount).isEqualTo(1)
|
||||
assertThat(SignalDatabase.attachments.getAttachment(localBackupAttachmentId)!!.archiveTransferState).isEqualTo(AttachmentTable.ArchiveTransferState.NONE)
|
||||
assertThat(SignalDatabase.attachments.getAttachment(nonLocalBackupAttachmentId)!!.archiveTransferState).isEqualTo(AttachmentTable.ArchiveTransferState.FINISHED)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun given10NewerAnd10OlderAttachments_whenIGetEachBatch_thenIExpectProperBucketing() {
|
||||
val now = System.currentTimeMillis().milliseconds
|
||||
@@ -418,6 +439,39 @@ class AttachmentTableTest {
|
||||
assertThat(dbAttachment2.archiveTransferState).isEqualTo(AttachmentTable.ArchiveTransferState.FINISHED)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenLocalBackupRestore_whenIFinalizeAttachment_thenIExpectArchiveStateNoneSoItGetsUploaded() {
|
||||
val data = byteArrayOf(1, 2, 3, 4, 5)
|
||||
val attachment = createAttachmentPointer("remote-key-1".toByteArray(), data.size)
|
||||
|
||||
val messageResult = SignalDatabase.messages.insertMessageInbox(createIncomingMessage(serverTime = 0.days, attachment = attachment)).get()
|
||||
val attachmentId = messageResult.insertedAttachments!![attachment]!!
|
||||
SignalDatabase.attachments.setTransferState(messageResult.messageId, attachmentId, AttachmentTable.TRANSFER_PROGRESS_STARTED)
|
||||
|
||||
// Data is restored from a local backup file, not the archive CDN
|
||||
SignalDatabase.attachments.finalizeAttachmentAfterDownload(messageResult.messageId, attachmentId, ByteArrayInputStream(data), archiveRestore = true, restoredFromArchiveCdn = false)
|
||||
|
||||
val result = SignalDatabase.attachments.getAttachment(attachmentId)!!
|
||||
assertThat(result.archiveTransferState).isEqualTo(AttachmentTable.ArchiveTransferState.NONE)
|
||||
assertThat(result.archiveCdn).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenArchiveCdnRestore_whenIFinalizeAttachment_thenIExpectArchiveStateFinished() {
|
||||
val data = byteArrayOf(1, 2, 3, 4, 5)
|
||||
val attachment = createAttachmentPointer("remote-key-1".toByteArray(), data.size)
|
||||
|
||||
val messageResult = SignalDatabase.messages.insertMessageInbox(createIncomingMessage(serverTime = 0.days, attachment = attachment)).get()
|
||||
val attachmentId = messageResult.insertedAttachments!![attachment]!!
|
||||
SignalDatabase.attachments.setTransferState(messageResult.messageId, attachmentId, AttachmentTable.TRANSFER_PROGRESS_STARTED)
|
||||
|
||||
// Data is restored directly from the archive CDN
|
||||
SignalDatabase.attachments.finalizeAttachmentAfterDownload(messageResult.messageId, attachmentId, ByteArrayInputStream(data), archiveRestore = true, restoredFromArchiveCdn = true)
|
||||
|
||||
val result = SignalDatabase.attachments.getAttachment(attachmentId)!!
|
||||
assertThat(result.archiveTransferState).isEqualTo(AttachmentTable.ArchiveTransferState.FINISHED)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenAttachmentsWithMatchingMediaId_whenISetArchiveFinishedForMatchingMediaObjects_thenIExpectThoseAttachmentsToBeMarkedFinished() {
|
||||
// GIVEN
|
||||
@@ -585,7 +639,7 @@ class AttachmentTableTest {
|
||||
).get()
|
||||
}
|
||||
|
||||
private fun createArchivedAttachment(): Attachment {
|
||||
private fun createArchivedAttachment(localBackupKey: ByteArray? = null): Attachment {
|
||||
return ArchivedAttachment(
|
||||
contentType = "image/jpeg",
|
||||
size = 1024,
|
||||
@@ -609,7 +663,7 @@ class AttachmentTableTest {
|
||||
quoteTargetContentType = null,
|
||||
uuid = UUID.randomUUID(),
|
||||
fileName = null,
|
||||
localBackupKey = null
|
||||
localBackupKey = localBackupKey
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -10,9 +10,14 @@ import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.signal.ringrtc.CallId
|
||||
import org.signal.ringrtc.CallManager
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.testing.Flag
|
||||
import org.thoughtcrime.securesms.testing.RemoteConfigForTest
|
||||
import org.thoughtcrime.securesms.testing.SignalActivityRule
|
||||
import org.thoughtcrime.securesms.testing.TestRemoteConfigFlag
|
||||
|
||||
@RemoteConfigForTest(flags = [Flag(TestRemoteConfigFlag.DISAPPEAR_MORE, "true")])
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class CallTableTest {
|
||||
|
||||
@@ -977,6 +982,80 @@ class CallTableTest {
|
||||
// assertEquals(0, allCallEvents.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenAMissedOneToOneCall_whenIMarkAllCallEventsRead_thenTimerShouldStart() {
|
||||
val callId = 1L
|
||||
val peer = harness.others[0]
|
||||
insertExpiringThread(peer)
|
||||
|
||||
SignalDatabase.calls.insertOneToOneCall(callId, System.currentTimeMillis(), peer, CallTable.Type.AUDIO_CALL, CallTable.Direction.INCOMING, CallTable.Event.MISSED)
|
||||
|
||||
val readAt = System.currentTimeMillis()
|
||||
SignalDatabase.calls.markAllCallEventsRead(readAt = readAt)
|
||||
|
||||
val call = SignalDatabase.calls.getCallById(callId, peer)
|
||||
assertEquals(readAt, SignalDatabase.messages.getMessageRecord(call!!.messageId!!).expireStarted)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenAMissedNotificationProfileOneToOneCall_whenIMarkAllCallEventsRead_thenTimerShouldStart() {
|
||||
val callId = 1L
|
||||
val peer = harness.others[0]
|
||||
insertExpiringThread(peer)
|
||||
|
||||
SignalDatabase.calls.insertOneToOneCall(callId, System.currentTimeMillis(), peer, CallTable.Type.AUDIO_CALL, CallTable.Direction.INCOMING, CallTable.Event.MISSED_NOTIFICATION_PROFILE)
|
||||
|
||||
val readAt = System.currentTimeMillis()
|
||||
SignalDatabase.calls.markAllCallEventsRead(readAt = readAt)
|
||||
|
||||
val call = SignalDatabase.calls.getCallById(callId, peer)
|
||||
assertEquals(readAt, SignalDatabase.messages.getMessageRecord(call!!.messageId!!).expireStarted)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenAMissedGroupCall_whenIMarkAllCallEventsRead_thenTimerShouldStart() {
|
||||
val callId = 1L
|
||||
SignalDatabase.recipients.setExpireMessagesForGroup(groupRecipientId, 60)
|
||||
|
||||
SignalDatabase.calls.insertOrUpdateGroupCallFromRingState(callId, groupRecipientId, harness.others[1], System.currentTimeMillis(), CallManager.RingUpdate.EXPIRED_REQUEST)
|
||||
|
||||
val readAt = System.currentTimeMillis()
|
||||
SignalDatabase.calls.markAllCallEventsRead(readAt = readAt)
|
||||
|
||||
val call = SignalDatabase.calls.getCallById(callId, groupRecipientId)
|
||||
assertEquals(readAt, SignalDatabase.messages.getMessageRecord(call!!.messageId!!).expireStarted)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenAMissedNotificationProfileGroupCall_whenIMarkAllCallEventsRead_thenTimerShouldStart() {
|
||||
val callId = 1L
|
||||
val ringerAci = Recipient.resolved(harness.others[1]).requireAci()
|
||||
|
||||
SignalDatabase.recipients.setExpireMessagesForGroup(groupRecipientId, 60)
|
||||
|
||||
SignalDatabase.calls.insertOrUpdateGroupCallFromRingState(callId, groupRecipientId, ringerAci, System.currentTimeMillis(), CallManager.RingUpdate.EXPIRED_REQUEST, true)
|
||||
|
||||
val readAt = System.currentTimeMillis()
|
||||
SignalDatabase.calls.markAllCallEventsRead(readAt = readAt)
|
||||
|
||||
val call = SignalDatabase.calls.getCallById(callId, groupRecipientId)
|
||||
assertEquals(readAt, SignalDatabase.messages.getMessageRecord(call!!.messageId!!).expireStarted)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenAnOutgoingOneToOneCallFromSync_whenInserted_thenTimerIsStarted() {
|
||||
val callId = 1L
|
||||
val peer = harness.others[0]
|
||||
val timestamp = System.currentTimeMillis()
|
||||
|
||||
insertExpiringThread(peer)
|
||||
SignalDatabase.calls.insertOneToOneCall(callId, timestamp, peer, CallTable.Type.AUDIO_CALL, CallTable.Direction.OUTGOING, CallTable.Event.OUTGOING_RING, true)
|
||||
|
||||
val call = SignalDatabase.calls.getCallById(callId, peer)
|
||||
val message = SignalDatabase.messages.getMessageRecord(call!!.messageId!!)
|
||||
assertNotEquals(0L, message.expireStarted)
|
||||
}
|
||||
|
||||
private fun insertTwoCallEvents() {
|
||||
SignalDatabase.calls.insertAcceptedGroupCall(
|
||||
1,
|
||||
@@ -992,4 +1071,10 @@ class CallTableTest {
|
||||
2000
|
||||
)
|
||||
}
|
||||
|
||||
private fun insertExpiringThread(recipientId: RecipientId) {
|
||||
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(Recipient.resolved(recipientId))
|
||||
MmsHelper.insert(recipient = Recipient.resolved(recipientId), expiresIn = 30_000L, threadId = threadId)
|
||||
SignalDatabase.threads.update(threadId, false)
|
||||
}
|
||||
}
|
||||
|
||||
+58
-3
@@ -3,19 +3,28 @@ package org.thoughtcrime.securesms.dependencies
|
||||
import android.app.Application
|
||||
import io.mockk.mockk
|
||||
import io.mockk.spyk
|
||||
import okhttp3.OkHttpClient
|
||||
import org.signal.core.util.UptimeSleepTimer
|
||||
import org.signal.core.util.billing.BillingApi
|
||||
import org.signal.libsignal.net.Network
|
||||
import org.signal.libsignal.zkgroup.receipts.ClientZkReceiptOperations
|
||||
import org.signal.network.api.ArchiveApi
|
||||
import org.thoughtcrime.securesms.push.SignalServiceNetworkAccess
|
||||
import org.thoughtcrime.securesms.recipients.LiveRecipientCache
|
||||
import org.thoughtcrime.securesms.testing.endpoints.DonationTestServer
|
||||
import org.thoughtcrime.securesms.testing.endpoints.MockEndpoints
|
||||
import org.thoughtcrime.securesms.testing.endpoints.ResponderInterceptor
|
||||
import org.thoughtcrime.securesms.testing.endpoints.ResponderWebSocketConnection
|
||||
import org.whispersystems.signalservice.api.SignalServiceDataStore
|
||||
import org.whispersystems.signalservice.api.SignalServiceMessageSender
|
||||
import org.whispersystems.signalservice.api.account.AccountApi
|
||||
import org.whispersystems.signalservice.api.donations.DonationsApi
|
||||
import org.whispersystems.signalservice.api.keys.KeysApi
|
||||
import org.whispersystems.signalservice.api.message.MessageApi
|
||||
import org.whispersystems.signalservice.api.websocket.SignalWebSocket
|
||||
import org.whispersystems.signalservice.internal.configuration.SignalServiceConfiguration
|
||||
import org.whispersystems.signalservice.internal.push.PushServiceSocket
|
||||
import java.util.function.Supplier
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
/**
|
||||
* Dependency provider used for instrumentation tests (aka androidTests).
|
||||
@@ -45,8 +54,54 @@ class InstrumentationApplicationDependencyProvider(val application: Application,
|
||||
return mockk()
|
||||
}
|
||||
|
||||
override fun provideDonationsApi(authWebSocket: SignalWebSocket.AuthenticatedWebSocket, unauthWebSocket: SignalWebSocket.UnauthenticatedWebSocket): DonationsApi {
|
||||
return mockk()
|
||||
/**
|
||||
* Adds the Stripe-matching [ResponderInterceptor] on top of the default client (which supplies the
|
||||
* user agent + DNS), so `api.stripe.com` requests made by [org.signal.donations.StripeApi] are
|
||||
* answered from the shared [MockEndpoints.responder] and never hit the real network.
|
||||
*/
|
||||
override fun provideOkHttpClient(): OkHttpClient {
|
||||
return default.provideOkHttpClient()
|
||||
.newBuilder()
|
||||
.addInterceptor(ResponderInterceptor(MockEndpoints.responder))
|
||||
.build()
|
||||
}
|
||||
|
||||
/**
|
||||
* Backs the real [org.whispersystems.signalservice.api.donations.DonationsApi] (and any other
|
||||
* websocket API) with a [ResponderWebSocketConnection] driven by the shared [MockEndpoints.responder],
|
||||
* so Signal-service requests are answered from the scenario's "world" and never hit the network.
|
||||
*/
|
||||
override fun provideAuthWebSocket(
|
||||
signalServiceConfigurationSupplier: Supplier<SignalServiceConfiguration>,
|
||||
libSignalNetworkSupplier: Supplier<Network>
|
||||
): SignalWebSocket.AuthenticatedWebSocket {
|
||||
return SignalWebSocket.AuthenticatedWebSocket(
|
||||
connectionFactory = { ResponderWebSocketConnection(MockEndpoints.responder) },
|
||||
canConnect = { true },
|
||||
sleepTimer = UptimeSleepTimer(),
|
||||
disconnectTimeoutMs = 15.seconds.inWholeMilliseconds
|
||||
)
|
||||
}
|
||||
|
||||
override fun provideUnauthWebSocket(
|
||||
signalServiceConfigurationSupplier: Supplier<SignalServiceConfiguration>,
|
||||
libSignalNetworkSupplier: Supplier<Network>
|
||||
): SignalWebSocket.UnauthenticatedWebSocket {
|
||||
return SignalWebSocket.UnauthenticatedWebSocket(
|
||||
connectionFactory = { ResponderWebSocketConnection(MockEndpoints.responder) },
|
||||
canConnect = { true },
|
||||
sleepTimer = UptimeSleepTimer(),
|
||||
disconnectTimeoutMs = 15.seconds.inWholeMilliseconds
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Uses the test zk server's public params so credentials minted by [MockEndpoints] validate here.
|
||||
* The real [ClientZkReceiptOperations.receiveReceiptCredential] validation still runs in the
|
||||
* receipt-credential context job — only the params are swapped for test ones.
|
||||
*/
|
||||
override fun provideClientZkReceiptOperations(signalServiceConfiguration: SignalServiceConfiguration): ClientZkReceiptOperations {
|
||||
return DonationTestServer.clientReceiptOperations
|
||||
}
|
||||
|
||||
override fun provideSignalServiceMessageSender(
|
||||
|
||||
+279
@@ -0,0 +1,279 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.jobs
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.isEqualTo
|
||||
import assertk.assertions.isFalse
|
||||
import assertk.assertions.isNull
|
||||
import io.mockk.Runs
|
||||
import io.mockk.every
|
||||
import io.mockk.just
|
||||
import io.mockk.mockkObject
|
||||
import io.mockk.unmockkAll
|
||||
import io.mockk.verify
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.signal.core.models.backup.MediaName
|
||||
import org.signal.core.models.database.AttachmentId
|
||||
import org.signal.core.util.Base64.decodeBase64OrThrow
|
||||
import org.signal.network.NetworkResult
|
||||
import org.thoughtcrime.securesms.attachments.Attachment
|
||||
import org.thoughtcrime.securesms.attachments.PointerAttachment
|
||||
import org.thoughtcrime.securesms.backup.v2.BackupRepository
|
||||
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
|
||||
import org.thoughtcrime.securesms.database.AttachmentTable
|
||||
import org.thoughtcrime.securesms.database.BackupMediaSnapshotTable.MediaEntry
|
||||
import org.thoughtcrime.securesms.database.MessageType
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.mms.IncomingMessage
|
||||
import org.thoughtcrime.securesms.testing.SignalActivityRule
|
||||
import org.thoughtcrime.securesms.util.MediaUtil
|
||||
import org.whispersystems.signalservice.api.archive.ArchiveGetMediaItemsResponse
|
||||
import org.whispersystems.signalservice.api.archive.ArchiveGetMediaItemsResponse.StoredMediaObject
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentPointer
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentRemoteId
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.util.Optional
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.days
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ArchiveAttachmentReconciliationJobTest {
|
||||
|
||||
@get:Rule
|
||||
val harness = SignalActivityRule()
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
SignalStore.backup.backupTier = MessageBackupTier.PAID
|
||||
SignalStore.backup.hasBackupBeenUploaded = true
|
||||
SignalStore.backup.lastAttachmentReconciliationTime = System.currentTimeMillis()
|
||||
SignalStore.backup.localRestoreReconcilePending = false
|
||||
|
||||
mockkObject(BackupRepository)
|
||||
mockkObject(ArchiveCommitAttachmentDeletesJob)
|
||||
every { ArchiveCommitAttachmentDeletesJob.deleteMediaObjectsFromCdn(any(), any(), any(), any()) } returns null
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
unmockkAll()
|
||||
}
|
||||
|
||||
/**
|
||||
* The core of the reconcile-first restore flow: a local restore resets everything to [AttachmentTable.ArchiveTransferState.NONE], so media that genuinely is
|
||||
* on the CDN must be promoted back to FINISHED during reconciliation -- otherwise the backfill would needlessly re-upload it. This only happens while
|
||||
* [localRestoreReconcilePending] is set, so it never runs in the common periodic reconciliation.
|
||||
*/
|
||||
@Test
|
||||
fun givenLocalRestorePendingAndAttachmentOnCdn_whenIReconcile_thenIExpectItMarkedFinished() {
|
||||
SignalStore.backup.localRestoreReconcilePending = true
|
||||
|
||||
val attachmentId = seedFinalizedAttachment("remote-key-1".toByteArray(), byteArrayOf(1, 2, 3, 4, 5))
|
||||
commitSnapshotFor(attachmentId, cdn = 3)
|
||||
fakeCdnContains(attachmentId, cdn = 3)
|
||||
|
||||
ArchiveAttachmentReconciliationJob(forced = true).run()
|
||||
|
||||
assertThat(SignalDatabase.attachments.getAttachment(attachmentId)!!.archiveTransferState).isEqualTo(AttachmentTable.ArchiveTransferState.FINISHED)
|
||||
}
|
||||
|
||||
/**
|
||||
* Guards the reconcile-first promotion above: outside the local-restore flow (the common periodic reconciliation), NONE media that happens to be on the CDN is
|
||||
* left alone, so we don't do the expensive mark-finished scan in the common case.
|
||||
*/
|
||||
@Test
|
||||
fun givenNoLocalRestorePendingAndNoneAttachmentOnCdn_whenIReconcile_thenItStaysNone() {
|
||||
val attachmentId = seedFinalizedAttachment("remote-key-common".toByteArray(), byteArrayOf(2, 3, 4, 5, 6))
|
||||
commitSnapshotFor(attachmentId, cdn = 3)
|
||||
fakeCdnContains(attachmentId, cdn = 3)
|
||||
|
||||
ArchiveAttachmentReconciliationJob(forced = true).run()
|
||||
|
||||
assertThat(SignalDatabase.attachments.getAttachment(attachmentId)!!.archiveTransferState).isEqualTo(AttachmentTable.ArchiveTransferState.NONE)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenFinishedAttachmentMissingFromCdn_whenIReconcile_thenIExpectItResetToNone() {
|
||||
val attachmentId = seedFinalizedAttachment("remote-key-2".toByteArray(), byteArrayOf(6, 7, 8, 9, 10))
|
||||
SignalDatabase.attachments.setArchiveTransferState(attachmentId, AttachmentTable.ArchiveTransferState.FINISHED)
|
||||
commitSnapshotFor(attachmentId, cdn = 3)
|
||||
fakeCdnEmpty()
|
||||
|
||||
ArchiveAttachmentReconciliationJob(forced = true).run()
|
||||
|
||||
assertThat(SignalDatabase.attachments.getAttachment(attachmentId)!!.archiveTransferState).isEqualTo(AttachmentTable.ArchiveTransferState.NONE)
|
||||
}
|
||||
|
||||
/**
|
||||
* The eventual safety net: an ordinary (non-forced) periodic reconciliation, run after the sync interval has elapsed, heals the bad state on its own -- media
|
||||
* in the snapshot but missing from the CDN is reset to [AttachmentTable.ArchiveTransferState.NONE] and a re-upload is enqueued -- with no help from the
|
||||
* migration or the reconcile-first flow.
|
||||
*/
|
||||
@Test
|
||||
fun givenFinishedMediaMissingFromCdn_whenAnOrdinaryPeriodicReconciliationRuns_thenItHealsToNoneAndReUploads() {
|
||||
SignalStore.backup.lastAttachmentReconciliationTime = System.currentTimeMillis() - 60.days.inWholeMilliseconds
|
||||
mockkObject(BackupMessagesJob)
|
||||
every { BackupMessagesJob.enqueue() } just Runs
|
||||
|
||||
val attachmentId = seedFinalizedAttachment("remote-key-periodic".toByteArray(), byteArrayOf(1, 2, 3, 4, 5))
|
||||
SignalDatabase.attachments.setArchiveTransferState(attachmentId, AttachmentTable.ArchiveTransferState.FINISHED)
|
||||
commitSnapshotFor(attachmentId, cdn = 3)
|
||||
fakeCdnEmpty()
|
||||
|
||||
ArchiveAttachmentReconciliationJob(forced = false).run()
|
||||
|
||||
val healed = SignalDatabase.attachments.getAttachment(attachmentId)!!
|
||||
assertThat(healed.archiveTransferState).isEqualTo(AttachmentTable.ArchiveTransferState.NONE)
|
||||
assertThat(healed.archiveCdn).isNull()
|
||||
verify(exactly = 1) { BackupMessagesJob.enqueue() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconciliation must only repair genuinely-broken state. Media that is actually present on the CDN stays [AttachmentTable.ArchiveTransferState.FINISHED], so
|
||||
* we never needlessly reset (and therefore re-upload) media that was correctly archived.
|
||||
*/
|
||||
@Test
|
||||
fun givenFinishedMediaStillOnCdn_whenIReconcile_thenItStaysFinished() {
|
||||
val attachmentId = seedFinalizedAttachment("remote-key-on-cdn".toByteArray(), byteArrayOf(6, 7, 8, 9, 10))
|
||||
SignalDatabase.attachments.setArchiveTransferState(attachmentId, AttachmentTable.ArchiveTransferState.FINISHED)
|
||||
commitSnapshotFor(attachmentId, cdn = 3)
|
||||
fakeCdnContains(attachmentId, cdn = 3)
|
||||
|
||||
ArchiveAttachmentReconciliationJob(forced = true).run()
|
||||
|
||||
assertThat(SignalDatabase.attachments.getAttachment(attachmentId)!!.archiveTransferState).isEqualTo(AttachmentTable.ArchiveTransferState.FINISHED)
|
||||
}
|
||||
|
||||
/**
|
||||
* The other healing direction: media that is on the CDN but locally marked [AttachmentTable.ArchiveTransferState.NONE] and absent from the current snapshot is
|
||||
* treated as a delete-candidate. Before deleting, reconciliation confirms it's still referenced locally and recovers it to
|
||||
* [AttachmentTable.ArchiveTransferState.FINISHED] rather than deleting it from the CDN.
|
||||
*/
|
||||
@Test
|
||||
fun givenNoneMediaOnCdnButNotInSnapshot_whenIReconcile_thenItIsRecoveredToFinished() {
|
||||
val attachmentId = seedFinalizedAttachment("remote-key-flow2".toByteArray(), byteArrayOf(11, 12, 13, 14, 15))
|
||||
SignalDatabase.attachments.setArchiveTransferState(attachmentId, AttachmentTable.ArchiveTransferState.NONE)
|
||||
fakeCdnContains(attachmentId, cdn = 3)
|
||||
|
||||
ArchiveAttachmentReconciliationJob(forced = true).run()
|
||||
|
||||
assertThat(SignalDatabase.attachments.getAttachment(attachmentId)!!.archiveTransferState).isEqualTo(AttachmentTable.ArchiveTransferState.FINISHED)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenFirstEverReconciliation_whenIForceIt_thenItStillRunsAndRepairs() {
|
||||
SignalStore.backup.lastAttachmentReconciliationTime = -1
|
||||
|
||||
val attachmentId = seedFinalizedAttachment("remote-key-3".toByteArray(), byteArrayOf(11, 12, 13, 14, 15))
|
||||
SignalDatabase.attachments.setArchiveTransferState(attachmentId, AttachmentTable.ArchiveTransferState.FINISHED)
|
||||
commitSnapshotFor(attachmentId, cdn = 3)
|
||||
fakeCdnEmpty()
|
||||
|
||||
ArchiveAttachmentReconciliationJob(forced = true).run()
|
||||
|
||||
assertThat(SignalDatabase.attachments.getAttachment(attachmentId)!!.archiveTransferState).isEqualTo(AttachmentTable.ArchiveTransferState.NONE)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenLocalRestoreReconcilePending_whenReconcileCompletes_thenIExpectFlagCleared() {
|
||||
SignalStore.backup.localRestoreReconcilePending = true
|
||||
fakeCdnEmpty()
|
||||
|
||||
ArchiveAttachmentReconciliationJob(forced = true).run()
|
||||
|
||||
assertThat(SignalStore.backup.localRestoreReconcilePending).isFalse()
|
||||
}
|
||||
|
||||
private fun seedFinalizedAttachment(remoteKey: ByteArray, data: ByteArray): AttachmentId {
|
||||
val attachment = createAttachmentPointer(remoteKey, data.size)
|
||||
val messageResult = SignalDatabase.messages.insertMessageInbox(createIncomingMessage(serverTime = 0.days, attachment = attachment)).get()
|
||||
val attachmentId = messageResult.insertedAttachments!![attachment]!!
|
||||
SignalDatabase.attachments.setTransferState(messageResult.messageId, attachmentId, AttachmentTable.TRANSFER_PROGRESS_STARTED)
|
||||
SignalDatabase.attachments.finalizeAttachmentAfterDownload(messageResult.messageId, attachmentId, ByteArrayInputStream(data))
|
||||
return attachmentId
|
||||
}
|
||||
|
||||
private fun commitSnapshotFor(attachmentId: AttachmentId, cdn: Int) {
|
||||
val attachment = SignalDatabase.attachments.getAttachment(attachmentId)!!
|
||||
val plaintextHash = attachment.dataHash!!.decodeBase64OrThrow()
|
||||
val remoteKey = attachment.remoteKey!!.decodeBase64OrThrow()
|
||||
val mediaId = MediaName.fromPlaintextHashAndRemoteKey(plaintextHash, remoteKey).toMediaId(SignalStore.backup.mediaRootBackupKey).encode()
|
||||
|
||||
SignalDatabase.backupMediaSnapshots.writePendingMediaEntries(
|
||||
listOf(MediaEntry(mediaId = mediaId, cdn = cdn, plaintextHash = plaintextHash, remoteKey = remoteKey, isThumbnail = false))
|
||||
)
|
||||
SignalDatabase.backupMediaSnapshots.commitPendingRows()
|
||||
}
|
||||
|
||||
private fun fakeCdnContains(attachmentId: AttachmentId, cdn: Int) {
|
||||
val attachment = SignalDatabase.attachments.getAttachment(attachmentId)!!
|
||||
val plaintextHash = attachment.dataHash!!.decodeBase64OrThrow()
|
||||
val remoteKey = attachment.remoteKey!!.decodeBase64OrThrow()
|
||||
val mediaId = MediaName.fromPlaintextHashAndRemoteKey(plaintextHash, remoteKey).toMediaId(SignalStore.backup.mediaRootBackupKey).encode()
|
||||
|
||||
every { BackupRepository.listRemoteMediaObjects(any(), any()) } returns NetworkResult.Success(
|
||||
ArchiveGetMediaItemsResponse(
|
||||
storedMediaObjects = listOf(StoredMediaObject(cdn = cdn, mediaId = mediaId, objectLength = attachment.size)),
|
||||
backupDir = null,
|
||||
mediaDir = null,
|
||||
cursor = null
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun fakeCdnEmpty() {
|
||||
every { BackupRepository.listRemoteMediaObjects(any(), any()) } returns NetworkResult.Success(
|
||||
ArchiveGetMediaItemsResponse(storedMediaObjects = emptyList(), backupDir = null, mediaDir = null, cursor = null)
|
||||
)
|
||||
}
|
||||
|
||||
private fun createIncomingMessage(serverTime: Duration, attachment: Attachment): IncomingMessage {
|
||||
return IncomingMessage(
|
||||
type = MessageType.NORMAL,
|
||||
from = harness.others[0],
|
||||
body = null,
|
||||
sentTimeMillis = serverTime.inWholeMilliseconds,
|
||||
serverTimeMillis = serverTime.inWholeMilliseconds,
|
||||
receivedTimeMillis = serverTime.inWholeMilliseconds,
|
||||
attachments = listOf(attachment)
|
||||
)
|
||||
}
|
||||
|
||||
private fun createAttachmentPointer(key: ByteArray, size: Int): Attachment {
|
||||
return PointerAttachment.forPointer(
|
||||
pointer = Optional.of(
|
||||
SignalServiceAttachmentPointer(
|
||||
cdnNumber = 3,
|
||||
remoteId = SignalServiceAttachmentRemoteId.V4("asdf"),
|
||||
contentType = MediaUtil.IMAGE_JPEG,
|
||||
key = key,
|
||||
size = Optional.of(size),
|
||||
preview = Optional.empty(),
|
||||
width = 2,
|
||||
height = 2,
|
||||
digest = Optional.of(byteArrayOf()),
|
||||
incrementalDigest = Optional.empty(),
|
||||
incrementalMacChunkSize = 0,
|
||||
fileName = Optional.of("file.jpg"),
|
||||
voiceNote = false,
|
||||
isBorderless = false,
|
||||
isGif = false,
|
||||
caption = Optional.empty(),
|
||||
blurHash = Optional.empty(),
|
||||
uploadTimestamp = 0,
|
||||
uuid = null
|
||||
)
|
||||
)
|
||||
).get()
|
||||
}
|
||||
}
|
||||
+1
-2
@@ -88,6 +88,7 @@ class BackupSubscriptionCheckJobTest {
|
||||
every { RecurringInAppPaymentRepository.getActiveSubscriptionSync(InAppPaymentSubscriberRecord.Type.BACKUP) } returns NetworkResult.Success(
|
||||
createActiveSubscription()
|
||||
)
|
||||
every { RecurringInAppPaymentRepository.ensureSubscriberIdSync(any(), any(), any()) } returns Unit
|
||||
|
||||
mockkObject(BackupRepository)
|
||||
every { BackupRepository.getBackupTier() } answers {
|
||||
@@ -120,8 +121,6 @@ class BackupSubscriptionCheckJobTest {
|
||||
)
|
||||
)
|
||||
|
||||
every { AppDependencies.donationsApi.putSubscription(any()) } returns NetworkResult.Success(Unit)
|
||||
|
||||
insertSubscriber()
|
||||
}
|
||||
|
||||
|
||||
+8
-6
@@ -49,7 +49,6 @@ import java.util.concurrent.TimeUnit
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class MainNavigationLaunchTest {
|
||||
|
||||
@get:Rule
|
||||
val harness = SignalActivityRule(othersCount = 2)
|
||||
|
||||
@@ -89,7 +88,8 @@ class MainNavigationLaunchTest {
|
||||
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}")
|
||||
appendLine("vm.detailLocation: ${vm.detailLocation.value}")
|
||||
appendLine("vm.chatsBackStackEntries: ${vm.chatsBackStackEntries.toList()}")
|
||||
}
|
||||
}
|
||||
throw IllegalStateException("${e.message}\n$state", e)
|
||||
@@ -254,8 +254,9 @@ class MainNavigationLaunchTest {
|
||||
"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}"
|
||||
val staged = runOnMainSync { vm.chatsBackStackEntries.filterNot { it is MainNavigationDetailLocation.Empty } }
|
||||
check(staged.isEmpty()) {
|
||||
"Expected no detail to be staged on the chats back stack, got $staged"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -289,8 +290,9 @@ class MainNavigationLaunchTest {
|
||||
"Expected default CHATS, got ${vm.mainNavigationState.value.currentListLocation}"
|
||||
}
|
||||
Thread.sleep(750)
|
||||
check(vm.earlyNavigationDetailLocationRequested == null) {
|
||||
"Expected no early detail, got ${vm.earlyNavigationDetailLocationRequested}"
|
||||
val detailLocation = runOnMainSync { vm.detailLocation.value }
|
||||
check(detailLocation == MainNavigationDetailLocation.Empty) {
|
||||
"Expected Empty detail location, got $detailLocation"
|
||||
}
|
||||
check(recorder.createdArgs.isEmpty()) {
|
||||
"Expected no ConversationFragment for bare launch, got ${recorder.createdArgs.size}"
|
||||
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.megaphone
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.isEqualTo
|
||||
import assertk.assertions.isFalse
|
||||
import assertk.assertions.isNull
|
||||
import assertk.assertions.isTrue
|
||||
import io.mockk.every
|
||||
import io.mockk.mockkObject
|
||||
import io.mockk.mockkStatic
|
||||
import io.mockk.unmockkObject
|
||||
import io.mockk.unmockkStatic
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.signal.core.util.deleteAll
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppDonations
|
||||
import org.thoughtcrime.securesms.database.InAppPaymentTable
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.testing.InAppPaymentsRule
|
||||
import org.thoughtcrime.securesms.testing.SignalActivityRule
|
||||
import org.thoughtcrime.securesms.util.VersionTracker
|
||||
|
||||
/**
|
||||
* The "user might be a sustainer" rule for the donations remote megaphone: the `standard_donate`
|
||||
* conditional (via [RemoteMegaphoneRepository.getRemoteMegaphoneToShow]) suppresses the megaphone for
|
||||
* existing donors and shows it otherwise.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class DonationMegaphoneGatingTest {
|
||||
|
||||
@get:Rule
|
||||
val harness = SignalActivityRule()
|
||||
|
||||
@get:Rule
|
||||
val iapRule = InAppPaymentsRule()
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
SignalDatabase.inAppPayments.writableDatabase.deleteAll(InAppPaymentTable.TABLE_NAME)
|
||||
SignalDatabase.remoteMegaphones.debugRemoveAll()
|
||||
setSelfBadges(emptyList())
|
||||
|
||||
// Freshly-installed test APKs report 0 days installed and no configured payment methods, both of
|
||||
// which independently fail shouldShowDonateMegaphone. Fix them so the donor badge is the only
|
||||
// variable across the gating tests.
|
||||
mockkStatic(VersionTracker::class)
|
||||
mockkObject(InAppDonations)
|
||||
every { VersionTracker.getDaysSinceFirstInstalled(any()) } returns 30L
|
||||
every { InAppDonations.hasAtLeastOnePaymentMethodAvailable() } returns true
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
unmockkStatic(VersionTracker::class)
|
||||
unmockkObject(InAppDonations)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nonDonor_showsStandardDonateMegaphone() {
|
||||
val record = donateMegaphoneRecord(conditionalId = "standard_donate")
|
||||
SignalDatabase.remoteMegaphones.insert(record)
|
||||
|
||||
assertThat(RemoteMegaphoneRepository.getRemoteMegaphoneToShow()?.uuid).isEqualTo(record.uuid)
|
||||
assertThat(RemoteMegaphoneRepository.hasRemoteMegaphoneToShow(canShowLocalDonate = true)).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sustainer_donorBadge_suppressesStandardDonateMegaphone() {
|
||||
SignalDatabase.remoteMegaphones.insert(donateMegaphoneRecord(conditionalId = "standard_donate"))
|
||||
setSelfBadges(listOf(donorBadge()))
|
||||
|
||||
assertThat(RemoteMegaphoneRepository.getRemoteMegaphoneToShow()).isNull()
|
||||
assertThat(RemoteMegaphoneRepository.hasRemoteMegaphoneToShow(canShowLocalDonate = true)).isFalse()
|
||||
}
|
||||
}
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.megaphone
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.compose.ui.test.junit4.v2.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.fragment.app.DialogFragment
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.isEqualTo
|
||||
import assertk.assertions.isNotNull
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.signal.core.ui.compose.theme.SignalTheme
|
||||
import org.signal.core.util.getSerializableCompat
|
||||
import org.signal.donations.InAppPaymentType
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.CheckoutFlowActivity
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.model.RemoteMegaphoneRecord
|
||||
import org.thoughtcrime.securesms.testing.InAppPaymentsRule
|
||||
import org.thoughtcrime.securesms.testing.SignalActivityRule
|
||||
|
||||
/**
|
||||
* Entry-path coverage for the donations remote megaphone: the megaphone shown on the conversation list
|
||||
* that carries a "Donate" action into the one-time checkout flow.
|
||||
*
|
||||
* The megaphone renders through [MegaphoneComponent], so this renders that composable directly (rather
|
||||
* than launching the full [org.thoughtcrime.securesms.MainActivity]) and asserts that tapping Donate
|
||||
* runs the production [RemoteMegaphoneRepository] action, which navigates to [CheckoutFlowActivity]
|
||||
* with [InAppPaymentType.ONE_TIME_DONATION]. The sustainer gating is covered by [DonationMegaphoneGatingTest].
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class DonationMegaphoneTest {
|
||||
|
||||
@get:Rule
|
||||
val harness = SignalActivityRule()
|
||||
|
||||
@get:Rule
|
||||
val iapRule = InAppPaymentsRule()
|
||||
|
||||
@get:Rule
|
||||
val composeRule = createComposeRule()
|
||||
|
||||
private val context: Context get() = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
SignalDatabase.remoteMegaphones.debugRemoveAll()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun donateMegaphone_donateClick_opensOneTimeCheckout() {
|
||||
val record = donateMegaphoneRecord(conditionalId = null)
|
||||
SignalDatabase.remoteMegaphones.insert(record)
|
||||
|
||||
val controller = RecordingMegaphoneActionController()
|
||||
|
||||
composeRule.setContent {
|
||||
SignalTheme {
|
||||
MegaphoneComponent(buildDonateMegaphone(record), controller)
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.onNodeWithText(record.primaryActionText!!).performClick()
|
||||
|
||||
val intent = controller.navigationIntent
|
||||
assertThat(intent).isNotNull()
|
||||
assertThat(intent!!.component?.className).isEqualTo(CheckoutFlowActivity::class.java.name)
|
||||
|
||||
val type = intent.extras!!.getSerializableCompat(CheckoutFlowActivity.ARG_IN_APP_PAYMENT_TYPE, InAppPaymentType::class.java)
|
||||
assertThat(type).isEqualTo(InAppPaymentType.ONE_TIME_DONATION)
|
||||
}
|
||||
|
||||
/** Mirrors [Megaphones.buildRemoteMegaphone]: wires the primary button to the real repository action. */
|
||||
private fun buildDonateMegaphone(record: RemoteMegaphoneRecord): Megaphone {
|
||||
return Megaphone.Builder(Megaphones.Event.REMOTE_MEGAPHONE, Megaphone.Style.BASIC)
|
||||
.setTitle(record.title)
|
||||
.setBody(record.body)
|
||||
.setActionButton(record.primaryActionText!!) { _, controller ->
|
||||
RemoteMegaphoneRepository.getAction(record.primaryActionId!!).run(context, controller, record)
|
||||
}
|
||||
.build()
|
||||
}
|
||||
|
||||
private class RecordingMegaphoneActionController : MegaphoneActionController {
|
||||
var navigationIntent: Intent? = null
|
||||
private set
|
||||
|
||||
override fun onMegaphoneNavigationRequested(intent: Intent) {
|
||||
navigationIntent = intent
|
||||
}
|
||||
|
||||
override fun onMegaphoneNavigationRequested(intent: Intent, requestCode: Int) {
|
||||
navigationIntent = intent
|
||||
}
|
||||
|
||||
override fun onMegaphoneToastRequested(string: String) = Unit
|
||||
override fun getMegaphoneActivity(): Activity = throw UnsupportedOperationException("not used")
|
||||
override fun onMegaphoneSnooze(event: Megaphones.Event) = Unit
|
||||
override fun onMegaphoneCompleted(event: Megaphones.Event) = Unit
|
||||
override fun onMegaphoneDialogFragmentRequested(dialogFragment: DialogFragment) = Unit
|
||||
}
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.megaphone
|
||||
|
||||
import android.net.Uri
|
||||
import org.thoughtcrime.securesms.badges.models.Badge
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.model.RemoteMegaphoneRecord
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
fun donateMegaphoneRecord(conditionalId: String?): RemoteMegaphoneRecord {
|
||||
val now = System.currentTimeMillis()
|
||||
return RemoteMegaphoneRecord(
|
||||
priority = 100,
|
||||
uuid = UUID.randomUUID().toString(),
|
||||
countries = null,
|
||||
minimumVersion = 1,
|
||||
doNotShowBefore = now - TimeUnit.DAYS.toMillis(2),
|
||||
doNotShowAfter = now + TimeUnit.DAYS.toMillis(28),
|
||||
showForNumberOfDays = 30,
|
||||
conditionalId = conditionalId,
|
||||
primaryActionId = RemoteMegaphoneRecord.ActionId.DONATE,
|
||||
secondaryActionId = RemoteMegaphoneRecord.ActionId.SNOOZE,
|
||||
imageUrl = null,
|
||||
title = "Donate Test",
|
||||
body = "Donate body test.",
|
||||
primaryActionText = "Donate",
|
||||
secondaryActionText = "Snooze"
|
||||
)
|
||||
}
|
||||
|
||||
fun donorBadge(): Badge {
|
||||
return Badge(
|
||||
id = "test-donor-badge",
|
||||
category = Badge.Category.Donor,
|
||||
name = "Signal Sustainer",
|
||||
description = "",
|
||||
imageUrl = Uri.EMPTY,
|
||||
imageDensity = "xxhdpi",
|
||||
expirationTimestamp = System.currentTimeMillis() + TimeUnit.DAYS.toMillis(30),
|
||||
visible = true,
|
||||
duration = TimeUnit.DAYS.toMillis(30)
|
||||
)
|
||||
}
|
||||
|
||||
fun setSelfBadges(badges: List<Badge>) {
|
||||
SignalDatabase.recipients.setBadges(Recipient.self().id, badges)
|
||||
Recipient.self().fresh()
|
||||
}
|
||||
+9
@@ -26,6 +26,7 @@ import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.testing.MessageContentFuzzer
|
||||
import org.thoughtcrime.securesms.testing.SignalActivityRule
|
||||
import org.thoughtcrime.securesms.util.MediaUtil
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress
|
||||
import org.whispersystems.signalservice.internal.push.AddressableMessage
|
||||
import org.whispersystems.signalservice.internal.push.AttachmentPointer
|
||||
@@ -51,6 +52,14 @@ class SyncMessageProcessorTest_attachmentBackfill {
|
||||
originalDeviceId = SignalStore.account.deviceId
|
||||
// Make this device a linked device so backfill response handling activates.
|
||||
SignalStore.account.deviceId = 2
|
||||
|
||||
// Prevent AttachmentDownloadJob onAdded from async changing the attachment state.
|
||||
TextSecurePreferences.getSharedPreferences(harness.application)
|
||||
.edit()
|
||||
.putStringSet(TextSecurePreferences.MEDIA_DOWNLOAD_WIFI_PREF, emptySet())
|
||||
.putStringSet(TextSecurePreferences.MEDIA_DOWNLOAD_MOBILE_PREF, emptySet())
|
||||
.putStringSet(TextSecurePreferences.MEDIA_DOWNLOAD_ROAMING_PREF, emptySet())
|
||||
.commit()
|
||||
}
|
||||
|
||||
@After
|
||||
|
||||
+33
-3
@@ -236,8 +236,9 @@ class SyncMessageProcessorTest_synchronizePniChangeNumber {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun skipsRedeliveryWhenPniAlreadyMatches() {
|
||||
sendPniChangeNumber()
|
||||
fun skipsRedeliveryWithSameServerTimestamp() {
|
||||
val timestamp = messageHelper.nextStartTime()
|
||||
sendPniChangeNumber(timestamp = timestamp)
|
||||
val afterFirstApply = captureOriginalState()
|
||||
|
||||
val otherIdentity = IdentityKeyPair.generate()
|
||||
@@ -247,12 +248,41 @@ class SyncMessageProcessorTest_synchronizePniChangeNumber {
|
||||
identityKeyPair = otherIdentity.serialize().toByteString(),
|
||||
signedPreKey = otherSignedPreKey.serialize().toByteString(),
|
||||
e164 = "+15555550100",
|
||||
timestamp = messageHelper.nextStartTime() + 1000
|
||||
timestamp = timestamp
|
||||
)
|
||||
|
||||
assertOriginalStatePreserved(afterFirstApply)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun reappliesWhenServerTimestampIsNewer() {
|
||||
sendPniChangeNumber()
|
||||
|
||||
val secondPniUuid = UUID.randomUUID()
|
||||
val secondPni = ServiceId.PNI.from(secondPniUuid)
|
||||
val secondPniBytes = UuidUtil.toByteArray(secondPniUuid).toByteString()
|
||||
val secondIdentity = IdentityKeyPair.generate()
|
||||
val secondSignedPreKey = PreKeyUtil.generateSignedPreKey(9999, secondIdentity.privateKey)
|
||||
val secondE164 = "+15555550100"
|
||||
val secondRegistrationId = 7777
|
||||
|
||||
sendPniChangeNumber(
|
||||
identityKeyPair = secondIdentity.serialize().toByteString(),
|
||||
signedPreKey = secondSignedPreKey.serialize().toByteString(),
|
||||
lastResortKyberPreKey = null,
|
||||
registrationId = secondRegistrationId,
|
||||
e164 = secondE164,
|
||||
envelopePniBinary = secondPniBytes,
|
||||
timestamp = messageHelper.nextStartTime() + 1000
|
||||
)
|
||||
|
||||
assertThat(SignalStore.account.e164).isEqualTo(secondE164)
|
||||
assertThat(SignalStore.account.pni).isEqualTo(secondPni)
|
||||
assertThat(SignalStore.account.pniRegistrationId).isEqualTo(secondRegistrationId)
|
||||
assertThat(SignalStore.account.pniIdentityKey.publicKey.serialize().toByteString())
|
||||
.isEqualTo(secondIdentity.publicKey.serialize().toByteString())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bailsWhenServerTimestampStale() {
|
||||
sendPniChangeNumber()
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.testing
|
||||
|
||||
import com.google.android.gms.wallet.PaymentData
|
||||
import io.mockk.Runs
|
||||
import io.mockk.every
|
||||
import io.mockk.just
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkConstructor
|
||||
import io.mockk.unmockkConstructor
|
||||
import io.reactivex.rxjava3.core.Completable
|
||||
import org.junit.rules.ExternalResource
|
||||
import org.signal.donations.GooglePayApi
|
||||
|
||||
/**
|
||||
* Makes Google Pay appear available and return a fake [com.google.android.gms.wallet.PaymentData] without
|
||||
* launching the real Google Pay sheet, allowing checkout to be driven to the payment pipeline in instrumentation.
|
||||
*/
|
||||
class GooglePayTestRule : ExternalResource() {
|
||||
override fun before() {
|
||||
val paymentData = mockk<PaymentData> {
|
||||
every { toJson() } returns GOOGLE_PAY_PAYMENT_DATA_JSON
|
||||
}
|
||||
|
||||
mockkConstructor(GooglePayApi::class)
|
||||
every { anyConstructed<GooglePayApi>().queryIsReadyToPay() } returns Completable.complete()
|
||||
every { anyConstructed<GooglePayApi>().requestPayment(any(), any(), any()) } just Runs
|
||||
every { anyConstructed<GooglePayApi>().onActivityResult(any(), any(), any(), any(), any()) } answers {
|
||||
arg<GooglePayApi.PaymentRequestCallback>(4).onSuccess(paymentData)
|
||||
}
|
||||
}
|
||||
|
||||
override fun after() {
|
||||
unmockkConstructor(GooglePayApi::class)
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Minimal but well-formed Google Pay payload. [org.signal.donations.GooglePayPaymentSource] parses
|
||||
* `paymentMethodData.tokenizationData.token` (itself a JSON object with an `id`) when the source is
|
||||
* serialized into the setup job, so a relaxed mock that returns an empty body fails before the job runs.
|
||||
*/
|
||||
const val GOOGLE_PAY_PAYMENT_DATA_JSON = """{"paymentMethodData":{"tokenizationData":{"token":"{\"id\":\"tok_test\"}"}},"email":"test@signal.org"}"""
|
||||
}
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.testing
|
||||
|
||||
import android.os.SystemClock
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import io.reactivex.rxjava3.schedulers.TestScheduler
|
||||
|
||||
/**
|
||||
* Timing primitives for in-app-payment instrumentation tests.
|
||||
*
|
||||
* The checkout flow runs against two clocks: the Rx pipeline that drives the UI (creating the payment,
|
||||
* enqueuing the setup job, reacting to state changes, rendering dialogs) runs on the test's [TestScheduler]
|
||||
* (virtual time), while the setup job itself executes on a live JobManager worker thread (real time). Neither
|
||||
* clock can be waited on alone — the scheduler must be advanced for the pipeline to make progress, and the
|
||||
* test must yield real time for the job to run and commit its result. [flushUntil] does both.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Advances virtual Rx time on this [TestScheduler], drains the main looper, and yields briefly for real
|
||||
* JobManager work, repeating until [condition] holds or [timeoutMs] elapses.
|
||||
*
|
||||
* [TestScheduler.triggerActions] runs the pipeline's scheduled work (which may enqueue a job or process a
|
||||
* database update), `waitForIdleSync` renders whatever that produced, and the short sleep lets the real setup
|
||||
* job run on its worker thread and commit the next state before the loop advances the scheduler again to pick
|
||||
* it up. Throws once [timeoutMs] is exceeded, which means the expected UI state never materialised.
|
||||
*
|
||||
* [condition] may either return `false` or throw (e.g. an Espresso `check` that has not yet matched) to signal
|
||||
* "not satisfied"; a thrown failure is retained and chained as the cause of the timeout [AssertionError] so a
|
||||
* flake surfaces the underlying Espresso error rather than an opaque "condition not satisfied".
|
||||
*/
|
||||
fun TestScheduler.flushUntil(timeoutMs: Long = 15_000, condition: () -> Boolean) {
|
||||
val deadline = SystemClock.uptimeMillis() + timeoutMs
|
||||
var lastFailure: Throwable? = null
|
||||
|
||||
while (true) {
|
||||
triggerActions()
|
||||
InstrumentationRegistry.getInstrumentation().waitForIdleSync()
|
||||
|
||||
try {
|
||||
if (condition()) {
|
||||
return
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
lastFailure = t
|
||||
}
|
||||
|
||||
if (SystemClock.uptimeMillis() >= deadline) {
|
||||
throw AssertionError("Condition was not satisfied within ${timeoutMs}ms of flushing the checkout pipeline.", lastFailure)
|
||||
}
|
||||
|
||||
Thread.sleep(50)
|
||||
}
|
||||
}
|
||||
@@ -7,38 +7,90 @@ package org.thoughtcrime.securesms.testing
|
||||
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import io.mockk.every
|
||||
import org.json.JSONObject
|
||||
import org.junit.rules.ExternalResource
|
||||
import org.signal.core.util.JsonUtils
|
||||
import org.signal.network.NetworkResult
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.whispersystems.signalservice.internal.push.SubscriptionsConfiguration
|
||||
import org.thoughtcrime.securesms.testing.endpoints.DonationResponses
|
||||
import org.thoughtcrime.securesms.testing.endpoints.DonationTestServer
|
||||
import org.thoughtcrime.securesms.testing.endpoints.EndpointResponse
|
||||
import org.thoughtcrime.securesms.testing.endpoints.MockEndpoints
|
||||
import org.thoughtcrime.securesms.testing.endpoints.delete
|
||||
import org.thoughtcrime.securesms.testing.endpoints.get
|
||||
import org.thoughtcrime.securesms.testing.endpoints.ok
|
||||
import org.thoughtcrime.securesms.testing.endpoints.post
|
||||
import org.thoughtcrime.securesms.testing.endpoints.put
|
||||
import org.whispersystems.signalservice.internal.push.WhoAmIResponse
|
||||
|
||||
/**
|
||||
* Sets up some common infrastructure for on-device InAppPayment testing
|
||||
* Sets up common infrastructure for on-device InAppPayment testing.
|
||||
*
|
||||
* The Signal-service edge is driven through the shared [MockEndpoints.responder] (the real
|
||||
* [org.whispersystems.signalservice.api.donations.DonationsApi] runs against these handlers), while
|
||||
* account/archive lookups that are not websocket-backed here stay mocked via mockk. A scenario can
|
||||
* override any default by registering a more specific handler after this rule runs (last-registered
|
||||
* wins).
|
||||
*/
|
||||
class InAppPaymentsRule : ExternalResource() {
|
||||
|
||||
override fun before() {
|
||||
initialiseConfigurationResponse()
|
||||
initialisePutSubscription()
|
||||
MockEndpoints.responder.clear()
|
||||
registerDonationServiceDefaults()
|
||||
initialiseSetArchiveBackupId()
|
||||
initialiseSetAccountAttributes()
|
||||
initialiseAccountLookups()
|
||||
}
|
||||
|
||||
private fun initialiseConfigurationResponse() {
|
||||
val assets = InstrumentationRegistry.getInstrumentation().context.resources.assets
|
||||
val response = assets.open("inAppPaymentsTests/configuration.json").use { stream ->
|
||||
NetworkResult.Success(JsonUtils.fromJson(stream, SubscriptionsConfiguration::class.java))
|
||||
}
|
||||
|
||||
AppDependencies.donationsApi.apply {
|
||||
every { getDonationsConfiguration(any()) } returns response
|
||||
}
|
||||
override fun after() {
|
||||
MockEndpoints.responder.clear()
|
||||
}
|
||||
|
||||
private fun initialisePutSubscription() {
|
||||
AppDependencies.donationsApi.apply {
|
||||
every { putSubscription(any()) } returns NetworkResult.Success(Unit)
|
||||
every { createSubscriber(any(), any()) } returns NetworkResult.Success(Unit)
|
||||
/**
|
||||
* Default happy-path responses for the Signal donations endpoints. Registered generic-to-specific
|
||||
* so more specific paths registered later win.
|
||||
*/
|
||||
private fun registerDonationServiceDefaults() {
|
||||
val responder = MockEndpoints.responder
|
||||
|
||||
// Subscriber lifecycle: create/put/update-level (PUT), cancel (DELETE) all succeed by default.
|
||||
responder.put("/v1/subscription/") { ok() }
|
||||
responder.delete("/v1/subscription/") { ok() }
|
||||
|
||||
// getSubscription defaults to "no active subscription".
|
||||
responder.get("/v1/subscription/") { ok(DonationResponses.emptySubscription()) }
|
||||
|
||||
// Donations configuration (registered after the generic GET so it wins for its path).
|
||||
val configuration = InstrumentationRegistry.getInstrumentation().context.resources.assets
|
||||
.open("inAppPaymentsTests/configuration.json")
|
||||
.use { it.readBytes().decodeToString() }
|
||||
responder.get("/v1/subscription/configuration") { ok(configuration) }
|
||||
|
||||
// SEPA bank-transfer mandate text (registered after the generic GET so it wins for its path).
|
||||
responder.get("/v1/subscription/bank_mandate/") { ok(DonationResponses.bankMandate()) }
|
||||
|
||||
// One-time (boost) payment intent.
|
||||
responder.post("/v1/subscription/boost/create") { ok(DonationResponses.stripeClientSecret()) }
|
||||
|
||||
// Recurring payment method setup + default payment method + level are keyed by subscriber id in
|
||||
// the middle of the path, so match on a distinctive suffix.
|
||||
responder.register({ it.method == "POST" && it.path.contains("/create_payment_method") && !it.path.contains("paypal") }) {
|
||||
ok(DonationResponses.setupIntentClientSecret())
|
||||
}
|
||||
responder.register({ it.method == "POST" && it.path.contains("/default_payment_method") }) { ok() }
|
||||
|
||||
// Receipt credential submission (boost + recurring) — mint a valid credential from the client's request.
|
||||
responder.register({ it.method == "POST" && it.path.contains("/receipt_credentials") }) { request ->
|
||||
mintReceiptCredential(request.bodyString)
|
||||
}
|
||||
|
||||
// Redemption.
|
||||
responder.post("/v1/donation/redeem-receipt") { ok() }
|
||||
}
|
||||
|
||||
private fun mintReceiptCredential(requestBody: String): EndpointResponse {
|
||||
val requestBase64 = JSONObject(requestBody).getString("receiptCredentialRequest")
|
||||
val minted = DonationTestServer.issueReceiptCredential(requestBase64)
|
||||
return ok(DonationResponses.receiptCredential(minted))
|
||||
}
|
||||
|
||||
private fun initialiseSetArchiveBackupId() {
|
||||
@@ -46,4 +98,20 @@ class InAppPaymentsRule : ExternalResource() {
|
||||
every { triggerBackupIdReservation(any(), any(), any()) } returns NetworkResult.Success(Unit)
|
||||
}
|
||||
}
|
||||
|
||||
private fun initialiseSetAccountAttributes() {
|
||||
AppDependencies.accountApi.apply {
|
||||
every { setAccountAttributes(any()) } returns NetworkResult.Success(Unit)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* whoAmI is served by the (still mocked) account API rather than the websocket responder, so
|
||||
* background jobs that resolve the account during a test hit a handled path instead of throwing.
|
||||
*/
|
||||
private fun initialiseAccountLookups() {
|
||||
AppDependencies.accountApi.apply {
|
||||
every { whoAmI() } returns NetworkResult.Success(WhoAmIResponse(number = "+15555550123"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,7 +46,8 @@ annotation class RawFlag(val key: String, val value: String)
|
||||
*/
|
||||
enum class TestRemoteConfigFlag(private val property: KProperty0<*>) {
|
||||
INTERNAL_USER(RemoteConfig::internalUser),
|
||||
DEFAULT_MAX_BACKOFF(RemoteConfig::defaultMaxBackoff);
|
||||
DEFAULT_MAX_BACKOFF(RemoteConfig::defaultMaxBackoff),
|
||||
DISAPPEAR_MORE(RemoteConfig::disappearMore);
|
||||
|
||||
val key: String
|
||||
get() {
|
||||
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.testing.actions
|
||||
|
||||
import android.os.SystemClock
|
||||
import android.view.View
|
||||
import androidx.annotation.IdRes
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.test.espresso.Espresso.onView
|
||||
import androidx.test.espresso.PerformException
|
||||
import androidx.test.espresso.contrib.RecyclerViewActions
|
||||
import androidx.test.espresso.matcher.ViewMatchers.hasDescendant
|
||||
import androidx.test.espresso.matcher.ViewMatchers.withId
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import io.reactivex.rxjava3.schedulers.TestScheduler
|
||||
import org.hamcrest.Matcher
|
||||
|
||||
/**
|
||||
* Scrolls the [RecyclerView] with id [recyclerViewId] to the view holder whose item view matches [target], or
|
||||
* whose item view contains a descendant matching [target] (e.g. a preset button inside a container row),
|
||||
* binding it if necessary, then returns. Off-screen presets/buttons are brought on-screen before a click or
|
||||
* assertion regardless of device size — important for Firebase Test Lab's varied screens.
|
||||
*
|
||||
* The DSL screens use [androidx.recyclerview.widget.ListAdapter], which diffs `submitList` on a background
|
||||
* thread and posts the result to the main thread. espresso-contrib's [RecyclerViewActions.scrollTo] scans the
|
||||
* adapter once and fails fast if that diff has not yet committed, so we retry within [timeoutMs], pumping the
|
||||
* main looper (and, if supplied, advancing [scheduler] to run the Rx work that produces the list) between
|
||||
* attempts. This is the async-diff analogue of the codebase's existing poll-until-ready test idiom; Android
|
||||
* exposes no deterministic completion hook for the differ.
|
||||
*/
|
||||
fun scrollToDescendant(
|
||||
@IdRes recyclerViewId: Int,
|
||||
target: Matcher<View>,
|
||||
scheduler: TestScheduler? = null,
|
||||
timeoutMs: Long = 5_000
|
||||
) {
|
||||
val deadline = SystemClock.uptimeMillis() + timeoutMs
|
||||
|
||||
while (true) {
|
||||
// A holder's item view may itself be the target (a bare button row) or contain it as a descendant
|
||||
// (a preset within a container), so try both rather than assume one shape.
|
||||
if (tryScrollTo(recyclerViewId, target) || tryScrollTo(recyclerViewId, hasDescendant(target))) {
|
||||
return
|
||||
}
|
||||
|
||||
if (SystemClock.uptimeMillis() >= deadline) {
|
||||
throw AssertionError("RecyclerView (id=$recyclerViewId) never bound a holder matching $target within ${timeoutMs}ms.")
|
||||
}
|
||||
|
||||
scheduler?.triggerActions()
|
||||
InstrumentationRegistry.getInstrumentation().waitForIdleSync()
|
||||
Thread.sleep(50)
|
||||
}
|
||||
}
|
||||
|
||||
private fun tryScrollTo(@IdRes recyclerViewId: Int, holderMatcher: Matcher<View>): Boolean {
|
||||
return try {
|
||||
onView(withId(recyclerViewId)).perform(RecyclerViewActions.scrollTo<RecyclerView.ViewHolder>(holderMatcher))
|
||||
true
|
||||
} catch (e: PerformException) {
|
||||
false
|
||||
}
|
||||
}
|
||||
-34
@@ -1,34 +0,0 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.testing.actions
|
||||
|
||||
import android.view.View
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.test.espresso.UiController
|
||||
import androidx.test.espresso.ViewAction
|
||||
import androidx.test.espresso.matcher.ViewMatchers.isAssignableFrom
|
||||
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
|
||||
import org.hamcrest.CoreMatchers.allOf
|
||||
import org.hamcrest.Matcher
|
||||
|
||||
/**
|
||||
* Scrolls the RecyclerView to the bottom position.
|
||||
*
|
||||
* Borrowed from [https://stackoverflow.com/a/55990445](https://stackoverflow.com/a/55990445)
|
||||
*/
|
||||
object RecyclerViewScrollToBottomAction : ViewAction {
|
||||
override fun getDescription(): String = "scroll RecyclerView to bottom"
|
||||
|
||||
override fun getConstraints(): Matcher<View> = allOf(isAssignableFrom(RecyclerView::class.java), isDisplayed())
|
||||
|
||||
override fun perform(uiController: UiController?, view: View?) {
|
||||
val recyclerView = view as RecyclerView
|
||||
val itemCount = recyclerView.adapter?.itemCount
|
||||
val position = itemCount?.minus(1) ?: 0
|
||||
recyclerView.scrollToPosition(position)
|
||||
uiController?.loopMainThreadUntilIdle()
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import org.signal.benchmark.setup.Harness
|
||||
import org.signal.benchmark.setup.OtherClient
|
||||
import org.signal.core.util.ThreadUtil
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.network.websocket.WebSocketRequestMessage
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.TestDbUtils
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
@@ -23,7 +24,6 @@ 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.signal.network.websocket.WebSocketRequestMessage
|
||||
import kotlin.random.Random
|
||||
|
||||
/**
|
||||
|
||||
@@ -78,7 +78,6 @@ class NoOpJob(parameters: Parameters) : Job(parameters) {
|
||||
StoryOnboardingDownloadJob.KEY
|
||||
)
|
||||
|
||||
fun replaceFactories(factories: Map<String, Job.Factory<*>>): Map<String, Job.Factory<*>> =
|
||||
factories.mapValues { if (it.key in STARTUP_NETWORK_JOB_KEYS) Factory() else it.value }
|
||||
fun replaceFactories(factories: Map<String, Job.Factory<*>>): Map<String, Job.Factory<*>> = factories.mapValues { if (it.key in STARTUP_NETWORK_JOB_KEYS) Factory() else it.value }
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -8,10 +8,10 @@ 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.core.util.JsonUtils
|
||||
import org.signal.network.websocket.WebSocketRequestMessage
|
||||
import org.signal.network.websocket.WebSocketResponseMessage
|
||||
import org.signal.network.websocket.WebsocketResponse
|
||||
import org.signal.core.util.JsonUtils
|
||||
import org.thoughtcrime.securesms.util.SignalTrace
|
||||
import org.whispersystems.signalservice.api.websocket.WebSocketConnectionState
|
||||
import org.whispersystems.signalservice.internal.push.SendMessageResponse
|
||||
|
||||
@@ -438,12 +438,7 @@
|
||||
android:theme="@style/Theme.Signal.DayNight.NoActionBar"
|
||||
android:windowSoftInputMode="adjustResize" />
|
||||
|
||||
<activity
|
||||
android:name=".calls.links.details.CallLinkDetailsActivity"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
|
||||
android:exported="false"
|
||||
android:theme="@style/Theme.Signal.DayNight.NoActionBar"
|
||||
android:windowSoftInputMode="stateAlwaysHidden" />
|
||||
|
||||
|
||||
<activity
|
||||
android:name=".calls.new.NewCallActivity"
|
||||
@@ -527,6 +522,13 @@
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<activity
|
||||
android:name=".preferences.EditProxyActivity"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
|
||||
android:exported="false"
|
||||
android:theme="@style/Signal.DayNight.NoActionBar"
|
||||
android:windowSoftInputMode="adjustResize" />
|
||||
|
||||
<activity
|
||||
android:name=".stories.my.MyStoriesActivity"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
|
||||
@@ -1534,7 +1536,7 @@
|
||||
</receiver>
|
||||
|
||||
<receiver
|
||||
android:name="org.thoughtcrime.securesms.jobs.ForegroundServiceUtil$Receiver"
|
||||
android:name="org.signal.core.util.ForegroundServiceUtil$Receiver"
|
||||
android:exported="false" />
|
||||
|
||||
<receiver
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -52,6 +52,7 @@ import org.signal.ringrtc.CallManager;
|
||||
import org.thoughtcrime.securesms.apkupdate.ApkUpdateRefreshListener;
|
||||
import org.thoughtcrime.securesms.avatar.AvatarPickerStorage;
|
||||
import org.thoughtcrime.securesms.backup.v2.BackupRepository;
|
||||
import org.thoughtcrime.securesms.preferences.EditProxyActivity;
|
||||
import org.thoughtcrime.securesms.conversation.drafts.DraftBlobs;
|
||||
import org.thoughtcrime.securesms.crypto.AppAttachmentSecretStore;
|
||||
import org.thoughtcrime.securesms.crypto.DatabaseSecretProvider;
|
||||
@@ -118,14 +119,16 @@ import org.thoughtcrime.securesms.service.webrtc.AndroidTelecomUtil;
|
||||
import org.thoughtcrime.securesms.storage.StorageSyncHelper;
|
||||
import org.thoughtcrime.securesms.util.AppStartup;
|
||||
import org.thoughtcrime.securesms.util.BatterySnapshotTracker;
|
||||
import org.thoughtcrime.securesms.util.CommunicationActions;
|
||||
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.signal.core.util.PlayServicesUtil;
|
||||
import org.thoughtcrime.securesms.util.RemoteConfig;
|
||||
import org.thoughtcrime.securesms.util.SignalLocalMetrics;
|
||||
import org.thoughtcrime.securesms.util.SignalUncaughtExceptionHandler;
|
||||
import org.thoughtcrime.securesms.util.SqlCipherLogTarget;
|
||||
import org.thoughtcrime.securesms.util.SupportEmailUtil;
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
import org.thoughtcrime.securesms.util.VersionTracker;
|
||||
import org.thoughtcrime.securesms.util.dynamiclanguage.DynamicLanguageContextWrapper;
|
||||
@@ -183,9 +186,9 @@ public class ApplicationContext extends Application implements AppForegroundObse
|
||||
initializeLogging();
|
||||
Log.i(TAG, "onCreate()");
|
||||
})
|
||||
.addBlocking("security-provider", this::initializeSecurityProvider)
|
||||
.addBlocking("app-dependencies", this::initializeAppDependencies)
|
||||
.addBlocking("anr-detector", this::startAnrDetector)
|
||||
.addBlocking("security-provider", this::initializeSecurityProvider)
|
||||
.addBlocking("crash-handling", this::initializeCrashHandling)
|
||||
.addBlocking("rx-init", this::initializeRx)
|
||||
.addBlocking("event-bus", () -> EventBus.builder().logNoSubscriberMessages(false).installDefaultEventBus())
|
||||
@@ -433,6 +436,15 @@ public class ApplicationContext extends Application implements AppForegroundObse
|
||||
context -> {
|
||||
context.startActivity(new Intent(context, SubmitDebugLogActivity.class));
|
||||
return Unit.INSTANCE;
|
||||
},
|
||||
context -> {
|
||||
context.startActivity(EditProxyActivity.intent(context));
|
||||
return Unit.INSTANCE;
|
||||
},
|
||||
(context, subject) -> {
|
||||
String body = SupportEmailUtil.generateSupportEmailBody(context, subject, null, null);
|
||||
CommunicationActions.openEmail(context, SupportEmailUtil.getSupportEmailAddress(context), subject, body);
|
||||
return Unit.INSTANCE;
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
@@ -280,7 +280,7 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
false,
|
||||
new ContactSelectionListAdapter.ArbitraryRepository(),
|
||||
new SearchRepository(requireContext().getString(R.string.note_to_self)),
|
||||
new ContactSearchPagedDataSourceRepository(requireContext()),
|
||||
new ContactSearchPagedDataSourceRepository(requireContext(), requireContext().getString(R.string.note_to_self)),
|
||||
fixedContacts,
|
||||
false
|
||||
)
|
||||
|
||||
@@ -9,10 +9,10 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder;
|
||||
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
|
||||
public class DeviceProvisioningActivity extends PassphraseRequiredActivity {
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
private static final String TAG = Log.tag(DeviceProvisioningActivity.class);
|
||||
|
||||
@Override
|
||||
@@ -22,6 +22,13 @@ public class DeviceProvisioningActivity extends PassphraseRequiredActivity {
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle bundle, boolean ready) {
|
||||
if (SignalStore.account().isLinkedDevice()) {
|
||||
Log.i(TAG, "Cannot link a device from a linked device. Ignoring provisioning intent.");
|
||||
startActivity(MainActivity.clearTop(this));
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
|
||||
AlertDialog dialog = new MaterialAlertDialogBuilder(this)
|
||||
.setTitle(getString(R.string.DeviceProvisioningActivity_link_a_signal_device))
|
||||
.setMessage(getString(R.string.DeviceProvisioningActivity_to_link_a_desktop_or_ipad_to_this_signal_account))
|
||||
|
||||
@@ -9,6 +9,7 @@ import android.annotation.SuppressLint
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
@@ -23,6 +24,7 @@ import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.activity.viewModels
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.BoxWithConstraintsScope
|
||||
@@ -57,10 +59,13 @@ import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.platform.LocalResources
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.fragment.app.DialogFragment
|
||||
import androidx.fragment.compose.AndroidFragment
|
||||
import androidx.fragment.compose.rememberFragmentState
|
||||
@@ -70,7 +75,10 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.createSavedStateHandle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import androidx.navigation3.runtime.entryProvider
|
||||
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
|
||||
import androidx.navigation3.ui.NavDisplay
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
@@ -101,6 +109,7 @@ import org.thoughtcrime.securesms.backup.v2.ArchiveRestoreProgressState
|
||||
import org.thoughtcrime.securesms.backup.v2.ui.CouldNotCompleteBackupRestoreSheet
|
||||
import org.thoughtcrime.securesms.backup.v2.ui.verify.VerifyBackupKeyActivity
|
||||
import org.thoughtcrime.securesms.calls.YouAreAlreadyInACallSnackbar.show
|
||||
import org.thoughtcrime.securesms.calls.callsNavEntries
|
||||
import org.thoughtcrime.securesms.calls.log.CallLogFilter
|
||||
import org.thoughtcrime.securesms.calls.log.CallLogFragment
|
||||
import org.thoughtcrime.securesms.calls.new.NewCallActivity
|
||||
@@ -137,7 +146,6 @@ import org.thoughtcrime.securesms.devicetransfer.olddevice.OldDeviceExitActivity
|
||||
import org.thoughtcrime.securesms.groups.ui.creategroup.CreateGroupActivity
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.lock.v2.CreateSvrPinActivity
|
||||
import org.thoughtcrime.securesms.main.DetailsScreenNavHost
|
||||
import org.thoughtcrime.securesms.main.MainBottomChrome
|
||||
import org.thoughtcrime.securesms.main.MainBottomChromeCallback
|
||||
import org.thoughtcrime.securesms.main.MainBottomChromeState
|
||||
@@ -157,11 +165,6 @@ import org.thoughtcrime.securesms.main.MainToolbarMode
|
||||
import org.thoughtcrime.securesms.main.MainToolbarState
|
||||
import org.thoughtcrime.securesms.main.MainToolbarViewModel
|
||||
import org.thoughtcrime.securesms.main.Material3OnScrollHelperBinder
|
||||
import org.thoughtcrime.securesms.main.callNavGraphBuilder
|
||||
import org.thoughtcrime.securesms.main.navigateToDetailLocation
|
||||
import org.thoughtcrime.securesms.main.rememberDetailNavHostController
|
||||
import org.thoughtcrime.securesms.main.rememberFocusRequester
|
||||
import org.thoughtcrime.securesms.main.storiesNavGraphBuilder
|
||||
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionActivity
|
||||
import org.thoughtcrime.securesms.mediasend.v3.mediaSendLauncher
|
||||
import org.thoughtcrime.securesms.megaphone.Megaphone
|
||||
@@ -176,14 +179,12 @@ import org.thoughtcrime.securesms.service.BackupMediaRestoreService
|
||||
import org.thoughtcrime.securesms.service.KeyCachingService
|
||||
import org.thoughtcrime.securesms.starred.StarredMessagesActivity
|
||||
import org.thoughtcrime.securesms.stories.Stories
|
||||
import org.thoughtcrime.securesms.stories.archive.StoryArchiveActivity
|
||||
import org.thoughtcrime.securesms.stories.landing.StoriesLandingFragment
|
||||
import org.thoughtcrime.securesms.stories.settings.StorySettingsActivity
|
||||
import org.thoughtcrime.securesms.stories.storiesNavEntries
|
||||
import org.thoughtcrime.securesms.util.AppStartup
|
||||
import org.thoughtcrime.securesms.util.CachedInflater
|
||||
import org.thoughtcrime.securesms.util.CommunicationActions
|
||||
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme
|
||||
import org.thoughtcrime.securesms.util.DynamicTheme
|
||||
import org.thoughtcrime.securesms.util.Material3OnScrollHelper
|
||||
import org.thoughtcrime.securesms.util.SplashScreenUtil
|
||||
import org.thoughtcrime.securesms.util.TopToastPopup
|
||||
@@ -196,6 +197,7 @@ import org.thoughtcrime.securesms.window.NavigationType
|
||||
import org.thoughtcrime.securesms.window.rememberThreePaneScaffoldNavigatorDelegate
|
||||
import org.whispersystems.signalservice.api.websocket.WebSocketConnectionState
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
import org.signal.core.ui.R as CoreUiR
|
||||
|
||||
class MainActivity :
|
||||
PassphraseRequiredActivity(),
|
||||
@@ -287,14 +289,6 @@ class MainActivity :
|
||||
|
||||
AppStartup.getInstance().onCriticalRenderEventStart()
|
||||
|
||||
enableEdgeToEdge(
|
||||
navigationBarStyle = if (DynamicTheme.isDarkTheme(this)) {
|
||||
SystemBarStyle.dark(0)
|
||||
} else {
|
||||
SystemBarStyle.light(0, 0)
|
||||
}
|
||||
)
|
||||
|
||||
super.onCreate(savedInstanceState, ready)
|
||||
navigator = MainNavigator(this, mainNavigationViewModel)
|
||||
|
||||
@@ -507,54 +501,6 @@ class MainActivity :
|
||||
mainNavigationViewModel.onSplitPaneChanged(isSplitPane)
|
||||
}
|
||||
|
||||
val callsNavHostController = rememberDetailNavHostController(
|
||||
onRequestFocus = rememberFocusRequester(
|
||||
mainNavigationViewModel = mainNavigationViewModel,
|
||||
currentListLocation = mainNavigationState.currentListLocation
|
||||
) { it == MainNavigationListLocation.CALLS }
|
||||
) {
|
||||
callNavGraphBuilder(it)
|
||||
}
|
||||
|
||||
val storiesNavHostController = rememberDetailNavHostController(
|
||||
onRequestFocus = rememberFocusRequester(
|
||||
mainNavigationViewModel = mainNavigationViewModel,
|
||||
currentListLocation = mainNavigationState.currentListLocation
|
||||
) { it == MainNavigationListLocation.STORIES }
|
||||
) {
|
||||
storiesNavGraphBuilder()
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
fun navigateToLocation(location: MainNavigationDetailLocation) {
|
||||
when (location) {
|
||||
is MainNavigationDetailLocation.Empty -> {
|
||||
when (mainNavigationState.currentListLocation) {
|
||||
MainNavigationListLocation.CHATS, MainNavigationListLocation.ARCHIVE -> {
|
||||
throw IllegalStateException("Navigation to ${mainNavigationState.currentListLocation} should be handled by ChatsBackStack.")
|
||||
}
|
||||
|
||||
MainNavigationListLocation.CALLS -> callsNavHostController
|
||||
MainNavigationListLocation.STORIES -> storiesNavHostController
|
||||
}.navigateToDetailLocation(location)
|
||||
}
|
||||
|
||||
is MainNavigationDetailLocation.Conversation, is MainNavigationDetailLocation.Chats -> {
|
||||
throw IllegalStateException("Navigation to $location should be handled by ChatsBackStack.")
|
||||
}
|
||||
|
||||
is MainNavigationDetailLocation.CallLinkDetails -> callsNavHostController.navigateToDetailLocation(location)
|
||||
is MainNavigationDetailLocation.Calls -> callsNavHostController.navigateToDetailLocation(location)
|
||||
is MainNavigationDetailLocation.Stories -> storiesNavHostController.navigateToDetailLocation(location)
|
||||
}
|
||||
}
|
||||
|
||||
mainNavigationViewModel.earlyNavigationDetailLocationRequested?.let { navigateToLocation(it) }
|
||||
mainNavigationViewModel.clearEarlyDetailLocation()
|
||||
|
||||
mainNavigationViewModel.detailLocation.collect { navigateToLocation(it) }
|
||||
}
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
BackHandler(paneExpansionState.currentAnchor == detailOnlyAnchor) {
|
||||
@@ -726,30 +672,54 @@ class MainActivity :
|
||||
}
|
||||
},
|
||||
primaryContent = {
|
||||
when (mainNavigationState.currentListLocation) {
|
||||
MainNavigationListLocation.CHATS, MainNavigationListLocation.ARCHIVE -> {
|
||||
NavDisplay(
|
||||
backStack = mainNavigationViewModel.chatsBackStackEntries,
|
||||
onBack = { mainNavigationViewModel.popChatsDetailLocation() },
|
||||
transitionSpec = TransitionSpecs.HorizontalSlide.transitionSpec,
|
||||
popTransitionSpec = TransitionSpecs.HorizontalSlide.popTransitionSpec,
|
||||
predictivePopTransitionSpec = TransitionSpecs.HorizontalSlide.predictivePopTransitionSpec,
|
||||
entryProvider = entryProvider { chatsNavEntries(convoTransitionState) }
|
||||
)
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(end = contentLayoutData.detailPaddingEnd)
|
||||
.clip(contentLayoutData.shape)
|
||||
.background(color = MaterialTheme.colorScheme.surface)
|
||||
.fillMaxSize()
|
||||
) {
|
||||
when (mainNavigationState.currentListLocation) {
|
||||
MainNavigationListLocation.CHATS, MainNavigationListLocation.ARCHIVE -> {
|
||||
NavDisplay<NavKey>(
|
||||
backStack = mainNavigationViewModel.chatsBackStackEntries,
|
||||
onBack = { mainNavigationViewModel.popChatsDetailLocation() },
|
||||
transitionSpec = { TransitionSpecs.HorizontalSlide.transitionSpec },
|
||||
popTransitionSpec = { TransitionSpecs.HorizontalSlide.popTransitionSpec },
|
||||
predictivePopTransitionSpec = { TransitionSpecs.HorizontalSlide.predictivePopTransitionSpec },
|
||||
entryProvider = entryProvider { chatsNavEntries(convoTransitionState) }
|
||||
)
|
||||
}
|
||||
|
||||
MainNavigationListLocation.CALLS -> {
|
||||
DetailsScreenNavHost(
|
||||
navHostController = callsNavHostController,
|
||||
contentLayoutData = contentLayoutData
|
||||
)
|
||||
}
|
||||
MainNavigationListLocation.CALLS -> {
|
||||
NavDisplay<NavKey>(
|
||||
backStack = mainNavigationViewModel.callsBackStackEntries,
|
||||
onBack = { mainNavigationViewModel.popCallsDetailLocation() },
|
||||
transitionSpec = { TransitionSpecs.HorizontalSlide.transitionSpec },
|
||||
popTransitionSpec = { TransitionSpecs.HorizontalSlide.popTransitionSpec },
|
||||
predictivePopTransitionSpec = { TransitionSpecs.HorizontalSlide.predictivePopTransitionSpec },
|
||||
entryDecorators = listOf(
|
||||
rememberSaveableStateHolderNavEntryDecorator(),
|
||||
rememberViewModelStoreNavEntryDecorator()
|
||||
),
|
||||
entryProvider = entryProvider { callsNavEntries(isSplitPane) }
|
||||
)
|
||||
}
|
||||
|
||||
MainNavigationListLocation.STORIES -> {
|
||||
DetailsScreenNavHost(
|
||||
navHostController = storiesNavHostController,
|
||||
contentLayoutData = contentLayoutData
|
||||
)
|
||||
MainNavigationListLocation.STORIES -> {
|
||||
NavDisplay<NavKey>(
|
||||
backStack = mainNavigationViewModel.storiesBackStackEntries,
|
||||
onBack = { mainNavigationViewModel.popStoriesDetailLocation() },
|
||||
transitionSpec = { TransitionSpecs.HorizontalSlide.transitionSpec },
|
||||
popTransitionSpec = { TransitionSpecs.HorizontalSlide.popTransitionSpec },
|
||||
predictivePopTransitionSpec = { TransitionSpecs.HorizontalSlide.predictivePopTransitionSpec },
|
||||
entryDecorators = listOf(
|
||||
rememberSaveableStateHolderNavEntryDecorator(),
|
||||
rememberViewModelStoreNavEntryDecorator()
|
||||
),
|
||||
entryProvider = entryProvider { storiesNavEntries() }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -831,6 +801,23 @@ class MainActivity :
|
||||
SignalTheme.colors.colorSurface1
|
||||
}
|
||||
|
||||
val context = LocalContext.current
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
val navBarColor = if (isSplitPane) backgroundColor.toArgb() else ContextCompat.getColor(context, CoreUiR.color.signal_colorSurface2)
|
||||
LaunchedEffect(isDarkTheme, navBarColor) {
|
||||
if (Build.VERSION.SDK_INT >= 26) {
|
||||
enableEdgeToEdge(
|
||||
navigationBarStyle = if (isDarkTheme) {
|
||||
SystemBarStyle.dark(navBarColor)
|
||||
} else {
|
||||
SystemBarStyle.light(navBarColor, navBarColor)
|
||||
}
|
||||
)
|
||||
} else {
|
||||
enableEdgeToEdge()
|
||||
}
|
||||
}
|
||||
|
||||
val modifier = when {
|
||||
isSplitPane -> {
|
||||
Modifier
|
||||
@@ -1171,7 +1158,7 @@ class MainActivity :
|
||||
} else if (SignalStore.internal.useNewMediaActivity) {
|
||||
mediaSendLauncher.launch(
|
||||
MediaSendActivityContract.Args(
|
||||
isCameraFirst = false,
|
||||
isCameraFirst = true,
|
||||
isStory = destination == MainNavigationListLocation.STORIES
|
||||
)
|
||||
)
|
||||
@@ -1249,11 +1236,11 @@ class MainActivity :
|
||||
}
|
||||
|
||||
override fun onStoryPrivacyClick() {
|
||||
startActivity(StorySettingsActivity.getIntent(this@MainActivity))
|
||||
mainNavigationViewModel.goTo(MainNavigationDetailLocation.Stories.PrivacySettings)
|
||||
}
|
||||
|
||||
override fun onStoryArchiveClick() {
|
||||
startActivity(StoryArchiveActivity.createIntent(this@MainActivity))
|
||||
mainNavigationViewModel.goTo(MainNavigationDetailLocation.Stories.Archive)
|
||||
}
|
||||
|
||||
override fun onCloseSearchClick() {
|
||||
|
||||
@@ -13,9 +13,11 @@ import androidx.core.content.ContextCompat;
|
||||
import androidx.fragment.app.Fragment;
|
||||
|
||||
import org.greenrobot.eventbus.EventBus;
|
||||
import org.signal.core.util.AppForegroundObserver;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.signal.core.util.tracing.Tracer;
|
||||
import org.signal.devicetransfer.TransferStatus;
|
||||
import org.signal.registration.RegistrationRoute;
|
||||
import org.thoughtcrime.securesms.components.settings.app.changenumber.ChangeNumberLockActivity;
|
||||
import org.thoughtcrime.securesms.crypto.MasterSecretUtil;
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies;
|
||||
@@ -30,11 +32,10 @@ import org.thoughtcrime.securesms.profiles.edit.CreateProfileActivity;
|
||||
import org.thoughtcrime.securesms.push.SignalServiceNetworkAccess;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.registration.ui.RegistrationActivity;
|
||||
import org.thoughtcrime.securesms.util.Environment;
|
||||
import org.thoughtcrime.securesms.restore.RestoreActivity;
|
||||
import org.thoughtcrime.securesms.service.KeyCachingService;
|
||||
import org.signal.core.util.AppForegroundObserver;
|
||||
import org.thoughtcrime.securesms.util.AppStartup;
|
||||
import org.thoughtcrime.securesms.util.Environment;
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
|
||||
import java.util.Locale;
|
||||
@@ -57,6 +58,7 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
|
||||
private static final int STATE_TRANSFER_LOCKED = 9;
|
||||
private static final int STATE_CHANGE_NUMBER_LOCK = 10;
|
||||
private static final int STATE_TRANSFER_OR_RESTORE = 11;
|
||||
private static final int STATE_RESUME_LINKING_REG = 12;
|
||||
|
||||
private SignalServiceNetworkAccess networkAccess;
|
||||
private BroadcastReceiver clearKeyReceiver;
|
||||
@@ -155,6 +157,7 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
|
||||
case STATE_TRANSFER_LOCKED: return getOldDeviceTransferLockedIntent();
|
||||
case STATE_CHANGE_NUMBER_LOCK: return getChangeNumberLockIntent();
|
||||
case STATE_TRANSFER_OR_RESTORE: return getTransferOrRestoreIntent();
|
||||
case STATE_RESUME_LINKING_REG: return getResumeLinkedRegistrationIntent();
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
@@ -168,6 +171,8 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
|
||||
return STATE_UI_BLOCKING_UPGRADE;
|
||||
} else if (!TextSecurePreferences.hasPromptedPushRegistration(this)) {
|
||||
return STATE_WELCOME_PUSH_SCREEN;
|
||||
} else if (shouldResumeLinkingRegistration()) {
|
||||
return STATE_RESUME_LINKING_REG;
|
||||
} else if (userCanTransferOrRestore()) {
|
||||
return STATE_TRANSFER_OR_RESTORE;
|
||||
} else if (SignalStore.storageService().getNeedsAccountRestore()) {
|
||||
@@ -192,6 +197,14 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
|
||||
RestoreDecisionStateUtil.isDecisionPending(SignalStore.registration().getRestoreDecisionState());
|
||||
}
|
||||
|
||||
private boolean shouldResumeLinkingRegistration() {
|
||||
return Environment.USE_NEW_REGISTRATION &&
|
||||
SignalStore.account().isRegistered() &&
|
||||
!SignalStore.account().isPrimaryDevice() &&
|
||||
!SignalStore.registration().isRegistrationComplete() &&
|
||||
RestoreDecisionStateUtil.isDecisionPending(SignalStore.registration().getRestoreDecisionState());
|
||||
}
|
||||
|
||||
private boolean userMustCreateSignalPin() {
|
||||
return !SignalStore.registration().isRegistrationComplete() &&
|
||||
!SignalStore.svr().hasPin() &&
|
||||
@@ -222,11 +235,7 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
|
||||
}
|
||||
|
||||
private Intent getPushRegistrationIntent() {
|
||||
if (Environment.USE_NEW_REGISTRATION) {
|
||||
return org.signal.registration.RegistrationActivity.createIntent(this, MainActivity.clearTop(this));
|
||||
} else {
|
||||
return RegistrationActivity.newIntentForNewRegistration(this, getIntent());
|
||||
}
|
||||
return RegistrationActivity.newIntentForNewRegistration(this, getIntent());
|
||||
}
|
||||
|
||||
private Intent getEnterSignalPinIntent() {
|
||||
@@ -246,10 +255,17 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
|
||||
}
|
||||
|
||||
private Intent getTransferOrRestoreIntent() {
|
||||
if (Environment.USE_NEW_REGISTRATION) {
|
||||
return org.signal.registration.RegistrationActivity.createIntent(this, MainActivity.clearTop(this));
|
||||
}
|
||||
Intent intent = RestoreActivity.getRestoreIntent(this);
|
||||
return getRoutedIntent(intent, MainActivity.clearTop(this));
|
||||
}
|
||||
|
||||
private Intent getResumeLinkedRegistrationIntent() {
|
||||
return org.signal.registration.RegistrationActivity.createIntent(this, MainActivity.clearTop(this), RegistrationRoute.MessageSync.INSTANCE);
|
||||
}
|
||||
|
||||
private Intent getCreateProfileNameIntent() {
|
||||
Intent intent = CreateProfileActivity.getIntentForUserProfile(this);
|
||||
return getRoutedIntent(intent, getIntent());
|
||||
|
||||
@@ -79,7 +79,13 @@ class SystemContactsEntrypointViewModel : ViewModel() {
|
||||
}
|
||||
|
||||
private fun getDestinationForSyncAdapter(context: Context, intent: Intent): DestinationAndBody {
|
||||
context.contentResolver.query(intent.data!!, null, null, null, null).use { cursor ->
|
||||
val uri = intent.data
|
||||
if (uri == null || uri.authority != ContactsContract.AUTHORITY) {
|
||||
Log.w(TAG, "Ignoring content URI with an unexpected authority.")
|
||||
return DestinationAndBody("", "")
|
||||
}
|
||||
|
||||
context.contentResolver.query(uri, null, null, null, null).use { cursor ->
|
||||
if (cursor != null && cursor.moveToNext()) {
|
||||
return DestinationAndBody(cursor.getString(cursor.getColumnIndexOrThrow(ContactsContract.RawContacts.Data.DATA1)), "")
|
||||
}
|
||||
|
||||
@@ -13,10 +13,14 @@ import androidx.fragment.app.setFragmentResult
|
||||
import androidx.fragment.app.setFragmentResultListener
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.navigation.Navigation
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import org.signal.core.models.media.Media
|
||||
import org.signal.core.ui.WindowBreakpoint
|
||||
import org.signal.core.ui.getWindowBreakpoint
|
||||
import org.signal.core.ui.permissions.Permissions
|
||||
import org.signal.core.util.ThreadUtil
|
||||
import org.signal.core.util.dp
|
||||
import org.signal.core.util.getParcelableExtraCompat
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.avatar.Avatar
|
||||
@@ -31,6 +35,7 @@ import org.thoughtcrime.securesms.mediasend.AvatarSelectionActivity
|
||||
import org.thoughtcrime.securesms.util.ViewUtil
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
|
||||
import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
||||
import org.thoughtcrime.securesms.util.padding
|
||||
import org.thoughtcrime.securesms.util.visible
|
||||
|
||||
/**
|
||||
@@ -65,8 +70,25 @@ class AvatarPickerFragment : Fragment(R.layout.avatar_picker_fragment) {
|
||||
val saveButton: View = view.findViewById(R.id.avatar_picker_save)
|
||||
val clearButton: View = view.findViewById(R.id.avatar_picker_clear)
|
||||
|
||||
val spanCount = when (resources.getWindowBreakpoint()) {
|
||||
is WindowBreakpoint.Small -> 4
|
||||
else -> 6
|
||||
}
|
||||
|
||||
val recyclerPadding = when (resources.getWindowBreakpoint()) {
|
||||
is WindowBreakpoint.Small -> 0
|
||||
else -> 112.dp
|
||||
}
|
||||
|
||||
recycler = view.findViewById(R.id.avatar_picker_recycler)
|
||||
recycler.addItemDecoration(GridDividerDecoration(4, ViewUtil.dpToPx(16)))
|
||||
recycler.addItemDecoration(GridDividerDecoration(spanCount, ViewUtil.dpToPx(16)))
|
||||
recycler.padding(
|
||||
left = recyclerPadding,
|
||||
right = recyclerPadding
|
||||
)
|
||||
|
||||
val gridLayoutManager: GridLayoutManager = recycler.layoutManager as GridLayoutManager
|
||||
gridLayoutManager.spanCount = spanCount
|
||||
|
||||
val adapter = MappingAdapter()
|
||||
AvatarPickerItem.register(adapter, this::onAvatarClick, this::onAvatarLongClick)
|
||||
|
||||
@@ -50,7 +50,7 @@ object ArchiveUploadProgress {
|
||||
|
||||
private val TAG = Log.tag(ArchiveUploadProgress::class)
|
||||
|
||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
||||
|
||||
private val _progress: MutableSharedFlow<Unit> = MutableSharedFlow(replay = 1)
|
||||
|
||||
@@ -119,7 +119,7 @@ object ArchiveUploadProgress {
|
||||
updateState(notify = false) { updated }
|
||||
}
|
||||
.onStart { emit(uploadProgress) }
|
||||
.flowOn(Dispatchers.IO)
|
||||
.flowOn(Dispatchers.Default)
|
||||
.shareIn(scope, SharingStarted.Eagerly, replay = 1)
|
||||
|
||||
init {
|
||||
|
||||
@@ -53,7 +53,7 @@ public enum BackupFileIOError {
|
||||
|
||||
PendingIntent pendingIntent = PendingIntent.getActivity(context, -1, AppSettingsActivity.backups(context), PendingIntentFlags.mutable());
|
||||
Notification backupFailedNotification = new NotificationCompat.Builder(context, NotificationChannels.getInstance().FAILURES)
|
||||
.setSmallIcon(R.drawable.ic_signal_backup)
|
||||
.setSmallIcon(org.signal.core.ui.R.drawable.ic_signal_backup)
|
||||
.setContentTitle(context.getString(titleId))
|
||||
.setContentText(context.getString(messageId))
|
||||
.setStyle(new NotificationCompat.BigTextStyle().bigText(context.getString(messageId)))
|
||||
|
||||
@@ -82,7 +82,7 @@ object ArchiveRestoreProgress {
|
||||
val stateFlow: Flow<ArchiveRestoreProgressState> = store
|
||||
.throttleLatest(1.seconds)
|
||||
.distinctUntilChanged()
|
||||
.flowOn(Dispatchers.IO)
|
||||
.flowOn(Dispatchers.Default)
|
||||
|
||||
init {
|
||||
SignalExecutors.BOUNDED.execute { update() }
|
||||
|
||||
@@ -102,6 +102,7 @@ import org.thoughtcrime.securesms.database.KeyValueDatabase
|
||||
import org.thoughtcrime.securesms.database.KyberPreKeyTable
|
||||
import org.thoughtcrime.securesms.database.OneTimePreKeyTable
|
||||
import org.thoughtcrime.securesms.database.SearchTable
|
||||
import org.thoughtcrime.securesms.database.SessionTable
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.SignedPreKeyTable
|
||||
import org.thoughtcrime.securesms.database.StickerTable
|
||||
@@ -184,6 +185,7 @@ import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.days
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
import org.signal.registration.R as RegistrationR
|
||||
|
||||
object BackupRepository {
|
||||
|
||||
@@ -646,7 +648,7 @@ object BackupRepository {
|
||||
val state = SignalStore.backup.backupDownloadNotifierState ?: return null
|
||||
val nextSheetDisplayTime = state.lastSheetDisplaySeconds.seconds + state.intervalSeconds.seconds
|
||||
|
||||
val remainingAttachmentSize = withContext(SignalDispatchers.IO) {
|
||||
val remainingAttachmentSize = withContext(SignalDispatchers.Default) {
|
||||
SignalDatabase.attachments.getRemainingRestorableAttachmentSize()
|
||||
}
|
||||
|
||||
@@ -1126,7 +1128,7 @@ object BackupRepository {
|
||||
}
|
||||
|
||||
return frameReader.use { reader ->
|
||||
import(reader, selfData, cancellationSignal = { false })
|
||||
import(reader, selfData, backupMode = BackupMode.LOCAL, cancellationSignal = { false })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1157,7 +1159,7 @@ object BackupRepository {
|
||||
}
|
||||
|
||||
return frameReader.use { reader ->
|
||||
import(reader, selfData, cancellationSignal)
|
||||
import(reader, selfData, backupMode = BackupMode.REMOTE, cancellationSignal = cancellationSignal)
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "Unable to restore signal backup", e)
|
||||
@@ -1185,7 +1187,7 @@ object BackupRepository {
|
||||
)
|
||||
|
||||
return frameReader.use { reader ->
|
||||
import(reader, selfData, cancellationSignal)
|
||||
import(reader, selfData, backupMode = BackupMode.LINK_SYNC, cancellationSignal = cancellationSignal)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1211,7 +1213,7 @@ object BackupRepository {
|
||||
}
|
||||
|
||||
return frameReader.use { reader ->
|
||||
import(reader, selfData, cancellationSignal)
|
||||
import(reader, selfData, backupMode = BackupMode.REMOTE, cancellationSignal = cancellationSignal)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1227,13 +1229,14 @@ object BackupRepository {
|
||||
val frameReader = PlainTextBackupReader(inputStreamFactory(), length)
|
||||
|
||||
return frameReader.use { reader ->
|
||||
import(reader, selfData, cancellationSignal)
|
||||
import(reader, selfData, backupMode = BackupMode.PLAINTEXT_EXPORT, cancellationSignal = cancellationSignal)
|
||||
}
|
||||
}
|
||||
|
||||
private fun import(
|
||||
frameReader: BackupImportReader,
|
||||
selfData: SelfData,
|
||||
backupMode: BackupMode,
|
||||
cancellationSignal: () -> Boolean
|
||||
): ImportResult {
|
||||
val stopwatch = Stopwatch("import")
|
||||
@@ -1250,6 +1253,7 @@ object BackupRepository {
|
||||
return ImportResult.Failure
|
||||
}
|
||||
SignalStore.backup.hasInvalidBackupVersion = false
|
||||
val selfId: RecipientId
|
||||
|
||||
var transactionSuccessful = false
|
||||
try {
|
||||
@@ -1290,7 +1294,16 @@ object BackupRepository {
|
||||
}
|
||||
|
||||
Log.d(TAG, "[import] --- Recreating all tables ---")
|
||||
val skipTables = setOf(KyberPreKeyTable.TABLE_NAME, OneTimePreKeyTable.TABLE_NAME, SignedPreKeyTable.TABLE_NAME)
|
||||
val skipTables = buildSet {
|
||||
add(KyberPreKeyTable.TABLE_NAME)
|
||||
add(OneTimePreKeyTable.TABLE_NAME)
|
||||
add(SignedPreKeyTable.TABLE_NAME)
|
||||
|
||||
// Preserve the session established with the primary during linking
|
||||
if (backupMode.isLinkAndSync) {
|
||||
add(SessionTable.TABLE_NAME)
|
||||
}
|
||||
}
|
||||
val tableMetadata = SignalDatabase.rawDatabase.getAllTableDefinitions().filter { !it.name.startsWith(SearchTable.FTS_TABLE_NAME + "_") }
|
||||
for (table in tableMetadata) {
|
||||
if (skipTables.contains(table.name)) {
|
||||
@@ -1321,7 +1334,7 @@ object BackupRepository {
|
||||
SignalStore.backup.mediaRootBackupKey = mediaRootBackupKey
|
||||
|
||||
// Add back self after clearing data
|
||||
val selfId: RecipientId = SignalDatabase.recipients.getAndPossiblyMerge(selfData.aci, selfData.pni, selfData.e164, pniVerified = true, changeSelf = true)
|
||||
selfId = SignalDatabase.recipients.getAndPossiblyMerge(selfData.aci, selfData.pni, selfData.e164, pniVerified = true, changeSelf = true)
|
||||
SignalDatabase.recipients.setProfileKey(selfId, selfData.profileKey)
|
||||
SignalDatabase.recipients.setProfileSharing(selfId, true)
|
||||
|
||||
@@ -1532,7 +1545,7 @@ object BackupRepository {
|
||||
Log.d(TAG, "[import] Finished! ${eventTimer.stop().summary}")
|
||||
stopwatch.stop(TAG)
|
||||
|
||||
return ImportResult.Success(backupTime = header.backupTimeMs)
|
||||
return ImportResult.Success(backupTime = header.backupTimeMs, selfRecipientId = selfId)
|
||||
}
|
||||
|
||||
fun listRemoteMediaObjects(limit: Int, cursor: String? = null): NetworkResult<ArchiveGetMediaItemsResponse> {
|
||||
@@ -1663,6 +1676,19 @@ object BackupRepository {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores the remote backup's last-modified time in [BackupValues.lastBackupTime], (404/401 clear it to 0).
|
||||
*/
|
||||
fun refreshBackupFileTimestamp(): NetworkResult<ZonedDateTime> {
|
||||
return getBackupFileLastModified().also { result ->
|
||||
when (result) {
|
||||
is NetworkResult.Success -> SignalStore.backup.lastBackupTime = result.result.toMillis()
|
||||
is NetworkResult.StatusCodeError if (result.code == 404 || result.code == 401) -> SignalStore.backup.lastBackupTime = 0L
|
||||
else -> Log.w(TAG, "Failed to refresh last backup time from remote: ${result::class.simpleName}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an object with details about the remote backup state.
|
||||
*/
|
||||
@@ -2310,26 +2336,30 @@ object BackupRepository {
|
||||
forwardSecrecyToken = forwardSecrecyToken,
|
||||
cancellationSignal = cancellationSignal
|
||||
)
|
||||
if (result == ImportResult.Failure) {
|
||||
Log.w(TAG, "[remoteRestore] Failed to import backup")
|
||||
return RemoteRestoreResult.Failure
|
||||
|
||||
return when (result) {
|
||||
is ImportResult.Failure -> {
|
||||
Log.w(TAG, "[remoteRestore] Failed to import backup")
|
||||
RemoteRestoreResult.Failure
|
||||
}
|
||||
|
||||
is ImportResult.Success -> {
|
||||
Log.i(TAG, "[remoteRestore] Restore successful")
|
||||
BackupMediaRestoreService.resetTimeout()
|
||||
AppDependencies.jobManager.add(BackupRestoreMediaJob())
|
||||
RemoteRestoreResult.Success(result.selfRecipientId)
|
||||
}
|
||||
}
|
||||
|
||||
BackupMediaRestoreService.resetTimeout()
|
||||
AppDependencies.jobManager.add(BackupRestoreMediaJob())
|
||||
|
||||
Log.i(TAG, "[remoteRestore] Restore successful")
|
||||
return RemoteRestoreResult.Success
|
||||
}
|
||||
|
||||
suspend fun restoreLinkAndSyncBackup(response: TransferArchiveResponse, ephemeralBackupKey: MessageBackupKey) {
|
||||
suspend fun restoreLinkAndSyncBackup(response: TransferArchiveResponse, ephemeralBackupKey: MessageBackupKey): RemoteRestoreResult {
|
||||
val context = AppDependencies.application
|
||||
ArchiveRestoreProgress.onRestorePending()
|
||||
|
||||
try {
|
||||
DataRestoreConstraint.isRestoringData = true
|
||||
return withContext(Dispatchers.IO) {
|
||||
return@withContext BackupProgressService.start(context, context.getString(R.string.BackupProgressService_title)).use {
|
||||
return@withContext BackupProgressService.start(context, context.getString(RegistrationR.string.MessageSyncScreen__syncing_messages)).use {
|
||||
restoreLinkAndSyncBackup(response, ephemeralBackupKey, controller = it, cancellationSignal = { !isActive })
|
||||
}
|
||||
}
|
||||
@@ -2344,7 +2374,7 @@ object BackupRepository {
|
||||
val progressListener = object : ProgressListener {
|
||||
override fun onAttachmentProgress(progress: AttachmentTransferProgress) {
|
||||
controller.update(
|
||||
title = AppDependencies.application.getString(R.string.BackupProgressService_title_downloading),
|
||||
title = AppDependencies.application.getString(RegistrationR.string.MessageSyncScreen__syncing_messages),
|
||||
progress = progress.value,
|
||||
indeterminate = false
|
||||
)
|
||||
@@ -2354,9 +2384,16 @@ object BackupRepository {
|
||||
override fun shouldCancel() = cancellationSignal()
|
||||
}
|
||||
|
||||
val cdn = response.cdn
|
||||
val key = response.key
|
||||
if (cdn == null || key == null) {
|
||||
Log.w(TAG, "[restoreLinkAndSyncBackup] Response has no archive location (error=${response.error}); nothing to download.")
|
||||
return RemoteRestoreResult.Failure
|
||||
}
|
||||
|
||||
Log.i(TAG, "[restoreLinkAndSyncBackup] Downloading backup")
|
||||
val tempBackupFile = AppDependencies.blobs.forNonAutoEncryptingSingleSessionOnDisk(AppDependencies.application)
|
||||
when (val result = AppDependencies.signalServiceMessageReceiver.retrieveLinkAndSyncBackup(response.cdn, response.key, tempBackupFile, progressListener)) {
|
||||
when (val result = AppDependencies.signalServiceMessageReceiver.retrieveLinkAndSyncBackup(cdn, key, tempBackupFile, progressListener)) {
|
||||
is NetworkResult.Success -> Log.i(TAG, "[restoreLinkAndSyncBackup] Download successful")
|
||||
else -> {
|
||||
Log.w(TAG, "[restoreLinkAndSyncBackup] Failed to download backup file", result.getCause())
|
||||
@@ -2369,7 +2406,7 @@ object BackupRepository {
|
||||
}
|
||||
|
||||
controller.update(
|
||||
title = AppDependencies.application.getString(R.string.BackupProgressService_title),
|
||||
title = AppDependencies.application.getString(RegistrationR.string.MessageSyncScreen__syncing_messages),
|
||||
progress = 0f,
|
||||
indeterminate = true
|
||||
)
|
||||
@@ -2385,16 +2422,19 @@ object BackupRepository {
|
||||
cancellationSignal = cancellationSignal
|
||||
)
|
||||
|
||||
if (result == ImportResult.Failure) {
|
||||
Log.w(TAG, "[restoreLinkAndSyncBackup] Failed to import backup")
|
||||
return RemoteRestoreResult.Failure
|
||||
return when (result) {
|
||||
is ImportResult.Failure -> {
|
||||
Log.w(TAG, "[restoreLinkAndSyncBackup] Failed to import backup")
|
||||
RemoteRestoreResult.Failure
|
||||
}
|
||||
|
||||
is ImportResult.Success -> {
|
||||
Log.i(TAG, "[restoreLinkAndSyncBackup] Restore successful")
|
||||
BackupMediaRestoreService.resetTimeout()
|
||||
AppDependencies.jobManager.add(BackupRestoreMediaJob())
|
||||
RemoteRestoreResult.Success(result.selfRecipientId)
|
||||
}
|
||||
}
|
||||
|
||||
BackupMediaRestoreService.resetTimeout()
|
||||
AppDependencies.jobManager.add(BackupRestoreMediaJob())
|
||||
|
||||
Log.i(TAG, "[restoreLinkAndSyncBackup] Restore successful")
|
||||
return RemoteRestoreResult.Success
|
||||
}
|
||||
|
||||
private fun buildDebugInfo(): ByteString {
|
||||
@@ -2524,12 +2564,12 @@ data class StagedBackupKeyRotations(
|
||||
)
|
||||
|
||||
sealed class ImportResult {
|
||||
data class Success(val backupTime: Long) : ImportResult()
|
||||
data class Success(val backupTime: Long, val selfRecipientId: RecipientId) : ImportResult()
|
||||
data object Failure : ImportResult()
|
||||
}
|
||||
|
||||
sealed interface RemoteRestoreResult {
|
||||
data object Success : RemoteRestoreResult
|
||||
data class Success(val selfRecipientId: RecipientId) : RemoteRestoreResult
|
||||
data object NetworkError : RemoteRestoreResult
|
||||
data object Canceled : RemoteRestoreResult
|
||||
data object Failure : RemoteRestoreResult
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
package org.thoughtcrime.securesms.backup.v2
|
||||
|
||||
import org.signal.core.util.LongSerializer
|
||||
import org.signal.libsignal.zkgroup.backups.BackupLevel
|
||||
|
||||
/**
|
||||
* Serializable enum value for what we think a user's current backup tier is.
|
||||
@@ -18,6 +19,13 @@ enum class MessageBackupTier(val value: Int) {
|
||||
FREE(0),
|
||||
PAID(1);
|
||||
|
||||
fun toBackupLevel(): Long {
|
||||
return when (this) {
|
||||
FREE -> BackupLevel.FREE.value.toLong()
|
||||
PAID -> BackupLevel.PAID.value.toLong()
|
||||
}
|
||||
}
|
||||
|
||||
companion object Serializer : LongSerializer<MessageBackupTier?> {
|
||||
override fun serialize(data: MessageBackupTier?): Long {
|
||||
return data?.value?.toLong() ?: -1
|
||||
@@ -26,5 +34,13 @@ enum class MessageBackupTier(val value: Int) {
|
||||
override fun deserialize(data: Long): MessageBackupTier? {
|
||||
return entries.firstOrNull { it.value == data.toInt() }
|
||||
}
|
||||
|
||||
fun fromBackupLevel(backupLevel: Long?): MessageBackupTier? {
|
||||
return when (backupLevel) {
|
||||
BackupLevel.FREE.value.toLong() -> FREE
|
||||
BackupLevel.PAID.value.toLong() -> PAID
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ import org.signal.core.util.toJson
|
||||
import org.signal.libsignal.crypto.Aes256Ctr32
|
||||
import org.thoughtcrime.securesms.backup.LocalExportProgress
|
||||
import org.thoughtcrime.securesms.backup.v2.BackupRepository
|
||||
import org.thoughtcrime.securesms.backup.v2.ImportResult
|
||||
import org.thoughtcrime.securesms.database.AttachmentTable
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
@@ -269,13 +270,18 @@ object LocalArchiver {
|
||||
|
||||
val mainStreamLength = snapshotFileSystem.mainLength() ?: return ArchiveResult.failure(RestoreFailure.MainStream)
|
||||
|
||||
BackupRepository.importLocal(
|
||||
val importResult = BackupRepository.importLocal(
|
||||
mainStreamFactory = { snapshotFileSystem.mainInputStream()!! },
|
||||
mainStreamLength = mainStreamLength,
|
||||
selfData = selfData,
|
||||
backupId = backupId,
|
||||
messageBackupKey = messageBackupKey
|
||||
)
|
||||
|
||||
if (importResult is ImportResult.Failure) {
|
||||
Log.w(TAG, "Local backup import failed")
|
||||
return RestoreResult.failure(RestoreFailure.ImportFailed)
|
||||
}
|
||||
} finally {
|
||||
metadataStream?.close()
|
||||
}
|
||||
@@ -350,6 +356,7 @@ object LocalArchiver {
|
||||
data object MainStream : RestoreFailure
|
||||
data object Cancelled : RestoreFailure
|
||||
data object BackupIdMissing : RestoreFailure
|
||||
data object ImportFailed : RestoreFailure
|
||||
data class VersionMismatch(val backupVersion: Int, val supportedVersion: Int) : RestoreFailure
|
||||
}
|
||||
|
||||
|
||||
+2
-19
@@ -16,7 +16,6 @@ import org.signal.core.models.database.AttachmentId
|
||||
import org.signal.core.util.UuidUtil
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.core.util.toByteArray
|
||||
import org.signal.libsignal.zkgroup.backups.BackupLevel
|
||||
import org.signal.mediasend.SentMediaQuality
|
||||
import org.thoughtcrime.securesms.backup.v2.ExportState
|
||||
import org.thoughtcrime.securesms.backup.v2.ImportState
|
||||
@@ -127,7 +126,7 @@ object AccountDataArchiveProcessor {
|
||||
hasCompletedUsernameOnboarding = signalStore.uiHintValues.hasCompletedUsernameOnboarding(),
|
||||
customChatColors = db.chatColorsTable.getSavedChatColors().toRemoteChatColors().also { colors -> exportState.customChatColorIds.addAll(colors.map { it.id }) },
|
||||
optimizeOnDeviceStorage = signalStore.backupValues.optimizeStorage && signalStore.backupValues.backupTier == MessageBackupTier.PAID,
|
||||
backupTier = signalStore.backupValues.backupTier.toRemoteBackupTier(),
|
||||
backupTier = signalStore.backupValues.backupTier?.toBackupLevel(),
|
||||
defaultSentMediaQuality = signalStore.settingsValues.sentMediaQuality.toRemoteSentMediaQuality(),
|
||||
autoDownloadSettings = AccountData.AutoDownloadSettings(
|
||||
images = getRemoteAutoDownloadOption("image", mobileAutoDownload, wifiAutoDownload),
|
||||
@@ -277,7 +276,7 @@ object AccountDataArchiveProcessor {
|
||||
SignalStore.story.userHasSeenGroupStoryEducationSheet = settings.hasSeenGroupStoryEducationSheet
|
||||
SignalStore.story.viewedReceiptsEnabled = settings.storyViewReceiptsEnabled ?: settings.readReceipts
|
||||
SignalStore.backup.optimizeStorage = settings.optimizeOnDeviceStorage
|
||||
SignalStore.backup.backupTier = settings.backupTier?.toLocalBackupTier()
|
||||
SignalStore.backup.backupTier = MessageBackupTier.fromBackupLevel(settings.backupTier)
|
||||
SignalStore.settings.sentMediaQuality = settings.defaultSentMediaQuality.toLocalSentMediaQuality()
|
||||
SignalStore.settings.setTheme(settings.appTheme.toLocalTheme())
|
||||
SignalStore.settings.setCallDataMode(settings.callsUseLessDataSetting.toLocalCallDataMode())
|
||||
@@ -456,22 +455,6 @@ object AccountDataArchiveProcessor {
|
||||
}
|
||||
}
|
||||
|
||||
private fun MessageBackupTier?.toRemoteBackupTier(): Long? {
|
||||
return when (this) {
|
||||
MessageBackupTier.FREE -> BackupLevel.FREE.value.toLong()
|
||||
MessageBackupTier.PAID -> BackupLevel.PAID.value.toLong()
|
||||
null -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun Long?.toLocalBackupTier(): MessageBackupTier? {
|
||||
return when (this) {
|
||||
BackupLevel.FREE.value.toLong() -> MessageBackupTier.FREE
|
||||
BackupLevel.PAID.value.toLong() -> MessageBackupTier.PAID
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun SentMediaQuality.toRemoteSentMediaQuality(): AccountData.SentMediaQuality {
|
||||
return when (this) {
|
||||
SentMediaQuality.STANDARD -> AccountData.SentMediaQuality.STANDARD
|
||||
|
||||
@@ -49,7 +49,7 @@ object BackupAlertDelegate {
|
||||
}
|
||||
|
||||
private suspend fun displayBackupDownloadNotifier(fragmentManager: FragmentManager) {
|
||||
val downloadYourBackupToday = withContext(SignalDispatchers.IO) { BackupRepository.getDownloadYourBackupData() }
|
||||
val downloadYourBackupToday = withContext(SignalDispatchers.Default) { BackupRepository.getDownloadYourBackupData() }
|
||||
when (downloadYourBackupToday?.type) {
|
||||
BackupDownloadNotifierState.Type.SHEET -> {
|
||||
Log.d(TAG, "Displaying 'Download your backup today' sheet.")
|
||||
|
||||
+2
-2
@@ -145,7 +145,7 @@ class MessageBackupsFlowViewModel(
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.d(TAG, "Failed to handle purchase.", e)
|
||||
withContext(SignalDispatchers.IO) {
|
||||
withContext(SignalDispatchers.Default) {
|
||||
InAppPaymentsRepository.handlePipelineError(
|
||||
inAppPaymentId = id,
|
||||
error = e
|
||||
@@ -297,7 +297,7 @@ class MessageBackupsFlowViewModel(
|
||||
private fun validateTypeAndUpdateState(state: MessageBackupsFlowState): MessageBackupsFlowState {
|
||||
return when (state.selectedMessageBackupTier!!) {
|
||||
MessageBackupTier.FREE -> {
|
||||
viewModelScope.launch(SignalDispatchers.IO) {
|
||||
viewModelScope.launch(SignalDispatchers.Default) {
|
||||
SignalDatabase.recipients.markNeedsSync(Recipient.self().id)
|
||||
StorageSyncHelper.scheduleSyncForDataChange()
|
||||
}
|
||||
|
||||
+1
-1
@@ -40,7 +40,7 @@ class MonthlyDonationCanceledViewModel(
|
||||
|
||||
private fun initializeFromInAppPaymentId(inAppPaymentId: InAppPaymentTable.InAppPaymentId) {
|
||||
viewModelScope.launch {
|
||||
val inAppPayment = withContext(Dispatchers.IO) {
|
||||
val inAppPayment = withContext(Dispatchers.Default) {
|
||||
SignalDatabase.inAppPayments.getById(inAppPaymentId)
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ package org.thoughtcrime.securesms.banner
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.runtime.Composable
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.emptyFlow
|
||||
|
||||
/**
|
||||
* This class represents a banner across the top of the screen.
|
||||
@@ -28,6 +29,12 @@ abstract class Banner<Model> {
|
||||
*/
|
||||
abstract val dataFlow: Flow<Model>
|
||||
|
||||
/**
|
||||
* Emits whenever this banner's [enabled] state may have changed.
|
||||
*/
|
||||
open val stateUpdates: Flow<Unit>
|
||||
get() = emptyFlow()
|
||||
|
||||
/**
|
||||
* Composable function to display the content emitted from [dataFlow].
|
||||
* You likely want to use [org.thoughtcrime.securesms.banner.ui.compose.DefaultBanner].
|
||||
|
||||
@@ -15,6 +15,11 @@ import androidx.compose.ui.platform.ViewCompositionStrategy
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.merge
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import org.signal.core.ui.compose.theme.SignalTheme
|
||||
import org.signal.core.util.logging.Log
|
||||
|
||||
@@ -32,6 +37,22 @@ class BannerManager @JvmOverloads constructor(
|
||||
val TAG = Log.tag(BannerManager::class)
|
||||
}
|
||||
|
||||
private fun selectEnabledBanner(): Banner<Any>? = banners.firstOrNull { it.enabled } as Banner<Any>?
|
||||
|
||||
/**
|
||||
* Reactively selects which [Banner] should be shown. Re-evaluates whenever any banner signals via
|
||||
* [Banner.stateUpdates] that its eligibility may have changed.
|
||||
*/
|
||||
private val selectedBanner: Flow<Banner<Any>?> =
|
||||
if (banners.isEmpty()) {
|
||||
flowOf(null)
|
||||
} else {
|
||||
merge(*banners.map { it.stateUpdates }.toTypedArray())
|
||||
.onStart { emit(Unit) }
|
||||
.map { selectEnabledBanner() }
|
||||
.distinctUntilChanged()
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-evaluates the [Banner]s, choosing one to render (if any) and updating the view.
|
||||
*/
|
||||
@@ -39,19 +60,20 @@ class BannerManager @JvmOverloads constructor(
|
||||
composeView.apply {
|
||||
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
|
||||
setContent {
|
||||
val banner: Banner<Any>? = banners.firstOrNull { it.enabled } as Banner<Any>?
|
||||
if (banner == null) {
|
||||
val banner: Banner<Any>? by selectedBanner.collectAsStateWithLifecycle(initialValue = selectEnabledBanner())
|
||||
val selected = banner
|
||||
if (selected == null) {
|
||||
onNoBannerShownListener()
|
||||
return@setContent
|
||||
}
|
||||
|
||||
key(banner) {
|
||||
val bannerState by banner.dataFlow.collectAsStateWithLifecycle(initialValue = null)
|
||||
key(selected) {
|
||||
val bannerState by selected.dataFlow.collectAsStateWithLifecycle(initialValue = null)
|
||||
|
||||
bannerState?.let { model ->
|
||||
SignalTheme {
|
||||
Box {
|
||||
banner.DisplayBanner(model, PaddingValues(horizontal = 12.dp, vertical = 8.dp))
|
||||
selected.DisplayBanner(model, PaddingValues(horizontal = 12.dp, vertical = 8.dp))
|
||||
}
|
||||
}
|
||||
onNewBannerShownListener()
|
||||
@@ -66,16 +88,14 @@ class BannerManager @JvmOverloads constructor(
|
||||
*/
|
||||
@Composable
|
||||
fun Banner() {
|
||||
val banner: Banner<Any>? = banners.firstOrNull { it.enabled } as Banner<Any>?
|
||||
if (banner == null) {
|
||||
return
|
||||
}
|
||||
val banner: Banner<Any>? by selectedBanner.collectAsStateWithLifecycle(initialValue = selectEnabledBanner())
|
||||
val selected = banner ?: return
|
||||
|
||||
key(banner) {
|
||||
val bannerState by banner.dataFlow.collectAsStateWithLifecycle(initialValue = null)
|
||||
key(selected) {
|
||||
val bannerState by selected.dataFlow.collectAsStateWithLifecycle(initialValue = null)
|
||||
|
||||
bannerState?.let { model ->
|
||||
banner.DisplayBanner(model, PaddingValues(horizontal = 12.dp, vertical = 8.dp))
|
||||
selected.DisplayBanner(model, PaddingValues(horizontal = 12.dp, vertical = 8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+8
-2
@@ -9,7 +9,9 @@ import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.runtime.Composable
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import org.thoughtcrime.securesms.backup.v2.ArchiveRestoreProgress
|
||||
import org.thoughtcrime.securesms.backup.v2.ArchiveRestoreProgressState
|
||||
@@ -32,6 +34,12 @@ class ArchiveRestoreStatusBanner(private val listener: RestoreProgressBannerList
|
||||
}
|
||||
}
|
||||
|
||||
override val stateUpdates: Flow<Unit>
|
||||
get() = ArchiveRestoreProgress.stateFlow
|
||||
.map { enabled }
|
||||
.distinctUntilChanged()
|
||||
.map { }
|
||||
|
||||
@Composable
|
||||
override fun DisplayBanner(model: ArchiveRestoreProgressState, contentPadding: PaddingValues) {
|
||||
ArchiveRestoreStatusBanner(
|
||||
@@ -40,7 +48,6 @@ class ArchiveRestoreStatusBanner(private val listener: RestoreProgressBannerList
|
||||
onActionClick = listener::onActionClick,
|
||||
onDismissClick = {
|
||||
ArchiveRestoreProgress.clearFinishedStatus()
|
||||
listener.onDismissComplete()
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -48,6 +55,5 @@ class ArchiveRestoreStatusBanner(private val listener: RestoreProgressBannerList
|
||||
interface RestoreProgressBannerListener {
|
||||
fun onBannerClick()
|
||||
fun onActionClick(data: ArchiveRestoreProgressState)
|
||||
fun onDismissComplete()
|
||||
}
|
||||
}
|
||||
|
||||
+8
-2
@@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.runtime.Composable
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
import org.signal.core.util.bytes
|
||||
import org.thoughtcrime.securesms.backup.ArchiveUploadProgress
|
||||
@@ -78,6 +79,12 @@ class ArchiveUploadStatusBanner(private val listener: UploadProgressBannerListen
|
||||
}
|
||||
}
|
||||
|
||||
override val stateUpdates: Flow<Unit>
|
||||
get() = ArchiveUploadProgress.progress
|
||||
.map { enabled }
|
||||
.distinctUntilChanged()
|
||||
.map { }
|
||||
|
||||
@Composable
|
||||
override fun DisplayBanner(model: ArchiveUploadStatusBannerViewState, contentPadding: PaddingValues) {
|
||||
ArchiveUploadStatusBannerView(
|
||||
@@ -92,7 +99,7 @@ class ArchiveUploadStatusBanner(private val listener: UploadProgressBannerListen
|
||||
}
|
||||
ArchiveUploadStatusBannerViewEvents.HideClicked -> {
|
||||
SignalStore.backup.uploadBannerVisible = false
|
||||
listener.onHidden()
|
||||
ArchiveUploadProgress.triggerUpdate()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -105,6 +112,5 @@ class ArchiveUploadStatusBanner(private val listener: UploadProgressBannerListen
|
||||
interface UploadProgressBannerListener {
|
||||
fun onBannerClick()
|
||||
fun onCancelClicked()
|
||||
fun onHidden()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,10 @@ import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.rx3.asFlow
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.thoughtcrime.securesms.R
|
||||
@@ -21,6 +24,8 @@ import org.thoughtcrime.securesms.banner.ui.compose.Action
|
||||
import org.thoughtcrime.securesms.banner.ui.compose.DefaultBanner
|
||||
import org.thoughtcrime.securesms.banner.ui.compose.Importance
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.net.DeviceTransferBlockingInterceptor
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.registration.ui.RegistrationActivity
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences
|
||||
|
||||
@@ -35,23 +40,47 @@ class UnauthorizedBanner(val context: Context) : Banner<Unit>() {
|
||||
override val dataFlow: Flow<Unit>
|
||||
get() = flowOf(Unit)
|
||||
|
||||
override val stateUpdates: Flow<Unit>
|
||||
get() = Recipient.self()
|
||||
.live()
|
||||
.observable()
|
||||
.asFlow()
|
||||
.map { enabled }
|
||||
.distinctUntilChanged()
|
||||
.map { }
|
||||
|
||||
@Composable
|
||||
override fun DisplayBanner(model: Unit, contentPadding: PaddingValues) {
|
||||
Banner(contentPadding)
|
||||
Banner(contentPadding, SignalStore.account.isLinkedDevice)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Banner(contentPadding: PaddingValues) {
|
||||
private fun Banner(contentPadding: PaddingValues, isLinkedDevice: Boolean) {
|
||||
val context = LocalContext.current
|
||||
|
||||
DefaultBanner(
|
||||
title = null,
|
||||
body = stringResource(id = R.string.UnauthorizedReminder_this_is_likely_because_you_registered_your_phone_number_with_Signal_on_a_different_device),
|
||||
body = stringResource(
|
||||
id = if (isLinkedDevice) {
|
||||
R.string.UnauthorizedReminder_this_device_is_no_longer_linked_relink_to_continue_messaging
|
||||
} else {
|
||||
R.string.UnauthorizedReminder_this_is_likely_because_you_registered_your_phone_number_with_Signal_on_a_different_device
|
||||
}
|
||||
),
|
||||
importance = Importance.ERROR,
|
||||
actions = listOf(
|
||||
Action(R.string.UnauthorizedReminder_reregister_action) {
|
||||
val registrationIntent = RegistrationActivity.newIntentForReRegistration(context)
|
||||
Action(if (isLinkedDevice) R.string.UnauthorizedReminder_relink_action else R.string.UnauthorizedReminder_reregister_action) {
|
||||
if (SignalStore.misc.isOldDeviceTransferLocked) {
|
||||
SignalStore.misc.isOldDeviceTransferLocked = false
|
||||
DeviceTransferBlockingInterceptor.getInstance().unblockNetwork()
|
||||
}
|
||||
|
||||
val registrationIntent = if (isLinkedDevice) {
|
||||
RegistrationActivity.newIntentForReLinkDevice(context)
|
||||
} else {
|
||||
RegistrationActivity.newIntentForReRegistration(context)
|
||||
}
|
||||
context.startActivity(registrationIntent)
|
||||
}
|
||||
),
|
||||
@@ -63,6 +92,6 @@ private fun Banner(contentPadding: PaddingValues) {
|
||||
@Composable
|
||||
private fun BannerPreview() {
|
||||
Previews.Preview {
|
||||
Banner(PaddingValues(0.dp))
|
||||
Banner(PaddingValues(0.dp), isLinkedDevice = false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,10 @@ class UsernameOutOfSyncBanner(private val onActionClick: (UsernameSyncState) ->
|
||||
|
||||
override val enabled: Boolean
|
||||
get() {
|
||||
if (SignalStore.account.isLinkedDevice) {
|
||||
return false
|
||||
}
|
||||
|
||||
return when (SignalStore.account.usernameSyncState) {
|
||||
AccountValues.UsernameSyncState.USERNAME_AND_LINK_CORRUPTED -> true
|
||||
AccountValues.UsernameSyncState.LINK_CORRUPTED -> true
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.calls
|
||||
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.saveable.Saver
|
||||
import androidx.compose.runtime.snapshots.SnapshotStateList
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.viewmodel.compose.SavedStateHandleSaveableApi
|
||||
import androidx.lifecycle.viewmodel.compose.saveable
|
||||
import org.thoughtcrime.securesms.calls.log.CallLogRow
|
||||
import org.thoughtcrime.securesms.main.MainDetailBackStack
|
||||
import org.thoughtcrime.securesms.main.MainNavigationDetailLocation
|
||||
|
||||
/**
|
||||
* Controls the navigation stack used by the calls screen.
|
||||
*/
|
||||
@OptIn(SavedStateHandleSaveableApi::class)
|
||||
class CallsBackStack(savedStateHandle: SavedStateHandle) : MainDetailBackStack {
|
||||
|
||||
companion object {
|
||||
private const val KEY = "calls_back_stack"
|
||||
|
||||
val saver: Saver<SnapshotStateList<MainNavigationDetailLocation>, ArrayList<MainNavigationDetailLocation>> = Saver(
|
||||
save = { ArrayList(it) },
|
||||
restore = { mutableStateListOf(*it.toTypedArray()) }
|
||||
)
|
||||
}
|
||||
|
||||
override val entries: SnapshotStateList<MainNavigationDetailLocation> = savedStateHandle.saveable(
|
||||
key = KEY,
|
||||
saver = saver
|
||||
) {
|
||||
mutableStateListOf(MainNavigationDetailLocation.Empty)
|
||||
}
|
||||
|
||||
val activeCallId: CallLogRow.Id?
|
||||
get() = entries.asReversed().firstNotNullOfOrNull { location ->
|
||||
when (location) {
|
||||
is MainNavigationDetailLocation.Calls -> location.controllerKey
|
||||
is MainNavigationDetailLocation.CallLinkDetails -> location.controllerKey
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pushes an entry onto the stack.
|
||||
*/
|
||||
override fun push(location: MainNavigationDetailLocation) {
|
||||
when {
|
||||
location is MainNavigationDetailLocation.Empty || location == entries.lastOrNull() -> Unit
|
||||
|
||||
location.isContentRoot -> {
|
||||
entries.removeAll { it !is MainNavigationDetailLocation.Empty }
|
||||
entries.add(location)
|
||||
}
|
||||
|
||||
else -> entries.add(location)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.calls
|
||||
|
||||
import androidx.activity.compose.LocalActivity
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.navigation3.runtime.EntryProviderScope
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import org.signal.core.ui.navigation.TransitionSpecs
|
||||
import org.thoughtcrime.securesms.MainNavigator
|
||||
import org.thoughtcrime.securesms.calls.links.EditCallLinkNameScreen
|
||||
import org.thoughtcrime.securesms.calls.links.details.CallLinkDetailsScreen
|
||||
import org.thoughtcrime.securesms.main.EmptyDetailScreen
|
||||
import org.thoughtcrime.securesms.main.MainNavigationDetailLocation
|
||||
|
||||
fun EntryProviderScope<NavKey>.callsNavEntries(isSplitPane: Boolean) {
|
||||
entry<MainNavigationDetailLocation.Empty> {
|
||||
NoCallSelectedEntry()
|
||||
}
|
||||
|
||||
entry<MainNavigationDetailLocation.CallLinkDetails>(
|
||||
metadata = if (isSplitPane) TransitionSpecs.None.metadata else emptyMap()
|
||||
) { route ->
|
||||
CallLinkDetailsEntry(route)
|
||||
}
|
||||
|
||||
entry<MainNavigationDetailLocation.Calls.CallLinks.EditCallLinkName> { route ->
|
||||
EditCallLinkNameEntry(route)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NoCallSelectedEntry() {
|
||||
EmptyDetailScreen()
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CallLinkDetailsEntry(route: MainNavigationDetailLocation.CallLinkDetails) {
|
||||
informNavigatorWeAreReady()
|
||||
|
||||
CallLinkDetailsScreen(roomId = route.callLinkRoomId)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EditCallLinkNameEntry(route: MainNavigationDetailLocation.Calls.CallLinks.EditCallLinkName) {
|
||||
informNavigatorWeAreReady()
|
||||
|
||||
EditCallLinkNameScreen(
|
||||
roomId = route.callLinkRoomId,
|
||||
initialName = route.currentName
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun informNavigatorWeAreReady() {
|
||||
val navigator = LocalActivity.current as? MainNavigator.NavigatorProvider
|
||||
LaunchedEffect(navigator) {
|
||||
navigator?.onFirstRender()
|
||||
}
|
||||
}
|
||||
@@ -110,6 +110,9 @@ object CallLinks {
|
||||
} catch (_: UnsupportedEncodingException) {
|
||||
Log.w(TAG, "Invalid url: $url")
|
||||
return null
|
||||
} catch (_: IllegalArgumentException) {
|
||||
Log.w(TAG, "Invalid url: $url")
|
||||
return null
|
||||
}
|
||||
|
||||
val key = fragmentQuery[ROOT_KEY]
|
||||
|
||||
+3
-2
@@ -89,7 +89,8 @@ class EditCallLinkNameDialogFragment : ComposeDialogFragment() {
|
||||
|
||||
@Composable
|
||||
fun EditCallLinkNameScreen(
|
||||
roomId: CallLinkRoomId
|
||||
roomId: CallLinkRoomId,
|
||||
initialName: String
|
||||
) {
|
||||
val viewModel: CallLinkDetailsViewModel = viewModel {
|
||||
CallLinkDetailsViewModel(roomId)
|
||||
@@ -99,7 +100,7 @@ fun EditCallLinkNameScreen(
|
||||
val lifecycleScope = LocalLifecycleOwner.current.lifecycleScope
|
||||
|
||||
EditCallLinkNameScreen(
|
||||
initialNameValue = viewModel.nameSnapshot,
|
||||
initialNameValue = initialName,
|
||||
onSaveClick = {
|
||||
lifecycleScope.launch {
|
||||
viewModel.setName(it)
|
||||
|
||||
-71
@@ -1,71 +0,0 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.calls.links.details
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.core.os.bundleOf
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import org.signal.core.ui.compose.theme.SignalTheme
|
||||
import org.signal.core.util.getParcelableExtraCompat
|
||||
import org.thoughtcrime.securesms.calls.links.EditCallLinkNameDialogFragment
|
||||
import org.thoughtcrime.securesms.main.MainNavigationCallDetailRouter
|
||||
import org.thoughtcrime.securesms.main.MainNavigationDetailLocation
|
||||
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId
|
||||
import org.thoughtcrime.securesms.util.viewModel
|
||||
|
||||
class CallLinkDetailsActivity : FragmentActivity() {
|
||||
|
||||
companion object {
|
||||
private const val ARG_ROOM_ID = "room.id"
|
||||
|
||||
fun createIntent(context: Context, callLinkRoomId: CallLinkRoomId): Intent {
|
||||
return Intent(context, CallLinkDetailsActivity::class.java)
|
||||
.putExtra(ARG_ROOM_ID, callLinkRoomId)
|
||||
}
|
||||
}
|
||||
|
||||
private val roomId: CallLinkRoomId
|
||||
get() = intent.getParcelableExtraCompat(ARG_ROOM_ID, CallLinkRoomId::class.java)!!
|
||||
|
||||
private val viewModel: CallLinkDetailsViewModel by viewModel {
|
||||
CallLinkDetailsViewModel(roomId)
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
enableEdgeToEdge()
|
||||
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
setContent {
|
||||
SignalTheme {
|
||||
CallLinkDetailsScreen(
|
||||
roomId = roomId,
|
||||
viewModel = viewModel,
|
||||
router = remember { Router() }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private inner class Router : 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun exitDetailLocation() = finishAfterTransition()
|
||||
}
|
||||
}
|
||||
+6
-1
@@ -113,7 +113,12 @@ class DefaultCallLinkDetailsCallback(
|
||||
}
|
||||
|
||||
override fun onEditNameClicked() {
|
||||
router.goToCallDetail(MainNavigationDetailLocation.Calls.CallLinks.EditCallLinkName(callLinkRoomId = viewModel.recipientSnapshot!!.requireCallLinkRoomId()))
|
||||
router.goToCallDetail(
|
||||
MainNavigationDetailLocation.Calls.CallLinks.EditCallLinkName(
|
||||
callLinkRoomId = viewModel.recipientSnapshot!!.requireCallLinkRoomId(),
|
||||
currentName = viewModel.nameSnapshot
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override fun onShareClicked() {
|
||||
|
||||
-7
@@ -6,7 +6,6 @@
|
||||
package org.thoughtcrime.securesms.calls.links.details
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
import io.reactivex.rxjava3.kotlin.plusAssign
|
||||
import io.reactivex.rxjava3.kotlin.subscribeBy
|
||||
@@ -194,10 +193,4 @@ class CallLinkDetailsViewModel(
|
||||
private fun toastCouldNotDeleteCallLink() {
|
||||
_state.update { it.copy(failureSnackbar = CallLinkDetailsState.FailureSnackbar.COULD_NOT_DELETE_CALL_LINK) }
|
||||
}
|
||||
|
||||
class Factory(private val callLinkRoomId: CallLinkRoomId) : ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
return modelClass.cast(CallLinkDetailsViewModel(callLinkRoomId)) as T
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,13 +9,13 @@ import org.signal.core.util.concurrent.LifecycleDisposable
|
||||
import org.signal.core.util.dp
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.calls.YouAreAlreadyInACallSnackbar
|
||||
import org.thoughtcrime.securesms.calls.links.details.CallLinkDetailsActivity
|
||||
import org.thoughtcrime.securesms.components.menu.ActionItem
|
||||
import org.thoughtcrime.securesms.components.menu.SignalContextMenu
|
||||
import org.thoughtcrime.securesms.components.settings.conversation.ConversationSettingsActivity
|
||||
import org.thoughtcrime.securesms.conversation.ConversationIntents
|
||||
import org.thoughtcrime.securesms.database.CallTable
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId
|
||||
import org.thoughtcrime.securesms.util.CommunicationActions
|
||||
import org.signal.core.ui.R as CoreUiR
|
||||
|
||||
@@ -122,11 +122,10 @@ class CallLogContextMenu(
|
||||
iconRes = CoreUiR.drawable.symbol_info_24,
|
||||
title = fragment.getString(R.string.CallContextMenu__info)
|
||||
) {
|
||||
val intent = when {
|
||||
peer.isCallLink -> CallLinkDetailsActivity.createIntent(fragment.requireContext(), peer.requireCallLinkRoomId())
|
||||
else -> ConversationSettingsActivity.forCall(fragment.requireContext(), peer, messageIds)
|
||||
when {
|
||||
peer.isCallLink -> callbacks.goToCallLinkDetails(peer.requireCallLinkRoomId())
|
||||
else -> fragment.startActivity(ConversationSettingsActivity.forCall(fragment.requireContext(), peer, messageIds))
|
||||
}
|
||||
fragment.startActivity(intent)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,6 +153,7 @@ class CallLogContextMenu(
|
||||
|
||||
interface Callbacks {
|
||||
fun startSelection(call: CallLogRow)
|
||||
fun goToCallLinkDetails(roomId: CallLinkRoomId)
|
||||
fun deleteCall(call: CallLogRow)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,6 @@ import org.signal.core.util.orNull
|
||||
import org.thoughtcrime.securesms.MainNavigator
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.calls.links.create.CreateCallLinkBottomSheetDialogFragment
|
||||
import org.thoughtcrime.securesms.calls.links.details.CallLinkDetailsActivity
|
||||
import org.thoughtcrime.securesms.components.ProgressCardDialogFragment
|
||||
import org.thoughtcrime.securesms.components.ScrollToPositionDelegate
|
||||
import org.thoughtcrime.securesms.components.ViewBinderDelegate
|
||||
@@ -58,6 +57,7 @@ import org.thoughtcrime.securesms.main.MainToolbarMode
|
||||
import org.thoughtcrime.securesms.main.MainToolbarViewModel
|
||||
import org.thoughtcrime.securesms.main.Material3OnScrollHelperBinder
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId
|
||||
import org.thoughtcrime.securesms.util.CommunicationActions
|
||||
import org.thoughtcrime.securesms.util.ViewUtil
|
||||
import org.thoughtcrime.securesms.util.doAfterNextLayout
|
||||
@@ -242,9 +242,11 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
|
||||
MainToolbarViewModel.Event.Search.Close -> {
|
||||
viewModel.setSearchQuery("")
|
||||
}
|
||||
|
||||
MainToolbarViewModel.Event.Search.Open -> {
|
||||
mainToolbarViewModel.setSearchHint(R.string.SearchToolbar_search)
|
||||
}
|
||||
|
||||
is MainToolbarViewModel.Event.Search.Query -> {
|
||||
viewModel.setSearchQuery(it.query.trim())
|
||||
}
|
||||
@@ -325,7 +327,7 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
|
||||
)
|
||||
startActivity(intent)
|
||||
} else {
|
||||
startActivity(CallLinkDetailsActivity.createIntent(requireContext(), callLogRow.peer.requireCallLinkRoomId()))
|
||||
goToCallLinkDetails(callLogRow.peer.requireCallLinkRoomId())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -375,6 +377,7 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
CallLogRow.CanStartCall.GROUP_TERMINATED -> ConversationDialogs.displayCannotStartGroupCallDueToGroupEndedDialog(requireContext())
|
||||
CallLogRow.CanStartCall.NOT_A_MEMBER -> ConversationDialogs.displayCannotStartGroupCallDueToNoLongerAMemberDialog(requireContext())
|
||||
CallLogRow.CanStartCall.ADMIN_ONLY -> ConversationDialogs.displayCannotStartGroupCallDueToPermissionsDialog(requireContext())
|
||||
@@ -386,6 +389,10 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
|
||||
viewModel.toggleSelected(call.id)
|
||||
}
|
||||
|
||||
override fun goToCallLinkDetails(roomId: CallLinkRoomId) {
|
||||
mainNavigationViewModel.goTo(MainNavigationDetailLocation.CallLinkDetails(roomId))
|
||||
}
|
||||
|
||||
override fun deleteCall(call: CallLogRow) {
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(resources.getQuantityString(R.plurals.CallLogFragment__delete_d_calls, 1, 1))
|
||||
|
||||
@@ -53,7 +53,7 @@ class NewCallViewModel : ViewModel() {
|
||||
}
|
||||
|
||||
private suspend fun resolveAndStartCall(id: RecipientId) {
|
||||
val recipient = withContext(Dispatchers.IO) {
|
||||
val recipient = withContext(Dispatchers.Default) {
|
||||
Recipient.resolved(id)
|
||||
}
|
||||
openCall(recipient)
|
||||
|
||||
@@ -11,6 +11,7 @@ import androidx.compose.runtime.snapshots.SnapshotStateList
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.viewmodel.compose.SavedStateHandleSaveableApi
|
||||
import androidx.lifecycle.viewmodel.compose.saveable
|
||||
import org.thoughtcrime.securesms.main.MainDetailBackStack
|
||||
import org.thoughtcrime.securesms.main.MainNavigationDetailLocation
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
|
||||
@@ -18,7 +19,7 @@ import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
* Controls the navigation stack used by the chats screen.
|
||||
*/
|
||||
@OptIn(SavedStateHandleSaveableApi::class)
|
||||
class ChatsBackStack(savedStateHandle: SavedStateHandle) {
|
||||
class ChatsBackStack(savedStateHandle: SavedStateHandle) : MainDetailBackStack {
|
||||
|
||||
companion object {
|
||||
private const val KEY = "chats_back_stack"
|
||||
@@ -29,7 +30,7 @@ class ChatsBackStack(savedStateHandle: SavedStateHandle) {
|
||||
)
|
||||
}
|
||||
|
||||
val entries: SnapshotStateList<MainNavigationDetailLocation> = savedStateHandle.saveable(
|
||||
override val entries: SnapshotStateList<MainNavigationDetailLocation> = savedStateHandle.saveable(
|
||||
key = KEY,
|
||||
saver = saver
|
||||
) {
|
||||
@@ -45,13 +46,10 @@ class ChatsBackStack(savedStateHandle: SavedStateHandle) {
|
||||
}
|
||||
}
|
||||
|
||||
val isEmpty: Boolean
|
||||
get() = entries.singleOrNull() is MainNavigationDetailLocation.Empty
|
||||
|
||||
/**
|
||||
* Pushes an entry onto the stack.
|
||||
*/
|
||||
fun push(location: MainNavigationDetailLocation) {
|
||||
override fun push(location: MainNavigationDetailLocation) {
|
||||
when (location) {
|
||||
is MainNavigationDetailLocation.Empty, entries.lastOrNull() -> Unit
|
||||
|
||||
@@ -63,23 +61,4 @@ class ChatsBackStack(savedStateHandle: SavedStateHandle) {
|
||||
else -> entries.add(location)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pops the top entry off the stack. Returns true if something was popped, false if the stack is already at its root.
|
||||
*/
|
||||
fun pop(): Boolean {
|
||||
if (entries.size <= 1) return false
|
||||
entries.removeAt(entries.lastIndex)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the stack to its base empty state.
|
||||
*/
|
||||
fun reset() {
|
||||
entries.removeAll { it !is MainNavigationDetailLocation.Empty }
|
||||
if (entries.isEmpty()) {
|
||||
entries.add(MainNavigationDetailLocation.Empty)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,11 +48,15 @@ class SignalProgressDialog private constructor(
|
||||
}
|
||||
|
||||
fun hide() {
|
||||
dialog.hide()
|
||||
if (dialog.window?.decorView?.isAttachedToWindow == true) {
|
||||
dialog.hide()
|
||||
}
|
||||
}
|
||||
|
||||
fun dismiss() {
|
||||
dialog.dismiss()
|
||||
if (dialog.window?.decorView?.isAttachedToWindow == true) {
|
||||
dialog.dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -274,8 +274,17 @@ class DividerPreferenceViewHolder(itemView: View) : MappingViewHolder<DividerPre
|
||||
class SectionHeaderPreferenceViewHolder(itemView: View) : MappingViewHolder<SectionHeaderPreference>(itemView) {
|
||||
|
||||
private val sectionHeader: TextView = itemView.findViewById(R.id.section_header)
|
||||
private val iconEndView: ImageView = itemView.findViewById(R.id.icon_end)
|
||||
|
||||
override fun bind(model: SectionHeaderPreference) {
|
||||
sectionHeader.text = model.title.resolve(context)
|
||||
|
||||
val iconEnd = model.iconEnd?.resolve(context)
|
||||
iconEndView.setImageDrawable(iconEnd)
|
||||
iconEndView.visible = iconEnd != null
|
||||
|
||||
if (model.onClick != null) {
|
||||
iconEndView.setOnClickListener { model.onClick() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+5
-1
@@ -90,7 +90,11 @@ class AppSettingsActivity : DSLSettingsActivity(), GooglePayComponent {
|
||||
is AppSettingsRoute.BackupsRoute.Backups -> AppSettingsFragmentDirections.actionDirectToBackupsSettingsFragment().setLaunchCheckoutFlow(appSettingsRoute.launchCheckoutFlow)
|
||||
AppSettingsRoute.Invite -> AppSettingsFragmentDirections.actionDirectToInviteFragment()
|
||||
AppSettingsRoute.DataAndStorageRoute.DataAndStorage -> AppSettingsFragmentDirections.actionDirectToStoragePreferenceFragment()
|
||||
AppSettingsRoute.AccountRoute.Account -> AppSettingsFragmentDirections.actionDirectToAccountSettingsFragment()
|
||||
AppSettingsRoute.AccountRoute.Account -> if (SignalStore.account.isPrimaryDevice) {
|
||||
AppSettingsFragmentDirections.actionDirectToAccountSettingsFragment()
|
||||
} else {
|
||||
AppSettingsFragmentDirections.actionDirectToLinkedDeviceAccountSettingsFragment()
|
||||
}
|
||||
else -> error("Unsupported start location: ${appSettingsRoute?.javaClass?.name}")
|
||||
}
|
||||
}
|
||||
|
||||
+31
-26
@@ -76,6 +76,7 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaym
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.completed.InAppPaymentsBottomSheetDelegate
|
||||
import org.thoughtcrime.securesms.compose.rememberStatusBarColorNestedScrollModifier
|
||||
import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.profiles.ProfileName
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.util.CommunicationActions
|
||||
@@ -96,7 +97,11 @@ class AppSettingsFragment : ComposeFragment(), Callbacks {
|
||||
appSettingsRouter.currentRoute.collect { route ->
|
||||
when (route) {
|
||||
is AppSettingsRoute.BackupsRoute.Remote -> findNavController().safeNavigate(R.id.action_appSettingsFragment_to_remoteBackupsSettingsFragment)
|
||||
is AppSettingsRoute.AccountRoute.Account -> findNavController().safeNavigate(R.id.action_appSettingsFragment_to_accountSettingsFragment)
|
||||
is AppSettingsRoute.AccountRoute.Account -> if (SignalStore.account.isPrimaryDevice) {
|
||||
findNavController().safeNavigate(R.id.action_appSettingsFragment_to_accountSettingsFragment)
|
||||
} else {
|
||||
findNavController().safeNavigate(R.id.action_appSettingsFragment_to_linkedDeviceAccountSettingsFragment)
|
||||
}
|
||||
is AppSettingsRoute.LinkDeviceRoute.LinkDevice -> findNavController().safeNavigate(R.id.action_appSettingsFragment_to_linkDeviceFragment)
|
||||
is AppSettingsRoute.DonationsRoute.Donations -> findNavController().safeNavigate(R.id.action_appSettingsFragment_to_manageDonationsFragment)
|
||||
is AppSettingsRoute.AppearanceRoute.Appearance -> findNavController().safeNavigate(R.id.action_appSettingsFragment_to_appearanceSettingsFragment)
|
||||
@@ -180,7 +185,7 @@ class AppSettingsFragment : ComposeFragment(), Callbacks {
|
||||
@StringRes toastSuccessStringRes: Int
|
||||
) {
|
||||
lifecycleScope.launch {
|
||||
val subscriber = withContext(Dispatchers.IO) {
|
||||
val subscriber = withContext(Dispatchers.Default) {
|
||||
InAppPaymentsRepository.getSubscriber(subscriberType)
|
||||
}
|
||||
|
||||
@@ -292,17 +297,17 @@ private fun AppSettingsContent(
|
||||
BackupFailureState.NONE -> Unit
|
||||
}
|
||||
|
||||
if (state.isPrimaryDevice) {
|
||||
item {
|
||||
Rows.TextRow(
|
||||
text = stringResource(R.string.AccountSettingsFragment__account),
|
||||
icon = painterResource(CoreUiR.drawable.symbol_person_circle_24),
|
||||
onClick = {
|
||||
callbacks.navigate(AppSettingsRoute.AccountRoute.Account)
|
||||
}
|
||||
)
|
||||
}
|
||||
item {
|
||||
Rows.TextRow(
|
||||
text = stringResource(R.string.AccountSettingsFragment__account),
|
||||
icon = painterResource(CoreUiR.drawable.symbol_person_circle_24),
|
||||
onClick = {
|
||||
callbacks.navigate(AppSettingsRoute.AccountRoute.Account)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (state.isPrimaryDevice) {
|
||||
item {
|
||||
Rows.TextRow(
|
||||
text = stringResource(R.string.preferences__linked_devices),
|
||||
@@ -412,20 +417,20 @@ private fun AppSettingsContent(
|
||||
)
|
||||
}
|
||||
|
||||
if (state.isPrimaryDevice) {
|
||||
item {
|
||||
Rows.TextRow(
|
||||
icon = SignalIcons.Backup.imageVector,
|
||||
text = stringResource(R.string.preferences_chats__backups),
|
||||
onClick = {
|
||||
callbacks.navigate(AppSettingsRoute.BackupsRoute.Backups())
|
||||
},
|
||||
onLongClick = {
|
||||
callbacks.copyRemoteBackupsSubscriberIdToClipboard()
|
||||
},
|
||||
enabled = isRegisteredAndUpToDate
|
||||
)
|
||||
}
|
||||
item {
|
||||
Rows.TextRow(
|
||||
icon = SignalIcons.Backup.imageVector,
|
||||
text = stringResource(R.string.preferences_chats__backups),
|
||||
onClick = {
|
||||
callbacks.navigate(AppSettingsRoute.BackupsRoute.Backups())
|
||||
},
|
||||
onLongClick = if (state.isPrimaryDevice) {
|
||||
{ callbacks.copyRemoteBackupsSubscriberIdToClipboard() }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
enabled = isRegisteredAndUpToDate
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.settings.app.account
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import org.signal.core.ui.BottomSheetUtil
|
||||
import org.signal.core.ui.compose.BottomSheets
|
||||
import org.signal.core.ui.compose.ComposeBottomSheetDialogFragment
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.SignalIcons
|
||||
import org.signal.core.ui.compose.horizontalGutters
|
||||
import org.thoughtcrime.securesms.R
|
||||
|
||||
/**
|
||||
* Bottom sheet shown from the linked-device Account settings screen explaining what linked devices
|
||||
* are and that account management lives on the primary device.
|
||||
*/
|
||||
class LinkedDeviceAccountLearnMoreBottomSheet : ComposeBottomSheetDialogFragment() {
|
||||
|
||||
override val peekHeightPercentage: Float = 0.75f
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
fun show(fragmentManager: FragmentManager) {
|
||||
LinkedDeviceAccountLearnMoreBottomSheet()
|
||||
.show(fragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun SheetContent() {
|
||||
LinkedDeviceAccountLearnMoreSheet()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LinkedDeviceAccountLearnMoreSheet() {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.horizontalGutters()
|
||||
.padding(bottom = 48.dp)
|
||||
) {
|
||||
BottomSheets.Handle()
|
||||
|
||||
Icon(
|
||||
imageVector = SignalIcons.Devices.imageVector,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier
|
||||
.padding(top = 16.dp, bottom = 24.dp)
|
||||
.clip(RoundedCornerShape(50))
|
||||
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.12f))
|
||||
.padding(horizontal = 20.dp, vertical = 6.dp)
|
||||
.size(40.dp)
|
||||
)
|
||||
|
||||
val linkedDevices = stringResource(R.string.LinkedDeviceAccountLearnMoreBottomSheet__linked_devices)
|
||||
val text = stringResource(R.string.LinkedDeviceAccountLearnMoreBottomSheet__linked_devices_let_you_access)
|
||||
val annotatedText = remember(text, linkedDevices) {
|
||||
buildAnnotatedString {
|
||||
val start = text.indexOf(linkedDevices)
|
||||
if (start >= 0) {
|
||||
append(text.substring(0, start))
|
||||
withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) {
|
||||
append(linkedDevices)
|
||||
}
|
||||
append(text.substring(start + linkedDevices.length))
|
||||
} else {
|
||||
append(text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = annotatedText,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 20.dp)
|
||||
)
|
||||
|
||||
BulletRow(stringResource(R.string.LinkedDeviceAccountLearnMoreBottomSheet__you_can_link_a_desktop))
|
||||
BulletRow(stringResource(R.string.LinkedDeviceAccountLearnMoreBottomSheet__your_primary_device_manages))
|
||||
BulletRow(stringResource(R.string.LinkedDeviceAccountLearnMoreBottomSheet__some_account_settings))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BulletRow(text: String) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 12.dp),
|
||||
verticalAlignment = Alignment.Top
|
||||
) {
|
||||
Text(
|
||||
text = "\u2022",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
modifier = Modifier.padding(end = 8.dp)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun LinkedDeviceAccountLearnMoreSheetPreview() {
|
||||
Previews.BottomSheetContentPreview {
|
||||
LinkedDeviceAccountLearnMoreSheet()
|
||||
}
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.settings.app.account
|
||||
|
||||
sealed interface LinkedDeviceAccountSettingsEvent {
|
||||
/** The user tapped "Learn more" on the linked-device callout. */
|
||||
data object LearnMoreClicked : LinkedDeviceAccountSettingsEvent
|
||||
|
||||
/** The user tapped the navigation (back) icon. */
|
||||
data object NavigateBackClicked : LinkedDeviceAccountSettingsEvent
|
||||
|
||||
/** The user tapped the "Delete app data" row. */
|
||||
data object DeleteAppDataClicked : LinkedDeviceAccountSettingsEvent
|
||||
|
||||
/** The user confirmed the delete in the confirmation dialog. */
|
||||
data object DeleteConfirmed : LinkedDeviceAccountSettingsEvent
|
||||
|
||||
/** The user dismissed the delete confirmation dialog. */
|
||||
data object DeleteDismissed : LinkedDeviceAccountSettingsEvent
|
||||
|
||||
/** The fragment reported that clearing application data failed. */
|
||||
data object DataWipeFailed : LinkedDeviceAccountSettingsEvent
|
||||
|
||||
/** The fragment has handled the current [LinkedDeviceAccountSettingsState.oneTimeEvent]. */
|
||||
data object ConsumeOneTimeEvent : LinkedDeviceAccountSettingsEvent
|
||||
}
|
||||
+220
@@ -0,0 +1,220 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.settings.app.account
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.background
|
||||
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.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
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.compose.ui.unit.dp
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import org.signal.core.ui.compose.Buttons
|
||||
import org.signal.core.ui.compose.ComposeFragment
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Dialogs
|
||||
import org.signal.core.ui.compose.Dividers
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.Rows
|
||||
import org.signal.core.ui.compose.Scaffolds
|
||||
import org.signal.core.ui.compose.SignalIcons
|
||||
import org.signal.core.ui.compose.theme.SignalTheme
|
||||
import org.signal.core.util.ServiceUtil
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.app.account.LinkedDeviceAccountSettingsState.OneTimeEvent
|
||||
import org.thoughtcrime.securesms.compose.rememberStatusBarColorNestedScrollModifier
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
|
||||
/**
|
||||
* Account settings shown when the current device is a linked (non-primary) device. Account
|
||||
* management lives on the primary device, so this screen only surfaces an informational callout
|
||||
* and the ability to delete the Signal data stored on this device.
|
||||
*/
|
||||
class LinkedDeviceAccountSettingsFragment : ComposeFragment() {
|
||||
|
||||
private val viewModel: LinkedDeviceAccountSettingsViewModel by viewModels()
|
||||
|
||||
@Composable
|
||||
override fun FragmentContent() {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
LaunchedEffect(state.oneTimeEvent) {
|
||||
viewModel.onEvent(LinkedDeviceAccountSettingsEvent.ConsumeOneTimeEvent)
|
||||
when (state.oneTimeEvent) {
|
||||
OneTimeEvent.OpenLearnMore -> LinkedDeviceAccountLearnMoreBottomSheet.show(childFragmentManager)
|
||||
OneTimeEvent.NavigateBack -> requireActivity().onBackPressedDispatcher.onBackPressed()
|
||||
OneTimeEvent.WipeData -> {
|
||||
if (!ServiceUtil.getActivityManager(AppDependencies.application).clearApplicationUserData()) {
|
||||
viewModel.onEvent(LinkedDeviceAccountSettingsEvent.DataWipeFailed)
|
||||
}
|
||||
}
|
||||
OneTimeEvent.DeleteFailed -> Toast.makeText(requireContext(), R.string.preferences_account_delete_all_data_failed, Toast.LENGTH_LONG).show()
|
||||
null -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
LinkedDeviceAccountSettingsScreen(
|
||||
state = state,
|
||||
onEvent = viewModel::onEvent
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LinkedDeviceAccountSettingsScreen(
|
||||
state: LinkedDeviceAccountSettingsState,
|
||||
onEvent: (LinkedDeviceAccountSettingsEvent) -> Unit
|
||||
) {
|
||||
Scaffolds.Settings(
|
||||
title = stringResource(R.string.AccountSettingsFragment__account),
|
||||
onNavigationClick = { onEvent(LinkedDeviceAccountSettingsEvent.NavigateBackClicked) },
|
||||
navigationIcon = SignalIcons.ArrowStart.imageVector
|
||||
) { paddingValues ->
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.padding(paddingValues)
|
||||
.then(rememberStatusBarColorNestedScrollModifier())
|
||||
) {
|
||||
item {
|
||||
LinkedDeviceCallout(onLearnMoreClick = { onEvent(LinkedDeviceAccountSettingsEvent.LearnMoreClicked) })
|
||||
}
|
||||
|
||||
item {
|
||||
Dividers.Default()
|
||||
}
|
||||
|
||||
item {
|
||||
Rows.TextRow(
|
||||
text = stringResource(R.string.LinkedDeviceAccountSettingsFragment__delete_app_data),
|
||||
label = stringResource(R.string.LinkedDeviceAccountSettingsFragment__this_will_remove_all_data),
|
||||
onClick = { onEvent(LinkedDeviceAccountSettingsEvent.DeleteAppDataClicked) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (state.showDeleteConfirmationDialog) {
|
||||
DeleteAppDataConfirmationDialog(
|
||||
onConfirm = { onEvent(LinkedDeviceAccountSettingsEvent.DeleteConfirmed) },
|
||||
onDismiss = { onEvent(LinkedDeviceAccountSettingsEvent.DeleteDismissed) }
|
||||
)
|
||||
}
|
||||
|
||||
if (state.deleting) {
|
||||
Dialogs.IndeterminateProgressDialog()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LinkedDeviceCallout(
|
||||
onLearnMoreClick: () -> Unit
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 24.dp, vertical = 12.dp)
|
||||
.fillMaxWidth()
|
||||
.background(color = SignalTheme.colors.colorSurface2, shape = RoundedCornerShape(12.dp))
|
||||
.padding(20.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = SignalIcons.Devices.imageVector,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(24.dp))
|
||||
|
||||
Column {
|
||||
Text(
|
||||
text = stringResource(R.string.LinkedDeviceAccountSettingsFragment__this_is_a_linked_device),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.LinkedDeviceAccountSettingsFragment__to_manage_your_account_settings),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
Buttons.Small(
|
||||
onClick = onLearnMoreClick,
|
||||
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.secondaryContainer, contentColor = MaterialTheme.colorScheme.onSecondaryContainer)
|
||||
) {
|
||||
Text(text = stringResource(R.string.LearnMoreTextView_learn_more))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DeleteAppDataConfirmationDialog(
|
||||
onConfirm: () -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
Dialogs.SimpleAlertDialog(
|
||||
title = stringResource(R.string.LinkedDeviceAccountSettingsFragment__delete_app_data_question),
|
||||
body = stringResource(R.string.LinkedDeviceAccountSettingsFragment__this_will_delete_all_data_and_messages),
|
||||
confirm = stringResource(R.string.delete),
|
||||
confirmColor = MaterialTheme.colorScheme.error,
|
||||
onConfirm = onConfirm,
|
||||
dismiss = stringResource(android.R.string.cancel),
|
||||
onDismiss = onDismiss
|
||||
)
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun LinkedDeviceAccountSettingsScreenPreview() {
|
||||
Previews.Preview {
|
||||
LinkedDeviceAccountSettingsScreen(
|
||||
state = LinkedDeviceAccountSettingsState(),
|
||||
onEvent = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun LinkedDeviceAccountSettingsScreenDeleteConfirmationPreview() {
|
||||
Previews.Preview {
|
||||
LinkedDeviceAccountSettingsScreen(
|
||||
state = LinkedDeviceAccountSettingsState(showDeleteConfirmationDialog = true),
|
||||
onEvent = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun LinkedDeviceAccountSettingsScreenDeletingPreview() {
|
||||
Previews.Preview {
|
||||
LinkedDeviceAccountSettingsScreen(
|
||||
state = LinkedDeviceAccountSettingsState(deleting = true),
|
||||
onEvent = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.settings.app.account
|
||||
|
||||
data class LinkedDeviceAccountSettingsState(
|
||||
val showDeleteConfirmationDialog: Boolean = false,
|
||||
val deleting: Boolean = false,
|
||||
val oneTimeEvent: OneTimeEvent? = null
|
||||
) {
|
||||
/** One-time side effects the fragment must carry out (they need Android/fragment context). Consumed via [LinkedDeviceAccountSettingsEvent.ConsumeOneTimeEvent]. */
|
||||
sealed interface OneTimeEvent {
|
||||
data object OpenLearnMore : OneTimeEvent
|
||||
data object NavigateBack : OneTimeEvent
|
||||
data object WipeData : OneTimeEvent
|
||||
data object DeleteFailed : OneTimeEvent
|
||||
}
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.settings.app.account
|
||||
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.signal.core.ui.compose.EventDrivenViewModel
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.components.settings.app.account.LinkedDeviceAccountSettingsState.OneTimeEvent
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.linkdevice.LinkDeviceRepository
|
||||
|
||||
/**
|
||||
* Drives the account settings screen shown on a linked (non-primary) device. Owns the screen state
|
||||
* and, on delete, makes a best-effort attempt to unlink this device from the server before the
|
||||
* fragment wipes local data.
|
||||
*/
|
||||
class LinkedDeviceAccountSettingsViewModel(
|
||||
private val selfDeviceId: () -> Int = { SignalStore.account.deviceId },
|
||||
private val removeDevice: suspend (Int) -> Boolean = LinkDeviceRepository::removeDevice
|
||||
) : EventDrivenViewModel<LinkedDeviceAccountSettingsEvent>(TAG) {
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(LinkedDeviceAccountSettingsViewModel::class)
|
||||
}
|
||||
|
||||
private val _state = MutableStateFlow(LinkedDeviceAccountSettingsState())
|
||||
val state: StateFlow<LinkedDeviceAccountSettingsState> = _state.asStateFlow()
|
||||
|
||||
override suspend fun processEvent(event: LinkedDeviceAccountSettingsEvent) {
|
||||
applyEvent(_state.value, event) { _state.value = it }
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
suspend fun applyEvent(state: LinkedDeviceAccountSettingsState, event: LinkedDeviceAccountSettingsEvent, stateEmitter: (LinkedDeviceAccountSettingsState) -> Unit) {
|
||||
when (event) {
|
||||
LinkedDeviceAccountSettingsEvent.LearnMoreClicked -> stateEmitter(state.copy(oneTimeEvent = OneTimeEvent.OpenLearnMore))
|
||||
LinkedDeviceAccountSettingsEvent.NavigateBackClicked -> stateEmitter(state.copy(oneTimeEvent = OneTimeEvent.NavigateBack))
|
||||
LinkedDeviceAccountSettingsEvent.DeleteAppDataClicked -> stateEmitter(state.copy(showDeleteConfirmationDialog = true))
|
||||
LinkedDeviceAccountSettingsEvent.DeleteDismissed -> stateEmitter(state.copy(showDeleteConfirmationDialog = false))
|
||||
LinkedDeviceAccountSettingsEvent.DeleteConfirmed -> {
|
||||
val deletingState = state.copy(showDeleteConfirmationDialog = false, deleting = true)
|
||||
stateEmitter(deletingState)
|
||||
withContext(Dispatchers.IO) { removeDevice(selfDeviceId()) }
|
||||
stateEmitter(deletingState.copy(oneTimeEvent = OneTimeEvent.WipeData))
|
||||
}
|
||||
LinkedDeviceAccountSettingsEvent.DataWipeFailed -> stateEmitter(state.copy(deleting = false, oneTimeEvent = OneTimeEvent.DeleteFailed))
|
||||
LinkedDeviceAccountSettingsEvent.ConsumeOneTimeEvent -> stateEmitter(state.copy(oneTimeEvent = null))
|
||||
}
|
||||
}
|
||||
}
|
||||
+6
-6
@@ -58,7 +58,7 @@ class BackupStateObserver(
|
||||
companion object {
|
||||
private val TAG = Log.tag(BackupStateObserver::class)
|
||||
|
||||
private val staticScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private val staticScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
private val backupTierChangedNotifier = MutableSharedFlow<Unit>()
|
||||
|
||||
/**
|
||||
@@ -107,13 +107,13 @@ class BackupStateObserver(
|
||||
}
|
||||
}
|
||||
|
||||
scope.launch(SignalDispatchers.IO) {
|
||||
scope.launch(SignalDispatchers.Default) {
|
||||
backupTierChangedNotifier.collect {
|
||||
requestBackupStateRefresh()
|
||||
}
|
||||
}
|
||||
|
||||
scope.launch(SignalDispatchers.IO) {
|
||||
scope.launch(SignalDispatchers.Default) {
|
||||
InternetConnectionObserver.observe().asFlow()
|
||||
.collect {
|
||||
if (backupState.value is BackupState.Error) {
|
||||
@@ -122,19 +122,19 @@ class BackupStateObserver(
|
||||
}
|
||||
}
|
||||
|
||||
scope.launch(SignalDispatchers.IO) {
|
||||
scope.launch(SignalDispatchers.Default) {
|
||||
InAppPaymentsRepository.observeLatestBackupPayment().collect {
|
||||
requestBackupStateRefresh()
|
||||
}
|
||||
}
|
||||
|
||||
scope.launch(SignalDispatchers.IO) {
|
||||
scope.launch(SignalDispatchers.Default) {
|
||||
SignalStore.backup.subscriptionStateMismatchDetectedFlow.collect {
|
||||
requestBackupStateRefresh()
|
||||
}
|
||||
}
|
||||
|
||||
scope.launch(SignalDispatchers.IO) {
|
||||
scope.launch(SignalDispatchers.Default) {
|
||||
SignalStore.backup.deletionStateFlow.collect {
|
||||
requestBackupStateRefresh()
|
||||
}
|
||||
|
||||
+145
-44
@@ -35,6 +35,11 @@ import androidx.compose.ui.platform.LocalLocale
|
||||
import androidx.compose.ui.res.dimensionResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.text.LinkAnnotation
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.withLink
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
@@ -58,6 +63,7 @@ import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsType
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.MessageBackupsCheckoutLauncher.createBackupsCheckoutLauncher
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
|
||||
import org.thoughtcrime.securesms.util.CommunicationActions
|
||||
import org.thoughtcrime.securesms.util.DateUtils
|
||||
import org.thoughtcrime.securesms.util.Environment
|
||||
import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
||||
@@ -86,7 +92,7 @@ class BackupsSettingsFragment : ComposeFragment() {
|
||||
findNavController().safeNavigate(R.id.action_backupsSettingsFragment_to_remoteBackupsSettingsFragment)
|
||||
}
|
||||
|
||||
if (savedInstanceState == null && args.launchCheckoutFlow) {
|
||||
if (savedInstanceState == null && args.launchCheckoutFlow && SignalStore.account.isPrimaryDevice) {
|
||||
checkoutLauncher.launch(null)
|
||||
}
|
||||
}
|
||||
@@ -103,7 +109,9 @@ class BackupsSettingsFragment : ComposeFragment() {
|
||||
is BackupState.Error -> Unit
|
||||
|
||||
BackupState.None -> {
|
||||
checkoutLauncher.launch(null)
|
||||
if (!state.isLinkedDevice) {
|
||||
checkoutLauncher.launch(null)
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
@@ -112,13 +120,14 @@ class BackupsSettingsFragment : ComposeFragment() {
|
||||
}
|
||||
},
|
||||
onOnDeviceBackupsRowClick = {
|
||||
if (SignalStore.backup.newLocalBackupsEnabled || (Environment.Backups.isNewFormatSupportedForLocalBackup() && !SignalStore.settings.isBackupEnabled)) {
|
||||
if (state.isLinkedDevice || SignalStore.backup.newLocalBackupsEnabled || (Environment.Backups.isNewFormatSupportedForLocalBackup() && !SignalStore.settings.isBackupEnabled)) {
|
||||
findNavController().safeNavigate(R.id.action_backupsSettingsFragment_to_localBackupsFragment)
|
||||
} else {
|
||||
findNavController().safeNavigate(R.id.action_backupsSettingsFragment_to_backupsPreferenceFragment)
|
||||
}
|
||||
},
|
||||
onBackupTierInternalOverrideChanged = { viewModel.onBackupTierInternalOverrideChanged(it) }
|
||||
onBackupTierInternalOverrideChanged = { viewModel.onBackupTierInternalOverrideChanged(it) },
|
||||
onLearnMoreClick = { CommunicationActions.openBrowserLink(requireContext(), "https://support.signal.org/hc/articles/360007059752") }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -129,7 +138,8 @@ private fun BackupsSettingsContent(
|
||||
onNavigationClick: () -> Unit = {},
|
||||
onBackupsRowClick: () -> Unit = {},
|
||||
onOnDeviceBackupsRowClick: () -> Unit = {},
|
||||
onBackupTierInternalOverrideChanged: (MessageBackupTier?) -> Unit = {}
|
||||
onBackupTierInternalOverrideChanged: (MessageBackupTier?) -> Unit = {},
|
||||
onLearnMoreClick: () -> Unit = {}
|
||||
) {
|
||||
Scaffolds.Settings(
|
||||
title = stringResource(R.string.preferences_chats__backups),
|
||||
@@ -166,21 +176,29 @@ private fun BackupsSettingsContent(
|
||||
}
|
||||
|
||||
item {
|
||||
val displayActionButton = !backupsSettingsState.isLinkedDevice
|
||||
|
||||
when (backupsSettingsState.backupState) {
|
||||
is BackupState.LocalStore -> {
|
||||
LocalStoreBackupRow(
|
||||
backupState = backupsSettingsState.backupState,
|
||||
lastBackupAt = backupsSettingsState.lastBackupAt,
|
||||
onBackupsRowClick = onBackupsRowClick
|
||||
onBackupsRowClick = onBackupsRowClick,
|
||||
displayActionButton = displayActionButton
|
||||
)
|
||||
|
||||
OtherWaysToBackUpHeading()
|
||||
}
|
||||
|
||||
is BackupState.Inactive -> {
|
||||
InactiveBackupsRow(
|
||||
onBackupsRowClick = onBackupsRowClick
|
||||
)
|
||||
if (backupsSettingsState.isLinkedDevice) {
|
||||
BackupsOffLinkedDeviceRow(onLearnMoreClick = onLearnMoreClick)
|
||||
} else {
|
||||
InactiveBackupsRow(
|
||||
onBackupsRowClick = onBackupsRowClick,
|
||||
displayActionButton = true
|
||||
)
|
||||
}
|
||||
|
||||
OtherWaysToBackUpHeading()
|
||||
}
|
||||
@@ -189,23 +207,30 @@ private fun BackupsSettingsContent(
|
||||
ActiveBackupsRow(
|
||||
backupState = backupsSettingsState.backupState,
|
||||
onBackupsRowClick = onBackupsRowClick,
|
||||
lastBackupAt = backupsSettingsState.lastBackupAt
|
||||
lastBackupAt = backupsSettingsState.lastBackupAt,
|
||||
displayActionButton = true
|
||||
)
|
||||
|
||||
OtherWaysToBackUpHeading()
|
||||
}
|
||||
|
||||
BackupState.None -> {
|
||||
NeverEnabledBackupsRow(
|
||||
onBackupsRowClick = onBackupsRowClick
|
||||
)
|
||||
if (backupsSettingsState.isLinkedDevice) {
|
||||
BackupsOffLinkedDeviceRow(onLearnMoreClick = onLearnMoreClick)
|
||||
} else {
|
||||
NeverEnabledBackupsRow(
|
||||
onBackupsRowClick = onBackupsRowClick,
|
||||
displayActionButton = true
|
||||
)
|
||||
}
|
||||
|
||||
OtherWaysToBackUpHeading()
|
||||
}
|
||||
|
||||
is BackupState.Error -> {
|
||||
WaitingForNetworkRow(
|
||||
onBackupsRowClick = onBackupsRowClick
|
||||
onBackupsRowClick = onBackupsRowClick,
|
||||
displayActionButton = displayActionButton
|
||||
)
|
||||
|
||||
OtherWaysToBackUpHeading()
|
||||
@@ -213,7 +238,8 @@ private fun BackupsSettingsContent(
|
||||
|
||||
BackupState.NotFound -> {
|
||||
NotFoundBackupRow(
|
||||
onBackupsRowClick = onBackupsRowClick
|
||||
onBackupsRowClick = onBackupsRowClick,
|
||||
displayActionButton = displayActionButton
|
||||
)
|
||||
|
||||
OtherWaysToBackUpHeading()
|
||||
@@ -221,7 +247,8 @@ private fun BackupsSettingsContent(
|
||||
|
||||
is BackupState.Pending -> {
|
||||
PendingBackupRow(
|
||||
onBackupsRowClick = onBackupsRowClick
|
||||
onBackupsRowClick = onBackupsRowClick,
|
||||
displayActionButton = displayActionButton
|
||||
)
|
||||
|
||||
OtherWaysToBackUpHeading()
|
||||
@@ -231,7 +258,8 @@ private fun BackupsSettingsContent(
|
||||
ActiveBackupsRow(
|
||||
backupState = backupsSettingsState.backupState,
|
||||
lastBackupAt = backupsSettingsState.lastBackupAt,
|
||||
onBackupsRowClick = onBackupsRowClick
|
||||
onBackupsRowClick = onBackupsRowClick,
|
||||
displayActionButton = displayActionButton
|
||||
)
|
||||
|
||||
OtherWaysToBackUpHeading()
|
||||
@@ -262,7 +290,8 @@ private fun OtherWaysToBackUpHeading() {
|
||||
|
||||
@Composable
|
||||
private fun NeverEnabledBackupsRow(
|
||||
onBackupsRowClick: () -> Unit = {}
|
||||
onBackupsRowClick: () -> Unit = {},
|
||||
displayActionButton: Boolean = true
|
||||
) {
|
||||
Rows.TextRow(
|
||||
modifier = Modifier.wrapContentHeight(),
|
||||
@@ -291,13 +320,15 @@ private fun NeverEnabledBackupsRow(
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
|
||||
Buttons.MediumTonal(
|
||||
onClick = onBackupsRowClick,
|
||||
modifier = Modifier.padding(top = 12.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.BackupsSettingsFragment_set_up)
|
||||
)
|
||||
if (displayActionButton) {
|
||||
Buttons.MediumTonal(
|
||||
onClick = onBackupsRowClick,
|
||||
modifier = Modifier.padding(top = 12.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.BackupsSettingsFragment_set_up)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -305,12 +336,57 @@ private fun NeverEnabledBackupsRow(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WaitingForNetworkRow(onBackupsRowClick: () -> Unit = {}) {
|
||||
private fun BackupsOffLinkedDeviceRow(
|
||||
onLearnMoreClick: () -> Unit = {}
|
||||
) {
|
||||
val description = buildAnnotatedString {
|
||||
append(stringResource(R.string.BackupsSettingsFragment__automatic_backups_get_started_on_your_phone))
|
||||
append(" ")
|
||||
withLink(LinkAnnotation.Clickable(tag = "learn-more") { onLearnMoreClick() }) {
|
||||
withStyle(SpanStyle(color = MaterialTheme.colorScheme.primary)) {
|
||||
append(stringResource(R.string.RemoteBackupsSettingsFragment__learn_more))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Rows.TextRow(
|
||||
modifier = Modifier.wrapContentHeight(),
|
||||
icon = {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(top = 12.dp)
|
||||
.align(Alignment.Top)
|
||||
) {
|
||||
Icon(
|
||||
painter = SignalIcons.Backup.painter,
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
},
|
||||
text = {
|
||||
Column {
|
||||
Text(
|
||||
text = stringResource(R.string.RemoteBackupsSettingsFragment__signal_backups),
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
|
||||
Text(
|
||||
text = description,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WaitingForNetworkRow(onBackupsRowClick: () -> Unit = {}, displayActionButton: Boolean = true) {
|
||||
Rows.TextRow(
|
||||
text = {
|
||||
Column {
|
||||
Text(text = stringResource(R.string.RemoteBackupsSettingsFragment__waiting_for_network))
|
||||
ViewSettingsButton(onBackupsRowClick)
|
||||
ViewSettingsButton(onBackupsRowClick, displayActionButton)
|
||||
}
|
||||
},
|
||||
icon = {
|
||||
@@ -321,7 +397,8 @@ private fun WaitingForNetworkRow(onBackupsRowClick: () -> Unit = {}) {
|
||||
|
||||
@Composable
|
||||
private fun InactiveBackupsRow(
|
||||
onBackupsRowClick: () -> Unit = {}
|
||||
onBackupsRowClick: () -> Unit = {},
|
||||
displayActionButton: Boolean = true
|
||||
) {
|
||||
Rows.TextRow(
|
||||
text = {
|
||||
@@ -336,7 +413,7 @@ private fun InactiveBackupsRow(
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
ViewSettingsButton(onBackupsRowClick)
|
||||
ViewSettingsButton(onBackupsRowClick, displayActionButton)
|
||||
}
|
||||
},
|
||||
icon = {
|
||||
@@ -354,7 +431,8 @@ private fun InactiveBackupsRow(
|
||||
|
||||
@Composable
|
||||
private fun NotFoundBackupRow(
|
||||
onBackupsRowClick: () -> Unit = {}
|
||||
onBackupsRowClick: () -> Unit = {},
|
||||
displayActionButton: Boolean = true
|
||||
) {
|
||||
Rows.TextRow(
|
||||
modifier = Modifier.wrapContentHeight(),
|
||||
@@ -382,7 +460,7 @@ private fun NotFoundBackupRow(
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
ViewSettingsButton(onBackupsRowClick)
|
||||
ViewSettingsButton(onBackupsRowClick, displayActionButton)
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -390,7 +468,8 @@ private fun NotFoundBackupRow(
|
||||
|
||||
@Composable
|
||||
private fun PendingBackupRow(
|
||||
onBackupsRowClick: () -> Unit = {}
|
||||
onBackupsRowClick: () -> Unit = {},
|
||||
displayActionButton: Boolean = true
|
||||
) {
|
||||
Rows.TextRow(
|
||||
modifier = Modifier.wrapContentHeight(),
|
||||
@@ -418,14 +497,18 @@ private fun PendingBackupRow(
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
|
||||
ViewSettingsButton(onBackupsRowClick)
|
||||
ViewSettingsButton(onBackupsRowClick, displayActionButton)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ViewSettingsButton(onClick: () -> Unit) {
|
||||
private fun ViewSettingsButton(onClick: () -> Unit, visible: Boolean = true) {
|
||||
if (!visible) {
|
||||
return
|
||||
}
|
||||
|
||||
Buttons.MediumTonal(
|
||||
onClick = onClick,
|
||||
modifier = Modifier.padding(top = 12.dp)
|
||||
@@ -440,7 +523,8 @@ private fun ViewSettingsButton(onClick: () -> Unit) {
|
||||
private fun LocalStoreBackupRow(
|
||||
backupState: BackupState.LocalStore,
|
||||
lastBackupAt: Duration,
|
||||
onBackupsRowClick: () -> Unit
|
||||
onBackupsRowClick: () -> Unit,
|
||||
displayActionButton: Boolean = true
|
||||
) {
|
||||
Rows.TextRow(
|
||||
modifier = Modifier.wrapContentHeight(),
|
||||
@@ -475,7 +559,7 @@ private fun LocalStoreBackupRow(
|
||||
)
|
||||
|
||||
LastBackedUpText(lastBackupAt)
|
||||
ViewSettingsButton(onBackupsRowClick)
|
||||
ViewSettingsButton(onBackupsRowClick, displayActionButton)
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -485,7 +569,8 @@ private fun LocalStoreBackupRow(
|
||||
private fun ActiveBackupsRow(
|
||||
backupState: BackupState.WithTypeAndRenewalTime,
|
||||
lastBackupAt: Duration,
|
||||
onBackupsRowClick: () -> Unit = {}
|
||||
onBackupsRowClick: () -> Unit = {},
|
||||
displayActionButton: Boolean = true
|
||||
) {
|
||||
Rows.TextRow(
|
||||
modifier = Modifier.wrapContentHeight(),
|
||||
@@ -552,7 +637,7 @@ private fun ActiveBackupsRow(
|
||||
|
||||
LastBackedUpText(lastBackupAt)
|
||||
|
||||
ViewSettingsButton(onBackupsRowClick)
|
||||
ViewSettingsButton(onBackupsRowClick, displayActionButton)
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -644,7 +729,8 @@ private fun BackupsSettingsContentPreview() {
|
||||
renewalTime = 0.seconds,
|
||||
price = FiatMoney(BigDecimal.valueOf(4), Currency.getInstance("CAD"))
|
||||
),
|
||||
lastBackupAt = 0.seconds
|
||||
lastBackupAt = 0.seconds,
|
||||
isLinkedDevice = false
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -652,14 +738,21 @@ private fun BackupsSettingsContentPreview() {
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun BackupsSettingsContentBackupTierInternalOverridePreview() {
|
||||
private fun BackupsSettingsContentLinkedDevicePreview() {
|
||||
Previews.Preview {
|
||||
BackupsSettingsContent(
|
||||
backupsSettingsState = BackupsSettingsState(
|
||||
backupState = BackupState.None,
|
||||
showBackupTierInternalOverride = true,
|
||||
backupTierInternalOverride = null,
|
||||
lastBackupAt = 0.seconds
|
||||
backupState = BackupState.ActivePaid(
|
||||
messageBackupsType = MessageBackupsType.Paid(
|
||||
pricePerMonth = FiatMoney(BigDecimal.valueOf(2.99), Currency.getInstance("USD")),
|
||||
storageAllowanceBytes = 1_000_000,
|
||||
mediaTtl = 30.days
|
||||
),
|
||||
renewalTime = 0.seconds,
|
||||
price = FiatMoney(BigDecimal.valueOf(2.99), Currency.getInstance("USD"))
|
||||
),
|
||||
lastBackupAt = 0.seconds,
|
||||
isLinkedDevice = true
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -758,3 +851,11 @@ private fun NeverEnabledBackupsRowPreview() {
|
||||
NeverEnabledBackupsRow()
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun BackupsOffLinkedDeviceRowPreview() {
|
||||
Previews.Preview {
|
||||
BackupsOffLinkedDeviceRow()
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -17,5 +17,6 @@ data class BackupsSettingsState(
|
||||
val backupState: BackupState,
|
||||
val lastBackupAt: Duration = SignalStore.backup.lastBackupTime.milliseconds,
|
||||
val showBackupTierInternalOverride: Boolean = false,
|
||||
val backupTierInternalOverride: MessageBackupTier? = null
|
||||
val backupTierInternalOverride: MessageBackupTier? = null,
|
||||
val isLinkedDevice: Boolean = SignalStore.account.isLinkedDevice
|
||||
)
|
||||
|
||||
+9
-2
@@ -15,6 +15,7 @@ import kotlinx.coroutines.launch
|
||||
import org.signal.core.util.concurrent.SignalDispatchers
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.backup.DeletionState
|
||||
import org.thoughtcrime.securesms.backup.v2.BackupRepository
|
||||
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
@@ -44,14 +45,14 @@ class BackupsSettingsViewModel : ViewModel() {
|
||||
it.copy(
|
||||
backupState = enabledState,
|
||||
lastBackupAt = SignalStore.backup.lastBackupTime.milliseconds,
|
||||
showBackupTierInternalOverride = Environment.IS_STAGING,
|
||||
showBackupTierInternalOverride = Environment.IS_STAGING && SignalStore.account.isPrimaryDevice,
|
||||
backupTierInternalOverride = SignalStore.backup.backupTierInternalOverride
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
viewModelScope.launch(Dispatchers.Default) {
|
||||
SignalStore.backup.lastBackupTimeFlow
|
||||
.collect { lastBackupTime ->
|
||||
internalStateFlow.update {
|
||||
@@ -59,6 +60,12 @@ class BackupsSettingsViewModel : ViewModel() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (SignalStore.account.isLinkedDevice) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
BackupRepository.refreshBackupFileTimestamp()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onBackupTierInternalOverrideChanged(tier: MessageBackupTier?) {
|
||||
|
||||
+6
@@ -33,6 +33,7 @@ sealed interface LocalBackupsSettingsCallback {
|
||||
fun onLearnMoreClick()
|
||||
fun onLaunchBackupLocationPickerClick()
|
||||
fun onTurnOffAndDeleteConfirmed()
|
||||
fun onViewOptimizeStorageSettingClick()
|
||||
|
||||
object Empty : LocalBackupsSettingsCallback {
|
||||
override fun onNavigationClick() = Unit
|
||||
@@ -43,6 +44,7 @@ sealed interface LocalBackupsSettingsCallback {
|
||||
override fun onLearnMoreClick() = Unit
|
||||
override fun onLaunchBackupLocationPickerClick() = Unit
|
||||
override fun onTurnOffAndDeleteConfirmed() = Unit
|
||||
override fun onViewOptimizeStorageSettingClick() = Unit
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,4 +146,8 @@ class DefaultLocalBackupsSettingsCallback(
|
||||
override fun onTurnOffAndDeleteConfirmed() {
|
||||
viewModel.turnOffAndDelete(fragment.requireContext())
|
||||
}
|
||||
|
||||
override fun onViewOptimizeStorageSettingClick() {
|
||||
fragment.findNavController().safeNavigate(R.id.action_direct_to_storagePreferenceFragment)
|
||||
}
|
||||
}
|
||||
|
||||
+30
-4
@@ -53,6 +53,7 @@ internal fun LocalBackupsSettingsScreen(
|
||||
val context = LocalContext.current
|
||||
var showChooseLocationDialog by rememberSaveable { mutableStateOf(false) }
|
||||
var showTurnOffAndDeleteDialog by rememberSaveable { mutableStateOf(false) }
|
||||
var showOptimizeStorageWarningDialog by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
val learnMore = stringResource(id = R.string.BackupsPreferenceFragment__learn_more)
|
||||
val restoreText = stringResource(id = R.string.OnDeviceBackupsScreen__to_restore_a_backup, learnMore).trim()
|
||||
@@ -80,6 +81,14 @@ internal fun LocalBackupsSettingsScreen(
|
||||
educationSheetMessage = stringResource(R.string.RemoteBackupsSettingsFragment__to_view_your_key)
|
||||
)
|
||||
|
||||
val proceedWithTurnOn = {
|
||||
if (BackupUtil.isUserSelectionRequired(context)) {
|
||||
showChooseLocationDialog = true
|
||||
} else {
|
||||
callback.onTurnOnClick()
|
||||
}
|
||||
}
|
||||
|
||||
Scaffolds.Settings(
|
||||
title = stringResource(id = R.string.RemoteBackupsSettingsFragment__on_device_backups),
|
||||
navigationIcon = ImageVector.vectorResource(CoreUiR.drawable.symbol_arrow_start_24),
|
||||
@@ -104,11 +113,10 @@ internal fun LocalBackupsSettingsScreen(
|
||||
|
||||
Buttons.MediumTonal(
|
||||
onClick = {
|
||||
// For the SAF-based flow, present an in-screen dialog before launching the picker.
|
||||
if (BackupUtil.isUserSelectionRequired(context)) {
|
||||
showChooseLocationDialog = true
|
||||
if (state.optimizeStorageEnabled) {
|
||||
showOptimizeStorageWarningDialog = true
|
||||
} else {
|
||||
callback.onTurnOnClick()
|
||||
proceedWithTurnOn()
|
||||
}
|
||||
},
|
||||
enabled = state.canTurnOn,
|
||||
@@ -220,6 +228,24 @@ internal fun LocalBackupsSettingsScreen(
|
||||
)
|
||||
}
|
||||
|
||||
if (showOptimizeStorageWarningDialog) {
|
||||
Dialogs.AdvancedAlertDialog(
|
||||
body = stringResource(id = R.string.OnDeviceBackupsScreen__you_have_optimize_signal_storage_on),
|
||||
positive = stringResource(id = R.string.OnDeviceBackupsScreen__view_setting),
|
||||
neutral = stringResource(id = R.string.OnDeviceBackupsScreen__continue_without_turning_off),
|
||||
negative = stringResource(id = android.R.string.cancel),
|
||||
onPositive = {
|
||||
showOptimizeStorageWarningDialog = false
|
||||
callback.onViewOptimizeStorageSettingClick()
|
||||
},
|
||||
onNeutral = {
|
||||
showOptimizeStorageWarningDialog = false
|
||||
proceedWithTurnOn()
|
||||
},
|
||||
onNegative = { showOptimizeStorageWarningDialog = false }
|
||||
)
|
||||
}
|
||||
|
||||
if (showTurnOffAndDeleteDialog) {
|
||||
Dialogs.SimpleAlertDialog(
|
||||
title = stringResource(id = R.string.BackupDialog_delete_backups),
|
||||
|
||||
+1
@@ -15,6 +15,7 @@ import org.thoughtcrime.securesms.keyvalue.protos.LocalBackupCreationProgress
|
||||
data class LocalBackupsSettingsState(
|
||||
val backupsEnabled: Boolean = false,
|
||||
val canTurnOn: Boolean = true,
|
||||
val optimizeStorageEnabled: Boolean = false,
|
||||
val lastBackupLabel: String? = null,
|
||||
val folderDisplayName: String? = null,
|
||||
val scheduleTimeLabel: String? = null,
|
||||
|
||||
+2
-1
@@ -103,7 +103,8 @@ class LocalBackupsViewModel : ViewModel(), BackupKeyCredentialManagerHandler {
|
||||
internalSettingsState.update {
|
||||
it.copy(
|
||||
canTurnOn = canTurnOn,
|
||||
scheduleTimeLabel = backupTime
|
||||
scheduleTimeLabel = backupTime,
|
||||
optimizeStorageEnabled = SignalStore.backup.optimizeStorage
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -44,7 +44,7 @@ class BackupKeyDisplayViewModel : ViewModel(), BackupKeyCredentialManagerHandler
|
||||
viewModelScope.launch {
|
||||
internalUiState.update { it.copy(rotationState = BackupKeyRotationState.GENERATING_KEY) }
|
||||
|
||||
val stagedKeyRotations = withContext(SignalDispatchers.IO) {
|
||||
val stagedKeyRotations = withContext(SignalDispatchers.Default) {
|
||||
BackupRepository.stageBackupKeyRotations()
|
||||
}
|
||||
|
||||
|
||||
+171
-38
@@ -452,7 +452,8 @@ private fun RemoteBackupsSettingsContent(
|
||||
state = state.backupState,
|
||||
onLearnMoreClick = contentCallbacks::onLearnMoreAboutLostSubscription,
|
||||
onRenewClick = contentCallbacks::onRenewLostSubscription,
|
||||
isRenewEnabled = backupDeleteState.isIdle()
|
||||
isRenewEnabled = backupDeleteState.isIdle(),
|
||||
isLinkedDevice = state.isLinkedDevice
|
||||
)
|
||||
}
|
||||
|
||||
@@ -464,7 +465,8 @@ private fun RemoteBackupsSettingsContent(
|
||||
onBackupTypeActionButtonClicked = contentCallbacks::onBackupTypeActionClick,
|
||||
isPaidTierPricingAvailable = state.isPaidTierPricingAvailable,
|
||||
isGooglePlayServicesAvailable = state.isGooglePlayServicesAvailable,
|
||||
buttonsEnabled = backupDeleteState.isIdle()
|
||||
buttonsEnabled = backupDeleteState.isIdle(),
|
||||
isLinkedDevice = state.isLinkedDevice
|
||||
)
|
||||
}
|
||||
|
||||
@@ -473,13 +475,16 @@ private fun RemoteBackupsSettingsContent(
|
||||
title = stringResource(R.string.RemoteBackupsSettingsFragment__your_subscription_was_not_found),
|
||||
onRenewClick = contentCallbacks::onRenewLostSubscription,
|
||||
onLearnMoreClick = contentCallbacks::onLearnMoreAboutLostSubscription,
|
||||
isRenewEnabled = backupDeleteState.isIdle()
|
||||
isRenewEnabled = backupDeleteState.isIdle(),
|
||||
isLinkedDevice = state.isLinkedDevice
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (backupDeleteState != DeletionState.NONE && backupDeleteState != DeletionState.CLEAR_LOCAL_STATE) {
|
||||
if (state.isLinkedDevice) {
|
||||
appendReducedBackupDetailsItems(state)
|
||||
} else if (backupDeleteState != DeletionState.NONE && backupDeleteState != DeletionState.CLEAR_LOCAL_STATE) {
|
||||
appendBackupDeletionItems(
|
||||
backupDeleteState = backupDeleteState,
|
||||
backupRestoreState = backupRestoreState,
|
||||
@@ -973,12 +978,58 @@ private fun LazyListScope.appendBackupDetailsItems(
|
||||
}
|
||||
}
|
||||
|
||||
private fun LazyListScope.appendReducedBackupDetailsItems(
|
||||
state: RemoteBackupsSettingsState
|
||||
) {
|
||||
item {
|
||||
Dividers.Default()
|
||||
}
|
||||
|
||||
item {
|
||||
Texts.SectionHeader(text = stringResource(id = R.string.RemoteBackupsSettingsFragment__backup_details))
|
||||
}
|
||||
|
||||
item {
|
||||
ReadOnlyLastBackupRow(lastBackupTimestamp = state.lastBackupTimestamp)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ReadOnlyLastBackupRow(
|
||||
lastBackupTimestamp: Long
|
||||
) {
|
||||
val label = if (lastBackupTimestamp > 0) {
|
||||
val context = LocalContext.current
|
||||
|
||||
val day = remember(lastBackupTimestamp) {
|
||||
DateUtils.getDayPrecisionTimeString(context, Locale.getDefault(), lastBackupTimestamp)
|
||||
}
|
||||
|
||||
val time = remember(lastBackupTimestamp) {
|
||||
DateUtils.getOnlyTimeString(context, lastBackupTimestamp)
|
||||
}
|
||||
|
||||
stringResource(
|
||||
id = R.string.RemoteBackupsSettingsFragment__your_phone_s,
|
||||
stringResource(id = R.string.RemoteBackupsSettingsFragment__s_at_s, day, time)
|
||||
)
|
||||
} else {
|
||||
stringResource(id = R.string.RemoteBackupsSettingsFragment__never)
|
||||
}
|
||||
|
||||
Rows.TextRow(
|
||||
text = stringResource(id = R.string.RemoteBackupsSettingsFragment__last_backup),
|
||||
label = label
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BackupCard(
|
||||
backupState: BackupState.WithTypeAndRenewalTime,
|
||||
isPaidTierPricingAvailable: Boolean,
|
||||
isGooglePlayServicesAvailable: Boolean,
|
||||
buttonsEnabled: Boolean,
|
||||
isLinkedDevice: Boolean = false,
|
||||
onBackupTypeActionButtonClicked: (MessageBackupTier) -> Unit = {}
|
||||
) {
|
||||
val messageBackupsType = backupState.messageBackupsType
|
||||
@@ -1070,7 +1121,21 @@ private fun BackupCard(
|
||||
)
|
||||
}
|
||||
|
||||
if (backupState.isActive() && isPaidTierPricingAvailable && isGooglePlayServicesAvailable) {
|
||||
if (isLinkedDevice) {
|
||||
val primaryDeviceText = when (backupState) {
|
||||
is BackupState.ActivePaid -> stringResource(R.string.RemoteBackupsSettingsFragment__you_can_manage_or_cancel_your_subscription_on_your_primary_device)
|
||||
is BackupState.Canceled -> stringResource(R.string.RemoteBackupsSettingsFragment__you_can_manage_or_renew_your_subscription_on_your_primary_device)
|
||||
is BackupState.ActiveFree -> stringResource(R.string.RemoteBackupsSettingsFragment__you_can_manage_or_upgrade_your_subscription_on_your_primary_device)
|
||||
else -> stringResource(R.string.RemoteBackupsSettingsFragment__you_can_manage_or_upgrade_backups_on_your_primary_device)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = primaryDeviceText,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(top = 12.dp)
|
||||
)
|
||||
} else if (backupState.isActive() && isPaidTierPricingAvailable && isGooglePlayServicesAvailable) {
|
||||
val buttonText = when (messageBackupsType) {
|
||||
is MessageBackupsType.Paid -> stringResource(R.string.RemoteBackupsSettingsFragment__manage_or_cancel)
|
||||
is MessageBackupsType.Free -> stringResource(R.string.RemoteBackupsSettingsFragment__upgrade)
|
||||
@@ -1270,6 +1335,7 @@ private fun PendingCard(
|
||||
private fun SubscriptionNotFoundCard(
|
||||
title: String,
|
||||
isRenewEnabled: Boolean,
|
||||
isLinkedDevice: Boolean = false,
|
||||
onRenewClick: () -> Unit = {},
|
||||
onLearnMoreClick: () -> Unit = {}
|
||||
) {
|
||||
@@ -1308,38 +1374,47 @@ private fun SubscriptionNotFoundCard(
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
horizontalArrangement = spacedBy(16.dp)
|
||||
) {
|
||||
Buttons.MediumTonal(
|
||||
onClick = onRenewClick,
|
||||
colors = ButtonDefaults.filledTonalButtonColors().copy(
|
||||
containerColor = SignalTheme.colors.colorTransparent5,
|
||||
contentColor = colorResource(CoreUiR.color.signal_light_colorOnSurface)
|
||||
),
|
||||
modifier = Modifier
|
||||
.padding(top = 24.dp)
|
||||
.weight(1f)
|
||||
if (isLinkedDevice) {
|
||||
Text(
|
||||
text = stringResource(R.string.RemoteBackupsSettingsFragment__you_can_manage_or_upgrade_backups_on_your_primary_device),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(top = 12.dp)
|
||||
)
|
||||
} else {
|
||||
Row(
|
||||
horizontalArrangement = spacedBy(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.RemoteBackupsSettingsFragment__renew)
|
||||
)
|
||||
}
|
||||
Buttons.MediumTonal(
|
||||
onClick = onRenewClick,
|
||||
colors = ButtonDefaults.filledTonalButtonColors().copy(
|
||||
containerColor = SignalTheme.colors.colorTransparent5,
|
||||
contentColor = colorResource(CoreUiR.color.signal_light_colorOnSurface)
|
||||
),
|
||||
modifier = Modifier
|
||||
.padding(top = 24.dp)
|
||||
.weight(1f)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.RemoteBackupsSettingsFragment__renew)
|
||||
)
|
||||
}
|
||||
|
||||
Buttons.MediumTonal(
|
||||
onClick = onLearnMoreClick,
|
||||
enabled = isRenewEnabled,
|
||||
colors = ButtonDefaults.filledTonalButtonColors().copy(
|
||||
containerColor = SignalTheme.colors.colorTransparent5,
|
||||
contentColor = colorResource(CoreUiR.color.signal_light_colorOnSurface)
|
||||
),
|
||||
modifier = Modifier
|
||||
.padding(top = 24.dp)
|
||||
.weight(1f)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.RemoteBackupsSettingsFragment__learn_more)
|
||||
)
|
||||
Buttons.MediumTonal(
|
||||
onClick = onLearnMoreClick,
|
||||
enabled = isRenewEnabled,
|
||||
colors = ButtonDefaults.filledTonalButtonColors().copy(
|
||||
containerColor = SignalTheme.colors.colorTransparent5,
|
||||
contentColor = colorResource(CoreUiR.color.signal_light_colorOnSurface)
|
||||
),
|
||||
modifier = Modifier
|
||||
.padding(top = 24.dp)
|
||||
.weight(1f)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.RemoteBackupsSettingsFragment__learn_more)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1349,6 +1424,7 @@ private fun SubscriptionNotFoundCard(
|
||||
private fun SubscriptionMismatchMissingGooglePlayCard(
|
||||
state: BackupState.SubscriptionMismatchMissingGooglePlay,
|
||||
isRenewEnabled: Boolean,
|
||||
isLinkedDevice: Boolean = false,
|
||||
onRenewClick: () -> Unit = {},
|
||||
onLearnMoreClick: () -> Unit = {}
|
||||
) {
|
||||
@@ -1357,6 +1433,7 @@ private fun SubscriptionMismatchMissingGooglePlayCard(
|
||||
SubscriptionNotFoundCard(
|
||||
title = pluralStringResource(R.plurals.RemoteBackupsSettingsFragment__your_subscription_on_this_device_is_valid, days.toInt(), days),
|
||||
isRenewEnabled = isRenewEnabled,
|
||||
isLinkedDevice = isLinkedDevice,
|
||||
onRenewClick = onRenewClick,
|
||||
onLearnMoreClick = onLearnMoreClick
|
||||
)
|
||||
@@ -1847,6 +1924,64 @@ private fun RemoteBackupsSettingsInternalUserContentPreview() {
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun RemoteBackupsSettingsLinkedDevicePaidContentPreview() {
|
||||
Previews.Preview {
|
||||
RemoteBackupsSettingsContent(
|
||||
state = RemoteBackupsSettingsState(
|
||||
backupsEnabled = true,
|
||||
isLinkedDevice = true,
|
||||
lastBackupTimestamp = -1,
|
||||
canBackUpUsingCellular = false,
|
||||
canRestoreUsingCellular = false,
|
||||
dialog = RemoteBackupsSettingsState.Dialog.NONE,
|
||||
snackbar = RemoteBackupsSettingsState.Snackbar.NONE,
|
||||
backupMediaSize = 2300000,
|
||||
backupState = BackupState.ActivePaid(
|
||||
messageBackupsType = MessageBackupsType.Paid(
|
||||
pricePerMonth = FiatMoney(BigDecimal.valueOf(2.99), Currency.getInstance("USD")),
|
||||
storageAllowanceBytes = 1_000_000,
|
||||
mediaTtl = 30.days
|
||||
),
|
||||
price = FiatMoney(BigDecimal.valueOf(2.99), Currency.getInstance("USD")),
|
||||
renewalTime = 1_752_710_400.seconds
|
||||
)
|
||||
),
|
||||
statusBarColorNestedScrollConnection = null,
|
||||
backupDeleteState = DeletionState.NONE,
|
||||
backupRestoreState = BackupRestoreState.None,
|
||||
contentCallbacks = ContentCallbacks.Empty,
|
||||
backupProgress = null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun RemoteBackupsSettingsLinkedDeviceNotFoundContentPreview() {
|
||||
Previews.Preview {
|
||||
RemoteBackupsSettingsContent(
|
||||
state = RemoteBackupsSettingsState(
|
||||
backupsEnabled = true,
|
||||
isLinkedDevice = true,
|
||||
lastBackupTimestamp = -1,
|
||||
canBackUpUsingCellular = false,
|
||||
canRestoreUsingCellular = false,
|
||||
dialog = RemoteBackupsSettingsState.Dialog.NONE,
|
||||
snackbar = RemoteBackupsSettingsState.Snackbar.NONE,
|
||||
backupMediaSize = 2300000,
|
||||
backupState = BackupState.NotFound
|
||||
),
|
||||
statusBarColorNestedScrollConnection = null,
|
||||
backupDeleteState = DeletionState.NONE,
|
||||
backupRestoreState = BackupRestoreState.None,
|
||||
contentCallbacks = ContentCallbacks.Empty,
|
||||
backupProgress = null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun RedemptionErrorAlertPreview() {
|
||||
@@ -1875,10 +2010,8 @@ private fun ErrorCardPreview() {
|
||||
@Composable
|
||||
private fun PendingCardPreview() {
|
||||
Previews.Preview {
|
||||
val locale = LocalLocale.current.platformLocale
|
||||
|
||||
PendingCard(
|
||||
price = FiatMoney(BigDecimal.TEN, Currency.getInstance(locale))
|
||||
price = FiatMoney(BigDecimal.TEN, Currency.getInstance(Locale.US))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -35,7 +35,8 @@ data class RemoteBackupsSettingsState(
|
||||
val backupCreationError: BackupValues.BackupCreationError? = null,
|
||||
val lastMessageCutoffTime: Long = 0,
|
||||
val freeTierMediaRetentionDays: Int = -1,
|
||||
val isGooglePlayServicesAvailable: Boolean = false
|
||||
val isGooglePlayServicesAvailable: Boolean = false,
|
||||
val isLinkedDevice: Boolean = false
|
||||
) {
|
||||
|
||||
data class BackupMediaDetails(
|
||||
|
||||
+39
-3
@@ -79,7 +79,8 @@ class RemoteBackupsSettingsViewModel : ViewModel() {
|
||||
internalUser = RemoteConfig.internalUser,
|
||||
includeDebuglog = SignalStore.internal.includeDebuglogInBackup.takeIf { RemoteConfig.internalUser },
|
||||
backupCreationError = SignalStore.backup.backupCreationError,
|
||||
lastMessageCutoffTime = SignalStore.backup.lastUsedMessageCutoffTime
|
||||
lastMessageCutoffTime = SignalStore.backup.lastUsedMessageCutoffTime,
|
||||
isLinkedDevice = SignalStore.account.isLinkedDevice
|
||||
)
|
||||
)
|
||||
|
||||
@@ -92,6 +93,14 @@ class RemoteBackupsSettingsViewModel : ViewModel() {
|
||||
private var forQuickRestore = false
|
||||
|
||||
init {
|
||||
if (state.value.isLinkedDevice) {
|
||||
initLinkedDevice()
|
||||
} else {
|
||||
initPrimaryDevice()
|
||||
}
|
||||
}
|
||||
|
||||
private fun initPrimaryDevice() {
|
||||
ArchiveUploadProgress.triggerUpdate()
|
||||
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
@@ -143,7 +152,7 @@ class RemoteBackupsSettingsViewModel : ViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
viewModelScope.launch(Dispatchers.Default) {
|
||||
var optimizedRemainingBytes = 0L
|
||||
while (isActive) {
|
||||
if (ArchiveRestoreProgress.state.let { it.restoreState.isMediaRestoreOperation || it.restoreStatus == RestoreStatus.FINISHED }) {
|
||||
@@ -194,11 +203,34 @@ class RemoteBackupsSettingsViewModel : ViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
viewModelScope.launch(Dispatchers.Default) {
|
||||
BackupRepository.maybeFixAnyDanglingUploadProgress()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render remote backups as read-only and refresh the last-backup time from the CDN.
|
||||
*/
|
||||
private fun initLinkedDevice() {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
BackupStateObserver(viewModelScope, useDatabaseFallbackOnNetworkError = true).backupState.collect { backupState ->
|
||||
_state.update {
|
||||
it.copy(backupState = backupState)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
viewModelScope.launch(Dispatchers.Default) {
|
||||
SignalStore.backup.lastBackupTimeFlow.collect { lastBackupTime ->
|
||||
_state.update { it.copy(lastBackupTimestamp = lastBackupTime) }
|
||||
}
|
||||
}
|
||||
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
BackupRepository.refreshBackupFileTimestamp()
|
||||
}
|
||||
}
|
||||
|
||||
fun setCanBackUpUsingCellular(canBackUpUsingCellular: Boolean) {
|
||||
SignalStore.backup.backupWithCellular = canBackUpUsingCellular
|
||||
_state.update {
|
||||
@@ -259,6 +291,10 @@ class RemoteBackupsSettingsViewModel : ViewModel() {
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
if (state.value.isLinkedDevice) {
|
||||
return
|
||||
}
|
||||
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val id = SignalDatabase.inAppPayments.getLatestInAppPaymentByType(InAppPaymentType.RECURRING_BACKUP)?.id
|
||||
|
||||
|
||||
+8
-3
@@ -128,11 +128,14 @@ class ChangeNumberEnterCodeFragment : LoggingFragment(R.layout.fragment_change_n
|
||||
binding.codeEntryLayout.resendSmsCountDown.startCountDownTo(state.nextSmsTimestamp.milliseconds)
|
||||
binding.codeEntryLayout.callMeCountDown.startCountDownTo(state.nextCallTimestamp.milliseconds)
|
||||
when (val outcome = state.changeNumberOutcome) {
|
||||
is ChangeNumberOutcome.RecoveryPasswordWorked,
|
||||
is ChangeNumberOutcome.VerificationCodeWorked -> changeNumberSuccess()
|
||||
is ChangeNumberOutcome.Succeeded -> changeNumberSuccess()
|
||||
|
||||
is ChangeNumberOutcome.ChangeNumberRequestOutcome -> if (!state.inProgress && !outcome.result.isSuccess()) {
|
||||
presentGenericError(outcome.result)
|
||||
if (outcome.result is VerificationCodeRequestResult.RequestVerificationCodeRateLimited) {
|
||||
Log.i(TAG, "Verification code request rate limited; staying on code entry screen.")
|
||||
} else {
|
||||
presentGenericError(outcome.result)
|
||||
}
|
||||
}
|
||||
|
||||
null -> Unit
|
||||
@@ -158,6 +161,8 @@ class ChangeNumberEnterCodeFragment : LoggingFragment(R.layout.fragment_change_n
|
||||
when (result) {
|
||||
is VerificationCodeRequestResult.Success -> binding.codeEntryLayout.keyboard.displaySuccess()
|
||||
is VerificationCodeRequestResult.RateLimited -> presentRateLimitedDialog()
|
||||
is VerificationCodeRequestResult.RequestVerificationCodeRateLimited -> presentRateLimitedDialog(retryAfterSeconds = (result.nextSmsTimestamp - System.currentTimeMillis().milliseconds).inWholeSeconds.coerceAtLeast(0))
|
||||
is VerificationCodeRequestResult.SubmitVerificationCodeRateLimited -> presentRateLimitedDialog()
|
||||
is VerificationCodeRequestResult.RegistrationLocked -> presentRegistrationLocked(result.timeRemaining)
|
||||
else -> presentGenericError(result)
|
||||
}
|
||||
|
||||
+1
-1
@@ -113,7 +113,7 @@ class ChangeNumberRegistrationLockFragment : LoggingFragment(R.layout.fragment_c
|
||||
}
|
||||
|
||||
private fun onStateUpdate(state: ChangeNumberState) {
|
||||
if (state.changeNumberOutcome == ChangeNumberOutcome.VerificationCodeWorked) {
|
||||
if (state.changeNumberOutcome == ChangeNumberOutcome.Succeeded) {
|
||||
handleSuccessfulPinEntry(state.enteredPin)
|
||||
}
|
||||
|
||||
|
||||
+32
-7
@@ -188,8 +188,7 @@ class ChangeNumberRepository(
|
||||
StorageSyncHelper.scheduleSyncForDataChange()
|
||||
}
|
||||
|
||||
SignalStore.account.setE164(e164)
|
||||
SignalStore.account.setPni(pni)
|
||||
SignalStore.account.setNumberAndPniIdentity(e164, pni, pniRegistrationId, pniIdentityKeyPair)
|
||||
AppDependencies.resetProtocolStores()
|
||||
|
||||
AppDependencies.groupsV2Authorization.clear()
|
||||
@@ -197,9 +196,6 @@ class ChangeNumberRepository(
|
||||
val pniProtocolStore = AppDependencies.protocolStore.pni()
|
||||
val pniMetadataStore = SignalStore.account.pniPreKeys
|
||||
|
||||
SignalStore.account.pniRegistrationId = pniRegistrationId
|
||||
SignalStore.account.setPniIdentityKeyAfterChangeNumber(pniIdentityKeyPair)
|
||||
|
||||
PreKeyUtil.storeSignedPreKey(pniProtocolStore, pniMetadataStore, pniSignedPreKey)
|
||||
pniMetadataStore.activeSignedPreKeyId = pniSignedPreKey.id
|
||||
|
||||
@@ -326,6 +322,10 @@ class ChangeNumberRepository(
|
||||
}
|
||||
}
|
||||
|
||||
if (result !is NetworkResult.Success) {
|
||||
result = verifyChangeAppliedDespiteError(newE164 = newE164, originalResult = result)
|
||||
}
|
||||
|
||||
if (result is NetworkResult.StatusCodeError) {
|
||||
SignalStore.misc.unlockChangeNumber()
|
||||
}
|
||||
@@ -336,13 +336,39 @@ class ChangeNumberRepository(
|
||||
NumberChangeResult(
|
||||
uuid = accountRegistrationResponse.uuid,
|
||||
pni = accountRegistrationResponse.pni,
|
||||
storageCapable = accountRegistrationResponse.storageCapable,
|
||||
number = accountRegistrationResponse.number
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The server can commit a number change but still return an error. Before trusting a non-success result, check
|
||||
* with the server and see if it already reports [newE164] as our number. If it does, then the change actually
|
||||
* went through, so treat it as a success instead of surfacing the error.
|
||||
*/
|
||||
private suspend fun verifyChangeAppliedDespiteError(newE164: String, originalResult: NetworkResult<VerifyAccountResponse>): NetworkResult<VerifyAccountResponse> {
|
||||
return try {
|
||||
val whoAmI = withContext(Dispatchers.IO) { whoAmI() }
|
||||
if (whoAmI.number == newE164 && whoAmI.pni != null) {
|
||||
Log.w(TAG, "Change number request did not succeed, but whoami reports the new number is already active. Treating the change as successful.")
|
||||
NetworkResult.Success(
|
||||
VerifyAccountResponse().apply {
|
||||
uuid = whoAmI.aci
|
||||
pni = whoAmI.pni
|
||||
number = whoAmI.number
|
||||
}
|
||||
)
|
||||
} else {
|
||||
Log.i(TAG, "Change number request did not succeed and whoami does not report the new number; treating as a genuine failure.")
|
||||
originalResult
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "Failed to query whoami while verifying change number outcome; treating as a genuine failure.", e)
|
||||
originalResult
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private fun createChangeNumberRequest(
|
||||
sessionId: String? = null,
|
||||
@@ -433,7 +459,6 @@ class ChangeNumberRepository(
|
||||
data class NumberChangeResult(
|
||||
val uuid: String,
|
||||
val pni: String,
|
||||
val storageCapable: Boolean,
|
||||
val number: String
|
||||
)
|
||||
}
|
||||
|
||||
+1
-2
@@ -43,8 +43,7 @@ data class ChangeNumberState(
|
||||
)
|
||||
|
||||
sealed interface ChangeNumberOutcome {
|
||||
data object RecoveryPasswordWorked : ChangeNumberOutcome
|
||||
data object VerificationCodeWorked : ChangeNumberOutcome
|
||||
data object Succeeded : ChangeNumberOutcome
|
||||
class ChangeNumberRequestOutcome(val result: VerificationCodeRequestResult) : ChangeNumberOutcome
|
||||
}
|
||||
|
||||
|
||||
+19
-8
@@ -89,8 +89,8 @@ class ChangeNumberVerifyFragment : LoggingFragment(R.layout.fragment_change_phon
|
||||
private fun handleRequestCodeResult(changeNumberOutcome: ChangeNumberOutcome) {
|
||||
Log.d(TAG, "Handling request code result: ${changeNumberOutcome.javaClass.name}")
|
||||
when (changeNumberOutcome) {
|
||||
is ChangeNumberOutcome.RecoveryPasswordWorked -> {
|
||||
Log.i(TAG, "Successfully changed number with recovery password.")
|
||||
is ChangeNumberOutcome.Succeeded -> {
|
||||
Log.i(TAG, "Successfully changed number.")
|
||||
changeNumberSuccess()
|
||||
}
|
||||
|
||||
@@ -109,11 +109,27 @@ class ChangeNumberVerifyFragment : LoggingFragment(R.layout.fragment_change_phon
|
||||
}
|
||||
}
|
||||
|
||||
is VerificationCodeRequestResult.RateLimited -> {
|
||||
is VerificationCodeRequestResult.RequestVerificationCodeRateLimited -> {
|
||||
if (castResult.willBeAbleToRequestAgain) {
|
||||
Log.i(TAG, "Verification code request rate limited; proceeding to code entry screen so the user can wait/resend rather than bailing.")
|
||||
findNavController().safeNavigate(ChangeNumberVerifyFragmentDirections.actionChangePhoneNumberVerifyFragmentToChangeNumberEnterCodeFragment())
|
||||
} else {
|
||||
Log.i(TAG, "Verification code request rate limited with no pending resend; showing rate limit error.")
|
||||
showErrorDialog(R.string.RegistrationActivity_rate_limited_to_service)
|
||||
}
|
||||
}
|
||||
|
||||
is VerificationCodeRequestResult.RateLimited,
|
||||
is VerificationCodeRequestResult.SubmitVerificationCodeRateLimited -> {
|
||||
Log.i(TAG, "Unable to request sms code due to rate limit")
|
||||
showErrorDialog(R.string.RegistrationActivity_rate_limited_to_service)
|
||||
}
|
||||
|
||||
is VerificationCodeRequestResult.RegistrationLocked -> {
|
||||
Log.i(TAG, "Destination number is registration locked; navigating to PIN entry.")
|
||||
findNavController().safeNavigate(ChangeNumberVerifyFragmentDirections.actionChangePhoneNumberVerifyFragmentToChangeNumberRegistrationLock(castResult.timeRemaining))
|
||||
}
|
||||
|
||||
is VerificationCodeRequestResult.TokenNotAccepted -> {
|
||||
Log.i(TAG, "Token was not accepted.")
|
||||
showErrorDialog(R.string.RegistrationActivity_additional_verification_required)
|
||||
@@ -125,11 +141,6 @@ class ChangeNumberVerifyFragment : LoggingFragment(R.layout.fragment_change_phon
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is ChangeNumberOutcome.VerificationCodeWorked -> {
|
||||
Log.i(TAG, "Successfully changed number with verification code.")
|
||||
changeNumberSuccess()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+104
-22
@@ -12,14 +12,12 @@ import androidx.lifecycle.viewModelScope
|
||||
import com.google.i18n.phonenumbers.NumberParseException
|
||||
import com.google.i18n.phonenumbers.PhoneNumberUtil
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.asCoroutineDispatcher
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.signal.core.models.ServiceId
|
||||
import org.signal.core.util.concurrent.SignalExecutors
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
@@ -36,6 +34,7 @@ import org.thoughtcrime.securesms.registration.viewmodel.NumberViewState
|
||||
import org.thoughtcrime.securesms.registration.viewmodel.SvrAuthCredentialSet
|
||||
import org.thoughtcrime.securesms.util.dualsim.MccMncProducer
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
/**
|
||||
* [ViewModel] for the change number flow.
|
||||
@@ -50,12 +49,13 @@ class ChangeNumberViewModel : ViewModel() {
|
||||
|
||||
private val repository = ChangeNumberRepository()
|
||||
private val store = MutableStateFlow(ChangeNumberState())
|
||||
private val serialContext = SignalExecutors.SERIAL.asCoroutineDispatcher()
|
||||
private val smsRetrieverReceiver: SmsRetrieverReceiver = SmsRetrieverReceiver(AppDependencies.application)
|
||||
|
||||
private val initialLocalNumber = SignalStore.account.e164
|
||||
private val password = SignalStore.account.servicePassword!!
|
||||
|
||||
private val changeNumberSessionInFlight = AtomicBoolean(false)
|
||||
|
||||
val uiState = store.asLiveData()
|
||||
val liveOldNumberState = store.map { it.oldPhoneNumber }.asLiveData()
|
||||
val liveNewNumberState = store.map { it.number }.asLiveData()
|
||||
@@ -261,14 +261,24 @@ class ChangeNumberViewModel : ViewModel() {
|
||||
}
|
||||
|
||||
private suspend fun verifyCodeInternal(context: Context, pin: String?, verificationErrorHandler: (VerificationCodeRequestResult) -> Unit, numberChangeErrorHandler: (ChangeNumberResult) -> Unit) {
|
||||
val sessionId = getOrCreateValidSession(context)?.sessionId ?: return bail { Log.i(TAG, "Bailing from code verification due to invalid session.") }
|
||||
val registrationData = getRegistrationData(context)
|
||||
val session = getOrCreateValidSession(context) ?: return bail { Log.i(TAG, "Bailing from code verification due to invalid session.") }
|
||||
val sessionId = session.sessionId
|
||||
|
||||
val verificationResponse = RegistrationRepository.submitVerificationCode(context, sessionId, registrationData)
|
||||
if (!session.verified) {
|
||||
if (store.value.enteredCode == null) {
|
||||
Log.w(TAG, "Session is not verified and no code is available to submit; cannot complete change number.")
|
||||
handleVerificationError(VerificationCodeRequestResult.UnknownError(IllegalStateException("No verification code available for an unverified session")), verificationErrorHandler)
|
||||
return bail { Log.i(TAG, "Bailing from code verification due to missing code for unverified session.") }
|
||||
}
|
||||
|
||||
if (verificationResponse !is VerificationCodeRequestResult.Success && verificationResponse !is VerificationCodeRequestResult.AlreadyVerified) {
|
||||
handleVerificationError(verificationResponse, verificationErrorHandler)
|
||||
return bail { Log.i(TAG, "Bailing from code verification due to non-successful response.") }
|
||||
val registrationData = getRegistrationData(context)
|
||||
|
||||
val verificationResponse = RegistrationRepository.submitVerificationCode(context, sessionId, registrationData)
|
||||
|
||||
if (verificationResponse !is VerificationCodeRequestResult.Success && verificationResponse !is VerificationCodeRequestResult.AlreadyVerified) {
|
||||
handleVerificationError(verificationResponse, verificationErrorHandler)
|
||||
return bail { Log.i(TAG, "Bailing from code verification due to non-successful response.") }
|
||||
}
|
||||
}
|
||||
|
||||
val result: ChangeNumberResult = if (pin == null) {
|
||||
@@ -286,7 +296,7 @@ class ChangeNumberViewModel : ViewModel() {
|
||||
}
|
||||
|
||||
if (result is ChangeNumberResult.Success) {
|
||||
handleSuccessfulChangedRemoteNumber(e164 = result.numberChangeResult.number, pni = ServiceId.PNI.parseOrThrow(result.numberChangeResult.pni), changeNumberOutcome = ChangeNumberOutcome.RecoveryPasswordWorked)
|
||||
handleSuccessfulChangedRemoteNumber(e164 = result.numberChangeResult.number, pni = ServiceId.PNI.parseOrThrow(result.numberChangeResult.pni), changeNumberOutcome = ChangeNumberOutcome.Succeeded)
|
||||
} else {
|
||||
handleChangeNumberError(result, numberChangeErrorHandler)
|
||||
}
|
||||
@@ -359,18 +369,26 @@ class ChangeNumberViewModel : ViewModel() {
|
||||
|
||||
fun initiateChangeNumberSession(context: Context, mode: RegistrationRepository.E164VerificationMode) {
|
||||
Log.v(TAG, "changeNumber()")
|
||||
if (!changeNumberSessionInFlight.compareAndSet(false, true)) {
|
||||
Log.i(TAG, "A change number session is already in progress; ignoring duplicate request.")
|
||||
return
|
||||
}
|
||||
store.update { it.copy(inProgress = true) }
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val encryptionDrained = repository.ensureDecryptionsDrained() ?: false
|
||||
try {
|
||||
val encryptionDrained = repository.ensureDecryptionsDrained() ?: false
|
||||
|
||||
if (!encryptionDrained) {
|
||||
return@launch bail { Log.i(TAG, "Failed to drain encryption.") }
|
||||
}
|
||||
if (!encryptionDrained) {
|
||||
return@launch bail { Log.i(TAG, "Failed to drain encryption.") }
|
||||
}
|
||||
|
||||
when (changeNumberWithRecoveryPassword()) {
|
||||
ChangeLocalNumberOutcome.NotPerformed -> requestVerificationCode(context, mode)
|
||||
ChangeLocalNumberOutcome.Success -> Log.d(TAG, "Successfully changed number using recovery password")
|
||||
ChangeLocalNumberOutcome.Failure -> Log.w(TAG, "Change number failed, bailing")
|
||||
when (changeNumberWithRecoveryPassword()) {
|
||||
ChangeLocalNumberOutcome.NotPerformed -> requestVerificationCode(context, mode)
|
||||
ChangeLocalNumberOutcome.Success -> Log.d(TAG, "Successfully changed number using recovery password")
|
||||
ChangeLocalNumberOutcome.Failure -> Log.w(TAG, "Change number failed, bailing")
|
||||
}
|
||||
} finally {
|
||||
changeNumberSessionInFlight.set(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -417,7 +435,7 @@ class ChangeNumberViewModel : ViewModel() {
|
||||
val result = repository.changeNumberWithRecoveryPassword(recoveryPassword = recoveryPassword, newE164 = number.e164Number)
|
||||
|
||||
if (result is ChangeNumberResult.Success) {
|
||||
return handleSuccessfulChangedRemoteNumber(e164 = result.numberChangeResult.number, pni = ServiceId.PNI.parseOrThrow(result.numberChangeResult.pni), changeNumberOutcome = ChangeNumberOutcome.RecoveryPasswordWorked)
|
||||
return handleSuccessfulChangedRemoteNumber(e164 = result.numberChangeResult.number, pni = ServiceId.PNI.parseOrThrow(result.numberChangeResult.pni), changeNumberOutcome = ChangeNumberOutcome.Succeeded)
|
||||
} else if (result is ChangeNumberResult.UnknownError) {
|
||||
store.update {
|
||||
it.copy(inProgress = false, changeNumberOutcome = ChangeNumberOutcome.ChangeNumberRequestOutcome(VerificationCodeRequestResult.UnknownError(result.getCause())))
|
||||
@@ -512,6 +530,12 @@ class ChangeNumberViewModel : ViewModel() {
|
||||
return
|
||||
}
|
||||
|
||||
if (validSession.verified) {
|
||||
Log.i(TAG, "Existing session is already verified; completing the change with the verified session instead of requesting a new code.")
|
||||
changeNumberWithVerifiedSession(validSession.sessionId)
|
||||
return
|
||||
}
|
||||
|
||||
val result = if (!validSession.allowedToRequestCode) {
|
||||
val challenges = validSession.challengesRequested.joinToString()
|
||||
Log.i(TAG, "Not allowed to request code! Remaining challenges: $challenges")
|
||||
@@ -532,14 +556,72 @@ class ChangeNumberViewModel : ViewModel() {
|
||||
}
|
||||
|
||||
if (result is VerificationCodeRequestResult.AlreadyVerified) {
|
||||
Log.w(TAG, "Already verified, not handled properly in change number flow, attempt to recover")
|
||||
resetLocalSessionState()
|
||||
Log.i(TAG, "Session became verified while requesting a code; completing the change with the verified session.")
|
||||
changeNumberWithVerifiedSession(validSession.sessionId)
|
||||
return
|
||||
}
|
||||
|
||||
Log.d(TAG, "Received result: ${result.javaClass.canonicalName}\nwith challenges: ${challengesRequested.joinToString { it.key }}")
|
||||
|
||||
val (nextSmsTimestamp, nextCallTimestamp) = when (result) {
|
||||
is VerificationCodeRequestResult.Success -> result.nextSmsTimestamp.inWholeMilliseconds to result.nextCallTimestamp.inWholeMilliseconds
|
||||
is VerificationCodeRequestResult.RequestVerificationCodeRateLimited -> result.nextSmsTimestamp.inWholeMilliseconds to result.nextCallTimestamp.inWholeMilliseconds
|
||||
else -> store.value.nextSmsTimestamp to store.value.nextCallTimestamp
|
||||
}
|
||||
|
||||
store.update {
|
||||
it.copy(changeNumberOutcome = ChangeNumberOutcome.ChangeNumberRequestOutcome(result), challengesRequested = challengesRequested, inProgress = false)
|
||||
it.copy(
|
||||
changeNumberOutcome = ChangeNumberOutcome.ChangeNumberRequestOutcome(result),
|
||||
challengesRequested = challengesRequested,
|
||||
inProgress = false,
|
||||
nextSmsTimestamp = nextSmsTimestamp,
|
||||
nextCallTimestamp = nextCallTimestamp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Completes a number change using a registration session that the service already considers verified.
|
||||
*/
|
||||
private suspend fun changeNumberWithVerifiedSession(sessionId: String) {
|
||||
Log.v(TAG, "changeNumberWithVerifiedSession()")
|
||||
when (val result = repository.changeNumberWithoutRegistrationLock(sessionId = sessionId, newE164 = number.e164Number)) {
|
||||
is ChangeNumberResult.Success -> {
|
||||
handleSuccessfulChangedRemoteNumber(
|
||||
e164 = result.numberChangeResult.number,
|
||||
pni = ServiceId.PNI.parseOrThrow(result.numberChangeResult.pni),
|
||||
changeNumberOutcome = ChangeNumberOutcome.Succeeded
|
||||
)
|
||||
}
|
||||
|
||||
is ChangeNumberResult.RegistrationLocked -> {
|
||||
if (result.svr2Credentials != null) {
|
||||
Log.i(TAG, "Destination number is registration locked; prompting for PIN.")
|
||||
store.update {
|
||||
it.copy(
|
||||
inProgress = false,
|
||||
svr2Credentials = result.svr2Credentials,
|
||||
svr3Credentials = result.svr3Credentials,
|
||||
lockedTimeRemaining = result.timeRemaining,
|
||||
changeNumberOutcome = ChangeNumberOutcome.ChangeNumberRequestOutcome(
|
||||
VerificationCodeRequestResult.RegistrationLocked(result.getCause(), result.timeRemaining, result.svr2Credentials, result.svr3Credentials)
|
||||
)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Log.w(TAG, "Destination number is registration locked but SVR credentials were missing, cannot prompt for PIN.")
|
||||
store.update {
|
||||
it.copy(inProgress = false, changeNumberOutcome = ChangeNumberOutcome.ChangeNumberRequestOutcome(VerificationCodeRequestResult.UnknownError(result.getCause())))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
Log.w(TAG, "Unable to complete change number with verified session.", result.getCause())
|
||||
store.update {
|
||||
it.copy(inProgress = false, changeNumberOutcome = ChangeNumberOutcome.ChangeNumberRequestOutcome(VerificationCodeRequestResult.UnknownError(result.getCause())))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+9
-9
@@ -24,7 +24,7 @@ class ChatFoldersViewModel : ViewModel() {
|
||||
val state = internalState.asStateFlow()
|
||||
|
||||
fun loadCurrentFolders(context: Context) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
viewModelScope.launch(Dispatchers.Default) {
|
||||
val folders = ChatFoldersRepository.getCurrentFolders()
|
||||
val suggestedFolders = getSuggestedFolders(context, folders)
|
||||
|
||||
@@ -93,7 +93,7 @@ class ChatFoldersViewModel : ViewModel() {
|
||||
}
|
||||
|
||||
fun setCurrentFolder(folder: ChatFolderRecord) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
viewModelScope.launch(Dispatchers.Default) {
|
||||
val includedRecipients = folder.includedChats.mapNotNull { threadId ->
|
||||
SignalDatabase.threads.getRecipientForThreadId(threadId)
|
||||
}
|
||||
@@ -150,7 +150,7 @@ class ChatFoldersViewModel : ViewModel() {
|
||||
}
|
||||
|
||||
fun deleteFolder(context: Context) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
viewModelScope.launch(Dispatchers.Default) {
|
||||
ChatFoldersRepository.deleteFolder(internalState.value.originalFolder.folderRecord)
|
||||
|
||||
loadCurrentFolders(context)
|
||||
@@ -167,7 +167,7 @@ class ChatFoldersViewModel : ViewModel() {
|
||||
}
|
||||
|
||||
fun createFolder(context: Context, folder: ChatFolderRecord? = null) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
viewModelScope.launch(Dispatchers.Default) {
|
||||
val currentFolder = if (folder != null) ChatFolder(folder) else internalState.value.currentFolder
|
||||
ChatFoldersRepository.createFolder(currentFolder.folderRecord, currentFolder.includedRecipients, currentFolder.excludedRecipients)
|
||||
loadCurrentFolders(context)
|
||||
@@ -192,13 +192,13 @@ class ChatFoldersViewModel : ViewModel() {
|
||||
val updatedFolders = state.value.folders.mapIndexed { index, chatFolderRecord ->
|
||||
chatFolderRecord.copy(position = index)
|
||||
}
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
viewModelScope.launch(Dispatchers.Default) {
|
||||
ChatFoldersRepository.updatePositions(updatedFolders)
|
||||
}
|
||||
}
|
||||
|
||||
fun updateFolder(context: Context) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
viewModelScope.launch(Dispatchers.Default) {
|
||||
val currentFolder = internalState.value.currentFolder
|
||||
ChatFoldersRepository.updateFolder(currentFolder.folderRecord, currentFolder.includedRecipients, currentFolder.excludedRecipients)
|
||||
loadCurrentFolders(context)
|
||||
@@ -210,7 +210,7 @@ class ChatFoldersViewModel : ViewModel() {
|
||||
}
|
||||
|
||||
fun setPendingChats() {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
viewModelScope.launch(Dispatchers.Default) {
|
||||
val currentFolder = internalState.value.currentFolder
|
||||
val includedChats = currentFolder.includedRecipients.map { recipient -> recipient.id }.toMutableSet()
|
||||
val excludedChats = currentFolder.excludedRecipients.map { recipient -> recipient.id }.toMutableSet()
|
||||
@@ -304,7 +304,7 @@ class ChatFoldersViewModel : ViewModel() {
|
||||
}
|
||||
|
||||
fun savePendingChats() {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
viewModelScope.launch(Dispatchers.Default) {
|
||||
val updatedFolder = internalState.value.currentFolder
|
||||
val includedChatIds = internalState.value.pendingIncludedRecipients
|
||||
val excludedChatIds = internalState.value.pendingExcludedRecipients
|
||||
@@ -346,7 +346,7 @@ class ChatFoldersViewModel : ViewModel() {
|
||||
|
||||
fun setCurrentFolderId(folderId: Long) {
|
||||
if (folderId != -1L) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
viewModelScope.launch(Dispatchers.Default) {
|
||||
val folder = ChatFoldersRepository.getFolder(folderId)
|
||||
setCurrentFolder(folder)
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user