mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-07-19 12:04:41 +01:00
Compare commits
62 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 |
@@ -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
|
||||
|
||||
@@ -14,9 +14,9 @@ jobs:
|
||||
- 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@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
- name: Build image
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
with:
|
||||
context: reproducible-builds
|
||||
tags: signal-android
|
||||
|
||||
+156
-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 = 1716
|
||||
val canonicalVersionName = "8.18.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 {
|
||||
@@ -792,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)
|
||||
@@ -975,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
|
||||
|
||||
+9
-3
@@ -22,12 +22,16 @@ 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.RecyclerViewScrollToBottomAction
|
||||
import org.thoughtcrime.securesms.testing.actions.scrollToDescendant
|
||||
|
||||
@Suppress("ClassName")
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
@@ -51,6 +55,7 @@ class CheckoutFlowActivityTest__OneTimeDonations {
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
SignalDatabase.inAppPayments.writableDatabase.deleteAll(InAppPaymentTable.TABLE_NAME)
|
||||
startJobLoopForTests()
|
||||
}
|
||||
|
||||
@@ -61,16 +66,17 @@ class CheckoutFlowActivityTest__OneTimeDonations {
|
||||
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()
|
||||
|
||||
onView(withId(R.id.recycler)).perform(RecyclerViewScrollToBottomAction)
|
||||
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)
|
||||
|
||||
awaitDonationErrorDialog(rxRule.defaultTestScheduler, R.string.DonationsErrors__error_processing_payment)
|
||||
awaitDialog(rxRule.defaultTestScheduler, R.string.DonationsErrors__error_processing_payment)
|
||||
onView(withText(R.string.DonationsErrors__your_payment)).inRoot(isDialog()).check(matches(isDisplayed()))
|
||||
}
|
||||
}
|
||||
|
||||
+17
-10
@@ -21,6 +21,7 @@ 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
|
||||
@@ -28,14 +29,17 @@ 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
|
||||
@@ -65,6 +69,7 @@ class CheckoutFlowActivityTest__RecurringDonations {
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
SignalDatabase.inAppPayments.writableDatabase.deleteAll(InAppPaymentTable.TABLE_NAME)
|
||||
startJobLoopForTests()
|
||||
}
|
||||
|
||||
@@ -76,14 +81,15 @@ class CheckoutFlowActivityTest__RecurringDonations {
|
||||
@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
|
||||
@@ -94,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()))
|
||||
}
|
||||
|
||||
@@ -107,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()))
|
||||
@@ -122,7 +129,7 @@ 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()))
|
||||
}
|
||||
@@ -134,13 +141,13 @@ class CheckoutFlowActivityTest__RecurringDonations {
|
||||
val scenario = ActivityScenario.launch<CheckoutFlowActivity>(intent)
|
||||
rxRule.defaultTestScheduler.triggerActions()
|
||||
|
||||
onView(withId(R.id.recycler)).perform(RecyclerViewScrollToBottomAction)
|
||||
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)
|
||||
|
||||
awaitDonationErrorDialog(rxRule.defaultTestScheduler, R.string.DonationsErrors__error_processing_payment)
|
||||
awaitDialog(rxRule.defaultTestScheduler, R.string.DonationsErrors__error_processing_payment)
|
||||
onView(withText(R.string.DonationsErrors__your_payment)).inRoot(isDialog()).check(matches(isDisplayed()))
|
||||
}
|
||||
|
||||
@@ -154,13 +161,13 @@ class CheckoutFlowActivityTest__RecurringDonations {
|
||||
val scenario = ActivityScenario.launch<CheckoutFlowActivity>(intent)
|
||||
rxRule.defaultTestScheduler.triggerActions()
|
||||
|
||||
onView(withId(R.id.recycler)).perform(RecyclerViewScrollToBottomAction)
|
||||
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)
|
||||
|
||||
awaitDonationErrorDialog(rxRule.defaultTestScheduler, R.string.DonationsErrors__error_processing_payment)
|
||||
awaitDialog(rxRule.defaultTestScheduler, R.string.DonationsErrors__error_processing_payment)
|
||||
onView(withText(R.string.DonationsErrors__your_payment)).inRoot(isDialog()).check(matches(isDisplayed()))
|
||||
}
|
||||
|
||||
|
||||
+34
-84
@@ -7,7 +7,6 @@ package org.thoughtcrime.securesms.components.settings.app.subscription.donate
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.os.SystemClock
|
||||
import androidx.compose.ui.test.junit4.ComposeTestRule
|
||||
import androidx.compose.ui.test.onAllNodesWithTag
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
@@ -18,18 +17,8 @@ import androidx.test.espresso.assertion.ViewAssertions.matches
|
||||
import androidx.test.espresso.matcher.RootMatchers.isDialog
|
||||
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
|
||||
import androidx.test.espresso.matcher.ViewMatchers.withText
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import 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 io.reactivex.rxjava3.schedulers.TestScheduler
|
||||
import org.junit.rules.ExternalResource
|
||||
import org.signal.donations.GooglePayApi
|
||||
import org.signal.donations.InAppPaymentType
|
||||
import org.signal.libsignal.net.RequestResult
|
||||
import org.signal.network.rest.RestStatusCodeError
|
||||
@@ -38,36 +27,7 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.GooglePay
|
||||
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
|
||||
|
||||
/**
|
||||
* 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)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
private const val GOOGLE_PAY_PAYMENT_DATA_JSON = """{"paymentMethodData":{"tokenizationData":{"token":"{\"id\":\"tok_test\"}"}},"email":"test@signal.org"}"""
|
||||
import org.thoughtcrime.securesms.testing.flushUntil
|
||||
|
||||
/**
|
||||
* Forces real donation-permit acquisition to fail at the issuer, exercising the permit code path through
|
||||
@@ -89,30 +49,33 @@ fun startJobLoopForTests() {
|
||||
/**
|
||||
* 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
|
||||
) {
|
||||
val deadline = SystemClock.uptimeMillis() + 10_000
|
||||
var present = false
|
||||
while (SystemClock.uptimeMillis() < deadline && !present) {
|
||||
scheduler.triggerActions()
|
||||
InstrumentationRegistry.getInstrumentation().waitForIdleSync()
|
||||
present = try {
|
||||
composeRule.onAllNodesWithTag(GatewaySelectorTestTags.GOOGLE_PAY_BUTTON).fetchSemanticsNodes().isNotEmpty()
|
||||
} catch (e: IllegalStateException) {
|
||||
false
|
||||
}
|
||||
if (!present) {
|
||||
Thread.sleep(100)
|
||||
}
|
||||
scheduler.flushUntil {
|
||||
composeRule.onAllNodesWithTag(GatewaySelectorTestTags.GOOGLE_PAY_BUTTON).fetchSemanticsNodes().isNotEmpty()
|
||||
}
|
||||
check(present) { "Google Pay button never appeared in the gateway selector." }
|
||||
|
||||
composeRule.onNodeWithTag(GatewaySelectorTestTags.GOOGLE_PAY_BUTTON).performClick()
|
||||
pump(scheduler, iterations = 20)
|
||||
|
||||
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(
|
||||
@@ -126,34 +89,21 @@ fun ActivityScenario<CheckoutFlowActivity>.selectGooglePay(
|
||||
}
|
||||
|
||||
/**
|
||||
* Advances the Rx [scheduler] and pumps the main looper until the checkout error dialog with [titleResId] is
|
||||
* displayed, bridging the real JobManager-backed setup job, the Rx observers, and dialog rendering.
|
||||
* 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 awaitDonationErrorDialog(scheduler: TestScheduler, titleResId: Int, timeoutMillis: Long = 15_000) {
|
||||
val deadline = SystemClock.uptimeMillis() + timeoutMillis
|
||||
var lastFailure: Throwable? = null
|
||||
|
||||
while (SystemClock.uptimeMillis() < deadline) {
|
||||
scheduler.triggerActions()
|
||||
InstrumentationRegistry.getInstrumentation().waitForIdleSync()
|
||||
|
||||
try {
|
||||
onView(withText(titleResId)).inRoot(isDialog()).check(matches(isDisplayed()))
|
||||
return
|
||||
} catch (t: Throwable) {
|
||||
lastFailure = t
|
||||
}
|
||||
|
||||
Thread.sleep(100)
|
||||
}
|
||||
|
||||
throw AssertionError("Donation error dialog ($titleResId) was not displayed within ${timeoutMillis}ms.", lastFailure)
|
||||
}
|
||||
|
||||
private fun pump(scheduler: TestScheduler, iterations: Int, intervalMs: Long = 100) {
|
||||
repeat(iterations) {
|
||||
scheduler.triggerActions()
|
||||
InstrumentationRegistry.getInstrumentation().waitForIdleSync()
|
||||
Thread.sleep(intervalMs)
|
||||
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
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+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()
|
||||
}
|
||||
}
|
||||
+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}"
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+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()
|
||||
}
|
||||
}
|
||||
@@ -522,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"
|
||||
|
||||
@@ -52,7 +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.components.settings.app.AppSettingsActivity;
|
||||
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;
|
||||
@@ -186,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())
|
||||
@@ -438,7 +438,7 @@ public class ApplicationContext extends Application implements AppForegroundObse
|
||||
return Unit.INSTANCE;
|
||||
},
|
||||
context -> {
|
||||
context.startActivity(AppSettingsActivity.proxy(context));
|
||||
context.startActivity(EditProxyActivity.intent(context));
|
||||
return Unit.INSTANCE;
|
||||
},
|
||||
(context, subject) -> {
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -145,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
|
||||
@@ -165,10 +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.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
|
||||
@@ -183,9 +178,8 @@ 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
|
||||
@@ -506,50 +500,6 @@ class MainActivity :
|
||||
mainNavigationViewModel.onSplitPaneChanged(isSplitPane)
|
||||
}
|
||||
|
||||
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 -> {
|
||||
throw IllegalStateException("Navigation to ${MainNavigationListLocation.CALLS} should be handled by CallsBackStack.")
|
||||
}
|
||||
|
||||
MainNavigationListLocation.STORIES -> storiesNavHostController.navigateToDetailLocation(location)
|
||||
}
|
||||
}
|
||||
|
||||
is MainNavigationDetailLocation.Conversation, is MainNavigationDetailLocation.Chats -> {
|
||||
throw IllegalStateException("Navigation to $location should be handled by ChatsBackStack.")
|
||||
}
|
||||
|
||||
is MainNavigationDetailLocation.CallLinkDetails, is MainNavigationDetailLocation.Calls -> {
|
||||
throw IllegalStateException("Navigation to $location should be handled by CallsBackStack.")
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -749,9 +699,17 @@ class MainActivity :
|
||||
}
|
||||
|
||||
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() }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1269,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() &&
|
||||
@@ -246,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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -22,6 +25,7 @@ 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
|
||||
|
||||
@@ -36,28 +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) {
|
||||
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 = RegistrationActivity.newIntentForReRegistration(context)
|
||||
val registrationIntent = if (isLinkedDevice) {
|
||||
RegistrationActivity.newIntentForReLinkDevice(context)
|
||||
} else {
|
||||
RegistrationActivity.newIntentForReRegistration(context)
|
||||
}
|
||||
context.startActivity(registrationIntent)
|
||||
}
|
||||
),
|
||||
@@ -69,6 +92,6 @@ private fun Banner(contentPadding: PaddingValues) {
|
||||
@Composable
|
||||
private fun BannerPreview() {
|
||||
Previews.Preview {
|
||||
Banner(PaddingValues(0.dp))
|
||||
Banner(PaddingValues(0.dp), isLinkedDevice = false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
+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()
|
||||
}
|
||||
}
|
||||
|
||||
+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)
|
||||
}
|
||||
|
||||
+2
-2
@@ -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()
|
||||
|
||||
+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) }
|
||||
}
|
||||
}
|
||||
|
||||
+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()
|
||||
|
||||
+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
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -50,7 +50,7 @@ class ManageDonationsViewModel : ViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
viewModelScope.launch(Dispatchers.Default) {
|
||||
InAppPaymentsRepository.observeInAppPaymentRedemption(InAppPaymentType.RECURRING_DONATION)
|
||||
.asFlow()
|
||||
.collect { redemptionStatus ->
|
||||
@@ -71,7 +71,7 @@ class ManageDonationsViewModel : ViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
viewModelScope.launch(Dispatchers.Default) {
|
||||
InAppPaymentsRepository.observeInAppPaymentRedemption(InAppPaymentType.ONE_TIME_DONATION)
|
||||
.asFlow()
|
||||
.collect { redemptionStatus ->
|
||||
|
||||
+32
-16
@@ -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()
|
||||
@@ -831,21 +851,17 @@ class ConversationSettingsFragment :
|
||||
resources.getQuantityString(R.plurals.ContactSelectionListFragment_d_members, memberCount, memberCount)
|
||||
}
|
||||
|
||||
if (RemoteConfig.internalUser) {
|
||||
sectionHeaderPref(
|
||||
title = DSLSettingsText.from(memberHeaderText),
|
||||
iconEnd = DSLSettingsIcon.from(CoreUiR.drawable.symbol_search_24),
|
||||
onClick = {
|
||||
val action = ConversationSettingsFragmentDirections.actionConversationSettingsFragmentToMemberSearchFragment(groupState.groupId)
|
||||
navController.safeNavigate(action)
|
||||
}
|
||||
)
|
||||
} else {
|
||||
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),
|
||||
@@ -1122,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -377,4 +377,8 @@ class SectionHeaderPreference(
|
||||
override val title: DSLSettingsText,
|
||||
override val iconEnd: DSLSettingsIcon? = null,
|
||||
val onClick: (() -> Unit)? = null
|
||||
) : PreferenceModel<SectionHeaderPreference>()
|
||||
) : 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)) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ import kotlin.Pair;
|
||||
public class ContactRepository {
|
||||
|
||||
private final RecipientTable recipientTable;
|
||||
private final String noteToSelfTitle;
|
||||
private final String selfTitle;
|
||||
|
||||
public static final String ID_COLUMN = "id";
|
||||
public static final String NAME_COLUMN = "name";
|
||||
@@ -101,14 +101,14 @@ public class ContactRepository {
|
||||
}));
|
||||
}};
|
||||
|
||||
public ContactRepository(@NonNull String noteToSelfTitle) {
|
||||
this.noteToSelfTitle = noteToSelfTitle;
|
||||
this.recipientTable = SignalDatabase.recipients();
|
||||
public ContactRepository(@NonNull String selfTitle) {
|
||||
this.selfTitle = selfTitle;
|
||||
this.recipientTable = SignalDatabase.recipients();
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
public @NonNull Cursor querySignalContacts(@NonNull String query) {
|
||||
return querySignalContacts(new RecipientTable.ContactSearchQuery(query, new RecipientTable.IncludeSelfMode.IncludeWithRemap(noteToSelfTitle), ContactSearchSortOrder.NATURAL));
|
||||
return querySignalContacts(new RecipientTable.ContactSearchQuery(query, new RecipientTable.IncludeSelfMode.IncludeWithRemap(selfTitle), ContactSearchSortOrder.NATURAL));
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
@@ -123,7 +123,7 @@ public class ContactRepository {
|
||||
public @NonNull Cursor queryGroupMemberContacts(@NonNull String query, @Nullable GroupId groupId) {
|
||||
Cursor cursor;
|
||||
if (groupId != null) {
|
||||
cursor = recipientTable.queryGroupMemberContactsForGroup(groupId, query);
|
||||
cursor = recipientTable.queryGroupMemberContactsForGroup(groupId, query, selfTitle);
|
||||
} else if (TextUtils.isEmpty(query)) {
|
||||
cursor = recipientTable.getGroupMemberContacts();
|
||||
} else {
|
||||
@@ -183,7 +183,8 @@ public class ContactRepository {
|
||||
|
||||
@Override
|
||||
public String getString(int columnIndex) {
|
||||
return String.valueOf(mappers.get(columnIndex).getSecond().get(wrapped));
|
||||
Object value = mappers.get(columnIndex).getSecond().get(wrapped);
|
||||
return value != null ? value.toString() : "";
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
+12
-1
@@ -135,7 +135,9 @@ class ContactSearchConfiguration private constructor(
|
||||
override val expandConfig: ExpandConfig? = null,
|
||||
val includeLetterHeaders: Boolean = false,
|
||||
val showGroupsInCommon: Boolean = true,
|
||||
val groupId: GroupId? = null
|
||||
val groupId: GroupId? = null,
|
||||
val showSelfAsYou: Boolean = false,
|
||||
val roleFilter: MemberRole = MemberRole.ALL
|
||||
) : Section(SectionKey.GROUP_MEMBERS)
|
||||
|
||||
/**
|
||||
@@ -299,6 +301,15 @@ class ContactSearchConfiguration private constructor(
|
||||
val maxCountWhenNotExpanded: (ActiveContactCount) -> Int = { 2 }
|
||||
)
|
||||
|
||||
/**
|
||||
* Role-based filter for [Section.GroupMembers].
|
||||
*/
|
||||
enum class MemberRole {
|
||||
ALL,
|
||||
ADMINS,
|
||||
CONTACTS
|
||||
}
|
||||
|
||||
/**
|
||||
* Network transport type for individual recipients.
|
||||
*/
|
||||
|
||||
@@ -33,7 +33,10 @@ sealed class ContactSearchData(val contactSearchKey: ContactSearchKey) {
|
||||
val recipient: Recipient,
|
||||
val shortSummary: Boolean = false,
|
||||
val headerLetter: String? = null,
|
||||
val groupsInCommon: GroupsInCommonSummary = GroupsInCommonSummary(listOf())
|
||||
val groupsInCommon: GroupsInCommonSummary = GroupsInCommonSummary(listOf()),
|
||||
val showSelfAsYou: Boolean = false,
|
||||
val showAdminLabel: Boolean = false,
|
||||
val query: String? = null
|
||||
) : ContactSearchData(ContactSearchKey.RecipientSearchKey(recipient.id, false))
|
||||
|
||||
/**
|
||||
|
||||
@@ -7,6 +7,7 @@ package org.thoughtcrime.securesms.contacts.paged
|
||||
|
||||
import android.content.Context
|
||||
import android.text.SpannableStringBuilder
|
||||
import android.text.style.ForegroundColorSpan
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.CheckBox
|
||||
@@ -19,7 +20,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
@@ -55,6 +55,7 @@ import org.thoughtcrime.securesms.database.model.DistributionListPrivacyMode
|
||||
import org.thoughtcrime.securesms.database.model.StoryViewState
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.util.SearchUtil
|
||||
import org.thoughtcrime.securesms.util.SpanUtil
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
|
||||
@@ -64,6 +65,7 @@ import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.compose.MappingEntryProvider
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.compose.MappingEntryProviderBuilder
|
||||
import org.thoughtcrime.securesms.util.visible
|
||||
import java.util.Locale
|
||||
import org.signal.core.ui.R as CoreUiR
|
||||
|
||||
/**
|
||||
@@ -231,7 +233,7 @@ object ContactSearchModels {
|
||||
key = { "EmptyModel" }
|
||||
) { model ->
|
||||
Text(
|
||||
text = stringResource(R.string.SearchFragment_no_results, model.empty.query ?: ""),
|
||||
text = if (model.empty.query.isNullOrEmpty()) stringResource(R.string.SearchFragment_no_results_empty) else stringResource(R.string.SearchFragment_no_results, model.empty.query),
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -467,7 +469,9 @@ object ContactSearchModels {
|
||||
) : MappingModel<RecipientModel>, FastScrollCharacterProvider {
|
||||
|
||||
override fun getFastScrollCharacter(context: Context): CharSequence {
|
||||
val name = if (knownRecipient.recipient.isSelf) {
|
||||
val name = if (knownRecipient.recipient.isSelf && knownRecipient.showSelfAsYou) {
|
||||
context.getString(R.string.Recipient_you)
|
||||
} else if (knownRecipient.recipient.isSelf) {
|
||||
context.getString(R.string.note_to_self)
|
||||
} else {
|
||||
knownRecipient.recipient.getDisplayName(context)
|
||||
@@ -577,6 +581,16 @@ object ContactSearchModels {
|
||||
override fun isSelected(model: RecipientModel): Boolean = model.isSelected
|
||||
override fun getData(model: RecipientModel): ContactSearchData.KnownRecipient = model.knownRecipient
|
||||
override fun getRecipient(model: RecipientModel): Recipient = model.knownRecipient.recipient
|
||||
override fun showSelfAsYou(model: RecipientModel): Boolean = model.knownRecipient.showSelfAsYou
|
||||
|
||||
override fun bindAvatar(model: RecipientModel) {
|
||||
if (model.knownRecipient.showSelfAsYou) {
|
||||
avatar.setAvatarUsingProfile(getRecipient(model))
|
||||
} else {
|
||||
super.bindAvatar(model)
|
||||
}
|
||||
}
|
||||
|
||||
override fun bindNumberField(model: RecipientModel) {
|
||||
val recipient = getRecipient(model)
|
||||
if (model.knownRecipient.sectionKey == ContactSearchConfiguration.SectionKey.GROUP_MEMBERS && displayOptions.displaySecondaryInformation != ContactSearchAdapter.DisplaySecondaryInformation.NEVER) {
|
||||
@@ -609,6 +623,10 @@ object ContactSearchModels {
|
||||
checkbox.isEnabled = !fixedContacts.contains(model.knownRecipient.contactSearchKey)
|
||||
}
|
||||
|
||||
override fun bindLabelField(model: RecipientModel) {
|
||||
adminLabel.visible = model.knownRecipient.showAdminLabel
|
||||
}
|
||||
|
||||
override fun isEnabled(model: RecipientModel): Boolean {
|
||||
return !fixedContacts.contains(model.knownRecipient.contactSearchKey)
|
||||
}
|
||||
@@ -620,6 +638,10 @@ object ContactSearchModels {
|
||||
override fun bindLongPress(model: RecipientModel) {
|
||||
itemView.setOnLongClickListener { onLongClick.onLongClicked(itemView, model.knownRecipient) }
|
||||
}
|
||||
|
||||
override fun getHighlightQuery(model: RecipientModel): String? {
|
||||
return model.knownRecipient.query
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -638,8 +660,10 @@ object ContactSearchModels {
|
||||
protected val name: FromTextView = itemView.findViewById(R.id.name)
|
||||
protected val number: TextView = itemView.findViewById(R.id.number)
|
||||
protected val label: TextView = itemView.findViewById(R.id.label)
|
||||
protected val adminLabel: TextView = itemView.findViewById(R.id.admin_label)
|
||||
private val startAudio: View = itemView.findViewById(R.id.start_audio)
|
||||
private val startVideo: View = itemView.findViewById(R.id.start_video)
|
||||
private val searchStyleFactory = SearchUtil.StyleFactory { arrayOf(ForegroundColorSpan(ContextCompat.getColor(context, CoreUiR.color.signal_colorOnSurface)), SpanUtil.getBoldSpan()) }
|
||||
|
||||
override fun bind(model: T) {
|
||||
if (isEnabled(model)) {
|
||||
@@ -666,7 +690,14 @@ object ContactSearchModels {
|
||||
} else {
|
||||
null
|
||||
}
|
||||
name.setText(recipient, suffix)
|
||||
val query = getHighlightQuery(model)
|
||||
val displayName: CharSequence = if (!query.isNullOrBlank()) {
|
||||
SearchUtil.getHighlightedSpan(Locale.getDefault(), searchStyleFactory, recipient.getDisplayName(context), query, SearchUtil.MATCH_ALL)
|
||||
} else {
|
||||
recipient.getDisplayName(context)
|
||||
}
|
||||
|
||||
name.setText(recipient, displayName, suffix, true, showSelfAsYou(model))
|
||||
|
||||
badge.setBadgeFromRecipient(getRecipient(model))
|
||||
|
||||
@@ -682,6 +713,8 @@ object ContactSearchModels {
|
||||
}
|
||||
|
||||
protected open fun isEnabled(model: T): Boolean = true
|
||||
protected open fun showSelfAsYou(model: T): Boolean = false
|
||||
protected open fun getHighlightQuery(model: T): String? = null
|
||||
|
||||
protected open fun bindAvatar(model: T) {
|
||||
avatar.setAvatar(getRecipient(model))
|
||||
|
||||
+31
-10
@@ -136,7 +136,7 @@ class ContactSearchPagedDataSource(
|
||||
is ContactSearchConfiguration.Section.Recents -> getRecentsSearchIterator(section, query).getCollectionSizeAndClose(section, query, null)
|
||||
is ContactSearchConfiguration.Section.Stories -> getStoriesSearchIterator(query).getCollectionSizeAndClose(section, query, null)
|
||||
is ContactSearchConfiguration.Section.Arbitrary -> arbitraryRepository?.getSize(section, query) ?: error("Invalid arbitrary section.")
|
||||
is ContactSearchConfiguration.Section.GroupMembers -> getGroupMembersSearchIterator(section, query).getCollectionSizeAndClose(section, query, null)
|
||||
is ContactSearchConfiguration.Section.GroupMembers -> getGroupMembersSearchIterator(section, query).getCollectionSizeAndClose(section, query, filterByRole(section))
|
||||
is ContactSearchConfiguration.Section.Chats -> getThreadData(query, section.isUnreadOnly).getCollectionSizeAndClose(section, query, null)
|
||||
is ContactSearchConfiguration.Section.Messages -> getMessageData(query).getCollectionSizeAndClose(section, query, null)
|
||||
is ContactSearchConfiguration.Section.GroupsWithMembers -> getGroupsWithMembersIterator(query).getCollectionSizeAndClose(section, query, null)
|
||||
@@ -243,22 +243,25 @@ class ContactSearchPagedDataSource(
|
||||
/**
|
||||
* Returns the letter header to display above the recipient at the cursor's current row, or null if
|
||||
* none should be shown. A header is shown only when this row begins a new letter group, determined by
|
||||
* comparing its letter to the immediately preceding row in display order. Peeking that single adjacent
|
||||
* comparing its letter to the first visible preceding row in display order (filtered by recordPredicate). Peeking that single
|
||||
* row means a letter group split across pages still yields exactly one header, anchored to the first
|
||||
* row of the group, without re-scanning the whole contact set.
|
||||
*
|
||||
* The cursor is restored to its original position before returning so iteration is unaffected.
|
||||
*/
|
||||
private fun getHeaderLetterForCurrentRow(cursor: Cursor): String? {
|
||||
private fun getHeaderLetterForCurrentRow(cursor: Cursor, recordPredicate: ((Cursor) -> Boolean)? = null): String? {
|
||||
val position = cursor.position
|
||||
val currentLetter = letterForCurrentRow(cursor) ?: return null
|
||||
|
||||
if (position <= 0) {
|
||||
return currentLetter
|
||||
var foundPrevious = false
|
||||
while (cursor.moveToPrevious()) {
|
||||
if (recordPredicate == null || recordPredicate.invoke(cursor)) {
|
||||
foundPrevious = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
cursor.moveToPosition(position - 1)
|
||||
val previousLetter = letterForCurrentRow(cursor)
|
||||
val previousLetter = if (foundPrevious) letterForCurrentRow(cursor) else null
|
||||
cursor.moveToPosition(position)
|
||||
|
||||
return if (previousLetter != currentLetter) currentLetter else null
|
||||
@@ -444,20 +447,38 @@ class ContactSearchPagedDataSource(
|
||||
}
|
||||
}
|
||||
|
||||
private fun filterByRole(section: ContactSearchConfiguration.Section.GroupMembers): ((Cursor) -> Boolean)? {
|
||||
if (section.roleFilter == ContactSearchConfiguration.MemberRole.ALL || section.groupId == null) {
|
||||
return null
|
||||
}
|
||||
|
||||
val groupRecord = contactSearchPagedDataSourceRepository.getGroupRecord(section.groupId) ?: return null
|
||||
return { cursor ->
|
||||
val recipient = contactSearchPagedDataSourceRepository.getRecipientFromSearchCursor(cursor)
|
||||
when (section.roleFilter) {
|
||||
ContactSearchConfiguration.MemberRole.ALL -> true
|
||||
ContactSearchConfiguration.MemberRole.ADMINS -> groupRecord.isAdmin(recipient)
|
||||
ContactSearchConfiguration.MemberRole.CONTACTS -> recipient.isSystemContact
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private fun getGroupMembersContactData(section: ContactSearchConfiguration.Section.GroupMembers, query: String?, startIndex: Int, endIndex: Int): List<ContactSearchData> {
|
||||
val groupRecord = section.groupId?.let { contactSearchPagedDataSourceRepository.getGroupRecord(it) }
|
||||
val recordPredicate = filterByRole(section)
|
||||
return getGroupMembersSearchIterator(section, query).use { records ->
|
||||
readContactData(
|
||||
records = records,
|
||||
recordsPredicate = null,
|
||||
recordsPredicate = recordPredicate,
|
||||
section = section,
|
||||
startIndex = startIndex,
|
||||
endIndex = endIndex,
|
||||
recordMapper = {
|
||||
val recipient = contactSearchPagedDataSourceRepository.getRecipientFromSearchCursor(it)
|
||||
val groupsInCommon = if (section.showGroupsInCommon) contactSearchPagedDataSourceRepository.getGroupsInCommon(recipient) else GroupsInCommonSummary(listOf())
|
||||
val headerLetter = if (section.includeLetterHeaders) getHeaderLetterForCurrentRow(it) else null
|
||||
ContactSearchData.KnownRecipient(section.sectionKey, recipient, groupsInCommon = groupsInCommon, headerLetter = headerLetter)
|
||||
val headerLetter = if (section.includeLetterHeaders) getHeaderLetterForCurrentRow(it, recordPredicate) else null
|
||||
ContactSearchData.KnownRecipient(section.sectionKey, recipient, groupsInCommon = groupsInCommon, headerLetter = headerLetter, showSelfAsYou = section.showSelfAsYou, showAdminLabel = groupRecord?.isAdmin(recipient) == true, query = query)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
+17
-2
@@ -6,6 +6,7 @@ import androidx.annotation.WorkerThread
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.signal.core.util.CursorUtil
|
||||
import org.signal.core.util.LRUCache
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.contacts.ContactRepository
|
||||
import org.thoughtcrime.securesms.contacts.paged.collections.ContactSearchIterator
|
||||
@@ -16,6 +17,7 @@ import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.ThreadTable
|
||||
import org.thoughtcrime.securesms.database.model.DistributionListPrivacyMode
|
||||
import org.thoughtcrime.securesms.database.model.GroupRecord
|
||||
import org.thoughtcrime.securesms.groups.GroupId
|
||||
import org.thoughtcrime.securesms.groups.GroupsInCommonRepository
|
||||
import org.thoughtcrime.securesms.groups.GroupsInCommonSummary
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
@@ -28,11 +30,13 @@ import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
* having to deal with database access.
|
||||
*/
|
||||
open class ContactSearchPagedDataSourceRepository(
|
||||
context: Context
|
||||
context: Context,
|
||||
selfTitle: String = context.getString(R.string.note_to_self)
|
||||
) {
|
||||
|
||||
private val contactRepository = ContactRepository(context.getString(R.string.note_to_self))
|
||||
private val contactRepository = ContactRepository(selfTitle)
|
||||
private val context = context.applicationContext
|
||||
private val groupRecordCache = LRUCache<GroupId, GroupRecord?>(100)
|
||||
|
||||
open fun getLatestStorySends(activeStoryCutoffDuration: Long): List<StorySend> {
|
||||
return SignalStore.story
|
||||
@@ -142,4 +146,15 @@ open class ContactSearchPagedDataSourceRepository(
|
||||
val myStory = context.getString(R.string.Recipient_my_story)
|
||||
return myStory.contains(query, ignoreCase = true)
|
||||
}
|
||||
|
||||
open fun getGroupRecord(groupId: GroupId): GroupRecord? {
|
||||
if (!groupRecordCache.containsKey(groupId)) {
|
||||
groupRecordCache[groupId] = SignalDatabase.groups.getGroup(groupId).orElse(null)
|
||||
}
|
||||
return groupRecordCache[groupId]
|
||||
}
|
||||
|
||||
open fun clearGroupRecordCache() {
|
||||
groupRecordCache.clear()
|
||||
}
|
||||
}
|
||||
|
||||
+6
-1
@@ -157,7 +157,7 @@ class ContactSearchViewModel(
|
||||
suspend fun setConfiguration(contactSearchConfiguration: ContactSearchConfiguration) {
|
||||
internalSearchInProgress.value = true
|
||||
try {
|
||||
val (pagedDataSource, size) = withContext(Dispatchers.IO) {
|
||||
val (pagedDataSource, size) = withContext(Dispatchers.Default) {
|
||||
val source = ContactSearchPagedDataSource(
|
||||
contactSearchConfiguration,
|
||||
arbitraryRepository = arbitraryRepository,
|
||||
@@ -269,6 +269,11 @@ class ContactSearchViewModel(
|
||||
controller.value?.onDataInvalidated()
|
||||
}
|
||||
|
||||
fun refreshGroupData() {
|
||||
contactSearchPagedDataSourceRepository.clearGroupRecordCache()
|
||||
refresh()
|
||||
}
|
||||
|
||||
data class ScrollRequest(val position: Int)
|
||||
|
||||
class Factory(
|
||||
|
||||
+1
-1
@@ -56,7 +56,7 @@ class PinnedMessagesViewModel(
|
||||
}
|
||||
|
||||
fun unpinMessage() {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
viewModelScope.launch(Dispatchers.Default) {
|
||||
repository.getPinnedMessageRecords(threadId).map {
|
||||
val unpinJob = UnpinMessageJob.create(messageId = it.id)
|
||||
if (unpinJob != null) {
|
||||
|
||||
@@ -31,6 +31,7 @@ import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.style.TextDirection
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.IconButtons.IconButton
|
||||
@@ -70,7 +71,7 @@ fun RecipientSearchBar(
|
||||
value = query,
|
||||
onValueChange = onQueryChange,
|
||||
modifier = Modifier.onFocusChanged { onFocusChanged(it.isFocused) },
|
||||
placeholder = { Text(hint) },
|
||||
placeholder = { Text(hint, maxLines = 1, overflow = TextOverflow.Ellipsis) },
|
||||
singleLine = true,
|
||||
textStyle = TextStyle(textDirection = TextDirection.ContentOrLtr),
|
||||
shape = SearchBarDefaults.inputFieldShape,
|
||||
|
||||
+7
-10
@@ -8,7 +8,6 @@ import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.menu.ActionItem
|
||||
import org.thoughtcrime.securesms.components.menu.SignalContextMenu
|
||||
import org.thoughtcrime.securesms.util.DateUtils
|
||||
import org.thoughtcrime.securesms.util.RemoteConfig
|
||||
import org.thoughtcrime.securesms.util.toLocalDateTime
|
||||
import org.thoughtcrime.securesms.util.toMillis
|
||||
import java.time.DayOfWeek
|
||||
@@ -73,15 +72,13 @@ class ScheduleMessageContextMenu {
|
||||
}
|
||||
}
|
||||
|
||||
if (RemoteConfig.internalUser) {
|
||||
val now = currentTimeMs.toLocalDateTime()
|
||||
if (now.dayOfWeek == DayOfWeek.FRIDAY || now.dayOfWeek == DayOfWeek.SATURDAY) {
|
||||
val nextMonday = now.with(TemporalAdjusters.next(DayOfWeek.MONDAY))
|
||||
.withHour(8)
|
||||
.withMinute(0)
|
||||
.withSecond(0)
|
||||
timestampList += nextMonday.toMillis()
|
||||
}
|
||||
val now = currentTimeMs.toLocalDateTime()
|
||||
if (now.dayOfWeek == DayOfWeek.FRIDAY || now.dayOfWeek == DayOfWeek.SATURDAY) {
|
||||
val nextMonday = now.with(TemporalAdjusters.next(DayOfWeek.MONDAY))
|
||||
.withHour(8)
|
||||
.withMinute(0)
|
||||
.withSecond(0)
|
||||
timestampList += nextMonday.toMillis()
|
||||
}
|
||||
|
||||
timestampList += -1
|
||||
|
||||
+1
-1
@@ -31,7 +31,7 @@ class PollVotesViewModel(pollId: Long) : ViewModel() {
|
||||
}
|
||||
|
||||
private fun loadPollInfo(pollId: Long) {
|
||||
viewModelScope.launch(SignalDispatchers.IO) {
|
||||
viewModelScope.launch(SignalDispatchers.Default) {
|
||||
val poll = SignalDatabase.polls.getPollFromId(pollId)!!
|
||||
val mostVotes = poll.pollOptions.maxByOrNull { option -> option.voters.size }?.voters?.size
|
||||
_state.update {
|
||||
|
||||
+5
-1
@@ -1247,7 +1247,7 @@ class ConversationFragment :
|
||||
|
||||
viewLifecycleOwner.lifecycle.addObserver(LastScrolledPositionUpdater(adapter, layoutManager, viewModel))
|
||||
|
||||
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) {
|
||||
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.Default) {
|
||||
var wasTerminated: Boolean? = null
|
||||
viewModel
|
||||
.groupRecordFlow
|
||||
@@ -4763,6 +4763,10 @@ class ConversationFragment :
|
||||
startActivity(RegistrationActivity.newIntentForReRegistration(requireContext()))
|
||||
}
|
||||
|
||||
override fun onReLinkDeviceClicked() {
|
||||
startActivity(RegistrationActivity.newIntentForReLinkDevice(requireContext()))
|
||||
}
|
||||
|
||||
override fun onCancelGroupRequestClicked() {
|
||||
conversationGroupViewModel
|
||||
.cancelJoinRequest()
|
||||
|
||||
+7
-7
@@ -349,7 +349,7 @@ class ConversationViewModel(
|
||||
}
|
||||
|
||||
fun onAvatarDownloadFailed() {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
viewModelScope.launch(Dispatchers.Default) {
|
||||
val recipient = recipientSnapshot
|
||||
if (recipient != null) {
|
||||
recipients.manuallyUpdateShowAvatar(recipient.id, false)
|
||||
@@ -359,7 +359,7 @@ class ConversationViewModel(
|
||||
}
|
||||
|
||||
private fun getPinnedMessages() {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
viewModelScope.launch(Dispatchers.Default) {
|
||||
val threadRecipient = SignalDatabase.threads.getRecipientForThreadId(threadId)
|
||||
internalPinnedMessages.value = repository.getPinnedMessages(threadId).map {
|
||||
ConversationMessage.ConversationMessageFactory.createWithUnresolvedData(AppDependencies.application, it, threadRecipient!!)
|
||||
@@ -436,18 +436,18 @@ class ConversationViewModel(
|
||||
),
|
||||
transform = { it.toList() }
|
||||
)
|
||||
.flowOn(Dispatchers.IO)
|
||||
.flowOn(Dispatchers.Default)
|
||||
}
|
||||
|
||||
fun onCollapseEvents(messageId: Long) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
viewModelScope.launch(Dispatchers.Default) {
|
||||
repository.collapseEvents(messageId)
|
||||
pagingController.onDataInvalidated()
|
||||
}
|
||||
}
|
||||
|
||||
fun onExpandEvents(messageId: Long) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
viewModelScope.launch(Dispatchers.Default) {
|
||||
repository.expandEvents(messageId)
|
||||
pagingController.onDataInvalidated()
|
||||
}
|
||||
@@ -743,7 +743,7 @@ class ConversationViewModel(
|
||||
}
|
||||
|
||||
fun toggleVote(poll: PollRecord, pollOption: PollOption, isChecked: Boolean) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
viewModelScope.launch(Dispatchers.Default) {
|
||||
val voteCount = if (isChecked) {
|
||||
SignalDatabase.polls.insertVote(poll, pollOption)
|
||||
} else {
|
||||
@@ -825,7 +825,7 @@ class ConversationViewModel(
|
||||
}
|
||||
|
||||
fun collapseAllEvents() {
|
||||
viewModelScope.launch(SignalDispatchers.IO) {
|
||||
viewModelScope.launch(SignalDispatchers.Default) {
|
||||
repository.collapseAllEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import com.google.android.material.button.MaterialButton
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.messagerequests.MessageRequestState
|
||||
import org.thoughtcrime.securesms.messagerequests.MessageRequestsBottomView
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
@@ -69,13 +70,23 @@ class DisabledInputView @JvmOverloads constructor(
|
||||
val message = findViewById<TextView>(R.id.logged_out_message)
|
||||
val actionButton = findViewById<MaterialButton>(R.id.logged_out_button)
|
||||
|
||||
message.setText(if (clientExpired) R.string.ExpiredBuildReminder_this_version_of_signal_has_expired else R.string.UnauthorizedReminder_this_is_likely_because_you_registered_your_phone_number_with_Signal_on_a_different_device)
|
||||
actionButton.setText(if (clientExpired) R.string.ConversationFragment__update_build else R.string.ConversationFragment__reregister_signal)
|
||||
actionButton.setOnClickListener {
|
||||
if (clientExpired) {
|
||||
listener?.onUpdateAppClicked()
|
||||
} else {
|
||||
listener?.onReRegisterClicked()
|
||||
when {
|
||||
clientExpired -> {
|
||||
message.setText(R.string.ExpiredBuildReminder_this_version_of_signal_has_expired)
|
||||
actionButton.setText(R.string.ConversationFragment__update_build)
|
||||
actionButton.setOnClickListener { listener?.onUpdateAppClicked() }
|
||||
}
|
||||
|
||||
unauthorized && SignalStore.account.isLinkedDevice -> {
|
||||
message.setText(R.string.UnauthorizedReminder_this_device_is_no_longer_linked_relink_to_continue_messaging)
|
||||
actionButton.setText(R.string.ConversationFragment__relink_signal)
|
||||
actionButton.setOnClickListener { listener?.onReLinkDeviceClicked() }
|
||||
}
|
||||
|
||||
else -> {
|
||||
message.setText(R.string.UnauthorizedReminder_this_is_likely_because_you_registered_your_phone_number_with_Signal_on_a_different_device)
|
||||
actionButton.setText(R.string.ConversationFragment__reregister_signal)
|
||||
actionButton.setOnClickListener { listener?.onReRegisterClicked() }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -275,6 +286,7 @@ class DisabledInputView @JvmOverloads constructor(
|
||||
interface Listener {
|
||||
fun onUpdateAppClicked()
|
||||
fun onReRegisterClicked()
|
||||
fun onReLinkDeviceClicked()
|
||||
fun onCancelGroupRequestClicked()
|
||||
fun onShowAdminsBottomSheetDialog()
|
||||
fun onAcceptMessageRequestClicked()
|
||||
|
||||
+1
-12
@@ -340,7 +340,7 @@ public class ConversationListFragment extends MainFragment implements Conversati
|
||||
false,
|
||||
new ConversationListSearchAdapter.ChatFilterRepository(),
|
||||
new SearchRepository(requireContext().getString(R.string.note_to_self)),
|
||||
new ContactSearchPagedDataSourceRepository(requireContext()),
|
||||
new ContactSearchPagedDataSourceRepository(requireContext(), requireContext().getString(R.string.note_to_self)),
|
||||
Collections.emptySet(),
|
||||
true
|
||||
)).get(ContactSearchViewModel.class);
|
||||
@@ -874,11 +874,6 @@ public class ConversationListFragment extends MainFragment implements Conversati
|
||||
.show();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDismissComplete() {
|
||||
bannerManager.updateContent(bannerView.get());
|
||||
}
|
||||
}),
|
||||
new ArchiveUploadStatusBanner(new ArchiveUploadStatusBanner.UploadProgressBannerListener() {
|
||||
@Override
|
||||
@@ -894,15 +889,9 @@ public class ConversationListFragment extends MainFragment implements Conversati
|
||||
.setNegativeButton(R.string.CancelBackupDialog_continue_action, null)
|
||||
.setPositiveButton(R.string.CancelBackupDialog_cancel_action, (d, w) -> {
|
||||
ArchiveUploadProgress.INSTANCE.cancel();
|
||||
bannerManager.updateContent(bannerView.get());
|
||||
})
|
||||
.show();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onHidden() {
|
||||
bannerManager.updateContent(bannerView.get());
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
+4
-4
@@ -215,7 +215,7 @@ sealed class ConversationListViewModel(
|
||||
}
|
||||
|
||||
private fun loadCurrentFolders() {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
viewModelScope.launch(Dispatchers.Default) {
|
||||
val folders = ChatFoldersRepository.getCurrentFolders()
|
||||
|
||||
val unreadCountAndEmptyAndMutedStatus: Map<Long, Triple<Int, Boolean, Boolean>> = if (folders.size > 1) {
|
||||
@@ -263,7 +263,7 @@ sealed class ConversationListViewModel(
|
||||
}
|
||||
|
||||
fun onUpdateMute(chatFolder: ChatFolderRecord, until: Long) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
viewModelScope.launch(Dispatchers.Default) {
|
||||
val ids = SignalDatabase.threads.getRecipientIdsByChatFolder(chatFolder)
|
||||
val recipientIds: List<RecipientId> = ids.filter { id ->
|
||||
Recipient.resolved(id).muteUntil != until
|
||||
@@ -284,14 +284,14 @@ sealed class ConversationListViewModel(
|
||||
}
|
||||
|
||||
fun removeChatFromFolder(threadId: Long) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
viewModelScope.launch(Dispatchers.Default) {
|
||||
SignalDatabase.chatFolders.removeFromFolder(currentFolder.id, threadId)
|
||||
scheduleChatFolderSync(currentFolder.id)
|
||||
}
|
||||
}
|
||||
|
||||
fun addToFolder(folderId: Long, threadIds: List<Long>) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
viewModelScope.launch(Dispatchers.Default) {
|
||||
val includedChats = folders.find { it.chatFolder.id == folderId }?.chatFolder?.includedChats
|
||||
val threadIdsNotIncluded = threadIds.filterNot { threadId ->
|
||||
includedChats?.contains(threadId) ?: false
|
||||
|
||||
@@ -960,6 +960,34 @@ class AttachmentTable(
|
||||
.run()
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the user has any archive-finished media that came from a local backup. Used to detect the local-restore bad state where restored media was
|
||||
* incorrectly marked as already archived, so we only run an expensive reconciliation for users who could actually be affected.
|
||||
*/
|
||||
fun hasArchiveFinishedLocalBackupMedia(): Boolean {
|
||||
return readableDatabase
|
||||
.exists("$TABLE_NAME INNER JOIN ${AttachmentMetadataTable.TABLE_NAME} ON $TABLE_NAME.$METADATA_ID = ${AttachmentMetadataTable.TABLE_NAME}.${AttachmentMetadataTable.ID}")
|
||||
.where("$TABLE_NAME.$ARCHIVE_TRANSFER_STATE = ? AND ${AttachmentMetadataTable.TABLE_NAME}.${AttachmentMetadataTable.LOCAL_BACKUP_KEY} NOT NULL", ArchiveTransferState.FINISHED.value)
|
||||
.run()
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets archive-finished media that came from a local backup, returning the number of attachments repaired. Safe only when the user doesn't back up media,
|
||||
* as they can't have anything legitimately on the archive CDN. See [hasArchiveFinishedLocalBackupMedia].
|
||||
*/
|
||||
fun resetArchiveTransferStateForLocalBackupMedia(): Int {
|
||||
return writableDatabase
|
||||
.update(TABLE_NAME)
|
||||
.values(
|
||||
ARCHIVE_TRANSFER_STATE to ArchiveTransferState.NONE.value,
|
||||
ARCHIVE_CDN to null
|
||||
)
|
||||
.where(
|
||||
"$ARCHIVE_TRANSFER_STATE = ${ArchiveTransferState.FINISHED.value} AND $METADATA_ID IN (SELECT ${AttachmentMetadataTable.ID} FROM ${AttachmentMetadataTable.TABLE_NAME} WHERE ${AttachmentMetadataTable.LOCAL_BACKUP_KEY} NOT NULL)"
|
||||
)
|
||||
.run()
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether or not there are thumbnails that need to be uploaded to the archive.
|
||||
*/
|
||||
@@ -1790,7 +1818,7 @@ class AttachmentTable(
|
||||
* that the content of the attachment will never change.
|
||||
*/
|
||||
@Throws(MmsException::class)
|
||||
fun finalizeAttachmentAfterDownload(mmsId: Long, attachmentId: AttachmentId, inputStream: InputStream, offloadRestoredAt: Duration? = null, archiveRestore: Boolean = false, notify: Boolean = true) {
|
||||
fun finalizeAttachmentAfterDownload(mmsId: Long, attachmentId: AttachmentId, inputStream: InputStream, offloadRestoredAt: Duration? = null, archiveRestore: Boolean = false, restoredFromArchiveCdn: Boolean = false, notify: Boolean = true) {
|
||||
Log.i(TAG, "[finalizeAttachmentAfterDownload] Finalizing downloaded data for $attachmentId. (MessageId: $mmsId, $attachmentId)")
|
||||
|
||||
val existingPlaceholder: DatabaseAttachment = getAttachment(attachmentId) ?: throw MmsException("No attachment found for id: $attachmentId")
|
||||
@@ -1835,8 +1863,11 @@ class AttachmentTable(
|
||||
values.put(DATA_HASH_START, fileWriteResult.hash)
|
||||
values.put(DATA_HASH_END, fileWriteResult.hash)
|
||||
|
||||
if (archiveRestore) {
|
||||
if (restoredFromArchiveCdn) {
|
||||
values.put(ARCHIVE_TRANSFER_STATE, ArchiveTransferState.FINISHED.value)
|
||||
} else if (archiveRestore) {
|
||||
values.putNull(ARCHIVE_CDN)
|
||||
values.put(ARCHIVE_TRANSFER_STATE, ArchiveTransferState.NONE.value)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3546,7 +3546,7 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
|
||||
val selfId = Recipient.self().id.toLong()
|
||||
arrayOf(
|
||||
ID,
|
||||
"""CASE WHEN ${TABLE_NAME}.$ID = $selfId THEN '${includeSelfMode.noteToSelfTitle}' ELSE $SYSTEM_JOINED_NAME END AS $SYSTEM_JOINED_NAME""",
|
||||
"""CASE WHEN ${TABLE_NAME}.$ID = $selfId THEN '${includeSelfMode.title}' ELSE $SYSTEM_JOINED_NAME END AS $SYSTEM_JOINED_NAME""",
|
||||
E164,
|
||||
EMAIL,
|
||||
SYSTEM_PHONE_LABEL,
|
||||
@@ -3556,9 +3556,9 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
|
||||
ABOUT_EMOJI,
|
||||
EXTRAS,
|
||||
GROUPS_IN_COMMON,
|
||||
"""CASE WHEN ${TABLE_NAME}.$ID = $selfId THEN '${includeSelfMode.noteToSelfTitle}' ELSE COALESCE(NULLIF($PROFILE_JOINED_NAME, ''), NULLIF($PROFILE_GIVEN_NAME, '')) END AS $SEARCH_PROFILE_NAME""",
|
||||
"""CASE WHEN ${TABLE_NAME}.$ID = $selfId THEN '${includeSelfMode.title}' ELSE COALESCE(NULLIF($PROFILE_JOINED_NAME, ''), NULLIF($PROFILE_GIVEN_NAME, '')) END AS $SEARCH_PROFILE_NAME""",
|
||||
"""
|
||||
CASE WHEN ${TABLE_NAME}.$ID = $selfId THEN '${includeSelfMode.noteToSelfTitle.lowercase()}' ELSE
|
||||
CASE WHEN ${TABLE_NAME}.$ID = $selfId THEN '${includeSelfMode.title.lowercase()}' ELSE
|
||||
LOWER(
|
||||
COALESCE(
|
||||
NULLIF($NICKNAME_JOINED_NAME, ''),
|
||||
@@ -3712,24 +3712,23 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
|
||||
return readableDatabase.query(TABLE_NAME, searchProjection(IncludeSelfMode.Exclude), selection, args, null, null, orderBy)
|
||||
}
|
||||
|
||||
fun queryGroupMemberContactsForGroup(groupId: GroupId, inputQuery: String): Cursor? {
|
||||
fun queryGroupMemberContactsForGroup(groupId: GroupId, inputQuery: String, selfTitle: String): Cursor? {
|
||||
val orderBy = orderByPreferringAlphaOverNumeric(SORT_NAME) + ", " + E164
|
||||
val queryFilter = if (inputQuery.isNotEmpty()) "AND ($SORT_NAME GLOB ? OR $USERNAME GLOB ?)" else ""
|
||||
|
||||
val selection = """
|
||||
$ID != ? AND
|
||||
$ID IN (SELECT ${GroupTable.MembershipTable.RECIPIENT_ID} FROM ${GroupTable.MembershipTable.TABLE_NAME} WHERE ${GroupTable.MembershipTable.GROUP_ID} = ?)
|
||||
$queryFilter
|
||||
"""
|
||||
|
||||
val args = if (queryFilter.isBlank()) {
|
||||
mutableListOf(Recipient.self().id.serialize(), groupId.toString())
|
||||
mutableListOf(groupId.toString())
|
||||
} else {
|
||||
val query = SqlUtil.buildCaseInsensitiveGlobPattern(inputQuery)
|
||||
mutableListOf(Recipient.self().id.serialize(), groupId.toString(), query, query)
|
||||
mutableListOf(groupId.toString(), query, query)
|
||||
}
|
||||
|
||||
return readableDatabase.query(TABLE_NAME, searchProjection(IncludeSelfMode.Exclude), selection, args.toTypedArray(), null, null, orderBy)
|
||||
return readableDatabase.query(TABLE_NAME, searchProjection(IncludeSelfMode.IncludeWithRemap(selfTitle)), selection, args.toTypedArray(), null, null, orderBy)
|
||||
}
|
||||
|
||||
fun queryAllContacts(inputQuery: String, includeSelfMode: IncludeSelfMode): Cursor? {
|
||||
@@ -4623,12 +4622,12 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
|
||||
|
||||
/**
|
||||
* By default, SQLite will prefer numbers over letters when sorting. e.g. (b, a, 1) is sorted as (1, a, b).
|
||||
* This order by will using a GLOB pattern to instead sort it as (a, b, 1).
|
||||
* This order by will using a GLOB pattern to instead sort it as (a, b, 1). We also put null names (eg deleted accounts) at the end
|
||||
*
|
||||
* @param column The name of the column to sort by
|
||||
*/
|
||||
private fun orderByPreferringAlphaOverNumeric(column: String): String {
|
||||
return "CASE WHEN $column GLOB '[0-9]*' THEN 1 ELSE 0 END, $column"
|
||||
return "CASE WHEN $column IS NULL THEN 2 WHEN $column GLOB '[0-9]*' THEN 1 ELSE 0 END, $column"
|
||||
}
|
||||
|
||||
private fun <T> Optional<T>.isAbsent(): Boolean {
|
||||
@@ -4785,7 +4784,7 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
|
||||
|
||||
data object Exclude : IncludeSelfMode
|
||||
data object IncludeWithoutRemap : IncludeSelfMode
|
||||
data class IncludeWithRemap(val noteToSelfTitle: String) : IncludeSelfMode
|
||||
data class IncludeWithRemap(val title: String) : IncludeSelfMode
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
|
||||
@@ -33,7 +33,7 @@ object GroupsInCommonRepository {
|
||||
getGroupsInCommonCount(recipientId)
|
||||
}
|
||||
|
||||
suspend fun getGroupsInCommonCount(recipientId: RecipientId): Int = withContext(Dispatchers.IO) {
|
||||
suspend fun getGroupsInCommonCount(recipientId: RecipientId): Int = withContext(Dispatchers.Default) {
|
||||
SignalDatabase.groups
|
||||
.getPushGroupsContainingMember(recipientId)
|
||||
.count { it.members.contains(Recipient.self().id) }
|
||||
@@ -56,7 +56,7 @@ object GroupsInCommonRepository {
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun getGroupsContainingRecipient(context: Context, recipientId: RecipientId): List<Recipient> = withContext(Dispatchers.IO) {
|
||||
private suspend fun getGroupsContainingRecipient(context: Context, recipientId: RecipientId): List<Recipient> = withContext(Dispatchers.Default) {
|
||||
SignalDatabase.groups
|
||||
.getPushGroupsContainingMember(recipientId)
|
||||
.asSequence()
|
||||
|
||||
+1
-1
@@ -30,7 +30,7 @@ class MemberLabelEducationViewModel(
|
||||
val uiState: StateFlow<UiState> = _uiState.asStateFlow()
|
||||
|
||||
init {
|
||||
viewModelScope.launch(SignalDispatchers.IO) {
|
||||
viewModelScope.launch(SignalDispatchers.Default) {
|
||||
val self = Recipient.self()
|
||||
val selfMemberLabel = repository.getLabel(groupId, self.id)
|
||||
val selfCanSetLabel = repository.canSetLabel(groupId, self)
|
||||
|
||||
+6
-6
@@ -40,7 +40,7 @@ class MemberLabelRepository private constructor(
|
||||
val instance: MemberLabelRepository by lazy { MemberLabelRepository() }
|
||||
}
|
||||
|
||||
suspend fun getRecipient(recipientId: RecipientId): Recipient = withContext(Dispatchers.IO) {
|
||||
suspend fun getRecipient(recipientId: RecipientId): Recipient = withContext(Dispatchers.Default) {
|
||||
Recipient.resolved(recipientId)
|
||||
}
|
||||
|
||||
@@ -83,7 +83,7 @@ class MemberLabelRepository private constructor(
|
||||
/**
|
||||
* Gets the member label for a specific recipient in the group.
|
||||
*/
|
||||
suspend fun getLabel(groupId: GroupId.V2, recipient: Recipient): MemberLabel? = withContext(Dispatchers.IO) {
|
||||
suspend fun getLabel(groupId: GroupId.V2, recipient: Recipient): MemberLabel? = withContext(Dispatchers.Default) {
|
||||
getLabelSync(groupId, recipient)
|
||||
}
|
||||
|
||||
@@ -92,14 +92,14 @@ class MemberLabelRepository private constructor(
|
||||
*
|
||||
* Returns a map of [RecipientId] to [MemberLabel] for members that have labels.
|
||||
*/
|
||||
suspend fun getLabels(groupId: GroupId.V2, recipients: List<Recipient>): Map<RecipientId, MemberLabel> = withContext(Dispatchers.IO) {
|
||||
suspend fun getLabels(groupId: GroupId.V2, recipients: List<Recipient>): Map<RecipientId, MemberLabel> = withContext(Dispatchers.Default) {
|
||||
getLabelsSync(groupId, recipients)
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether [recipient] has permission to set their member label in the given group.
|
||||
*/
|
||||
suspend fun canSetLabel(groupId: GroupId.V2, recipient: Recipient): Boolean = withContext(Dispatchers.IO) {
|
||||
suspend fun canSetLabel(groupId: GroupId.V2, recipient: Recipient): Boolean = withContext(Dispatchers.Default) {
|
||||
val groupRecord = groupsTable.getGroup(groupId).orNull() ?: return@withContext false
|
||||
|
||||
if (!isSelfAnActiveGroupMember(groupRecord)) return@withContext false
|
||||
@@ -116,7 +116,7 @@ class MemberLabelRepository private constructor(
|
||||
/**
|
||||
* Computes the sender [NameColor] for a recipient as seen by other group members.
|
||||
*/
|
||||
suspend fun getSenderNameColor(groupId: GroupId.V2, recipient: Recipient): NameColor = withContext(Dispatchers.IO) {
|
||||
suspend fun getSenderNameColor(groupId: GroupId.V2, recipient: Recipient): NameColor = withContext(Dispatchers.Default) {
|
||||
val groupMemberIds = groupsTable
|
||||
.getGroupMembers(groupId, GroupTable.MemberSet.FULL_MEMBERS_INCLUDING_SELF)
|
||||
.mapNotNull { it.serviceId.orNull() }
|
||||
@@ -127,7 +127,7 @@ class MemberLabelRepository private constructor(
|
||||
/**
|
||||
* Returns all group members who have labels set for the given group.
|
||||
*/
|
||||
suspend fun getMembersWithLabels(groupId: GroupId.V2): List<GroupMemberWithLabel> = withContext(Dispatchers.IO) {
|
||||
suspend fun getMembersWithLabels(groupId: GroupId.V2): List<GroupMemberWithLabel> = withContext(Dispatchers.Default) {
|
||||
val groupRecord = groupsTable.getGroup(groupId).orNull() ?: return@withContext emptyList()
|
||||
if (!isSelfAnActiveGroupMember(groupRecord)) return@withContext emptyList()
|
||||
|
||||
|
||||
+1
-1
@@ -50,7 +50,7 @@ class MemberLabelViewModel(
|
||||
}
|
||||
|
||||
private fun loadInitialState() {
|
||||
viewModelScope.launch(SignalDispatchers.IO) {
|
||||
viewModelScope.launch(SignalDispatchers.Default) {
|
||||
val recipient = memberLabelRepo.getRecipient(recipientId)
|
||||
val memberLabel = memberLabelRepo.getLabel(groupId, recipient)
|
||||
originalLabelEmoji = memberLabel?.emoji.orEmpty()
|
||||
|
||||
@@ -3,13 +3,17 @@ package org.thoughtcrime.securesms.groups.ui
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.activity.compose.LocalOnBackPressedDispatcherOwner
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
@@ -17,26 +21,39 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.stringArrayResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import androidx.fragment.app.setFragmentResult
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import org.signal.core.ui.compose.ComposeFragment
|
||||
import org.signal.core.ui.compose.Dialogs
|
||||
import org.signal.core.ui.compose.Dividers
|
||||
import org.signal.core.ui.compose.LocalFragmentManager
|
||||
import org.signal.core.ui.compose.Rows
|
||||
import org.signal.core.ui.compose.SignalIcons
|
||||
import org.signal.core.ui.compose.horizontalGutters
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.core.util.requireParcelableCompat
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.contacts.paged.ArbitraryRepository
|
||||
import org.thoughtcrime.securesms.contacts.paged.ContactSearch
|
||||
import org.thoughtcrime.securesms.contacts.paged.ContactSearchAdapter
|
||||
import org.thoughtcrime.securesms.contacts.paged.ContactSearchCallbacks
|
||||
import org.thoughtcrime.securesms.contacts.paged.ContactSearchConfiguration
|
||||
import org.thoughtcrime.securesms.contacts.paged.ContactSearchData
|
||||
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey
|
||||
import org.thoughtcrime.securesms.contacts.paged.ContactSearchPagedDataSourceRepository
|
||||
import org.thoughtcrime.securesms.contacts.paged.ContactSearchRepository
|
||||
@@ -46,24 +63,37 @@ import org.thoughtcrime.securesms.conversation.RecipientSearchBar
|
||||
import org.thoughtcrime.securesms.conversation.mutiselect.forward.SearchConfigurationProvider
|
||||
import org.thoughtcrime.securesms.groups.GroupId
|
||||
import org.thoughtcrime.securesms.groups.SelectionLimits
|
||||
import org.thoughtcrime.securesms.groups.ui.MemberSearchFragment.AddMembersModel
|
||||
import org.thoughtcrime.securesms.groups.ui.MemberSearchFragment.DividerModel
|
||||
import org.thoughtcrime.securesms.groups.ui.MemberSearchFragment.InviteViaModel
|
||||
import org.thoughtcrime.securesms.recipients.ui.bottomsheet.RecipientBottomSheetDialogFragment
|
||||
import org.thoughtcrime.securesms.recipients.ui.sharablegrouplink.GroupLinkBottomSheetDialogFragment
|
||||
import org.thoughtcrime.securesms.search.SearchRepository
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.compose.MappingEntryProvider
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.compose.rememberMappingEntryProvider
|
||||
import org.thoughtcrime.securesms.util.fragments.findListener
|
||||
|
||||
/**
|
||||
* Fragment that shows all members in a group (excluding self)
|
||||
* Fragment that shows all members in a group (including self)
|
||||
*/
|
||||
class MemberSearchFragment : ComposeFragment() {
|
||||
class MemberSearchFragment : ComposeFragment(), RecipientBottomSheetDialogFragment.Callback {
|
||||
|
||||
companion object {
|
||||
|
||||
private val TAG = Log.tag(MemberSearchFragment::class.java)
|
||||
private const val ARG_GROUP_ID = "group_id"
|
||||
private const val ARG_CAN_ADD = "can_add"
|
||||
private const val ARG_HAS_GROUP_LINK = "has_group_link"
|
||||
|
||||
fun newInstance(groupId: GroupId.V2): MemberSearchFragment {
|
||||
const val RESULT_ADD_MEMBERS = "result_add"
|
||||
|
||||
fun newInstance(groupId: GroupId.V2, canAdd: Boolean, hasGroupLink: Boolean): MemberSearchFragment {
|
||||
return MemberSearchFragment().apply {
|
||||
arguments = Bundle().apply {
|
||||
putParcelable(ARG_GROUP_ID, groupId)
|
||||
putBoolean(ARG_CAN_ADD, canAdd)
|
||||
putBoolean(ARG_HAS_GROUP_LINK, hasGroupLink)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -79,22 +109,39 @@ class MemberSearchFragment : ComposeFragment() {
|
||||
isMultiSelect = false,
|
||||
repository = ContactSearchRepository(),
|
||||
performSafetyNumberChecks = false,
|
||||
arbitraryRepository = findListener<SearchConfigurationProvider>()?.getArbitraryRepository(),
|
||||
arbitraryRepository = findListener<SearchConfigurationProvider>()?.getArbitraryRepository() ?: MemberArbitraryRepository(),
|
||||
searchRepository = SearchRepository(requireContext().getString(R.string.Recipient_you)),
|
||||
contactSearchPagedDataSourceRepository = ContactSearchPagedDataSourceRepository(requireContext())
|
||||
contactSearchPagedDataSourceRepository = ContactSearchPagedDataSourceRepository(requireContext(), requireContext().getString(R.string.Recipient_you))
|
||||
)
|
||||
}
|
||||
|
||||
private val memberSearchViewModel: MemberSearchViewModel by viewModels()
|
||||
|
||||
override fun onRecipientBottomSheetDismissed() {
|
||||
contactViewModel.refreshGroupData()
|
||||
}
|
||||
|
||||
override fun onMessageClicked() = Unit
|
||||
|
||||
@Composable
|
||||
override fun FragmentContent() {
|
||||
val memberFilter by memberSearchViewModel.memberFilter.collectAsStateWithLifecycle()
|
||||
val canAdd = requireArguments().getBoolean(ARG_CAN_ADD)
|
||||
val hasGroupLink = requireArguments().getBoolean(ARG_HAS_GROUP_LINK)
|
||||
CompositionLocalProvider(LocalFragmentManager provides childFragmentManager) {
|
||||
MemberSearchScreen(
|
||||
contactViewModel = contactViewModel,
|
||||
mapStateToConfiguration = this::getConfiguration,
|
||||
memberFilter = memberFilter,
|
||||
onFilterSelected = { memberSearchViewModel.setFilter(it) },
|
||||
mapStateToConfiguration = { state -> getConfiguration(state, memberFilter, canAdd, hasGroupLink) },
|
||||
contactSearchCallbacks = remember {
|
||||
SearchCallbacks(
|
||||
fragmentManager = childFragmentManager,
|
||||
groupId = groupId
|
||||
groupId = groupId,
|
||||
onAddMembers = {
|
||||
setFragmentResult(RESULT_ADD_MEMBERS, Bundle.EMPTY)
|
||||
findNavController().popBackStack()
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
@@ -103,7 +150,8 @@ class MemberSearchFragment : ComposeFragment() {
|
||||
|
||||
class SearchCallbacks(
|
||||
private val fragmentManager: FragmentManager,
|
||||
private val groupId: GroupId.V2
|
||||
private val groupId: GroupId.V2,
|
||||
private val onAddMembers: () -> Unit
|
||||
) : ContactSearchCallbacks.Simple() {
|
||||
override fun onBeforeContactsSelected(view: View?, contactSearchKeys: Set<ContactSearchKey>): Set<ContactSearchKey> {
|
||||
val recipientId = contactSearchKeys.filterIsInstance<ContactSearchKey.RecipientSearchKey>().firstOrNull()?.recipientId
|
||||
@@ -112,9 +160,17 @@ class MemberSearchFragment : ComposeFragment() {
|
||||
}
|
||||
return emptySet()
|
||||
}
|
||||
|
||||
fun onAdd() {
|
||||
onAddMembers()
|
||||
}
|
||||
|
||||
fun onInvite() {
|
||||
GroupLinkBottomSheetDialogFragment.show(fragmentManager, groupId)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getConfiguration(contactSearchState: ContactSearchState): ContactSearchConfiguration {
|
||||
private fun getConfiguration(contactSearchState: ContactSearchState, memberFilter: MemberSearchViewModel.MemberFilter, canAdd: Boolean, hasGroupLink: Boolean): ContactSearchConfiguration {
|
||||
return findListener<SearchConfigurationProvider>()?.getSearchConfiguration(childFragmentManager, contactSearchState) ?: ContactSearchConfiguration.build {
|
||||
query = contactSearchState.query
|
||||
|
||||
@@ -123,25 +179,102 @@ class MemberSearchFragment : ComposeFragment() {
|
||||
includeHeader = false,
|
||||
includeLetterHeaders = true,
|
||||
groupId = groupId,
|
||||
showGroupsInCommon = false
|
||||
showGroupsInCommon = false,
|
||||
showSelfAsYou = true,
|
||||
roleFilter = when (memberFilter) {
|
||||
MemberSearchViewModel.MemberFilter.ALL -> ContactSearchConfiguration.MemberRole.ALL
|
||||
MemberSearchViewModel.MemberFilter.ADMINS -> ContactSearchConfiguration.MemberRole.ADMINS
|
||||
MemberSearchViewModel.MemberFilter.CONTACTS -> ContactSearchConfiguration.MemberRole.CONTACTS
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
if (canAdd || hasGroupLink) {
|
||||
val set = if (canAdd && hasGroupLink) {
|
||||
setOf(MemberArbitraryRepository.TYPE_DIVIDER, MemberArbitraryRepository.TYPE_ADD_MEMBERS, MemberArbitraryRepository.TYPE_INVITE_VIA)
|
||||
} else if (canAdd) {
|
||||
setOf(MemberArbitraryRepository.TYPE_DIVIDER, MemberArbitraryRepository.TYPE_ADD_MEMBERS)
|
||||
} else {
|
||||
setOf(MemberArbitraryRepository.TYPE_DIVIDER, MemberArbitraryRepository.TYPE_INVITE_VIA)
|
||||
}
|
||||
addSection(ContactSearchConfiguration.Section.Arbitrary(set))
|
||||
}
|
||||
|
||||
withEmptyState {
|
||||
addSection(ContactSearchConfiguration.Section.Empty)
|
||||
addSection(
|
||||
ContactSearchConfiguration.Section.Arbitrary(
|
||||
setOf(MemberArbitraryRepository.TYPE_DIVIDER, MemberArbitraryRepository.TYPE_ADD_MEMBERS, MemberArbitraryRepository.TYPE_INVITE_VIA)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class DividerModel : MappingModel<DividerModel> {
|
||||
override fun areItemsTheSame(newItem: DividerModel) = true
|
||||
override fun areContentsTheSame(newItem: DividerModel) = true
|
||||
}
|
||||
|
||||
class AddMembersModel : MappingModel<AddMembersModel> {
|
||||
override fun areItemsTheSame(newItem: AddMembersModel) = true
|
||||
override fun areContentsTheSame(newItem: AddMembersModel) = true
|
||||
}
|
||||
|
||||
class InviteViaModel : MappingModel<InviteViaModel> {
|
||||
override fun areItemsTheSame(newItem: InviteViaModel) = true
|
||||
override fun areContentsTheSame(newItem: InviteViaModel) = true
|
||||
}
|
||||
|
||||
class MemberArbitraryRepository : ArbitraryRepository {
|
||||
override fun getSize(section: ContactSearchConfiguration.Section.Arbitrary, query: String?) = section.types.size
|
||||
|
||||
override fun getData(section: ContactSearchConfiguration.Section.Arbitrary, query: String?, startIndex: Int, endIndex: Int, totalSearchSize: Int): List<ContactSearchData.Arbitrary> {
|
||||
return section.types.toList().subList(startIndex, endIndex).map { ContactSearchData.Arbitrary(it) }
|
||||
}
|
||||
|
||||
override fun getMappingModel(arbitrary: ContactSearchData.Arbitrary): MappingModel<*> {
|
||||
return when (arbitrary.type) {
|
||||
TYPE_DIVIDER -> DividerModel()
|
||||
TYPE_ADD_MEMBERS -> AddMembersModel()
|
||||
TYPE_INVITE_VIA -> InviteViaModel()
|
||||
else -> throw IllegalArgumentException("Unknown type: ${arbitrary.type}")
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val TYPE_DIVIDER = "divider"
|
||||
const val TYPE_ADD_MEMBERS = "add-members"
|
||||
const val TYPE_INVITE_VIA = "invite-via"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun MemberSearchScreen(
|
||||
contactViewModel: ContactSearchViewModel,
|
||||
memberFilter: MemberSearchViewModel.MemberFilter,
|
||||
onFilterSelected: (MemberSearchViewModel.MemberFilter) -> Unit,
|
||||
mapStateToConfiguration: (ContactSearchState) -> ContactSearchConfiguration,
|
||||
contactSearchCallbacks: MemberSearchFragment.SearchCallbacks
|
||||
) {
|
||||
val onBackPressedDispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher
|
||||
var showFilterDialog by remember { mutableStateOf(false) }
|
||||
|
||||
if (showFilterDialog) {
|
||||
val filters = MemberSearchViewModel.MemberFilter.entries
|
||||
|
||||
Dialogs.RadioListDialog(
|
||||
onDismissRequest = { showFilterDialog = false },
|
||||
title = stringResource(R.string.MemberSearchFragment__filter),
|
||||
labels = stringArrayResource(R.array.filter_search_entries),
|
||||
values = filters.map { it.name }.toTypedArray(),
|
||||
selectedIndex = filters.indexOf(memberFilter),
|
||||
onSelected = { index -> onFilterSelected(filters[index]) }
|
||||
)
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
@@ -153,12 +286,27 @@ fun MemberSearchScreen(
|
||||
contentDescription = stringResource(R.string.DSLSettingsToolbar__navigate_up)
|
||||
)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
val filterActive = memberFilter != MemberSearchViewModel.MemberFilter.ALL
|
||||
IconButton(onClick = { showFilterDialog = true }) {
|
||||
Icon(
|
||||
imageVector = ImageVector.vectorResource(R.drawable.symbol_filter_20),
|
||||
tint = if (filterActive) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface,
|
||||
contentDescription = stringResource(R.string.MemberSearchFragment__filter),
|
||||
modifier = Modifier
|
||||
.size(26.dp)
|
||||
.background(color = if (filterActive) MaterialTheme.colorScheme.primary else Color.Transparent, shape = CircleShape)
|
||||
.padding(3.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) {
|
||||
MemberSearchContent(
|
||||
contactViewModel = contactViewModel,
|
||||
memberFilter = memberFilter,
|
||||
mapStateToConfiguration = mapStateToConfiguration,
|
||||
contactSearchCallbacks = contactSearchCallbacks,
|
||||
modifier = Modifier.padding(it)
|
||||
@@ -169,44 +317,94 @@ fun MemberSearchScreen(
|
||||
@Composable
|
||||
private fun MemberSearchContent(
|
||||
contactViewModel: ContactSearchViewModel,
|
||||
memberFilter: MemberSearchViewModel.MemberFilter,
|
||||
mapStateToConfiguration: (ContactSearchState) -> ContactSearchConfiguration,
|
||||
modifier: Modifier = Modifier,
|
||||
contactSearchCallbacks: MemberSearchFragment.SearchCallbacks
|
||||
) {
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val additionalEntries: MappingEntryProvider<Any> = rememberMappingEntryProvider {
|
||||
entry<DividerModel> { Dividers.Default() }
|
||||
|
||||
entry<AddMembersModel> {
|
||||
Rows.TextRow(
|
||||
text = { Text(text = stringResource(R.string.AddMembersActivity__add_members)) },
|
||||
icon = {
|
||||
Icon(
|
||||
imageVector = ImageVector.vectorResource(org.signal.core.ui.R.drawable.symbol_plus_24),
|
||||
tint = MaterialTheme.colorScheme.onSurface,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.background(color = MaterialTheme.colorScheme.surfaceVariant, shape = CircleShape)
|
||||
.padding(8.dp)
|
||||
)
|
||||
},
|
||||
onClick = contactSearchCallbacks::onAdd
|
||||
)
|
||||
}
|
||||
|
||||
entry<InviteViaModel> {
|
||||
Rows.TextRow(
|
||||
text = { Text(text = stringResource(R.string.MemberSearchFragment__invite_via)) },
|
||||
icon = {
|
||||
Icon(
|
||||
imageVector = ImageVector.vectorResource(org.signal.core.ui.R.drawable.symbol_link_24),
|
||||
tint = MaterialTheme.colorScheme.onSurface,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.background(color = MaterialTheme.colorScheme.surfaceVariant, shape = CircleShape)
|
||||
.padding(8.dp)
|
||||
)
|
||||
},
|
||||
onClick = contactSearchCallbacks::onInvite
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
|
||||
Row(modifier = modifier) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
) {
|
||||
val query by contactViewModel.query.collectAsStateWithLifecycle()
|
||||
RecipientSearchBar(
|
||||
hint = stringResource(R.string.MemberSearchFragment__search_members),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 12.dp)
|
||||
.focusRequester(focusRequester)
|
||||
.horizontalGutters(),
|
||||
query = query ?: "",
|
||||
onQueryChange = { contactViewModel.setQuery(it) },
|
||||
onSearch = { contactViewModel.setQuery(it) }
|
||||
)
|
||||
val currentMapStateToConfiguration by rememberUpdatedState(mapStateToConfiguration)
|
||||
LaunchedEffect(memberFilter) {
|
||||
contactViewModel.setConfiguration(
|
||||
currentMapStateToConfiguration(contactViewModel.configurationState.value)
|
||||
)
|
||||
}
|
||||
|
||||
ContactSearch(
|
||||
viewModel = contactViewModel,
|
||||
mapStateToConfiguration = mapStateToConfiguration,
|
||||
displayOptions = remember {
|
||||
ContactSearchAdapter.DisplayOptions(
|
||||
displaySecondaryInformation = ContactSearchAdapter.DisplaySecondaryInformation.NEVER
|
||||
)
|
||||
},
|
||||
callbacks = contactSearchCallbacks
|
||||
)
|
||||
Column(
|
||||
modifier = modifier.fillMaxSize()
|
||||
) {
|
||||
val query by contactViewModel.query.collectAsStateWithLifecycle()
|
||||
val hint = when (memberFilter) {
|
||||
MemberSearchViewModel.MemberFilter.ALL -> stringResource(R.string.MemberSearchFragment__search_members)
|
||||
MemberSearchViewModel.MemberFilter.ADMINS -> stringResource(R.string.MemberSearchFragment__search_admins)
|
||||
MemberSearchViewModel.MemberFilter.CONTACTS -> stringResource(R.string.MemberSearchFragment__search_contacts)
|
||||
}
|
||||
RecipientSearchBar(
|
||||
hint = hint,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 12.dp, horizontal = 16.dp)
|
||||
.focusRequester(focusRequester),
|
||||
query = query ?: "",
|
||||
onQueryChange = { contactViewModel.setQuery(it) },
|
||||
onSearch = { contactViewModel.setQuery(it) }
|
||||
)
|
||||
|
||||
ContactSearch(
|
||||
viewModel = contactViewModel,
|
||||
mapStateToConfiguration = mapStateToConfiguration,
|
||||
displayOptions = remember {
|
||||
ContactSearchAdapter.DisplayOptions(
|
||||
displaySecondaryInformation = ContactSearchAdapter.DisplaySecondaryInformation.NEVER
|
||||
)
|
||||
},
|
||||
additionalEntries = additionalEntries,
|
||||
callbacks = contactSearchCallbacks,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package org.thoughtcrime.securesms.groups.ui
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
class MemberSearchViewModel : ViewModel() {
|
||||
private val _memberFilter = MutableStateFlow(MemberFilter.ALL)
|
||||
val memberFilter: StateFlow<MemberFilter> = _memberFilter
|
||||
|
||||
fun setFilter(filter: MemberFilter) {
|
||||
_memberFilter.value = filter
|
||||
}
|
||||
|
||||
enum class MemberFilter {
|
||||
ALL,
|
||||
ADMINS,
|
||||
CONTACTS
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -50,7 +50,7 @@ class AddMembersViewModel(
|
||||
|
||||
suspend fun shouldAllowSelection(selection: RecipientSelection): Boolean {
|
||||
val recipientHasE164 = selection is RecipientSelection.HasId &&
|
||||
withContext(Dispatchers.IO) { Recipient.resolved(selection.id) }.hasE164
|
||||
withContext(Dispatchers.Default) { Recipient.resolved(selection.id) }.hasE164
|
||||
|
||||
return when {
|
||||
groupId.isV1 && !recipientHasE164 -> {
|
||||
@@ -101,7 +101,7 @@ class AddMembersViewModel(
|
||||
val confirmAddMessage = if (uiState.value.newSelections.size == 1) {
|
||||
UserMessage.ConfirmAddMember(
|
||||
group = group,
|
||||
recipient = withContext(Dispatchers.IO) {
|
||||
recipient = withContext(Dispatchers.Default) {
|
||||
Recipient.resolved(uiState.value.newSelections.single().orCreateRecipientId)
|
||||
}
|
||||
)
|
||||
|
||||
+1
-1
@@ -56,7 +56,7 @@ class AddToGroupsViewModel(
|
||||
|
||||
private fun confirmAddToGroup(groupRecipientId: RecipientId) {
|
||||
viewModelScope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
withContext(Dispatchers.Default) {
|
||||
if (!existingGroupMemberships.contains(groupRecipientId)) {
|
||||
internalUiState.update {
|
||||
it.copy(
|
||||
|
||||
+42
-3
@@ -82,6 +82,19 @@ class ArchiveAttachmentReconciliationJob private constructor(
|
||||
Log.i(TAG, "Skip enqueueing reconciliation job: attempt limit exceeded.")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconcile-first entry point for after a local restore. Runs a [BackupMessagesJob] (to capture a snapshot) chained into a forced reconciliation.
|
||||
* Sets [BackupValues.localRestoreReconcilePending] so the backup holds off on the bulk attachment backfill, letting reconciliation run first. Reconciliation
|
||||
* then clears the flag and re-triggers the backfill for whatever genuinely still needs uploading.
|
||||
*/
|
||||
fun enqueueReconcileFirstForLocalRestore() {
|
||||
SignalStore.backup.localRestoreReconcilePending = true
|
||||
AppDependencies.jobManager
|
||||
.startChain(BackupMessagesJob())
|
||||
.then(ArchiveAttachmentReconciliationJob(forced = true))
|
||||
.enqueue()
|
||||
}
|
||||
}
|
||||
|
||||
constructor(forced: Boolean = false) : this(
|
||||
@@ -116,7 +129,7 @@ class ArchiveAttachmentReconciliationJob private constructor(
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
if (SignalStore.backup.lastAttachmentReconciliationTime < 0) {
|
||||
if (!forced && SignalStore.backup.lastAttachmentReconciliationTime < 0) {
|
||||
Log.w(TAG, "First ever time we're attempting a reconciliation. Setting the last sync time to now, so we'll run at the proper interval. Skipping this iteration.", true)
|
||||
SignalStore.backup.lastAttachmentReconciliationTime = System.currentTimeMillis()
|
||||
return Result.success()
|
||||
@@ -138,10 +151,27 @@ class ArchiveAttachmentReconciliationJob private constructor(
|
||||
// we use to determine which attachments need to be re-uploaded will possibly result in us unnecessarily re-uploading attachments.
|
||||
snapshotVersion = snapshotVersion ?: SignalDatabase.backupMediaSnapshots.getCurrentSnapshotVersion()
|
||||
|
||||
return syncDataFromCdn(snapshotVersion!!) ?: Result.success()
|
||||
syncDataFromCdn(snapshotVersion!!)?.let { return it }
|
||||
|
||||
clearPendingLocalRestoreReconcile()
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
override fun onFailure() = Unit
|
||||
/**
|
||||
* Once a reconciliation has fully crawled the CDN, any media that was already archived has been marked finished, so the bulk backfill that was held off
|
||||
* during a local restore can proceed for whatever genuinely still needs uploading.
|
||||
*/
|
||||
private fun clearPendingLocalRestoreReconcile() {
|
||||
if (SignalStore.backup.localRestoreReconcilePending) {
|
||||
Log.i(TAG, "Local restore reconciliation complete. Clearing the pending flag and enqueueing a backup to upload any remaining media.", true)
|
||||
SignalStore.backup.localRestoreReconcilePending = false
|
||||
BackupMessagesJob.enqueue()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFailure() {
|
||||
clearPendingLocalRestoreReconcile()
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches all attachment metadata from the archive CDN and ensures that our local store is in sync with it.
|
||||
@@ -302,6 +332,8 @@ class ArchiveAttachmentReconciliationJob private constructor(
|
||||
* - Mark that page as seen on the remote.
|
||||
* - Fix any CDN mismatches by updating our local store with the correct CDN.
|
||||
* - Delete any orphaned attachments that are on the CDN but not in our local store.
|
||||
* - During the local-restore reconcile-first flow, mark media confirmed present on the CDN as finished. A local restore resets everything to NONE (we don't
|
||||
* trust the backup's CDN claims), so this is what promotes the media that genuinely is on the CDN back to finished, preventing a needless re-upload of it.
|
||||
*
|
||||
* @return A list of media objects that should be deleted (after being verified)
|
||||
*/
|
||||
@@ -329,6 +361,13 @@ class ArchiveAttachmentReconciliationJob private constructor(
|
||||
}
|
||||
}
|
||||
|
||||
if (SignalStore.backup.localRestoreReconcilePending) {
|
||||
val markedFinished = SignalDatabase.attachments.setArchiveFinishedForMatchingMediaObjects(mediaObjectsOnBothRemoteAndLocal.toSet())
|
||||
if (markedFinished > 0) {
|
||||
Log.i(TAG, "Marked $markedFinished media object group(s) as finished after confirming they are present on the CDN.", true)
|
||||
}
|
||||
}
|
||||
|
||||
return mediaOnRemoteButNotLocal
|
||||
}
|
||||
|
||||
|
||||
@@ -77,7 +77,7 @@ object AttachmentBackfill {
|
||||
|
||||
/** The scope the response timeouts run on. Overridable so tests can drive the timeout with virtual time. */
|
||||
@VisibleForTesting
|
||||
internal var timeoutScope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
internal var timeoutScope: CoroutineScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
||||
|
||||
/**
|
||||
* Considers a backfill request for [messageId] after a permanent download failure. The attachment is flagged
|
||||
|
||||
@@ -417,7 +417,10 @@ class BackupMessagesJob private constructor(
|
||||
return Result.failure()
|
||||
}
|
||||
|
||||
if (SignalStore.backup.backsUpMedia && SignalDatabase.attachments.doAnyAttachmentsNeedArchiveUpload()) {
|
||||
if (SignalStore.backup.localRestoreReconcilePending) {
|
||||
Log.i(TAG, "A local restore reconciliation is pending. Holding off on the attachment backfill until reconciliation has marked already-archived media as finished.", true)
|
||||
ArchiveUploadProgress.onMessageBackupFinishedEarly()
|
||||
} else if (SignalStore.backup.backsUpMedia && SignalDatabase.attachments.doAnyAttachmentsNeedArchiveUpload()) {
|
||||
Log.i(TAG, "Enqueuing attachment backfill job.", true)
|
||||
AppDependencies.jobManager.add(ArchiveAttachmentBackfillJob())
|
||||
} else {
|
||||
|
||||
@@ -103,6 +103,15 @@ class CheckRestoreMediaLeftJob private constructor(parameters: Parameters) : Job
|
||||
SignalStore.backup.deletionState = DeletionState.MEDIA_DOWNLOAD_FINISHED
|
||||
}
|
||||
|
||||
if (SignalStore.backup.localRestoreReconcilePending) {
|
||||
if (SignalStore.backup.backsUpMedia) {
|
||||
Log.i(TAG, "Local restore complete. Reconciling restored media against the archive CDN before uploading. (Flag cleared by the reconciliation job.)")
|
||||
ArchiveAttachmentReconciliationJob.enqueueReconcileFirstForLocalRestore()
|
||||
} else {
|
||||
SignalStore.backup.localRestoreReconcilePending = false
|
||||
}
|
||||
}
|
||||
|
||||
if (!SignalStore.backup.backsUpMedia) {
|
||||
SignalDatabase.attachments.markQuotesThatNeedReconstruction()
|
||||
AppDependencies.jobManager.add(QuoteThumbnailReconstructionJob())
|
||||
|
||||
@@ -33,6 +33,7 @@ import org.thoughtcrime.securesms.messages.IncomingMessageObserver;
|
||||
import org.thoughtcrime.securesms.net.SignalNetwork;
|
||||
import org.thoughtcrime.securesms.transport.RetryLaterException;
|
||||
import org.signal.core.util.PlayServicesUtil;
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
import org.whispersystems.signalservice.api.NetworkResultUtil;
|
||||
import org.signal.network.exceptions.NonSuccessfulResponseCodeException;
|
||||
|
||||
@@ -72,6 +73,11 @@ public class FcmRefreshJob extends BaseJob {
|
||||
|
||||
@Override
|
||||
public void onRun() throws Exception {
|
||||
if (TextSecurePreferences.isUnauthorizedReceived(context)) {
|
||||
Log.i(TAG, "No longer authorized. Ignoring.");
|
||||
return;
|
||||
}
|
||||
|
||||
Log.i(TAG, "Reregistering FCM...");
|
||||
|
||||
boolean playServicesMissing = PlayServicesUtil.getPlayServicesStatus(context) == PlayServicesUtil.PlayServicesStatus.MISSING ;
|
||||
|
||||
@@ -77,6 +77,7 @@ import org.thoughtcrime.securesms.migrations.GooglePlayBillingPurchaseTokenMigra
|
||||
import org.thoughtcrime.securesms.migrations.IdentityTableCleanupMigrationJob;
|
||||
import org.thoughtcrime.securesms.migrations.KeyTransparencyUsernameMigrationJob;
|
||||
import org.thoughtcrime.securesms.migrations.LegacyMigrationJob;
|
||||
import org.thoughtcrime.securesms.migrations.LocalArchiveReconciliationMigrationJob;
|
||||
import org.thoughtcrime.securesms.migrations.MigrationCompleteJob;
|
||||
import org.thoughtcrime.securesms.migrations.OptimizeMessageSearchIndexMigrationJob;
|
||||
import org.thoughtcrime.securesms.migrations.PassingMigrationJob;
|
||||
@@ -344,6 +345,7 @@ public final class JobManagerFactories {
|
||||
put(IdentityTableCleanupMigrationJob.KEY, new IdentityTableCleanupMigrationJob.Factory());
|
||||
put(KeyTransparencyUsernameMigrationJob.KEY, new KeyTransparencyUsernameMigrationJob.Factory());
|
||||
put(LegacyMigrationJob.KEY, new LegacyMigrationJob.Factory());
|
||||
put(LocalArchiveReconciliationMigrationJob.KEY, new LocalArchiveReconciliationMigrationJob.Factory());
|
||||
put(MigrationCompleteJob.KEY, new MigrationCompleteJob.Factory());
|
||||
put(OptimizeMessageSearchIndexMigrationJob.KEY, new OptimizeMessageSearchIndexMigrationJob.Factory());
|
||||
put(PinOptOutMigration.KEY, new PinOptOutMigration.Factory());
|
||||
|
||||
@@ -91,6 +91,11 @@ public class RefreshAttributesJob extends BaseJob {
|
||||
return;
|
||||
}
|
||||
|
||||
if (TextSecurePreferences.isUnauthorizedReceived(context)) {
|
||||
Log.i(TAG, "No longer authorized. Ignoring.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!forced && hasRefreshedThisAppCycle) {
|
||||
Log.d(TAG, "Already refreshed this app cycle. Skipping.");
|
||||
return;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user