Compare commits

..

62 Commits

Author SHA1 Message Date
Alex Hart c47a74ba09 Bump version to 8.19.2 2026-07-10 16:53:22 -03:00
Alex Hart 24287a15e2 Update translations and other static files. 2026-07-10 16:52:50 -03:00
Michelle Tang 77bef0b101 Disable disappearing calls. 2026-07-10 15:21:19 -04:00
Greyson Parrelli e2ef8c2805 Only run screenshot tests on CI for now. 2026-07-09 13:44:09 -04:00
Greyson Parrelli a52799e069 Update screenshot test with new translation. 2026-07-09 13:44:09 -04:00
Alex Hart 2cabdd0978 Bump version to 8.19.1 2026-07-08 16:18:02 -03:00
Alex Hart 02e134c16f Update translations and other static files. 2026-07-08 16:17:20 -03:00
Alex Hart fe87ae203a Revert "Update translations and other static files."
This reverts commit 1bad283af2.
2026-07-08 16:01:16 -03:00
Alex Hart 272754384d Bump version to 8.19.0 2026-07-08 15:49:51 -03:00
Alex Hart 1bad283af2 Update translations and other static files. 2026-07-08 15:48:59 -03:00
Greyson Parrelli 573013be5c Inline the groupTerminateSend flag. 2026-07-08 15:37:49 -03:00
Greyson Parrelli ca7762c3d0 Fix some more remote restore bugs in regV5. 2026-07-08 15:37:49 -03:00
Michelle Tang e45a4476d3 Turn on group search membership and disappearing calls. 2026-07-08 15:37:48 -03:00
Michelle Tang 636247f83a Update group membership search. 2026-07-08 15:37:48 -03:00
Greyson Parrelli cc72a2232e Fix some reglock bugs in regV5. 2026-07-08 15:37:48 -03:00
Paul Ohlhauser 80f6795665 Fix duplicate clipboard popup.
Co-authored-by: Leo Heitmann Ruiz <leo@heitmannruiz.org>

Closes signalapp/Signal-Android#14871
2026-07-08 15:37:48 -03:00
Alex Hart b55a8dc98e Utilize helper method when constructing proto charge failure.
Fixes #14871
2026-07-08 09:26:37 -03:00
Jeffrey Starke 4ab4bb458a Migrate stories tab to nav 3. 2026-07-07 17:10:23 -04:00
Greyson Parrelli 566a72ecf7 Fix padding on 'wrong number' button in regV5. 2026-07-07 17:00:41 -04:00
Greyson Parrelli 355332d604 Handle additional error cases in regV5. 2026-07-07 16:56:48 -04:00
Greyson Parrelli d8e77d8827 Fix RegV5 captcha WebView always rendering in dark theme. 2026-07-07 15:55:51 -04:00
Greyson Parrelli f5e48a3ff9 Reset regV5 flow state when re-registering. 2026-07-07 15:52:53 -04:00
Greyson Parrelli 46006cbfde Add a 'having trouble' support bottom sheet in regV5. 2026-07-07 15:47:53 -04:00
Greyson Parrelli d695cd8b5a Fix regV5 SMS auto-fill and paste. 2026-07-07 15:38:21 -04:00
Greyson Parrelli debed5f537 Fix proxy config in regV5. 2026-07-07 15:20:51 -04:00
Cody Henthorne ecbdde592e Add relink support and fix sad paths in all link flows.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-07-07 15:17:35 -04:00
Alex Hart 384344c91b Upload media restored from local backups to the archive CDN. 2026-07-07 16:08:51 -03:00
Greyson Parrelli e687718ec3 Disable enter-to-submit for invalid phone numbers in regV5. 2026-07-07 15:07:03 -04:00
Greyson Parrelli cf394aeee8 Update verification code entry UX in regV5. 2026-07-07 13:31:04 -04:00
Greyson Parrelli 8b4a38a2c6 Improve digit interactions on regV5 verification screen. 2026-07-07 13:31:00 -04:00
Greyson Parrelli 1358fefeee Update regV5 country picker UI. 2026-07-07 11:56:45 -04:00
Greyson Parrelli 958236467f Properly persist registrationId in regV5. 2026-07-07 11:56:45 -04:00
Greyson Parrelli c638af6671 Removed unused regV5 field. 2026-07-07 11:56:45 -04:00
Greyson Parrelli bdbe69c7b0 Persist regV5 scratch data with BlobProvider. 2026-07-07 11:56:45 -04:00
dweep 184e0bc3c2 Fix black bars around circular notification avatars
Uses the app's own square contact_photo_target_size dimension for
the Glide submit() call instead of the OEM-overridable system
dimensions android.R.dimen.notification_large_icon_width/height.
Those system dimensions aren't guaranteed to be square, so
CircleCrop() could produce a rectangular bitmap with transparent
letterbox padding, which some OEM skins render as black bars
instead of transparency.

Fixes #14811

Closes signalapp/Signal-Android#14867
2026-07-07 11:20:56 -03:00
Greyson Parrelli 6893a0abc0 Fix lifecycle of regV5 scratch storage. 2026-07-06 15:54:16 -04:00
Greyson Parrelli 74b431d988 Move some usages of Dispatchers.IO to Dispatchers.Default. 2026-07-06 15:29:06 -04:00
dependabot[bot] f6c7824050 ci: bump docker/build-push-action from 6.18.0 to 7.3.0
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-06 15:28:38 -04:00
dependabot[bot] b3904982a5 ci: bump docker/setup-buildx-action from 3.11.1 to 4.2.0
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-06 15:28:00 -04:00
Alex Hart 5f1a7397e1 Fix donation test. 2026-07-06 15:04:40 -03:00
Greyson Parrelli 55941d3ccc Use static timezone for RemoteRestoreScreenScreenshotTests. 2026-07-06 13:39:28 -03:00
Michelle Tang 0b92bc41fe Add invite and add member to group search. 2026-07-06 13:39:28 -03:00
Greyson Parrelli 2373f567d8 Include all build reports and test results in Github CI artifacts. 2026-07-06 13:39:28 -03:00
Greyson Parrelli 7e81240d4d Add a gradle task for firebase test lab. 2026-07-06 13:39:28 -03:00
Greyson Parrelli 5cf61665d4 Add unit test coverage for regV5 screens. 2026-07-06 13:39:28 -03:00
Greyson Parrelli 9814c520e2 Add screenshot tests for all of regV5. 2026-07-06 13:39:28 -03:00
Cody Henthorne 6664716930 Fix crash tapping not-yet-restored media in conversation settings shared media rail. 2026-07-06 13:39:25 -03:00
Greyson Parrelli 6194acfdba Fix some edge-to-edge stuff with regV5. 2026-07-06 13:39:25 -03:00
Greyson Parrelli 8db4a5238c Make regV5 available for internal users. 2026-07-06 13:39:25 -03:00
Greyson Parrelli 60192c4b46 Fix icons and restore options in regV5. 2026-07-06 13:39:25 -03:00
Cody Henthorne 512df1ec1b Implement phone friendly link device registration flow. 2026-07-06 13:39:25 -03:00
Michelle Tang a849af6880 Add more features to group member search. 2026-07-06 13:39:25 -03:00
Greyson Parrelli 3d0a743fe3 Constrain recipient search bar placeholder to a single line. 2026-07-06 13:39:25 -03:00
Greyson Parrelli f5e065d050 Cleanup regV5 backstack. 2026-07-06 13:39:25 -03:00
Greyson Parrelli 7e26badb4c Support automatic SMS fill in regV5. 2026-07-06 13:39:24 -03:00
Greyson Parrelli da8af40e4f Allow 'next monday' schedule for all users. 2026-07-06 13:39:24 -03:00
Greyson Parrelli 364f2baf4b Label scheduled-message times beyond tomorrow with their weekday. 2026-07-06 13:39:24 -03:00
Greyson Parrelli 97a0f7b659 Fix dependency initialization order. 2026-07-06 13:39:24 -03:00
Alex Hart 310e90be84 Harden donation checkout instrumentation tests. 2026-07-06 13:39:24 -03:00
Cody Henthorne b462ef2149 Allow banners to reactively update their enabled state. 2026-07-06 13:39:24 -03:00
Cody Henthorne cd5ead4981 Update unregistered UI for linked devices. 2026-07-06 13:39:24 -03:00
Cody Henthorne 9be6f488c4 Add warning dialogs when mixing local backups and optimize media. 2026-07-06 13:39:23 -03:00
734 changed files with 15110 additions and 2554 deletions
+8 -4
View File
@@ -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
+2 -2
View File
@@ -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
View File
@@ -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
@@ -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()))
}
}
@@ -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()))
}
@@ -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
}
}
@@ -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
)
}
@@ -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()
}
}
@@ -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"}"""
}
}
@@ -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))
}
}
}
@@ -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
}
}
@@ -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()
}
}
+7
View File
@@ -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.")
@@ -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()
}
@@ -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))
}
}
}
@@ -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()
}
}
@@ -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)
@@ -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)
}
@@ -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()
}
@@ -51,7 +51,7 @@ class BackupsSettingsViewModel : ViewModel() {
}
}
viewModelScope.launch(Dispatchers.IO) {
viewModelScope.launch(Dispatchers.Default) {
SignalStore.backup.lastBackupTimeFlow
.collect { lastBackupTime ->
internalStateFlow.update {
@@ -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)
}
}
@@ -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),
@@ -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,
@@ -103,7 +103,8 @@ class LocalBackupsViewModel : ViewModel(), BackupKeyCredentialManagerHandler {
internalSettingsState.update {
it.copy(
canTurnOn = canTurnOn,
scheduleTimeLabel = backupTime
scheduleTimeLabel = backupTime,
optimizeStorageEnabled = SignalStore.backup.optimizeStorage
)
}
}
@@ -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()
}
@@ -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()
}
}
@@ -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)
}
@@ -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()
}
@@ -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(
@@ -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() }
}
@@ -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()
@@ -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) }
}
}
@@ -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
@@ -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)
@@ -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"
) {
@@ -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
@@ -25,7 +25,7 @@ class DonationPendingBottomSheetViewModel(
init {
viewModelScope.launch {
val inAppPayment = withContext(SignalDispatchers.IO) {
val inAppPayment = withContext(SignalDispatchers.Default) {
SignalDatabase.inAppPayments.getById(inAppPaymentId)!!
}
@@ -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()
@@ -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!!)!!
}
@@ -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)!!
}
@@ -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(
@@ -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)!!
}
@@ -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)
}
@@ -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)!!
}
@@ -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
}
}
@@ -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 ->
@@ -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(
@@ -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)
@@ -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(
@@ -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
@@ -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
}
}
@@ -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
@@ -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))
@@ -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)
}
)
}
@@ -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()
}
}
@@ -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(
@@ -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,
@@ -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
@@ -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 {
@@ -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()
@@ -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()
@@ -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());
}
})
);
@@ -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()
@@ -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)
@@ -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()
@@ -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
}
}
@@ -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)
}
)
@@ -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(
@@ -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