mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-07-19 04:02:02 +01:00
Compare commits
141 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 837598657d | |||
| cf5aa92f84 | |||
| 1613adf088 | |||
| cc01d39240 | |||
| b29bae50af |
@@ -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@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # 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@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # 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
|
||||
|
||||
+1
-1
@@ -1,2 +1,2 @@
|
||||
java openjdk-17.0.2
|
||||
java openjdk-21.0.2
|
||||
uv latest
|
||||
|
||||
+157
-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 = 1712
|
||||
val canonicalVersionName = "8.17.2"
|
||||
val canonicalVersionCode = 1719
|
||||
val canonicalVersionName = "8.19.2"
|
||||
val currentHotfixVersion = 0
|
||||
val maxHotfixVersions = 100
|
||||
|
||||
@@ -598,6 +602,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 +711,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)
|
||||
@@ -791,6 +833,15 @@ dependencies {
|
||||
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
|
||||
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 +1025,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
|
||||
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
/*
|
||||
* 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 org.hamcrest.Matchers.allOf
|
||||
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.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()
|
||||
}
|
||||
|
||||
@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()))
|
||||
}
|
||||
}
|
||||
+75
-6
@@ -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
|
||||
@@ -12,21 +14,32 @@ 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.mockkObject
|
||||
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.libsignal.net.RequestResult
|
||||
import org.signal.network.NetworkResult
|
||||
import org.signal.network.exceptions.NonSuccessfulResponseCodeException
|
||||
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.thoughtcrime.securesms.testing.actions.scrollToDescendant
|
||||
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription
|
||||
import org.whispersystems.signalservice.api.subscriptions.SubscriberId
|
||||
import java.math.BigDecimal
|
||||
@@ -46,19 +59,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 +100,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 +114,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,11 +129,48 @@ 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() {
|
||||
AppDependencies.donationPermitsRepository.clearPermits()
|
||||
mockkObject(DonationPermits)
|
||||
every { DonationPermits.getDonationPermit() } returns RequestResult.Success("permit")
|
||||
every { AppDependencies.donationsApi.createStripeSubscriptionPaymentMethod(any(), any(), any()) } returns NetworkResult.StatusCodeError(NonSuccessfulResponseCodeException(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(
|
||||
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
/*
|
||||
* 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.junit4.ComposeTestRule
|
||||
import androidx.compose.ui.test.onAllNodesWithTag
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.performClick
|
||||
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.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.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.testing.flushUntil
|
||||
|
||||
/**
|
||||
* Forces real donation-permit acquisition to fail at the issuer, exercising the permit code path through
|
||||
* [org.thoughtcrime.securesms.components.settings.app.subscription.permits.DonationPermits].
|
||||
*/
|
||||
fun failDonationPermitAcquisition(statusCode: Int = 500) {
|
||||
AppDependencies.donationPermitsRepository.clearPermits()
|
||||
every { AppDependencies.donationsApi.createDonationPermits(any()) } returns RequestResult.NonSuccess(RestStatusCodeError(statusCode, emptyMap(), null))
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
fun startJobLoopForTests() {
|
||||
(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.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
|
||||
}
|
||||
}
|
||||
+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)
|
||||
}
|
||||
}
|
||||
|
||||
+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
@@ -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 {
|
||||
|
||||
+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}"
|
||||
|
||||
+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)
|
||||
}
|
||||
}
|
||||
@@ -10,8 +10,11 @@ import io.mockk.every
|
||||
import org.junit.rules.ExternalResource
|
||||
import org.signal.core.util.JsonUtils
|
||||
import org.signal.network.NetworkResult
|
||||
import org.signal.network.exceptions.NonSuccessfulResponseCodeException
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription
|
||||
import org.whispersystems.signalservice.internal.push.SubscriptionsConfiguration
|
||||
import org.whispersystems.signalservice.internal.push.WhoAmIResponse
|
||||
|
||||
/**
|
||||
* Sets up some common infrastructure for on-device InAppPayment testing
|
||||
@@ -21,6 +24,8 @@ class InAppPaymentsRule : ExternalResource() {
|
||||
initialiseConfigurationResponse()
|
||||
initialisePutSubscription()
|
||||
initialiseSetArchiveBackupId()
|
||||
initialiseSetAccountAttributes()
|
||||
initialiseAccountAndSubscriptionLookups()
|
||||
}
|
||||
|
||||
private fun initialiseConfigurationResponse() {
|
||||
@@ -46,4 +51,31 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Starting the real job loop lets background jobs unrelated to the assertion under test run against the strict
|
||||
* API mocks (e.g. [org.thoughtcrime.securesms.jobs.InAppPaymentRecurringContextJob] querying whoAmI, the
|
||||
* active subscription, and then submitting receipt credentials). Stub these calls so those jobs hit a handled
|
||||
* path and terminate quietly instead of throwing [io.mockk.MockKException] on a job thread, which crashes the
|
||||
* whole app process (the test asserts nothing, so it surfaces as "Application crashed" with no failed test).
|
||||
* Tests that supply an active subscription push the job past the [getSubscription] guard to
|
||||
* [submitReceiptCredentials], so that must be stubbed too. End-to-end coverage of that pipeline is tracked
|
||||
* separately; here we only keep the process alive and the logs clean.
|
||||
*/
|
||||
private fun initialiseAccountAndSubscriptionLookups() {
|
||||
AppDependencies.accountApi.apply {
|
||||
every { whoAmI() } returns NetworkResult.Success(WhoAmIResponse(number = "+15555550123"))
|
||||
}
|
||||
|
||||
AppDependencies.donationsApi.apply {
|
||||
every { getSubscription(any()) } returns NetworkResult.Success(ActiveSubscription.EMPTY)
|
||||
every { submitReceiptCredentials(any(), any()) } returns NetworkResult.StatusCodeError(NonSuccessfulResponseCodeException(402))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
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,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,9 @@ 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.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 +108,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 +145,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 +164,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 +178,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 +196,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 +288,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 +500,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) {
|
||||
@@ -739,16 +684,32 @@ class MainActivity :
|
||||
}
|
||||
|
||||
MainNavigationListLocation.CALLS -> {
|
||||
DetailsScreenNavHost(
|
||||
navHostController = callsNavHostController,
|
||||
contentLayoutData = contentLayoutData
|
||||
NavDisplay(
|
||||
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
|
||||
NavDisplay(
|
||||
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 +792,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 +1149,7 @@ class MainActivity :
|
||||
} else if (SignalStore.internal.useNewMediaActivity) {
|
||||
mediaSendLauncher.launch(
|
||||
MediaSendActivityContract.Args(
|
||||
isCameraFirst = false,
|
||||
isCameraFirst = true,
|
||||
isStory = destination == MainNavigationListLocation.STORIES
|
||||
)
|
||||
)
|
||||
@@ -1249,11 +1227,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() {
|
||||
@@ -250,6 +259,10 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
|
||||
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());
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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() }
|
||||
|
||||
@@ -646,7 +646,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()
|
||||
}
|
||||
|
||||
@@ -1250,6 +1250,7 @@ object BackupRepository {
|
||||
return ImportResult.Failure
|
||||
}
|
||||
SignalStore.backup.hasInvalidBackupVersion = false
|
||||
val selfId: RecipientId
|
||||
|
||||
var transactionSuccessful = false
|
||||
try {
|
||||
@@ -1321,7 +1322,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 +1533,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> {
|
||||
@@ -2310,19 +2311,23 @@ 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()
|
||||
|
||||
@@ -2354,9 +2359,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())
|
||||
@@ -2385,16 +2397,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 +2539,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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
+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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -180,7 +180,7 @@ class AppSettingsFragment : ComposeFragment(), Callbacks {
|
||||
@StringRes toastSuccessStringRes: Int
|
||||
) {
|
||||
lifecycleScope.launch {
|
||||
val subscriber = withContext(Dispatchers.IO) {
|
||||
val subscriber = withContext(Dispatchers.Default) {
|
||||
InAppPaymentsRepository.getSubscriber(subscriberType)
|
||||
}
|
||||
|
||||
|
||||
+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()
|
||||
}
|
||||
|
||||
+1
-1
@@ -51,7 +51,7 @@ class BackupsSettingsViewModel : ViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
viewModelScope.launch(Dispatchers.Default) {
|
||||
SignalStore.backup.lastBackupTimeFlow
|
||||
.collect { lastBackupTime ->
|
||||
internalStateFlow.update {
|
||||
|
||||
+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()
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -143,7 +143,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,7 +194,7 @@ class RemoteBackupsSettingsViewModel : ViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
viewModelScope.launch(Dispatchers.Default) {
|
||||
BackupRepository.maybeFixAnyDanglingUploadProgress()
|
||||
}
|
||||
}
|
||||
|
||||
+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)
|
||||
}
|
||||
|
||||
+1
-1
@@ -6,12 +6,12 @@ import androidx.lifecycle.ViewModelProvider
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import org.signal.core.util.PlayServicesUtil
|
||||
import org.signal.mediasend.SentMediaQuality
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.keyvalue.SettingsValues.ForceWebsocketMode
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.messages.IncomingMessageObserver
|
||||
import org.thoughtcrime.securesms.util.PlayServicesUtil
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences
|
||||
import org.thoughtcrime.securesms.webrtc.CallDataMode
|
||||
|
||||
|
||||
+3
-3
@@ -336,7 +336,7 @@ class InternalBackupPlaygroundViewModel : ViewModel() {
|
||||
|
||||
viewModelScope.launch {
|
||||
when (val result = BackupRepository.restoreRemoteBackup()) {
|
||||
RemoteRestoreResult.Success -> {
|
||||
is RemoteRestoreResult.Success -> {
|
||||
_state.value = _state.value.copy(statusMessage = "Import complete!")
|
||||
ThreadUtil.runOnMain { afterDbRestoreCallback() }
|
||||
}
|
||||
@@ -352,7 +352,7 @@ class InternalBackupPlaygroundViewModel : ViewModel() {
|
||||
}
|
||||
|
||||
fun loadStats() {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
viewModelScope.launch(Dispatchers.Default) {
|
||||
launch {
|
||||
var stats = SignalDatabase.attachments.debugGetAttachmentStats()
|
||||
|
||||
@@ -398,7 +398,7 @@ class InternalBackupPlaygroundViewModel : ViewModel() {
|
||||
return@withContext false
|
||||
}
|
||||
|
||||
suspend fun clearLocalMediaBackupState() = withContext(Dispatchers.IO) {
|
||||
suspend fun clearLocalMediaBackupState() = withContext(Dispatchers.Default) {
|
||||
SignalDatabase.attachments.clearAllArchiveData()
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -41,7 +41,7 @@ class DataSeedingPlaygroundViewModel(application: Application) : AndroidViewMode
|
||||
val state: StateFlow<DataSeedingPlaygroundState> = _state.asStateFlow()
|
||||
|
||||
fun loadThreads() {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
viewModelScope.launch(Dispatchers.Default) {
|
||||
try {
|
||||
val threads = mutableListOf<ThreadWithRecipient>()
|
||||
val cursor: Cursor = SignalDatabase.threads.getRecentConversationList(
|
||||
|
||||
+2
-2
@@ -42,7 +42,7 @@ class InternalIssuesViewModel(application: Application) : AndroidViewModel(appli
|
||||
|
||||
private fun load() {
|
||||
viewModelScope.launch {
|
||||
allIssues = withContext(Dispatchers.IO) { issues.getRecent() }
|
||||
allIssues = withContext(Dispatchers.Default) { issues.getRecent() }
|
||||
_state.update { it.copy(loading = false, notificationPriority = SignalStore.internal.issueNotificationPriority).withVisibleIssues() }
|
||||
}
|
||||
}
|
||||
@@ -54,7 +54,7 @@ class InternalIssuesViewModel(application: Application) : AndroidViewModel(appli
|
||||
|
||||
private fun clearAll() {
|
||||
viewModelScope.launch {
|
||||
withContext(Dispatchers.IO) { issues.clear() }
|
||||
withContext(Dispatchers.Default) { issues.clear() }
|
||||
allIssues = emptyList()
|
||||
_state.update { it.copy(nameFilter = null, expandedIds = emptySet()).withVisibleIssues() }
|
||||
}
|
||||
|
||||
+1
-1
@@ -27,7 +27,7 @@ class InternalSqlitePlaygroundViewModel : ViewModel() {
|
||||
get() = _queryResults
|
||||
|
||||
fun onQuerySubmitted(query: String) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
viewModelScope.launch(Dispatchers.Default) {
|
||||
_queryResults.value = null
|
||||
|
||||
val startTime = System.nanoTime()
|
||||
|
||||
+2
-1
@@ -10,6 +10,7 @@ sealed interface LabsSettingsEvents {
|
||||
data class ToggleStoryArchive(val enabled: Boolean) : LabsSettingsEvents
|
||||
data class ToggleIncognito(val enabled: Boolean) : LabsSettingsEvents
|
||||
data class ToggleBetterSearch(val enabled: Boolean) : LabsSettingsEvents
|
||||
data class ToggleAutoLowerHand(val enabled: Boolean) : LabsSettingsEvents
|
||||
data class ToggleStarredMessages(val enabled: Boolean) : LabsSettingsEvents
|
||||
data class ToggleStickerReplies(val enabled: Boolean) : LabsSettingsEvents
|
||||
data class ToggleMuteBreakthroughNotifications(val enabled: Boolean) : LabsSettingsEvents
|
||||
}
|
||||
|
||||
+18
-9
@@ -125,15 +125,6 @@ private fun LabsSettingsContent(
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Rows.ToggleRow(
|
||||
checked = state.autoLowerHand,
|
||||
text = "Auto Lower Hand Suggestion",
|
||||
label = "Show a prompt to lower your raised hand when you are speaking in a group call.",
|
||||
onCheckChanged = { onEvent(LabsSettingsEvents.ToggleAutoLowerHand(it)) }
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Rows.ToggleRow(
|
||||
checked = state.starredMessages,
|
||||
@@ -142,6 +133,24 @@ private fun LabsSettingsContent(
|
||||
onCheckChanged = { onEvent(LabsSettingsEvents.ToggleStarredMessages(it)) }
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Rows.ToggleRow(
|
||||
checked = state.stickerReplies,
|
||||
text = "Sticker Replies",
|
||||
label = "Keep a pending quote when sending a sticker so the sticker is sent as a reply. Normally the quote is dropped when you send a sticker.",
|
||||
onCheckChanged = { onEvent(LabsSettingsEvents.ToggleStickerReplies(it)) }
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Rows.ToggleRow(
|
||||
checked = state.muteBreakthroughNotifications,
|
||||
text = "Improved Notification Management",
|
||||
label = "Adds per-conversation controls to let calls and replies break through mute. New options in the sounds & notifications settings for a chat.",
|
||||
onCheckChanged = { onEvent(LabsSettingsEvents.ToggleMuteBreakthroughNotifications(it)) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+3
-2
@@ -13,6 +13,7 @@ data class LabsSettingsState(
|
||||
val storyArchive: Boolean = false,
|
||||
val incognito: Boolean = false,
|
||||
val betterSearch: Boolean = false,
|
||||
val autoLowerHand: Boolean = false,
|
||||
val starredMessages: Boolean = false
|
||||
val starredMessages: Boolean = false,
|
||||
val stickerReplies: Boolean = false,
|
||||
val muteBreakthroughNotifications: Boolean = false
|
||||
)
|
||||
|
||||
+11
-7
@@ -33,15 +33,18 @@ class LabsSettingsViewModel : ViewModel() {
|
||||
SignalStore.labs.betterSearch = event.enabled
|
||||
_state.value = _state.value.copy(betterSearch = event.enabled)
|
||||
}
|
||||
is LabsSettingsEvents.ToggleAutoLowerHand -> {
|
||||
SignalStore.labs.autoLowerHand = event.enabled
|
||||
_state.value = _state.value.copy(autoLowerHand = event.enabled)
|
||||
}
|
||||
|
||||
is LabsSettingsEvents.ToggleStarredMessages -> {
|
||||
SignalStore.labs.starredMessages = event.enabled
|
||||
_state.value = _state.value.copy(starredMessages = event.enabled)
|
||||
}
|
||||
is LabsSettingsEvents.ToggleStickerReplies -> {
|
||||
SignalStore.labs.stickerReplies = event.enabled
|
||||
_state.value = _state.value.copy(stickerReplies = event.enabled)
|
||||
}
|
||||
is LabsSettingsEvents.ToggleMuteBreakthroughNotifications -> {
|
||||
SignalStore.labs.muteBreakthroughNotifications = event.enabled
|
||||
_state.value = _state.value.copy(muteBreakthroughNotifications = event.enabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,8 +54,9 @@ class LabsSettingsViewModel : ViewModel() {
|
||||
storyArchive = SignalStore.labs.storyArchive,
|
||||
incognito = SignalStore.labs.incognito,
|
||||
betterSearch = SignalStore.labs.betterSearch,
|
||||
autoLowerHand = SignalStore.labs.autoLowerHand,
|
||||
starredMessages = SignalStore.labs.starredMessages
|
||||
starredMessages = SignalStore.labs.starredMessages,
|
||||
stickerReplies = SignalStore.labs.stickerReplies,
|
||||
muteBreakthroughNotifications = SignalStore.labs.muteBreakthroughNotifications
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -33,7 +33,7 @@ class NotificationsSettingsViewModel(private val sharedPreferences: SharedPrefer
|
||||
|
||||
// Calculating slow notification stuff isn't thread-safe, so we do it without to start off so we have most state populated, then fetch it in the background.
|
||||
store.update { getState(calculateSlowNotifications = false) }
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
viewModelScope.launch(Dispatchers.Default) {
|
||||
store.update { getState(calculateSlowNotifications = true) }
|
||||
}
|
||||
}
|
||||
|
||||
+20
-23
@@ -45,7 +45,6 @@ import org.signal.core.ui.compose.Texts
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.compose.rememberStatusBarColorNestedScrollModifier
|
||||
import org.thoughtcrime.securesms.util.CommunicationActions
|
||||
import org.thoughtcrime.securesms.util.RemoteConfig
|
||||
import org.thoughtcrime.securesms.util.viewModel
|
||||
|
||||
/**
|
||||
@@ -301,31 +300,29 @@ private fun AdvancedPrivacySettingsScreen(
|
||||
)
|
||||
}
|
||||
|
||||
if (RemoteConfig.internalUser) {
|
||||
item {
|
||||
Dividers.Default()
|
||||
}
|
||||
item {
|
||||
Dividers.Default()
|
||||
}
|
||||
|
||||
item {
|
||||
val label = buildAnnotatedString {
|
||||
append(stringResource(R.string.preferences_automatic_key_verification_body))
|
||||
append(" ")
|
||||
withLink(
|
||||
LinkAnnotation.Clickable("learn-more", linkInteractionListener = {
|
||||
callbacks.onAutomaticVerificationLearnMoreClick()
|
||||
})
|
||||
) {
|
||||
append(stringResource(R.string.LearnMoreTextView_learn_more))
|
||||
}
|
||||
item {
|
||||
val label = buildAnnotatedString {
|
||||
append(stringResource(R.string.preferences_automatic_key_verification_body))
|
||||
append(" ")
|
||||
withLink(
|
||||
LinkAnnotation.Clickable("learn-more", linkInteractionListener = {
|
||||
callbacks.onAutomaticVerificationLearnMoreClick()
|
||||
})
|
||||
) {
|
||||
append(stringResource(R.string.LearnMoreTextView_learn_more))
|
||||
}
|
||||
|
||||
Rows.ToggleRow(
|
||||
checked = state.allowAutomaticKeyVerification,
|
||||
text = AnnotatedString(stringResource(R.string.preferences_automatic_key_verification)),
|
||||
label = label,
|
||||
onCheckChanged = callbacks::onAllowAutomaticVerificationChanged
|
||||
)
|
||||
}
|
||||
|
||||
Rows.ToggleRow(
|
||||
checked = state.allowAutomaticKeyVerification,
|
||||
text = AnnotatedString(stringResource(R.string.preferences_automatic_key_verification)),
|
||||
label = label,
|
||||
onCheckChanged = callbacks::onAllowAutomaticVerificationChanged
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -72,7 +72,7 @@ class AdvancedPrivacySettingsViewModel(
|
||||
fun setAllowAutomaticVerification(enabled: Boolean) {
|
||||
SignalStore.settings.automaticVerificationEnabled = enabled
|
||||
refresh()
|
||||
viewModelScope.launch(SignalDispatchers.IO) {
|
||||
viewModelScope.launch(SignalDispatchers.Default) {
|
||||
if (!enabled) {
|
||||
SignalDatabase.recipients.clearAllKeyTransparencyData()
|
||||
SignalStore.account.distinguishedHead = null
|
||||
|
||||
+1
-1
@@ -32,7 +32,7 @@ class PhoneNumberPrivacySettingsViewModel : ViewModel() {
|
||||
val state: State<PhoneNumberPrivacySettingsState> = _state
|
||||
|
||||
init {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
viewModelScope.launch(Dispatchers.Default) {
|
||||
while (isActive) {
|
||||
refresh()
|
||||
delay(5.seconds)
|
||||
|
||||
+13
@@ -118,6 +118,8 @@ class ManageStorageSettingsFragment : ComposeFragment() {
|
||||
navController.navigate("paid-tier-pending")
|
||||
} else if (state.onDeviceStorageOptimizationState == ManageStorageSettingsViewModel.OnDeviceStorageOptimizationState.REQUIRES_PAID_TIER) {
|
||||
UpgradeToEnableOptimizedStorageSheet().show(parentFragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
|
||||
} else if (enabled && state.localBackupsEnabled) {
|
||||
navController.navigate("confirm-optimize-with-local-backup")
|
||||
} else {
|
||||
viewModel.setOptimizeStorage(enabled)
|
||||
}
|
||||
@@ -238,6 +240,17 @@ class ManageStorageSettingsFragment : ComposeFragment() {
|
||||
)
|
||||
}
|
||||
|
||||
dialog("confirm-optimize-with-local-backup") {
|
||||
Dialogs.SimpleAlertDialog(
|
||||
title = stringResource(id = R.string.ManageStorageSettingsFragment__media_will_be_removed_from_your_on_device_backup),
|
||||
body = stringResource(id = R.string.ManageStorageSettingsFragment__turning_on_optimize_signal_storage_will_offload),
|
||||
confirm = stringResource(id = R.string.ManageStorageSettingsFragment__turn_on),
|
||||
dismiss = stringResource(id = android.R.string.cancel),
|
||||
onConfirm = { viewModel.setOptimizeStorage(true) },
|
||||
onDismiss = { navController.popBackStack() }
|
||||
)
|
||||
}
|
||||
|
||||
dialog(
|
||||
route = "paid-tier-pending"
|
||||
) {
|
||||
|
||||
+5
-3
@@ -35,13 +35,14 @@ class ManageStorageSettingsViewModel : ViewModel() {
|
||||
ManageStorageState(
|
||||
keepMessagesDuration = SignalStore.settings.keepMessagesDuration,
|
||||
lengthLimit = if (SignalStore.settings.isTrimByLengthEnabled) SignalStore.settings.threadTrimLength else ManageStorageState.NO_LIMIT,
|
||||
syncTrimDeletes = SignalStore.settings.shouldSyncThreadTrimDeletes()
|
||||
syncTrimDeletes = SignalStore.settings.shouldSyncThreadTrimDeletes(),
|
||||
localBackupsEnabled = SignalStore.backup.newLocalBackupsEnabled
|
||||
)
|
||||
)
|
||||
val state = store.asStateFlow()
|
||||
|
||||
init {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
viewModelScope.launch(Dispatchers.Default) {
|
||||
InAppPaymentsRepository.observeLatestBackupPayment()
|
||||
.collectLatest { payment ->
|
||||
store.update { it.copy(isPaidTierPending = payment.state == InAppPaymentTable.State.PENDING) }
|
||||
@@ -179,7 +180,8 @@ class ManageStorageSettingsViewModel : ViewModel() {
|
||||
val breakdown: MediaTable.StorageBreakdown? = null,
|
||||
val onDeviceStorageOptimizationState: OnDeviceStorageOptimizationState = OnDeviceStorageOptimizationState.FEATURE_NOT_AVAILABLE,
|
||||
val storageOptimizationStateChanged: Boolean = false,
|
||||
val isPaidTierPending: Boolean = false
|
||||
val isPaidTierPending: Boolean = false,
|
||||
val localBackupsEnabled: Boolean = false
|
||||
) {
|
||||
companion object {
|
||||
const val NO_LIMIT = 0
|
||||
|
||||
+1
-1
@@ -25,7 +25,7 @@ class DonationPendingBottomSheetViewModel(
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
val inAppPayment = withContext(SignalDispatchers.IO) {
|
||||
val inAppPayment = withContext(SignalDispatchers.Default) {
|
||||
SignalDatabase.inAppPayments.getById(inAppPaymentId)!!
|
||||
}
|
||||
|
||||
|
||||
+1
-9
@@ -111,15 +111,7 @@ object InAppPaymentsRepository {
|
||||
.cancellation(
|
||||
InAppPaymentData.Cancellation(
|
||||
reason = if (chargeFailure != null) InAppPaymentData.Cancellation.Reason.PAST_DUE else InAppPaymentData.Cancellation.Reason.CANCELED,
|
||||
chargeFailure = chargeFailure?.let {
|
||||
InAppPaymentData.ChargeFailure(
|
||||
code = it.code,
|
||||
message = it.message,
|
||||
outcomeType = it.outcomeType,
|
||||
outcomeNetworkReason = it.outcomeNetworkReason ?: "",
|
||||
outcomeNetworkStatus = it.outcomeNetworkStatus
|
||||
)
|
||||
}
|
||||
chargeFailure = chargeFailure?.toInAppPaymentDataChargeFailure()
|
||||
)
|
||||
)
|
||||
.build()
|
||||
|
||||
+18
@@ -21,10 +21,12 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.DonationP
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPendingBottomSheetArgs
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.thanks.ThanksForYourSupportBottomSheetDialogFragment
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.thanks.ThanksForYourSupportBottomSheetDialogFragmentArgs
|
||||
import org.thoughtcrime.securesms.database.DatabaseObserver.InAppPaymentObserver
|
||||
import org.thoughtcrime.securesms.database.InAppPaymentTable
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.DonationErrorValue
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
|
||||
/**
|
||||
@@ -53,11 +55,27 @@ class InAppPaymentsBottomSheetDelegate(
|
||||
|
||||
private val badgeRepository = TerminalDonationRepository()
|
||||
|
||||
/**
|
||||
* Fires while resumed whenever an in-app payment changes, so terminal payments (e.g. a redirect/iDEAL
|
||||
* payment that finishes redeeming after we've already resumed) surface their sheet live rather than
|
||||
* waiting for the next resume.
|
||||
*/
|
||||
private val inAppPaymentObserver = InAppPaymentObserver {
|
||||
handleInAppPaymentDonationSheets()
|
||||
handleInAppPaymentBackupsSheets()
|
||||
}
|
||||
|
||||
override fun onResume(owner: LifecycleOwner) {
|
||||
handleLegacyTerminalDonationSheets()
|
||||
handleLegacyVerifiedMonthlyDonationSheets()
|
||||
handleInAppPaymentDonationSheets()
|
||||
handleInAppPaymentBackupsSheets()
|
||||
|
||||
AppDependencies.databaseObserver.registerInAppPaymentObserver(inAppPaymentObserver)
|
||||
}
|
||||
|
||||
override fun onPause(owner: LifecycleOwner) {
|
||||
AppDependencies.databaseObserver.unregisterObserver(inAppPaymentObserver)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+1
-1
@@ -135,7 +135,7 @@ class InAppPaymentCheckoutDelegate(
|
||||
callback.onSubscriptionCancelled(InAppPaymentType.RECURRING_DONATION)
|
||||
} else {
|
||||
fragment.lifecycleScope.launch {
|
||||
val inAppPayment = withContext(SignalDispatchers.IO) {
|
||||
val inAppPayment = withContext(SignalDispatchers.Default) {
|
||||
SignalDatabase.inAppPayments.getById(result.inAppPaymentId!!)!!
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -38,7 +38,7 @@ class CreditCardViewModel(
|
||||
val calendar = Calendar.getInstance()
|
||||
|
||||
viewModelScope.launch {
|
||||
val inAppPayment = withContext(Dispatchers.IO) {
|
||||
val inAppPayment = withContext(Dispatchers.Default) {
|
||||
SignalDatabase.inAppPayments.getById(inAppPaymentId)!!
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -37,7 +37,7 @@ object GatewaySelectorRepository {
|
||||
}
|
||||
|
||||
suspend fun setInAppPaymentMethodType(inAppPayment: InAppPaymentTable.InAppPayment, paymentMethodType: InAppPaymentData.PaymentMethodType): InAppPaymentTable.InAppPayment {
|
||||
return withContext(Dispatchers.IO) {
|
||||
return withContext(Dispatchers.Default) {
|
||||
SignalDatabase.inAppPayments.update(
|
||||
inAppPayment.copy(
|
||||
data = inAppPayment.data.copy(
|
||||
|
||||
+1
-1
@@ -39,7 +39,7 @@ class PayPalCompleteOrderBottomSheet : DSLSettingsBottomSheetFragment() {
|
||||
PayPalCompleteOrderPaymentItem.register(adapter)
|
||||
|
||||
lifecycleScope.launch {
|
||||
val inAppPayment = withContext(SignalDispatchers.IO) {
|
||||
val inAppPayment = withContext(SignalDispatchers.Default) {
|
||||
SignalDatabase.inAppPayments.getById(args.inAppPaymentId)!!
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -119,7 +119,7 @@ class Stripe3DSDialogFragment : DialogFragment(R.layout.donation_webview_fragmen
|
||||
val progress = ProgressCardDialogFragment.create()
|
||||
progress.show(parentFragmentManager, null)
|
||||
|
||||
withContext(Dispatchers.IO) {
|
||||
withContext(Dispatchers.Default) {
|
||||
SignalDatabase.inAppPayments.update(args.waitingForAuthPayment)
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -23,7 +23,7 @@ class IdealTransferDetailsViewModel(inAppPaymentId: InAppPaymentTable.InAppPayme
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
val inAppPayment = withContext(Dispatchers.IO) {
|
||||
val inAppPayment = withContext(Dispatchers.Default) {
|
||||
SignalDatabase.inAppPayments.getById(inAppPaymentId)!!
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -56,7 +56,7 @@ class BankTransferMandateViewModel(
|
||||
}
|
||||
|
||||
suspend fun getPaymentMethodType(): InAppPaymentData.PaymentMethodType {
|
||||
return withContext(SignalDispatchers.IO) {
|
||||
return withContext(SignalDispatchers.Default) {
|
||||
SignalDatabase.inAppPayments.getById(inAppPaymentId)!!.data.paymentMethodType
|
||||
}
|
||||
}
|
||||
|
||||
-14
@@ -7,14 +7,9 @@ import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.appcompat.widget.Toolbar
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.navigation.fragment.navArgs
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import org.signal.core.util.dp
|
||||
import org.signal.core.util.money.FiatMoney
|
||||
import org.signal.donations.InAppPaymentType
|
||||
@@ -29,7 +24,6 @@ import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationSerializationHelper.toFiatMoney
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.completed.InAppPaymentsBottomSheetDelegate
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.CheckoutFlowActivity
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.thanks.ThanksForYourSupportBottomSheetDialogFragment
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.ui.NetworkFailure
|
||||
import org.thoughtcrime.securesms.components.settings.configure
|
||||
import org.thoughtcrime.securesms.components.settings.models.IndeterminateLoadingCircle
|
||||
@@ -90,14 +84,6 @@ class ManageDonationsFragment :
|
||||
if (savedInstanceState == null && args.directToCheckoutType != InAppPaymentType.UNKNOWN) {
|
||||
launcher.launch(args.directToCheckoutType)
|
||||
}
|
||||
|
||||
lifecycleScope.launch {
|
||||
repeatOnLifecycle(Lifecycle.State.RESUMED) {
|
||||
viewModel.displayThanksBottomSheetPulse.collectLatest {
|
||||
ThanksForYourSupportBottomSheetDialogFragment.create(it).show(parentFragmentManager, ThanksForYourSupportBottomSheetDialogFragment.SHEET_TAG)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
|
||||
-42
@@ -1,42 +0,0 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.manage
|
||||
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.channels.trySendBlocking
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import org.thoughtcrime.securesms.database.DatabaseObserver.InAppPaymentObserver
|
||||
import org.thoughtcrime.securesms.database.InAppPaymentTable
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
|
||||
object ManageDonationsRepository {
|
||||
/**
|
||||
* Emits any time we see a successfully completed IDEAL payment that we've not notified the user about.
|
||||
*/
|
||||
fun consumeSuccessfulIdealPayments(): Flow<InAppPaymentTable.InAppPayment> {
|
||||
return callbackFlow {
|
||||
val observer = InAppPaymentObserver {
|
||||
if (it.state == InAppPaymentTable.State.END &&
|
||||
it.data.error == null &&
|
||||
it.data.paymentMethodType == InAppPaymentData.PaymentMethodType.IDEAL &&
|
||||
!it.notified
|
||||
) {
|
||||
trySendBlocking(it)
|
||||
|
||||
SignalDatabase.inAppPayments.update(
|
||||
it.copy(notified = true)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
AppDependencies.databaseObserver.registerInAppPaymentObserver(observer)
|
||||
awaitClose { AppDependencies.databaseObserver.unregisterObserver(observer) }
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-17
@@ -10,15 +10,10 @@ import io.reactivex.rxjava3.kotlin.plusAssign
|
||||
import io.reactivex.rxjava3.kotlin.subscribeBy
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.rx3.asFlow
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.donations.InAppPaymentType
|
||||
import org.thoughtcrime.securesms.badges.Badges
|
||||
import org.thoughtcrime.securesms.badges.models.Badge
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.RecurringInAppPaymentRepository
|
||||
import org.thoughtcrime.securesms.database.InAppPaymentTable
|
||||
@@ -40,9 +35,6 @@ class ManageDonationsViewModel : ViewModel() {
|
||||
private val networkDisposable: Disposable
|
||||
|
||||
val state: LiveData<ManageDonationsState> = store.stateLiveData
|
||||
private val internalDisplayThanksBottomSheetPulse = MutableSharedFlow<Badge>()
|
||||
|
||||
val displayThanksBottomSheetPulse: SharedFlow<Badge> = internalDisplayThanksBottomSheetPulse
|
||||
|
||||
init {
|
||||
store.update(Recipient.self().live().liveDataResolved) { self, state ->
|
||||
@@ -58,14 +50,7 @@ class ManageDonationsViewModel : ViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
ManageDonationsRepository.consumeSuccessfulIdealPayments()
|
||||
.collectLatest {
|
||||
internalDisplayThanksBottomSheetPulse.emit(Badges.fromDatabaseBadge(it.data.badge!!))
|
||||
}
|
||||
}
|
||||
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
viewModelScope.launch(Dispatchers.Default) {
|
||||
InAppPaymentsRepository.observeInAppPaymentRedemption(InAppPaymentType.RECURRING_DONATION)
|
||||
.asFlow()
|
||||
.collect { redemptionStatus ->
|
||||
@@ -86,7 +71,7 @@ class ManageDonationsViewModel : ViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
viewModelScope.launch(Dispatchers.Default) {
|
||||
InAppPaymentsRepository.observeInAppPaymentRedemption(InAppPaymentType.ONE_TIME_DONATION)
|
||||
.asFlow()
|
||||
.collect { redemptionStatus ->
|
||||
|
||||
+33
-5
@@ -84,12 +84,14 @@ import org.thoughtcrime.securesms.groups.ui.GroupErrors
|
||||
import org.thoughtcrime.securesms.groups.ui.GroupLimitDialog
|
||||
import org.thoughtcrime.securesms.groups.ui.GroupMemberEntry
|
||||
import org.thoughtcrime.securesms.groups.ui.LeaveGroupDialog
|
||||
import org.thoughtcrime.securesms.groups.ui.MemberSearchFragment
|
||||
import org.thoughtcrime.securesms.groups.ui.addmembers.AddMembersActivity
|
||||
import org.thoughtcrime.securesms.groups.ui.addtogroup.AddToGroupsActivity
|
||||
import org.thoughtcrime.securesms.groups.ui.invitesandrequests.ManagePendingAndRequestingMembersActivity
|
||||
import org.thoughtcrime.securesms.groups.ui.managegroup.dialogs.GroupDescriptionDialog
|
||||
import org.thoughtcrime.securesms.groups.ui.managegroup.dialogs.GroupInviteSentDialog
|
||||
import org.thoughtcrime.securesms.groups.ui.managegroup.dialogs.GroupsLearnMoreBottomSheetDialogFragment
|
||||
import org.thoughtcrime.securesms.jobs.AttachmentDownloadJob
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.main.MainNavigationChatDetailRouter
|
||||
import org.thoughtcrime.securesms.main.MainNavigationDetailLocation
|
||||
@@ -220,6 +222,10 @@ class ConversationSettingsFragment :
|
||||
navController.safeNavigate(ConversationSettingsFragmentDirections.actionConversationSettingsFragmentToMemberLabelFragment(groupId))
|
||||
}
|
||||
|
||||
parentFragmentManager.setFragmentResultListener(MemberSearchFragment.RESULT_ADD_MEMBERS, viewLifecycleOwner) { _, _ ->
|
||||
viewModel.onAddToGroup()
|
||||
}
|
||||
|
||||
recyclerView?.addOnScrollListener(ConversationSettingsOnUserScrolledAnimationHelper(toolbarAvatarContainer, toolbarTitle, toolbarBackground))
|
||||
}
|
||||
|
||||
@@ -698,8 +704,21 @@ class ConversationSettingsFragment :
|
||||
mediaRecords = state.sharedMedia,
|
||||
mediaIds = state.sharedMediaIds,
|
||||
onMediaRecordClick = { view, mediaRecord, isLtr ->
|
||||
if (mediaRecord.attachment?.transferState != AttachmentTable.TRANSFER_PROGRESS_DONE &&
|
||||
mediaRecord.attachment?.transferState != AttachmentTable.TRANSFER_RESTORE_OFFLOADED
|
||||
val attachment = mediaRecord.attachment
|
||||
if (attachment == null) {
|
||||
Toast.makeText(context, R.string.ConversationSettingsFragment__this_media_is_not_sent_yet, Toast.LENGTH_LONG).show()
|
||||
return@Model
|
||||
}
|
||||
if (attachment.displayUri == null) {
|
||||
if (attachment.transferState == AttachmentTable.TRANSFER_RESTORE_OFFLOADED) {
|
||||
AttachmentDownloadJob.downloadAttachmentIfNeeded(attachment)
|
||||
} else {
|
||||
Toast.makeText(context, R.string.ConversationSettingsFragment__this_media_is_not_sent_yet, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
return@Model
|
||||
}
|
||||
if (attachment.transferState != AttachmentTable.TRANSFER_PROGRESS_DONE &&
|
||||
attachment.transferState != AttachmentTable.TRANSFER_RESTORE_OFFLOADED
|
||||
) {
|
||||
Toast.makeText(context, R.string.ConversationSettingsFragment__this_media_is_not_sent_yet, Toast.LENGTH_LONG).show()
|
||||
return@Model
|
||||
@@ -821,6 +840,7 @@ class ConversationSettingsFragment :
|
||||
|
||||
state.withGroupSettingsState { groupState ->
|
||||
val memberCount = groupState.allMembers.size
|
||||
val canAdd = groupState.canAddToGroup && !groupState.isTerminated && !state.isDeprecatedOrUnregistered
|
||||
|
||||
if (groupState.canAddToGroup || memberCount > 0) {
|
||||
dividerPref()
|
||||
@@ -830,10 +850,18 @@ class ConversationSettingsFragment :
|
||||
} else {
|
||||
resources.getQuantityString(R.plurals.ContactSelectionListFragment_d_members, memberCount, memberCount)
|
||||
}
|
||||
sectionHeaderPref(DSLSettingsText.from(memberHeaderText))
|
||||
|
||||
sectionHeaderPref(
|
||||
title = DSLSettingsText.from(memberHeaderText),
|
||||
iconEnd = DSLSettingsIcon.from(CoreUiR.drawable.symbol_search_24),
|
||||
onClick = {
|
||||
val action = ConversationSettingsFragmentDirections.actionConversationSettingsFragmentToMemberSearchFragment(groupState.groupId, canAdd, groupState.groupLinkEnabled)
|
||||
navController.safeNavigate(action)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (groupState.canAddToGroup && !groupState.isTerminated && !state.isDeprecatedOrUnregistered) {
|
||||
if (canAdd) {
|
||||
customPref(
|
||||
LargeIconClickPreference.Model(
|
||||
title = DSLSettingsText.from(R.string.ConversationSettingsFragment__add_members),
|
||||
@@ -1110,7 +1138,7 @@ class ConversationSettingsFragment :
|
||||
}
|
||||
|
||||
state.withGroupSettingsState { groupState ->
|
||||
if (groupState.canEndGroup && RemoteConfig.groupTerminateSend) {
|
||||
if (groupState.canEndGroup) {
|
||||
dividerPref()
|
||||
|
||||
clickPref(
|
||||
|
||||
+1
-1
@@ -23,7 +23,7 @@ class ConversationSettingsNavHostFragment : NavHostFragment(), FragmentBackPress
|
||||
|
||||
companion object {
|
||||
suspend fun createArgs(recipientId: RecipientId): Bundle {
|
||||
val recipient = withContext(Dispatchers.IO) { Recipient.resolved(recipientId) }
|
||||
val recipient = withContext(Dispatchers.Default) { Recipient.resolved(recipientId) }
|
||||
|
||||
val args = if (recipient.isGroup) {
|
||||
ConversationSettingsFragmentArgs.Builder(null, recipient.requireGroupId(), null)
|
||||
|
||||
+3
-3
@@ -157,7 +157,7 @@ sealed class ConversationSettingsViewModel(
|
||||
if (state.threadId > 0) {
|
||||
val newArchived = !state.isArchived
|
||||
store.update { it.copy(isArchived = newArchived) }
|
||||
viewModelScope.launch(SignalDispatchers.IO) {
|
||||
viewModelScope.launch(SignalDispatchers.Default) {
|
||||
repository.setArchived(state.threadId, newArchived)
|
||||
}
|
||||
}
|
||||
@@ -548,7 +548,7 @@ sealed class ConversationSettingsViewModel(
|
||||
repository.unblock(groupId)
|
||||
}
|
||||
|
||||
private fun loadMemberLabels(v2GroupId: GroupId.V2, groupMembers: List<GroupMemberEntry.FullMember>) = viewModelScope.launch(SignalDispatchers.IO) {
|
||||
private fun loadMemberLabels(v2GroupId: GroupId.V2, groupMembers: List<GroupMemberEntry.FullMember>) = viewModelScope.launch(SignalDispatchers.Default) {
|
||||
val labelsByRecipientId = MemberLabelRepository.instance
|
||||
.getLabels(v2GroupId, groupMembers.map { it.member })
|
||||
|
||||
@@ -561,7 +561,7 @@ sealed class ConversationSettingsViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadCanSetMemberLabel(groupId: GroupId.V2) = viewModelScope.launch(SignalDispatchers.IO) {
|
||||
private fun loadCanSetMemberLabel(groupId: GroupId.V2) = viewModelScope.launch(SignalDispatchers.Default) {
|
||||
val canSetLabel = MemberLabelRepository.instance.canSetLabel(groupId, Recipient.self())
|
||||
store.update {
|
||||
it.copy(
|
||||
|
||||
+1
-1
@@ -174,7 +174,7 @@ class InternalConversationSettingsFragment : ComposeFragment(), InternalConversa
|
||||
}
|
||||
|
||||
override fun add1000Messages(recipientId: RecipientId) {
|
||||
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) {
|
||||
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.Default) {
|
||||
val recipient = Recipient.live(recipientId).get()
|
||||
val messageCount = 1000
|
||||
val startTime = System.currentTimeMillis() - messageCount
|
||||
|
||||
+4
-4
@@ -71,25 +71,25 @@ class SoundsAndNotificationsSettingsViewModel2(
|
||||
}
|
||||
|
||||
private fun applySetMuteUntil(muteUntil: Long) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
viewModelScope.launch(Dispatchers.Default) {
|
||||
SignalDatabase.recipients.setMuted(recipientId, muteUntil)
|
||||
}
|
||||
}
|
||||
|
||||
private fun applySetMentionSetting(setting: NotificationSetting) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
viewModelScope.launch(Dispatchers.Default) {
|
||||
SignalDatabase.recipients.setMentionSetting(recipientId, setting)
|
||||
}
|
||||
}
|
||||
|
||||
private fun applySetCallNotificationSetting(setting: NotificationSetting) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
viewModelScope.launch(Dispatchers.Default) {
|
||||
SignalDatabase.recipients.setCallNotificationSetting(recipientId, setting)
|
||||
}
|
||||
}
|
||||
|
||||
private fun applySetReplyNotificationSetting(setting: NotificationSetting) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
viewModelScope.launch(Dispatchers.Default) {
|
||||
SignalDatabase.recipients.setReplyNotificationSetting(recipientId, setting)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,13 +137,21 @@ class DSLConfiguration {
|
||||
children.add(preference)
|
||||
}
|
||||
|
||||
fun sectionHeaderPref(title: DSLSettingsText) {
|
||||
val preference = SectionHeaderPreference(title)
|
||||
fun sectionHeaderPref(
|
||||
title: DSLSettingsText,
|
||||
iconEnd: DSLSettingsIcon? = null,
|
||||
onClick: (() -> Unit)? = null
|
||||
) {
|
||||
val preference = SectionHeaderPreference(title, iconEnd, onClick)
|
||||
children.add(preference)
|
||||
}
|
||||
|
||||
fun sectionHeaderPref(title: Int) {
|
||||
val preference = SectionHeaderPreference(DSLSettingsText.from(title))
|
||||
fun sectionHeaderPref(
|
||||
title: Int,
|
||||
iconEnd: DSLSettingsIcon? = null,
|
||||
onClick: (() -> Unit)? = null
|
||||
) {
|
||||
val preference = SectionHeaderPreference(DSLSettingsText.from(title), iconEnd, onClick)
|
||||
children.add(preference)
|
||||
}
|
||||
|
||||
@@ -365,4 +373,12 @@ class ExternalLinkPreference(
|
||||
@StringRes val linkId: Int
|
||||
) : PreferenceModel<ExternalLinkPreference>()
|
||||
|
||||
class SectionHeaderPreference(override val title: DSLSettingsText) : PreferenceModel<SectionHeaderPreference>()
|
||||
class SectionHeaderPreference(
|
||||
override val title: DSLSettingsText,
|
||||
override val iconEnd: DSLSettingsIcon? = null,
|
||||
val onClick: (() -> Unit)? = null
|
||||
) : PreferenceModel<SectionHeaderPreference>() {
|
||||
override fun areContentsTheSame(newItem: SectionHeaderPreference): Boolean {
|
||||
return super.areContentsTheSame(newItem) && onClick == null && newItem.onClick == null
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -41,13 +41,13 @@ class CallPeerRepository(
|
||||
private val recipient: Flow<Recipient> = liveRecipient.flatMapLatest { it.observable().asFlow() }
|
||||
private val groupRecipient = recipient.filter { it.isActiveGroup }
|
||||
private val groupRecord: Flow<GroupRecord> = groupRecipient.mapLatest {
|
||||
withContext(Dispatchers.IO) {
|
||||
withContext(Dispatchers.Default) {
|
||||
SignalDatabase.groups.getGroup(it.requireGroupId()).get()
|
||||
}
|
||||
}
|
||||
|
||||
val groupMembers: Flow<List<GroupMemberEntry.FullMember>> = groupRecord.mapLatest { record ->
|
||||
withContext(Dispatchers.IO) {
|
||||
withContext(Dispatchers.Default) {
|
||||
record.members.map { Recipient.resolved(it) }.map { GroupMemberEntry.FullMember(it, record.isAdmin(it)) }
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user