Compare commits

...

69 Commits

Author SHA1 Message Date
Cody Henthorne eff0e973aa Bump version to 8.20.0 2026-07-15 17:40:28 -04:00
Cody Henthorne 34f336c2a2 Update baseline profile. 2026-07-15 17:13:28 -04:00
Cody Henthorne b0f71c8613 Update translations and other static files. 2026-07-15 17:03:02 -04:00
Cody Henthorne 95019407c2 Hide optimize media on linked devices. 2026-07-15 16:43:50 -04:00
Greyson Parrelli 0101d41cf9 Enable regV5 for external users. 2026-07-15 16:43:50 -04:00
Greyson Parrelli 75c18806d5 Ensure we always use the user-entered AEP in regV5. 2026-07-15 16:43:50 -04:00
Greyson Parrelli 7a07a96fba Fix local backup v2 restore pre-registration in regV5. 2026-07-15 16:43:50 -04:00
Cody Henthorne 4f6c1c4647 Show backups information on linked devices and allow on-device backups. 2026-07-15 16:43:50 -04:00
Greyson Parrelli df4afe4e92 Fix time display for local backup v2 in regV5. 2026-07-15 16:43:50 -04:00
Greyson Parrelli 6a18bcc915 Use better country code fallback in regV5. 2026-07-15 16:43:50 -04:00
Michelle Tang 06e7b79e3d Enable disappearing calls. 2026-07-15 16:43:50 -04:00
Greyson Parrelli 31f0945db4 Add tests for AppRegistrationStorageController. 2026-07-15 16:43:50 -04:00
Greyson Parrelli edc629e7fa Remove OneTimeEvent pattern from regV5. 2026-07-15 16:43:49 -04:00
Alex Hart bdfe06ed74 Upgrade compose bom to 2026.06.01 2026-07-15 16:43:49 -04:00
Cody Henthorne 1d11e29e94 Add linked device account screen. 2026-07-15 16:43:49 -04:00
andrew-signal f70ae10067 Bump libsignal to v0.97.3 2026-07-15 16:43:49 -04:00
Cody Henthorne 7ca57e5531 Fix flakey attachmentBackfill instrumentation test. 2026-07-15 16:43:49 -04:00
Greyson Parrelli adad1425d8 Navigate reglock mismatch to account locked in regV5. 2026-07-15 16:43:49 -04:00
Cody Henthorne 693b208fe2 Fix chat session refreshed on secondary after link+sync. 2026-07-15 16:43:49 -04:00
Michelle Tang e975084b8e Use new transcoding config. 2026-07-15 16:43:49 -04:00
Greyson Parrelli 3bc693961d Fix QR code color in dark theme in regV5. 2026-07-15 16:43:49 -04:00
Greyson Parrelli 2dde0b43b8 Take a persistent URI permission for local backups in regV5. 2026-07-15 16:43:49 -04:00
Alex Hart 88366cd416 Add donation checkout entry-point tests. 2026-07-15 16:43:49 -04:00
Greyson Parrelli 4ee2b71b13 Fix reglock interaction during backup restore in regV5. 2026-07-15 16:43:49 -04:00
Alex Hart 86aae39141 Fix nav for expiring timer. 2026-07-15 16:43:48 -04:00
Greyson Parrelli e71ec7c6d9 Refactor regV5 account storage persistence. 2026-07-15 16:43:48 -04:00
Michelle Tang 20d24d177d Refresh attributes on KT failure. 2026-07-15 16:43:48 -04:00
Alex Hart cfc1ed8d5c Check isResolving flag before displaying 'Deleted account'.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-07-15 16:43:48 -04:00
Alex Hart 25f7c31e75 Fix backup subscription check test. 2026-07-15 16:43:48 -04:00
Greyson Parrelli 929a391d50 Improve local restore progress in regV5. 2026-07-15 16:43:48 -04:00
dependabot[bot] 76f2543308 ci: bump the actions group with 2 updates.
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-15 16:43:48 -04:00
Cody Henthorne 5eebaa3528 Update support article links in linked device reg flow. 2026-07-15 16:43:48 -04:00
Greyson Parrelli 673324f46a Fix text theming issue in contact search. 2026-07-15 16:43:48 -04:00
Greyson Parrelli ea9cd4251e Fix pin restore from remote backup in regV5. 2026-07-15 16:43:48 -04:00
Cody Henthorne ac7d54331a Update assets for linked device registration flows. 2026-07-15 16:43:48 -04:00
Alex Hart 5e3a34e1bc Add donation checkout spec test suite.
Co-authored-by: Cody Henthorne <cody@signal.org>
2026-07-15 16:43:47 -04:00
Greyson Parrelli fe6a74eb56 Add end-to-end regV5 tests for wrong backup passphrases. 2026-07-15 16:43:47 -04:00
Greyson Parrelli 6a6b7a1924 Improve quick restore in regV5. 2026-07-15 16:43:47 -04:00
Greyson Parrelli f50e970b5d Add more end-to-end regV5 tests. 2026-07-15 16:43:47 -04:00
Greyson Parrelli 32961106e1 Add an example end-to-end registration junit test. 2026-07-15 16:43:47 -04:00
andrew-signal 8d3728b32a Bump libsignal to v0.97.1 2026-07-15 16:43:47 -04:00
emir-signal 2f3b25c787 Update to RingRTC v2.69.7 2026-07-15 16:43:47 -04:00
BarbossHack 5cc7266a9e Refactor the XML diff in reproducible script
Closes signalapp/Signal-Android#14879
2026-07-15 16:43:47 -04:00
Greyson Parrelli 90cba056f1 Remove emoji from plaintext export names. 2026-07-15 16:43:47 -04:00
Greyson Parrelli 07ca23ceae Evaluate original envelope source when rejecting sealed-sender PNI messages. 2026-07-15 16:43:47 -04:00
Greyson Parrelli 0d0b9d345c Strip user-ID prefix from content authority in isValidExternalUri. 2026-07-15 16:43:47 -04:00
Greyson Parrelli 7f63af659d Preserve storageCapable across restore progress emissions in local backup V1 restore. 2026-07-15 16:43:47 -04:00
Greyson Parrelli 66390b4fe4 Catch malformed percent escapes when parsing call link urls. 2026-07-15 16:43:47 -04:00
Greyson Parrelli b60f53f4df Reject dot-only chat names to prevent zip-slip in plaintext export. 2026-07-15 16:43:46 -04:00
Greyson Parrelli 8bc31386a5 Build eager prefetch sessions under the SignalSessionLock. 2026-07-15 16:43:46 -04:00
Greyson Parrelli 8f4b4aa160 Improve check for self. 2026-07-15 16:43:46 -04:00
Greyson Parrelli 12af41174a Cap mention processing in story reply and edit message paths. 2026-07-15 16:43:46 -04:00
Greyson Parrelli e7c5af7ea5 Handle device-transfer authentication failures per connection instead of terminating the server. 2026-07-15 16:43:46 -04:00
Greyson Parrelli 51b1dbd470 Bound backup frame read retries and cap frame length to prevent restore crash. 2026-07-15 16:43:46 -04:00
Greyson Parrelli dd38f72f60 Validate content URI authority in system contacts entrypoint. 2026-07-15 16:43:46 -04:00
Greyson Parrelli db8a705704 Ensure backups are re-enabled post-reg in regV5. 2026-07-15 16:43:46 -04:00
Greyson Parrelli ba43403009 Restore the AEP in v1 local backups in regV5. 2026-07-15 16:43:46 -04:00
Greyson Parrelli 417b9eb50a Report progress for V1 local restore in regV5. 2026-07-15 16:43:46 -04:00
Greyson Parrelli 76c9b4a7dc Do not reprompt for restore if you restored pre-reg in regV5. 2026-07-15 16:43:46 -04:00
Greyson Parrelli d1b916d61c Fix local backup v1 restores post-registration in regV5. 2026-07-15 16:43:46 -04:00
Greyson Parrelli 908bb8dbe6 Fix race condition with event stream in regV5. 2026-07-15 16:43:46 -04:00
Greyson Parrelli 31216592de Ensure that all regV5 viewmodels follow the right patterns. 2026-07-15 16:43:45 -04:00
Greyson Parrelli bcc9f17e41 Use regV5 for partial restore state. 2026-07-15 16:43:45 -04:00
Greyson Parrelli d9dd15eff3 Use idiomatic nav3 transition patterns. 2026-07-15 16:43:45 -04:00
Alex Hart 1a12eb992d Fix crash when trying to dismiss a detached dialog window. 2026-07-15 16:43:45 -04:00
Greyson Parrelli 7c1e010482 Improve phone number pasting in regV5. 2026-07-15 16:43:45 -04:00
Greyson Parrelli 8a5826555c Properly restore keys from local backups in regV5. 2026-07-10 16:56:33 -04:00
Greyson Parrelli 8ffbfb4000 Fix local backup restore v2 in regV5. 2026-07-10 16:56:32 -04:00
Greyson Parrelli 16fe25a38a Fix some remote restore bugs in regV5. 2026-07-10 16:56:32 -04:00
419 changed files with 21729 additions and 10013 deletions
+1 -1
View File
@@ -23,7 +23,7 @@ jobs:
lfs: true
- name: set up JDK 21
uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5
uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5
# gh api repos/actions/setup-java/commits/v5 --jq '.sha'
with:
distribution: temurin
+1 -1
View File
@@ -23,7 +23,7 @@ jobs:
ref: ${{ github.event.pull_request.base.sha }}
- name: set up JDK 21
uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5
uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5
# gh api repos/actions/setup-java/commits/v5 --jq '.sha'
with:
distribution: temurin
+1 -1
View File
@@ -14,7 +14,7 @@ jobs:
actions: write
steps:
- uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10
- uses: actions/stale@1e223db275d687790206a7acac4d1a11bd6fe629 # v10
# gh api repos/actions/stale/commits/v10 --jq '.sha'
with:
days-before-stale: 60
+14 -2
View File
@@ -32,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 = 1719
val canonicalVersionName = "8.19.2"
val canonicalVersionCode = 1720
val canonicalVersionName = "8.20.0"
val currentHotfixVersion = 0
val maxHotfixVersions = 100
@@ -132,6 +132,17 @@ ktlint {
version.set("1.5.0")
}
// ktlint only scans convention source dirs, so the shared dirs added to the compile tasks are
// otherwise skipped. Add them to the base test/androidTest ktlint tasks so ktlintCheck/format cover them.
tasks.withType(org.jlleitschuh.gradle.ktlint.tasks.BaseKtLintCheckTask::class.java).configureEach {
if (name.endsWith("OverTestSourceSet") || name.endsWith("OverAndroidTestSourceSet")) {
source("$projectDir/src/testShared")
}
if (name.endsWith("OverAndroidTestSourceSet")) {
source("$projectDir/src/benchmarkShared/java")
}
}
screenshotTests {
// Fraction of differing pixels tolerated before a screenshot test fails (0.0001 = 0.01%).
imageDifferenceThreshold = 0.0001f
@@ -831,6 +842,7 @@ dependencies {
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
androidTestImplementation(libs.androidx.compose.ui.test.manifest)
androidTestImplementation(testLibs.androidx.test.ext.junit)
androidTestImplementation(testLibs.espresso.core)
androidTestImplementation(testLibs.espresso.contrib) {
@@ -0,0 +1,191 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.subscription.donate
import android.app.Activity
import android.app.Application
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import androidx.fragment.app.Fragment
import androidx.navigation.fragment.NavHostFragment
import androidx.recyclerview.widget.RecyclerView
import androidx.test.core.app.ActivityScenario
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.ViewActions.click
import androidx.test.espresso.action.ViewActions.scrollTo
import androidx.test.espresso.contrib.RecyclerViewActions
import androidx.test.espresso.matcher.ViewMatchers.hasDescendant
import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.espresso.matcher.ViewMatchers.withText
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import assertk.assertThat
import assertk.assertions.isEqualTo
import assertk.assertions.isNotNull
import assertk.assertions.isNull
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.signal.core.util.getParcelableExtraCompat
import org.signal.donations.InAppPaymentType
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.badges.self.none.BecomeASustainerFragment
import org.thoughtcrime.securesms.badges.self.overview.BadgesOverviewFragment
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
import org.thoughtcrime.securesms.components.settings.app.routes.AppSettingsRoute
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.profiles.manage.EditProfileActivity
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.testing.InAppPaymentsRule
import org.thoughtcrime.securesms.testing.SignalActivityRule
import java.util.concurrent.TimeUnit
/**
* Entry-path coverage for the "become a subscriber" upsell.
*
* The upsell surface is [BecomeASustainerFragment], reached from the profile screen's badges row. The
* handoff test asserts the sheet's button launches the recurring-donation checkout route; the gating
* tests assert the "user might be a sustainer" rule in [org.thoughtcrime.securesms.profiles.manage.EditProfileFragment]:
* a non-donor sees the upsell, an existing donor is routed to badge management instead.
*/
@RunWith(AndroidJUnit4::class)
class BecomeASustainerUpsellTest {
@get:Rule
val harness = SignalActivityRule()
@get:Rule
val iapRule = InAppPaymentsRule()
private val context: Context get() = InstrumentationRegistry.getInstrumentation().targetContext
@Before
fun setUp() {
setSelfBadges(emptyList())
}
@Test
fun nonDonor_badgeTap_showsUpsellSheet() {
ActivityScenario.launch(EditProfileActivity::class.java).use { scenario ->
onView(withId(R.id.manage_profile_badges_container)).perform(scrollTo(), click())
val sheet = awaitNavHostFragment(scenario) { it.filterIsInstance<BecomeASustainerFragment>().firstOrNull() }
assertThat(sheet).isNotNull()
}
}
@Test
fun becomeASustainer_launchesRecurringCheckoutRoute() {
LaunchedActivityRecorder(AppSettingsActivity::class.java).use { recorder ->
ActivityScenario.launch(EditProfileActivity::class.java).use { scenario ->
onView(withId(R.id.manage_profile_badges_container)).perform(scrollTo(), click())
awaitNavHostFragment(scenario) { it.filterIsInstance<BecomeASustainerFragment>().firstOrNull() }
val becomeASustainer = withText(R.string.BecomeASustainerMegaphone__become_a_sustainer)
onView(withId(R.id.recycler)).perform(RecyclerViewActions.scrollTo<RecyclerView.ViewHolder>(hasDescendant(becomeASustainer)))
onView(becomeASustainer).perform(click())
val intent = recorder.awaitLaunch()
val route = intent.extras!!.keySet().firstNotNullOfOrNull { intent.getParcelableExtraCompat(it, AppSettingsRoute::class.java) }
assertThat(route).isEqualTo(AppSettingsRoute.DonationsRoute.Donations(directToCheckoutType = InAppPaymentType.RECURRING_DONATION))
}
}
}
@Test
fun donor_badgeTap_opensBadgeManagement() {
setSelfBadges(listOf(donorBadge()))
ActivityScenario.launch(EditProfileActivity::class.java).use { scenario ->
onView(withId(R.id.manage_profile_badges_container)).perform(scrollTo(), click())
val badgeManagement = awaitNavHostFragment(scenario) { it.filterIsInstance<BadgesOverviewFragment>().firstOrNull() }
assertThat(badgeManagement).isNotNull()
val upsell = navHostFragments(scenario).filterIsInstance<BecomeASustainerFragment>().firstOrNull()
assertThat(upsell).isNull()
}
}
private fun awaitNavHostFragment(scenario: ActivityScenario<EditProfileActivity>, selector: (List<Fragment>) -> Fragment?): Fragment {
return await(description = "nav host fragment") { selector(navHostFragments(scenario)) }
}
private fun navHostFragments(scenario: ActivityScenario<EditProfileActivity>): List<Fragment> {
var fragments: List<Fragment> = emptyList()
scenario.onActivity { activity ->
val navHost = activity.supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as? NavHostFragment
fragments = navHost?.childFragmentManager?.fragments ?: emptyList()
}
return fragments
}
private fun setSelfBadges(badges: List<Badge>) {
SignalDatabase.recipients.setBadges(Recipient.self().id, badges)
Recipient.self().fresh()
}
private fun donorBadge(): Badge {
return Badge(
id = "test-donor-badge",
category = Badge.Category.Donor,
name = "Signal Sustainer",
description = "",
imageUrl = Uri.EMPTY,
imageDensity = "xxhdpi",
expirationTimestamp = System.currentTimeMillis() + TimeUnit.DAYS.toMillis(30),
visible = true,
duration = TimeUnit.DAYS.toMillis(30)
)
}
private fun <T> await(timeoutMs: Long = 10_000, pollMs: Long = 50, description: String, supplier: () -> T?): T {
val deadline = System.currentTimeMillis() + timeoutMs
while (System.currentTimeMillis() < deadline) {
supplier()?.let { return it }
Thread.sleep(pollMs)
}
error("Timed out after ${timeoutMs}ms waiting for $description")
}
/**
* Captures the first launched activity of [activityClass] and finishes it immediately so its own
* downstream launches (e.g. the auto-launched checkout) don't cascade during the test.
*/
private inner class LaunchedActivityRecorder(private val activityClass: Class<out Activity>) : AutoCloseable {
@Volatile
private var launched: Intent? = null
private val app = context.applicationContext as Application
private val callbacks = object : Application.ActivityLifecycleCallbacks {
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {
if (activityClass.isInstance(activity) && launched == null) {
launched = activity.intent
activity.finish()
}
}
override fun onActivityStarted(activity: Activity) = Unit
override fun onActivityResumed(activity: Activity) = Unit
override fun onActivityPaused(activity: Activity) = Unit
override fun onActivityStopped(activity: Activity) = Unit
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) = Unit
override fun onActivityDestroyed(activity: Activity) = Unit
}
init {
app.registerActivityLifecycleCallbacks(callbacks)
}
fun awaitLaunch(): Intent = await(description = "${activityClass.simpleName} launch") { launched }
override fun close() = app.unregisterActivityLifecycleCallbacks(callbacks)
}
}
@@ -17,7 +17,9 @@ import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.espresso.matcher.ViewMatchers.withText
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import io.mockk.unmockkObject
import org.hamcrest.Matchers.allOf
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
@@ -25,6 +27,7 @@ import org.junit.runner.RunWith
import org.signal.core.util.deleteAll
import org.signal.donations.InAppPaymentType
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.app.subscription.permits.DonationPermits
import org.thoughtcrime.securesms.database.InAppPaymentTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.testing.GooglePayTestRule
@@ -59,6 +62,11 @@ class CheckoutFlowActivityTest__OneTimeDonations {
startJobLoopForTests()
}
@After
fun tearDown() {
unmockkObject(DonationPermits)
}
@Test
fun givenPermitAcquisitionFails_whenIDonateOnce_thenIExpectPaymentSetupErrorDialog() {
failDonationPermitAcquisition()
@@ -13,8 +13,6 @@ import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.espresso.matcher.ViewMatchers.withText
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import io.mockk.every
import io.mockk.mockkObject
import io.mockk.unmockkObject
import org.junit.After
import org.junit.Before
@@ -23,9 +21,6 @@ import org.junit.Test
import org.junit.runner.RunWith
import org.signal.core.util.deleteAll
import org.signal.donations.InAppPaymentType
import org.signal.libsignal.net.RequestResult
import org.signal.network.NetworkResult
import org.signal.network.exceptions.NonSuccessfulResponseCodeException
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.permits.DonationPermits
@@ -33,19 +28,18 @@ 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.scrollToDescendant
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription
import org.thoughtcrime.securesms.testing.endpoints.DonationResponses
import org.thoughtcrime.securesms.testing.endpoints.MockEndpoints
import org.thoughtcrime.securesms.testing.endpoints.failure
import org.thoughtcrime.securesms.testing.endpoints.ok
import org.whispersystems.signalservice.api.subscriptions.SubscriberId
import java.math.BigDecimal
import java.util.Currency
import kotlin.time.Duration.Companion.days
import kotlin.time.Duration.Companion.milliseconds
@Suppress("ClassName")
@RunWith(AndroidJUnit4::class)
@@ -153,10 +147,10 @@ class CheckoutFlowActivityTest__RecurringDonations {
@Test
fun givenSubscriptionPaymentMethodPermitRejected_whenISubscribe_thenIExpectPaymentSetupErrorDialog() {
AppDependencies.donationPermitsRepository.clearPermits()
mockkObject(DonationPermits)
every { DonationPermits.getDonationPermit() } returns RequestResult.Success("permit")
every { AppDependencies.donationsApi.createStripeSubscriptionPaymentMethod(any(), any(), any()) } returns NetworkResult.StatusCodeError(NonSuccessfulResponseCodeException(402))
succeedDonationPermitAcquisition()
MockEndpoints.responder.register({ it.method == "POST" && it.path.contains("/create_payment_method") }) {
failure(402)
}
val scenario = ActivityScenario.launch<CheckoutFlowActivity>(intent)
rxRule.defaultTestScheduler.triggerActions()
@@ -172,44 +166,22 @@ class CheckoutFlowActivityTest__RecurringDonations {
}
private fun initialiseActiveSubscription() {
val currency = Currency.getInstance("USD")
val subscriber = InAppPaymentSubscriberRecord(
subscriberId = SubscriberId.generate(),
currency = currency,
type = InAppPaymentSubscriberRecord.Type.DONATION,
requiresCancel = false,
paymentMethodType = InAppPaymentData.PaymentMethodType.CARD,
iapSubscriptionId = null
)
InAppPaymentsRepository.setSubscriber(subscriber)
SignalStore.inAppPayments.setRecurringDonationCurrency(currency)
AppDependencies.donationsApi.apply {
every { getSubscription(subscriber.subscriberId) } returns NetworkResult.Success(
ActiveSubscription(
ActiveSubscription.Subscription(
200,
currency.currencyCode,
BigDecimal.ONE,
System.currentTimeMillis().milliseconds.inWholeSeconds + 30.days.inWholeSeconds,
true,
System.currentTimeMillis().milliseconds.inWholeSeconds + 30.days.inWholeSeconds,
false,
"active",
"STRIPE",
"CARD",
false
),
null
)
)
every { deleteSubscription(subscriber.subscriberId) } returns NetworkResult.Success(Unit)
val subscriber = registerSubscriber()
val serialized = subscriber.subscriberId.serialize()
MockEndpoints.responder.register({ it.method == "GET" && it.path.contains(serialized) }) {
ok(DonationResponses.activeSubscription(status = "active", active = true))
}
}
private fun initialisePendingSubscription() {
val subscriber = registerSubscriber()
val serialized = subscriber.subscriberId.serialize()
MockEndpoints.responder.register({ it.method == "GET" && it.path.contains(serialized) }) {
ok(DonationResponses.activeSubscription(status = "incomplete", active = false))
}
}
private fun registerSubscriber(): InAppPaymentSubscriberRecord {
val currency = Currency.getInstance("USD")
val subscriber = InAppPaymentSubscriberRecord(
subscriberId = SubscriberId.generate(),
@@ -223,25 +195,6 @@ class CheckoutFlowActivityTest__RecurringDonations {
InAppPaymentsRepository.setSubscriber(subscriber)
SignalStore.inAppPayments.setRecurringDonationCurrency(currency)
AppDependencies.donationsApi.apply {
every { getSubscription(subscriber.subscriberId) } returns NetworkResult.Success(
ActiveSubscription(
ActiveSubscription.Subscription(
200,
currency.currencyCode,
BigDecimal.ONE,
System.currentTimeMillis().milliseconds.inWholeSeconds + 30.days.inWholeSeconds,
false,
System.currentTimeMillis().milliseconds.inWholeSeconds + 30.days.inWholeSeconds,
false,
"incomplete",
"STRIPE",
"CARD",
false
),
null
)
)
}
return subscriber
}
}
@@ -0,0 +1,99 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.donate
import androidx.compose.ui.test.junit4.v2.createEmptyComposeRule
import androidx.test.core.app.ActivityScenario
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.ViewActions
import androidx.test.espresso.matcher.ViewMatchers.isClickable
import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.espresso.matcher.ViewMatchers.withText
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import assertk.assertThat
import assertk.assertions.isNotEmpty
import io.mockk.unmockkObject
import org.hamcrest.Matchers.allOf
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.signal.core.util.deleteAll
import org.signal.donations.InAppPaymentType
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.app.subscription.permits.DonationPermits
import org.thoughtcrime.securesms.database.DonationReceiptTable
import org.thoughtcrime.securesms.database.InAppPaymentTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.InAppPaymentReceiptRecord
import org.thoughtcrime.securesms.testing.GooglePayTestRule
import org.thoughtcrime.securesms.testing.InAppPaymentsRule
import org.thoughtcrime.securesms.testing.RxTestSchedulerRule
import org.thoughtcrime.securesms.testing.SignalActivityRule
import org.thoughtcrime.securesms.testing.actions.scrollToDescendant
import org.thoughtcrime.securesms.testing.endpoints.MockEndpoints
import org.thoughtcrime.securesms.testing.endpoints.registerStripeHappyPath
import org.thoughtcrime.securesms.testing.flushUntil
/**
* Milestone integration test: drives a Google Pay one-time donation through the entire lifecycle to
* receipt redemption, exercising all three outer boundaries at once — the Signal-service websocket
* responder (boost intent + receipt credential + redeem), the Stripe HTTP interceptor (payment
* method + confirm + intent status), and the test zk server (real client-side credential validation).
*/
@Suppress("ClassName")
@RunWith(AndroidJUnit4::class)
class CheckoutFlowActivityTest__Redemption {
@get:Rule
val harness = SignalActivityRule(othersCount = 10)
@get:Rule
val iapRule = InAppPaymentsRule()
@get:Rule
val rxRule = RxTestSchedulerRule()
@get:Rule
val googlePayRule = GooglePayTestRule()
@get:Rule
val composeRule = createEmptyComposeRule()
private val intent = CheckoutFlowActivity.createIntent(InstrumentationRegistry.getInstrumentation().targetContext, InAppPaymentType.ONE_TIME_DONATION)
@Before
fun setUp() {
SignalDatabase.inAppPayments.writableDatabase.deleteAll(InAppPaymentTable.TABLE_NAME)
SignalDatabase.donationReceipts.writableDatabase.deleteAll(DonationReceiptTable.TABLE_NAME)
startJobLoopForTests()
succeedDonationPermitAcquisition()
MockEndpoints.responder.registerStripeHappyPath()
}
@After
fun tearDown() {
unmockkObject(DonationPermits)
}
@Test
fun givenAllEndpointsSucceed_whenIDonateOnceWithGooglePay_thenIExpectAReceipt() {
val scenario = ActivityScenario.launch<CheckoutFlowActivity>(intent)
rxRule.defaultTestScheduler.triggerActions()
scrollToDescendant(R.id.recycler, withId(R.id.boost_1), rxRule.defaultTestScheduler)
onView(allOf(withId(R.id.boost_1), isClickable())).perform(ViewActions.click())
rxRule.defaultTestScheduler.triggerActions()
scrollToDescendant(R.id.recycler, withText(R.string.DonateToSignalFragment__continue), rxRule.defaultTestScheduler)
onView(withText(R.string.DonateToSignalFragment__continue)).perform(ViewActions.click())
rxRule.defaultTestScheduler.triggerActions()
scenario.selectGooglePay(composeRule, rxRule.defaultTestScheduler, InAppPaymentType.ONE_TIME_DONATION)
rxRule.defaultTestScheduler.flushUntil {
SignalDatabase.donationReceipts.getReceipts(InAppPaymentReceiptRecord.Type.ONE_TIME_DONATION).isNotEmpty()
}
assertThat(SignalDatabase.donationReceipts.getReceipts(InAppPaymentReceiptRecord.Type.ONE_TIME_DONATION)).isNotEmpty()
}
}
@@ -0,0 +1,280 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.donate
import android.os.Bundle
import androidx.compose.ui.test.hasTestTag
import androidx.compose.ui.test.junit4.ComposeTestRule
import androidx.compose.ui.test.onAllNodesWithTag
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollToNode
import androidx.compose.ui.test.performTextInput
import androidx.core.os.bundleOf
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity
import androidx.fragment.app.FragmentManager
import androidx.navigation.fragment.NavHostFragment
import androidx.test.core.app.ActivityScenario
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.ViewActions
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.matcher.ViewMatchers.isClickable
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import androidx.test.espresso.matcher.ViewMatchers.isEnabled
import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.espresso.matcher.ViewMatchers.withText
import io.reactivex.rxjava3.schedulers.TestScheduler
import org.hamcrest.Matchers.allOf
import org.signal.donations.InAppPaymentType
import org.signal.donations.StripeIntentAccessor
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewaySelectorTestTags
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.paypal.PayPalConfirmationDialogFragment
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.paypal.PayPalConfirmationResult
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.stripe.Stripe3DSDialogFragment
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.transfer.DonationTransferTestTags
import org.thoughtcrime.securesms.testing.actions.scrollToDescendant
import org.thoughtcrime.securesms.testing.endpoints.StripeResponses
import org.thoughtcrime.securesms.testing.flushUntil
/**
* Drives [CheckoutFlowActivity] through the UI "as a user" for a [DonationsCheckoutTestSpec]: selects
* an amount/tier, continues to the gateway selector, and completes the chosen payment method. Each
* payment method is a strategy dispatched on [DonationsCheckoutTestSpec.paymentMethod].
*
* Webview approval steps (PayPal, Stripe 3DS) can't complete against a mocked network, so instead of
* loading the approval URL we inject the fragment result the webview would have posted — the same
* mechanism the production dialog uses on return.
*/
class CheckoutFlowDriver(
private val composeRule: ComposeTestRule,
private val scheduler: TestScheduler
) {
companion object {
private const val TEST_NAME = "Test McTesterson"
private const val TEST_EMAIL = "test@signal.org"
/** A structurally valid German IBAN (passes mod-97), for the SEPA form. */
private const val TEST_IBAN = "DE89370400440532013000"
}
fun drive(scenario: ActivityScenario<CheckoutFlowActivity>, spec: DonationsCheckoutTestSpec) {
scheduler.triggerActions()
selectAmountOrTier(spec.inAppPaymentType)
proceedToGateway()
when (spec.paymentMethod) {
TestPaymentMethod.GOOGLE_PAY -> scenario.selectGooglePay(composeRule, scheduler, spec.inAppPaymentType)
TestPaymentMethod.CREDIT_CARD -> completeCreditCard()
TestPaymentMethod.PAYPAL -> completePayPal(scenario, spec.inAppPaymentType)
TestPaymentMethod.SEPA_DEBIT -> completeSepa()
TestPaymentMethod.IDEAL -> completeIdeal(spec.inAppPaymentType)
}
if (spec.world.stripe == StripeOutcome.REQUIRES_3DS && spec.paymentMethod.usesStripe()) {
complete3ds(scenario, spec.inAppPaymentType)
}
}
private fun selectAmountOrTier(type: InAppPaymentType) {
if (type == InAppPaymentType.ONE_TIME_DONATION) {
scrollToDescendant(R.id.recycler, withId(R.id.boost_1), scheduler)
onView(allOf(withId(R.id.boost_1), isClickable())).perform(ViewActions.click())
scheduler.triggerActions()
}
// Recurring: the monthly tier is selected by default, so nothing to pick here.
}
private fun proceedToGateway() {
scrollToDescendant(R.id.recycler, withText(R.string.DonateToSignalFragment__continue), scheduler)
onView(withText(R.string.DonateToSignalFragment__continue)).perform(ViewActions.click())
scheduler.triggerActions()
}
/**
* Fills the [org.thoughtcrime.securesms.components.settings.app.subscription.donate.card.CreditCardFragment]
* with a valid test card and continues, driving the flow into the Stripe payment pipeline.
*/
private fun completeCreditCard() {
clickGatewayButton(GatewaySelectorTestTags.CREDIT_CARD_BUTTON)
scheduler.flushUntil {
onView(withId(R.id.card_number)).check(matches(isDisplayed()))
true
}
onView(withId(R.id.card_number)).perform(ViewActions.typeText("4242424242424242"), ViewActions.closeSoftKeyboard())
onView(withId(R.id.card_expiry)).perform(ViewActions.typeText("1228"), ViewActions.closeSoftKeyboard())
onView(withId(R.id.card_cvv)).perform(ViewActions.typeText("123"), ViewActions.closeSoftKeyboard())
scheduler.flushUntil {
onView(withId(R.id.continue_button)).check(matches(isEnabled()))
true
}
onView(withId(R.id.continue_button)).perform(ViewActions.click())
scheduler.triggerActions()
}
/**
* Selects PayPal, then injects the approval result the webview would have returned. One-time uses a
* [PayPalConfirmationResult]; recurring posts `true`.
*/
private fun completePayPal(scenario: ActivityScenario<CheckoutFlowActivity>, type: InAppPaymentType) {
clickGatewayButton(GatewaySelectorTestTags.PAYPAL_BUTTON)
val result: Any = if (type == InAppPaymentType.ONE_TIME_DONATION) {
PayPalConfirmationResult(payerId = "test-payer", paymentId = null, paymentToken = "test-token")
} else {
true
}
awaitDialogAndSetResult(
scenario = scenario,
dialogClass = PayPalConfirmationDialogFragment::class.java,
resultKey = PayPalConfirmationDialogFragment.REQUEST_KEY,
bundle = bundleOf(PayPalConfirmationDialogFragment.REQUEST_KEY to result)
)
}
/** Injects the 3DS return the Stripe webview would have posted, resuming the Stripe pipeline. */
private fun complete3ds(scenario: ActivityScenario<CheckoutFlowActivity>, type: InAppPaymentType) {
val accessor = if (type == InAppPaymentType.ONE_TIME_DONATION) {
StripeIntentAccessor(StripeIntentAccessor.ObjectType.PAYMENT_INTENT, StripeResponses.DEFAULT_PAYMENT_INTENT_ID, "${StripeResponses.DEFAULT_PAYMENT_INTENT_ID}_secret_test")
} else {
StripeIntentAccessor(StripeIntentAccessor.ObjectType.SETUP_INTENT, StripeResponses.DEFAULT_SETUP_INTENT_ID, "${StripeResponses.DEFAULT_SETUP_INTENT_ID}_secret_test")
}
awaitDialogAndSetResult(
scenario = scenario,
dialogClass = Stripe3DSDialogFragment::class.java,
resultKey = Stripe3DSDialogFragment.REQUEST_KEY,
bundle = bundleOf(Stripe3DSDialogFragment.REQUEST_KEY to accessor)
)
}
/**
* Selects SEPA, accepts the mandate (its button scrolls the text to the bottom before it agrees, so
* it is clicked until the details form appears), fills the bank details, and donates. SEPA requires
* a EUR donation currency (see [WorldState.currencyCode]).
*/
private fun completeSepa() {
clickGatewayButton(GatewaySelectorTestTags.SEPA_BUTTON)
scheduler.flushUntil {
val ibanPresent = runCatching {
composeRule.onAllNodesWithTag(DonationTransferTestTags.SEPA_IBAN_FIELD).fetchSemanticsNodes().isNotEmpty()
}.getOrDefault(false)
if (!ibanPresent) {
runCatching { composeRule.onNodeWithTag(DonationTransferTestTags.SEPA_MANDATE_CONTINUE_BUTTON).performClick() }
}
ibanPresent
}
typeIntoField(DonationTransferTestTags.SEPA_DETAILS_LIST, DonationTransferTestTags.SEPA_IBAN_FIELD, TEST_IBAN)
typeIntoField(DonationTransferTestTags.SEPA_DETAILS_LIST, DonationTransferTestTags.SEPA_NAME_FIELD, TEST_NAME)
typeIntoField(DonationTransferTestTags.SEPA_DETAILS_LIST, DonationTransferTestTags.SEPA_EMAIL_FIELD, TEST_EMAIL)
clickWhenReady(DonationTransferTestTags.SEPA_DONATE_BUTTON)
}
/**
* Selects iDEAL, fills the bank details (email is recurring-only), and donates. iDEAL requires a EUR
* donation currency and, on success, lands in an awaiting-authorization state.
*/
private fun completeIdeal(type: InAppPaymentType) {
clickGatewayButton(GatewaySelectorTestTags.IDEAL_BUTTON)
scheduler.flushUntil {
runCatching {
composeRule.onAllNodesWithTag(DonationTransferTestTags.IDEAL_NAME_FIELD).fetchSemanticsNodes().isNotEmpty()
}.getOrDefault(false)
}
typeIntoField(DonationTransferTestTags.IDEAL_DETAILS_LIST, DonationTransferTestTags.IDEAL_NAME_FIELD, TEST_NAME)
if (type.recurring) {
typeIntoField(DonationTransferTestTags.IDEAL_DETAILS_LIST, DonationTransferTestTags.IDEAL_EMAIL_FIELD, TEST_EMAIL)
}
clickWhenReady(DonationTransferTestTags.IDEAL_DONATE_BUTTON)
}
/**
* Scrolls the [listTag] lazy list until the field tagged [fieldTag] is composed and on-screen, then types
* [text] into it. The transfer forms lay their fields out in a [androidx.compose.foundation.lazy.LazyColumn],
* so a lower field is not composed on a short screen until scrolled to — without this, the input silently
* matches nothing on Firebase's smaller devices.
*/
private fun typeIntoField(listTag: String, fieldTag: String, text: String) {
composeRule.onNodeWithTag(listTag).performScrollToNode(hasTestTag(fieldTag))
composeRule.onNodeWithTag(fieldTag).performTextInput(text)
}
/** Retries clicking a Compose node until the click lands (e.g. once a submit button becomes enabled). */
private fun clickWhenReady(tag: String) {
scheduler.flushUntil {
runCatching {
composeRule.onNodeWithTag(tag).performClick()
true
}.getOrDefault(false)
}
scheduler.triggerActions()
}
/**
* Clicks a Compose gateway-selector button and waits for the bottom sheet to dismiss. For methods
* other than Google Pay the checkout then navigates to the method's fragment; Google Pay instead
* requires a result injection (see `selectGooglePay`).
*/
private fun clickGatewayButton(tag: String) {
scheduler.flushUntil {
composeRule.onAllNodesWithTag(tag).fetchSemanticsNodes().isNotEmpty()
}
// The selector is a vertically scrolling bottom sheet; on a short screen the lower gateway buttons are
// off-screen, so bring the target on-screen before clicking (mirrors GatewaySelectorBottomSheetContentTest).
composeRule.onNodeWithTag(GatewaySelectorTestTags.CONTAINER).performScrollToNode(hasTestTag(tag))
composeRule.onNodeWithTag(tag).performClick()
scheduler.flushUntil {
runCatching {
composeRule.onAllNodesWithTag(tag).fetchSemanticsNodes().isEmpty()
}.getOrDefault(true)
}
}
/**
* Waits for a navigated dialog of [dialogClass] to be present, then posts [bundle] as its fragment
* result on the nav host's fragment manager (where the in-progress fragment registered its listener).
*/
private fun <T : Fragment> awaitDialogAndSetResult(
scenario: ActivityScenario<CheckoutFlowActivity>,
dialogClass: Class<T>,
resultKey: String,
bundle: Bundle
) {
scheduler.flushUntil {
var delivered = false
scenario.onActivity { activity ->
val fragmentManager = navHostChildFragmentManager(activity)
if (fragmentManager != null && fragmentManager.fragments.any { dialogClass.isInstance(it) }) {
fragmentManager.setFragmentResult(resultKey, bundle)
delivered = true
}
}
delivered
}
scheduler.triggerActions()
}
private fun navHostChildFragmentManager(activity: FragmentActivity): FragmentManager? {
return activity.supportFragmentManager.fragments
.filterIsInstance<NavHostFragment>()
.firstOrNull()
?.childFragmentManager
}
private fun TestPaymentMethod.usesStripe(): Boolean {
return this != TestPaymentMethod.PAYPAL
}
}
@@ -7,10 +7,12 @@ package org.thoughtcrime.securesms.components.settings.app.subscription.donate
import android.app.Activity
import android.content.Intent
import androidx.compose.ui.test.hasTestTag
import androidx.compose.ui.test.junit4.ComposeTestRule
import androidx.compose.ui.test.onAllNodesWithTag
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollToNode
import androidx.test.core.app.ActivityScenario
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.assertion.ViewAssertions.matches
@@ -18,6 +20,7 @@ import androidx.test.espresso.matcher.RootMatchers.isDialog
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import androidx.test.espresso.matcher.ViewMatchers.withText
import io.mockk.every
import io.mockk.mockkObject
import io.reactivex.rxjava3.schedulers.TestScheduler
import org.signal.donations.InAppPaymentType
import org.signal.libsignal.net.RequestResult
@@ -26,23 +29,52 @@ import org.thoughtcrime.securesms.SignalInstrumentationApplicationContext
import org.thoughtcrime.securesms.components.settings.app.subscription.GooglePayComponent
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewaySelectorTestTags
import org.thoughtcrime.securesms.components.settings.app.subscription.permits.DonationPermits
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.testing.flushUntil
/**
* Forces real donation-permit acquisition to fail at the issuer, exercising the permit code path through
* [org.thoughtcrime.securesms.components.settings.app.subscription.permits.DonationPermits].
* Forces donation-permit acquisition to fail, so the checkout surfaces a payment-setup error before
* reaching the payment method. Stubs the [DonationPermits] object directly (the underlying
* `createDonationPermits` call rides the websocket, which the responder does not mint valid permits
* for). Call `unmockkObject(DonationPermits)` in teardown.
*/
fun failDonationPermitAcquisition(statusCode: Int = 500) {
AppDependencies.donationPermitsRepository.clearPermits()
every { AppDependencies.donationsApi.createDonationPermits(any()) } returns RequestResult.NonSuccess(RestStatusCodeError(statusCode, emptyMap(), null))
mockkObject(DonationPermits)
every { DonationPermits.getDonationPermit() } returns RequestResult.NonSuccess(RestStatusCodeError(statusCode, emptyMap(), null))
}
/**
* Makes donation-permit acquisition succeed with a placeholder permit, letting the checkout proceed
* past the permit gate to the payment method. Call `unmockkObject(DonationPermits)` in teardown.
*/
fun succeedDonationPermitAcquisition() {
AppDependencies.donationPermitsRepository.clearPermits()
mockkObject(DonationPermits)
every { DonationPermits.getDonationPermit() } returns RequestResult.Success("permit")
}
/**
* The instrumentation app stubs [org.thoughtcrime.securesms.ApplicationContext.beginJobLoop] to a no-op, so enqueued
* jobs never run. The checkout pipeline drives a real setup job through the JobManager, so the loop must be started.
*
* Before starting it, cancel any leftover in-app-payment jobs. A recurring setup job retries indefinitely over a
* one-day lifespan, so a spec that fails setup leaves one persisted in the JobManager. On Firebase Test Lab the
* orchestrator's `clearPackageData` does not reliably wipe the JobManager between parameterized specs (it does on a
* local emulator, which is why this only surfaces on-device), so that job survives into the next spec and runs after
* its InAppPayment row was deleted in setup — `InAppPaymentsRepository.resolveLock` then throws "Not found" from the
* job thread and crashes the whole app. Clearing the queues here makes each test start from a clean job state.
*/
fun startJobLoopForTests() {
AppDependencies.jobManager.cancelAllInQueues(
setOf(
InAppPaymentsRepository.getRecurringJobQueueKey(InAppPaymentType.RECURRING_DONATION),
InAppPaymentsRepository.getRecurringJobQueueKey(InAppPaymentType.RECURRING_BACKUP)
)
)
AppDependencies.jobManager.flush()
(AppDependencies.application as SignalInstrumentationApplicationContext).beginJobLoopForTests()
}
@@ -67,6 +99,7 @@ fun ActivityScenario<CheckoutFlowActivity>.selectGooglePay(
composeRule.onAllNodesWithTag(GatewaySelectorTestTags.GOOGLE_PAY_BUTTON).fetchSemanticsNodes().isNotEmpty()
}
composeRule.onNodeWithTag(GatewaySelectorTestTags.CONTAINER).performScrollToNode(hasTestTag(GatewaySelectorTestTags.GOOGLE_PAY_BUTTON))
composeRule.onNodeWithTag(GatewaySelectorTestTags.GOOGLE_PAY_BUTTON).performClick()
scheduler.flushUntil {
@@ -0,0 +1,282 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.donate
import androidx.compose.ui.test.junit4.v2.createEmptyComposeRule
import androidx.test.core.app.ActivityScenario
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.matcher.RootMatchers.isDialog
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import androidx.test.espresso.matcher.ViewMatchers.withText
import androidx.test.platform.app.InstrumentationRegistry
import assertk.assertThat
import assertk.assertions.isNotEmpty
import assertk.assertions.isNotNull
import assertk.assertions.isNull
import io.mockk.unmockkObject
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.Parameterized
import org.signal.core.util.deleteAll
import org.signal.donations.InAppPaymentType
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.app.subscription.permits.DonationPermits
import org.thoughtcrime.securesms.database.DonationReceiptTable
import org.thoughtcrime.securesms.database.InAppPaymentTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.InAppPaymentReceiptRecord
import org.thoughtcrime.securesms.testing.GooglePayTestRule
import org.thoughtcrime.securesms.testing.InAppPaymentsRule
import org.thoughtcrime.securesms.testing.RawFlag
import org.thoughtcrime.securesms.testing.RemoteConfigForTest
import org.thoughtcrime.securesms.testing.RxTestSchedulerRule
import org.thoughtcrime.securesms.testing.SignalActivityRule
import org.thoughtcrime.securesms.testing.flushUntil
/**
* Spec-driven integration test for donation checkout. Each [DonationsCheckoutTestSpec] declares a
* "world" (what each endpoint returns) and an expected outcome; this runner applies the world, drives
* [CheckoutFlowActivity] through the real UI via [CheckoutFlowDriver], and asserts the outcome.
*
* Adding coverage is data: append a spec to [DonationsCheckoutSpecs.ALL].
*/
@RunWith(Parameterized::class)
@RemoteConfigForTest(
rawFlags = [
RawFlag("android.sepa.debit.donations.5", "true"),
RawFlag("android.ideal.donations.5", "true"),
// Region codes are matched against the digit-only self E164 ("15555550101"), so no leading "+".
RawFlag("global.donations.sepaEnabledRegions", "1"),
RawFlag("global.donations.idealEnabledRegions", "1")
]
)
class DonationsCheckoutSpecTest(private val spec: DonationsCheckoutTestSpec) {
@get:Rule
val harness = SignalActivityRule(othersCount = 10)
@get:Rule
val iapRule = InAppPaymentsRule()
@get:Rule
val rxRule = RxTestSchedulerRule()
@get:Rule
val googlePayRule = GooglePayTestRule()
@get:Rule
val composeRule = createEmptyComposeRule()
@Before
fun setUp() {
SignalDatabase.inAppPayments.writableDatabase.deleteAll(InAppPaymentTable.TABLE_NAME)
SignalDatabase.donationReceipts.writableDatabase.deleteAll(DonationReceiptTable.TABLE_NAME)
startJobLoopForTests()
spec.world.apply()
}
@After
fun tearDown() {
unmockkObject(DonationPermits)
}
@Test
fun runSpec() {
val intent = CheckoutFlowActivity.createIntent(InstrumentationRegistry.getInstrumentation().targetContext, spec.inAppPaymentType)
val scenario = ActivityScenario.launch<CheckoutFlowActivity>(intent)
CheckoutFlowDriver(composeRule, rxRule.defaultTestScheduler).drive(scenario, spec)
when (val expected = spec.expected) {
is ExpectedOutcome.Redeemed -> {
rxRule.defaultTestScheduler.flushUntil { receipts().isNotEmpty() }
assertThat(receipts()).isNotEmpty()
}
is ExpectedOutcome.ErrorDialog -> {
awaitDialog(rxRule.defaultTestScheduler, expected.titleRes)
if (expected.messageRes != null) {
onView(withText(expected.messageRes)).inRoot(isDialog()).check(matches(isDisplayed()))
}
}
is ExpectedOutcome.PaymentSubmitted -> {
rxRule.defaultTestScheduler.flushUntil { submittedPayment() != null }
val payment = submittedPayment()
assertThat(payment).isNotNull()
assertThat(payment!!.data.error).isNull()
}
}
}
/** The latest payment once it has moved past CREATED with no error, or null if not yet there. */
private fun submittedPayment(): InAppPaymentTable.InAppPayment? {
return SignalDatabase.inAppPayments.getLatestInAppPaymentByType(spec.inAppPaymentType)
?.takeIf { it.state != InAppPaymentTable.State.CREATED && it.data.error == null }
}
private fun receipts(): List<InAppPaymentReceiptRecord> {
val type = if (spec.inAppPaymentType == InAppPaymentType.ONE_TIME_DONATION) {
InAppPaymentReceiptRecord.Type.ONE_TIME_DONATION
} else {
InAppPaymentReceiptRecord.Type.RECURRING_DONATION
}
return SignalDatabase.donationReceipts.getReceipts(type)
}
companion object {
@JvmStatic
@Parameterized.Parameters(name = "{0}")
fun specs(): Collection<DonationsCheckoutTestSpec> = DonationsCheckoutSpecs.ALL
}
}
/**
* The enumerated donation checkout scenarios. Currently seeded with Google Pay coverage; the other
* payment-method drivers (card, SEPA, iDEAL, PayPal) and their scenarios are added alongside their
* [CheckoutFlowDriver] strategies.
*/
object DonationsCheckoutSpecs {
private val paymentErrorDialog = ExpectedOutcome.ErrorDialog(
titleRes = R.string.DonationsErrors__error_processing_payment,
messageRes = R.string.DonationsErrors__your_payment
)
val ALL: List<DonationsCheckoutTestSpec> = listOf(
DonationsCheckoutTestSpec(
name = "googlePay_oneTime_happyPath_isRedeemed",
inAppPaymentType = InAppPaymentType.ONE_TIME_DONATION,
paymentMethod = TestPaymentMethod.GOOGLE_PAY,
world = WorldState(),
expected = ExpectedOutcome.Redeemed
),
DonationsCheckoutTestSpec(
name = "googlePay_oneTime_permitFailure_showsError",
inAppPaymentType = InAppPaymentType.ONE_TIME_DONATION,
paymentMethod = TestPaymentMethod.GOOGLE_PAY,
world = WorldState(permit = PermitOutcome.FAILURE),
expected = paymentErrorDialog
),
DonationsCheckoutTestSpec(
name = "googlePay_oneTime_stripeDecline_showsError",
inAppPaymentType = InAppPaymentType.ONE_TIME_DONATION,
paymentMethod = TestPaymentMethod.GOOGLE_PAY,
world = WorldState(stripe = StripeOutcome.DECLINE),
expected = paymentErrorDialog
),
DonationsCheckoutTestSpec(
name = "googlePay_oneTime_signalCreateIntent402_showsError",
inAppPaymentType = InAppPaymentType.ONE_TIME_DONATION,
paymentMethod = TestPaymentMethod.GOOGLE_PAY,
world = WorldState(signalCreatePaymentStatus = 402),
expected = paymentErrorDialog
),
DonationsCheckoutTestSpec(
name = "googlePay_recurring_permitFailure_showsError",
inAppPaymentType = InAppPaymentType.RECURRING_DONATION,
paymentMethod = TestPaymentMethod.GOOGLE_PAY,
world = WorldState(permit = PermitOutcome.FAILURE),
expected = paymentErrorDialog
),
DonationsCheckoutTestSpec(
name = "creditCard_oneTime_happyPath_isRedeemed",
inAppPaymentType = InAppPaymentType.ONE_TIME_DONATION,
paymentMethod = TestPaymentMethod.CREDIT_CARD,
world = WorldState(),
expected = ExpectedOutcome.Redeemed
),
DonationsCheckoutTestSpec(
name = "creditCard_oneTime_stripeDecline_showsError",
inAppPaymentType = InAppPaymentType.ONE_TIME_DONATION,
paymentMethod = TestPaymentMethod.CREDIT_CARD,
world = WorldState(stripe = StripeOutcome.DECLINE),
expected = paymentErrorDialog
),
DonationsCheckoutTestSpec(
name = "creditCard_oneTime_3dsApproved_isRedeemed",
inAppPaymentType = InAppPaymentType.ONE_TIME_DONATION,
paymentMethod = TestPaymentMethod.CREDIT_CARD,
world = WorldState(stripe = StripeOutcome.REQUIRES_3DS),
expected = ExpectedOutcome.Redeemed
),
DonationsCheckoutTestSpec(
name = "payPal_oneTime_happyPath_isRedeemed",
inAppPaymentType = InAppPaymentType.ONE_TIME_DONATION,
paymentMethod = TestPaymentMethod.PAYPAL,
world = WorldState(),
expected = ExpectedOutcome.Redeemed
),
DonationsCheckoutTestSpec(
name = "sepa_oneTime_happyPath_isSubmitted",
inAppPaymentType = InAppPaymentType.ONE_TIME_DONATION,
paymentMethod = TestPaymentMethod.SEPA_DEBIT,
world = WorldState(currencyCode = "EUR"),
expected = ExpectedOutcome.PaymentSubmitted
),
DonationsCheckoutTestSpec(
name = "ideal_oneTime_happyPath_isSubmitted",
inAppPaymentType = InAppPaymentType.ONE_TIME_DONATION,
paymentMethod = TestPaymentMethod.IDEAL,
world = WorldState(currencyCode = "EUR"),
expected = ExpectedOutcome.PaymentSubmitted
),
// Recurring happy paths. Asserted as "submitted": the recurring-specific work is the setup
// pipeline (create subscriber, setup intent, set default, set level); receipt redemption is
// shared with the one-time path already covered above.
DonationsCheckoutTestSpec(
name = "googlePay_recurring_happyPath_isSubmitted",
inAppPaymentType = InAppPaymentType.RECURRING_DONATION,
paymentMethod = TestPaymentMethod.GOOGLE_PAY,
world = WorldState(recurringActivatesAfterSetup = true),
expected = ExpectedOutcome.PaymentSubmitted
),
DonationsCheckoutTestSpec(
name = "creditCard_recurring_happyPath_isSubmitted",
inAppPaymentType = InAppPaymentType.RECURRING_DONATION,
paymentMethod = TestPaymentMethod.CREDIT_CARD,
world = WorldState(recurringActivatesAfterSetup = true),
expected = ExpectedOutcome.PaymentSubmitted
),
DonationsCheckoutTestSpec(
name = "payPal_recurring_happyPath_isSubmitted",
inAppPaymentType = InAppPaymentType.RECURRING_DONATION,
paymentMethod = TestPaymentMethod.PAYPAL,
world = WorldState(recurringActivatesAfterSetup = true),
expected = ExpectedOutcome.PaymentSubmitted
),
DonationsCheckoutTestSpec(
name = "sepa_recurring_happyPath_isSubmitted",
inAppPaymentType = InAppPaymentType.RECURRING_DONATION,
paymentMethod = TestPaymentMethod.SEPA_DEBIT,
world = WorldState(currencyCode = "EUR"),
expected = ExpectedOutcome.PaymentSubmitted
),
// Additional error cases.
DonationsCheckoutTestSpec(
name = "creditCard_oneTime_permitFailure_showsError",
inAppPaymentType = InAppPaymentType.ONE_TIME_DONATION,
paymentMethod = TestPaymentMethod.CREDIT_CARD,
world = WorldState(permit = PermitOutcome.FAILURE),
expected = paymentErrorDialog
),
DonationsCheckoutTestSpec(
name = "creditCard_recurring_stripeDecline_showsError",
inAppPaymentType = InAppPaymentType.RECURRING_DONATION,
paymentMethod = TestPaymentMethod.CREDIT_CARD,
world = WorldState(stripe = StripeOutcome.DECLINE),
expected = paymentErrorDialog
),
DonationsCheckoutTestSpec(
name = "googlePay_recurring_signalCreatePaymentMethod402_showsError",
inAppPaymentType = InAppPaymentType.RECURRING_DONATION,
paymentMethod = TestPaymentMethod.GOOGLE_PAY,
world = WorldState(signalCreatePaymentStatus = 402),
expected = paymentErrorDialog
)
)
}
@@ -0,0 +1,159 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.donate
import androidx.annotation.StringRes
import org.signal.donations.InAppPaymentType
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.testing.endpoints.DonationResponses
import org.thoughtcrime.securesms.testing.endpoints.EndpointResponse
import org.thoughtcrime.securesms.testing.endpoints.MockEndpoints
import org.thoughtcrime.securesms.testing.endpoints.StripeResponses
import org.thoughtcrime.securesms.testing.endpoints.failure
import org.thoughtcrime.securesms.testing.endpoints.ok
import org.thoughtcrime.securesms.testing.endpoints.registerPayPalHappyPath
import org.thoughtcrime.securesms.testing.endpoints.registerStripeHappyPath
import java.util.Currency
import java.util.concurrent.atomic.AtomicBoolean
/**
* A declarative "state of the world" + expected outcome for one donation checkout scenario. Specs
* are enumerated by [DonationsCheckoutSpecTest] and each one drives [CheckoutFlowActivity] end to end
* for its [paymentMethod].
*/
data class DonationsCheckoutTestSpec(
val name: String,
val inAppPaymentType: InAppPaymentType,
val paymentMethod: TestPaymentMethod,
val world: WorldState = WorldState(),
val expected: ExpectedOutcome
) {
/** Used as the parameterized test label. */
override fun toString(): String = name
}
enum class TestPaymentMethod {
GOOGLE_PAY,
CREDIT_CARD,
SEPA_DEBIT,
IDEAL,
PAYPAL
}
/** Whether donation-permit acquisition succeeds or fails. */
enum class PermitOutcome { SUCCESS, FAILURE }
/** The Stripe HTTP edge's behavior for a scenario. */
enum class StripeOutcome {
SUCCESS,
DECLINE,
FAILURE,
REQUIRES_3DS
}
/** Pre-existing recurring subscription state (recurring scenarios only). */
enum class SubscriptionState { NONE, ACTIVE, PENDING }
/**
* The knobs that define what each endpoint returns. [apply] registers the corresponding handlers on
* the shared responder (overriding the [org.thoughtcrime.securesms.testing.InAppPaymentsRule]
* defaults, since later registrations win) and sets the permit outcome.
*/
data class WorldState(
val permit: PermitOutcome = PermitOutcome.SUCCESS,
val stripe: StripeOutcome = StripeOutcome.SUCCESS,
val declineCode: String = "card_declined",
val signalCreatePaymentStatus: Int = 200,
val receiptCredentialStatus: Int = 200,
val redeemStatus: Int = 200,
val existingSubscription: SubscriptionState = SubscriptionState.NONE,
/** Donation currency to use. SEPA/iDEAL require EUR. */
val currencyCode: String = "USD",
/**
* For a new recurring donation: report no active subscription until the subscription level is set
* during setup, then report an active one. This lets the checkout show "Continue" (new subscriber)
* while still letting the recurring receipt-credential context job see an active subscription to redeem.
*/
val recurringActivatesAfterSetup: Boolean = false
) {
fun apply() {
val currency = Currency.getInstance(currencyCode)
SignalStore.inAppPayments.setOneTimeCurrency(currency)
SignalStore.inAppPayments.setRecurringDonationCurrency(currency)
when (permit) {
PermitOutcome.SUCCESS -> succeedDonationPermitAcquisition()
PermitOutcome.FAILURE -> failDonationPermitAcquisition()
}
val responder = MockEndpoints.responder
// Signal-service PayPal endpoints (harmless for non-PayPal specs; distinct paths).
responder.registerPayPalHappyPath()
// Stripe HTTP edge.
responder.registerStripeHappyPath()
when (stripe) {
StripeOutcome.SUCCESS -> Unit
StripeOutcome.DECLINE -> responder.register({ it.method == "POST" && (it.path.endsWith("/confirm") || it.path.contains("/payment_methods")) }) {
failure(402, StripeResponses.declineError(declineCode))
}
StripeOutcome.FAILURE -> responder.register({ it.method == "POST" && it.path.endsWith("/confirm") }) {
failure(402, StripeResponses.failureError("card_not_supported"))
}
StripeOutcome.REQUIRES_3DS -> responder.register({ it.method == "POST" && it.path.endsWith("/confirm") }) {
ok(StripeResponses.confirm3dsRequired())
}
}
// Signal-service edge overrides.
if (signalCreatePaymentStatus != 200) {
responder.register({ it.method == "POST" && (it.path.contains("/create_payment_method") || it.path.endsWith("/boost/create")) }) {
failure(signalCreatePaymentStatus)
}
}
if (receiptCredentialStatus != 200) {
responder.register({ it.method == "POST" && it.path.contains("/receipt_credentials") }) { failure(receiptCredentialStatus) }
}
if (redeemStatus != 200) {
responder.register({ it.method == "POST" && it.path.contains("/redeem-receipt") }) { failure(redeemStatus) }
}
when (existingSubscription) {
SubscriptionState.NONE -> Unit
SubscriptionState.ACTIVE -> registerSubscription(status = "active", active = true)
SubscriptionState.PENDING -> registerSubscription(status = "incomplete", active = false)
}
if (recurringActivatesAfterSetup) {
val activated = AtomicBoolean(false)
responder.register({ it.method == "PUT" && it.path.contains("/level/") }) {
activated.set(true)
ok()
}
responder.register({ it.method == "GET" && it.path.contains("/v1/subscription/") && !it.path.contains("configuration") && !it.path.contains("bank_mandate") }) {
if (activated.get()) ok(DonationResponses.activeSubscription()) else ok(DonationResponses.emptySubscription())
}
}
}
private fun registerSubscription(status: String, active: Boolean) {
MockEndpoints.responder.register({ it.method == "GET" && it.path.contains("/v1/subscription/") && !it.path.contains("configuration") }) {
EndpointResponse(status = 200, body = DonationResponses.activeSubscription(status = status, active = active))
}
}
}
/** The expected terminal result of driving a spec. */
sealed interface ExpectedOutcome {
/** An error dialog with [titleRes] (and optionally [messageRes]) is shown. */
data class ErrorDialog(@StringRes val titleRes: Int, @StringRes val messageRes: Int? = null) : ExpectedOutcome
/** The full lifecycle completes and a donation receipt is recorded. */
data object Redeemed : ExpectedOutcome
/**
* Payment setup succeeded and the payment moved past [org.thoughtcrime.securesms.database.InAppPaymentTable.State.CREATED]
* with no error. Used for bank-transfer methods (SEPA/iDEAL), which don't redeem immediately —
* they land in a pending / awaiting-authorization state.
*/
data object PaymentSubmitted : ExpectedOutcome
}
@@ -0,0 +1,200 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.conversation.v2
import android.app.Activity
import android.app.Application
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.view.View
import androidx.fragment.app.FragmentActivity
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import assertk.assertThat
import assertk.assertions.isEqualTo
import assertk.assertions.isInstanceOf
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.signal.core.util.getSerializableCompat
import org.signal.donations.InAppPaymentType
import org.thoughtcrime.securesms.MainActivity
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonateToSignalFragment
import org.thoughtcrime.securesms.conversation.ConversationIntents
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.testing.InAppPaymentsRule
import org.thoughtcrime.securesms.testing.SignalActivityRule
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
/**
* Entry-path coverage for the inline chat donation prompt: the Release-Channel "donation request"
* conversation item that renders a "Donate" action button and opens the one-time checkout.
*
* The conversation is hosted by [MainActivity], so this drives it the same way
* [org.thoughtcrime.securesms.main.MainNavigationLaunchTest] does — a manual lifecycle-callback launch
* plus direct view interaction, since ActivityScenario/Espresso misbehave for the conversation
* custom-action intent.
*
* Unlike the megaphone and subscriber-upsell paths, this prompt is intentionally NOT gated by sustainer
* status; [sustainer_donatePromptStillShown] documents that.
*/
@RunWith(AndroidJUnit4::class)
class InlineDonationPromptTest {
@get:Rule
val harness = SignalActivityRule()
@get:Rule
val iapRule = InAppPaymentsRule()
private val context: Context get() = InstrumentationRegistry.getInstrumentation().targetContext
@Before
fun setUp() {
SignalStore.inAppPayments.setLastEndOfPeriod(0L)
SignalDatabase.recipients.setBadges(Recipient.self().id, emptyList())
Recipient.self().fresh()
}
@Test
fun nonSustainer_donatePromptOpensOneTimeCheckout() {
seedDonationRequest()
assertDonatePromptOpensCheckout()
}
@Test
fun sustainer_donatePromptStillShown() {
SignalStore.inAppPayments.setLastEndOfPeriod(TimeUnit.MILLISECONDS.toSeconds(System.currentTimeMillis()) + TimeUnit.DAYS.toSeconds(30))
SignalDatabase.recipients.setBadges(Recipient.self().id, listOf(donorBadge()))
Recipient.self().fresh()
seedDonationRequest()
assertDonatePromptOpensCheckout()
}
private fun assertDonatePromptOpensCheckout() {
val activity = launchConversation()
val donateButton = awaitView(activity, R.id.conversation_update_action)
runOnMainSync { donateButton.performClick() }
val dialog = await(description = "one-time checkout dialog") {
activity.supportFragmentManager.findFragmentByTag(ONE_TIME_NAV_TAG)
}
assertThat(dialog).isInstanceOf(DonateToSignalFragment.Dialog::class)
val type = dialog.requireArguments().getSerializableCompat(DonateToSignalFragment.Dialog.ARG, InAppPaymentType::class.java)
assertThat(type).isEqualTo(InAppPaymentType.ONE_TIME_DONATION)
}
private fun seedDonationRequest() {
val recipientId: RecipientId = SignalDatabase.recipients.insertReleaseChannelRecipient()
SignalStore.releaseChannel.setReleaseChannelRecipientId(recipientId)
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(Recipient.resolved(recipientId))
SignalDatabase.messages.insertBoostRequestMessage(recipientId, threadId)
}
private fun launchConversation(): MainActivity {
val recipientId = SignalStore.releaseChannel.releaseChannelRecipientId!!
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(Recipient.resolved(recipientId))
val conversationIntent = ConversationIntents.createBuilder(context, recipientId, threadId).blockingGet().build()
val launchIntent = Intent(context, MainActivity::class.java).apply {
action = ConversationIntents.ACTION
putExtras(conversationIntent)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
val app = context.applicationContext as Application
val resumed = CountDownLatch(1)
val holder = arrayOfNulls<MainActivity>(1)
val callbacks = object : Application.ActivityLifecycleCallbacks {
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) = Unit
// Capture on resume rather than create: a CLEAR_TOP|SINGLE_TOP conversation intent may be
// delivered to an existing MainActivity via onNewIntent, which never fires onActivityCreated.
override fun onActivityResumed(activity: Activity) {
if (activity is MainActivity) {
holder[0] = activity
resumed.countDown()
}
}
override fun onActivityStarted(activity: Activity) = Unit
override fun onActivityPaused(activity: Activity) = Unit
override fun onActivityStopped(activity: Activity) = Unit
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) = Unit
override fun onActivityDestroyed(activity: Activity) = Unit
}
app.registerActivityLifecycleCallbacks(callbacks)
app.startActivity(launchIntent)
if (!resumed.await(15, TimeUnit.SECONDS)) {
app.unregisterActivityLifecycleCallbacks(callbacks)
error("MainActivity did not reach RESUMED within 15s")
}
app.unregisterActivityLifecycleCallbacks(callbacks)
return holder[0] ?: error("MainActivity was not captured")
}
private fun awaitView(activity: FragmentActivity, viewId: Int): View {
return await(description = "view $viewId") {
activity.findViewById<View>(viewId)?.takeIf { it.isShown }
}
}
private fun <T> await(
timeoutMs: Long = 15_000,
pollMs: Long = 50,
description: String,
supplier: () -> T?
): T {
val deadline = System.currentTimeMillis() + timeoutMs
while (System.currentTimeMillis() < deadline) {
val result = runOnMainSync(supplier)
if (result != null) return result
Thread.sleep(pollMs)
}
error("Timed out after ${timeoutMs}ms waiting for $description")
}
private fun <T> runOnMainSync(block: () -> T): T {
var result: Result<T> = Result.failure(IllegalStateException("runOnMainSync produced no result"))
InstrumentationRegistry.getInstrumentation().runOnMainSync { result = runCatching(block) }
return result.getOrThrow()
}
private fun donorBadge(): Badge {
return Badge(
id = "test-donor-badge",
category = Badge.Category.Donor,
name = "Signal Sustainer",
description = "",
imageUrl = Uri.EMPTY,
imageDensity = "xxhdpi",
expirationTimestamp = System.currentTimeMillis() + TimeUnit.DAYS.toMillis(30),
visible = true,
duration = TimeUnit.DAYS.toMillis(30)
)
}
companion object {
private const val ONE_TIME_NAV_TAG = "one_time_nav"
}
}
@@ -3,19 +3,28 @@ package org.thoughtcrime.securesms.dependencies
import android.app.Application
import io.mockk.mockk
import io.mockk.spyk
import okhttp3.OkHttpClient
import org.signal.core.util.UptimeSleepTimer
import org.signal.core.util.billing.BillingApi
import org.signal.libsignal.net.Network
import org.signal.libsignal.zkgroup.receipts.ClientZkReceiptOperations
import org.signal.network.api.ArchiveApi
import org.thoughtcrime.securesms.push.SignalServiceNetworkAccess
import org.thoughtcrime.securesms.recipients.LiveRecipientCache
import org.thoughtcrime.securesms.testing.endpoints.DonationTestServer
import org.thoughtcrime.securesms.testing.endpoints.MockEndpoints
import org.thoughtcrime.securesms.testing.endpoints.ResponderInterceptor
import org.thoughtcrime.securesms.testing.endpoints.ResponderWebSocketConnection
import org.whispersystems.signalservice.api.SignalServiceDataStore
import org.whispersystems.signalservice.api.SignalServiceMessageSender
import org.whispersystems.signalservice.api.account.AccountApi
import org.whispersystems.signalservice.api.donations.DonationsApi
import org.whispersystems.signalservice.api.keys.KeysApi
import org.whispersystems.signalservice.api.message.MessageApi
import org.whispersystems.signalservice.api.websocket.SignalWebSocket
import org.whispersystems.signalservice.internal.configuration.SignalServiceConfiguration
import org.whispersystems.signalservice.internal.push.PushServiceSocket
import java.util.function.Supplier
import kotlin.time.Duration.Companion.seconds
/**
* Dependency provider used for instrumentation tests (aka androidTests).
@@ -45,8 +54,54 @@ class InstrumentationApplicationDependencyProvider(val application: Application,
return mockk()
}
override fun provideDonationsApi(authWebSocket: SignalWebSocket.AuthenticatedWebSocket, unauthWebSocket: SignalWebSocket.UnauthenticatedWebSocket): DonationsApi {
return mockk()
/**
* Adds the Stripe-matching [ResponderInterceptor] on top of the default client (which supplies the
* user agent + DNS), so `api.stripe.com` requests made by [org.signal.donations.StripeApi] are
* answered from the shared [MockEndpoints.responder] and never hit the real network.
*/
override fun provideOkHttpClient(): OkHttpClient {
return default.provideOkHttpClient()
.newBuilder()
.addInterceptor(ResponderInterceptor(MockEndpoints.responder))
.build()
}
/**
* Backs the real [org.whispersystems.signalservice.api.donations.DonationsApi] (and any other
* websocket API) with a [ResponderWebSocketConnection] driven by the shared [MockEndpoints.responder],
* so Signal-service requests are answered from the scenario's "world" and never hit the network.
*/
override fun provideAuthWebSocket(
signalServiceConfigurationSupplier: Supplier<SignalServiceConfiguration>,
libSignalNetworkSupplier: Supplier<Network>
): SignalWebSocket.AuthenticatedWebSocket {
return SignalWebSocket.AuthenticatedWebSocket(
connectionFactory = { ResponderWebSocketConnection(MockEndpoints.responder) },
canConnect = { true },
sleepTimer = UptimeSleepTimer(),
disconnectTimeoutMs = 15.seconds.inWholeMilliseconds
)
}
override fun provideUnauthWebSocket(
signalServiceConfigurationSupplier: Supplier<SignalServiceConfiguration>,
libSignalNetworkSupplier: Supplier<Network>
): SignalWebSocket.UnauthenticatedWebSocket {
return SignalWebSocket.UnauthenticatedWebSocket(
connectionFactory = { ResponderWebSocketConnection(MockEndpoints.responder) },
canConnect = { true },
sleepTimer = UptimeSleepTimer(),
disconnectTimeoutMs = 15.seconds.inWholeMilliseconds
)
}
/**
* Uses the test zk server's public params so credentials minted by [MockEndpoints] validate here.
* The real [ClientZkReceiptOperations.receiveReceiptCredential] validation still runs in the
* receipt-credential context job — only the params are swapped for test ones.
*/
override fun provideClientZkReceiptOperations(signalServiceConfiguration: SignalServiceConfiguration): ClientZkReceiptOperations {
return DonationTestServer.clientReceiptOperations
}
override fun provideSignalServiceMessageSender(
@@ -121,8 +121,6 @@ class BackupSubscriptionCheckJobTest {
)
)
every { AppDependencies.donationsApi.putSubscription(any()) } returns NetworkResult.Success(Unit)
insertSubscriber()
}
@@ -0,0 +1,84 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.megaphone
import androidx.test.ext.junit.runners.AndroidJUnit4
import assertk.assertThat
import assertk.assertions.isEqualTo
import assertk.assertions.isFalse
import assertk.assertions.isNull
import assertk.assertions.isTrue
import io.mockk.every
import io.mockk.mockkObject
import io.mockk.mockkStatic
import io.mockk.unmockkObject
import io.mockk.unmockkStatic
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.signal.core.util.deleteAll
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppDonations
import org.thoughtcrime.securesms.database.InAppPaymentTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.testing.InAppPaymentsRule
import org.thoughtcrime.securesms.testing.SignalActivityRule
import org.thoughtcrime.securesms.util.VersionTracker
/**
* The "user might be a sustainer" rule for the donations remote megaphone: the `standard_donate`
* conditional (via [RemoteMegaphoneRepository.getRemoteMegaphoneToShow]) suppresses the megaphone for
* existing donors and shows it otherwise.
*/
@RunWith(AndroidJUnit4::class)
class DonationMegaphoneGatingTest {
@get:Rule
val harness = SignalActivityRule()
@get:Rule
val iapRule = InAppPaymentsRule()
@Before
fun setUp() {
SignalDatabase.inAppPayments.writableDatabase.deleteAll(InAppPaymentTable.TABLE_NAME)
SignalDatabase.remoteMegaphones.debugRemoveAll()
setSelfBadges(emptyList())
// Freshly-installed test APKs report 0 days installed and no configured payment methods, both of
// which independently fail shouldShowDonateMegaphone. Fix them so the donor badge is the only
// variable across the gating tests.
mockkStatic(VersionTracker::class)
mockkObject(InAppDonations)
every { VersionTracker.getDaysSinceFirstInstalled(any()) } returns 30L
every { InAppDonations.hasAtLeastOnePaymentMethodAvailable() } returns true
}
@After
fun tearDown() {
unmockkStatic(VersionTracker::class)
unmockkObject(InAppDonations)
}
@Test
fun nonDonor_showsStandardDonateMegaphone() {
val record = donateMegaphoneRecord(conditionalId = "standard_donate")
SignalDatabase.remoteMegaphones.insert(record)
assertThat(RemoteMegaphoneRepository.getRemoteMegaphoneToShow()?.uuid).isEqualTo(record.uuid)
assertThat(RemoteMegaphoneRepository.hasRemoteMegaphoneToShow(canShowLocalDonate = true)).isTrue()
}
@Test
fun sustainer_donorBadge_suppressesStandardDonateMegaphone() {
SignalDatabase.remoteMegaphones.insert(donateMegaphoneRecord(conditionalId = "standard_donate"))
setSelfBadges(listOf(donorBadge()))
assertThat(RemoteMegaphoneRepository.getRemoteMegaphoneToShow()).isNull()
assertThat(RemoteMegaphoneRepository.hasRemoteMegaphoneToShow(canShowLocalDonate = true)).isFalse()
}
}
@@ -0,0 +1,113 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.megaphone
import android.app.Activity
import android.content.Context
import android.content.Intent
import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.fragment.app.DialogFragment
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import assertk.assertThat
import assertk.assertions.isEqualTo
import assertk.assertions.isNotNull
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.signal.core.ui.compose.theme.SignalTheme
import org.signal.core.util.getSerializableCompat
import org.signal.donations.InAppPaymentType
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.CheckoutFlowActivity
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.RemoteMegaphoneRecord
import org.thoughtcrime.securesms.testing.InAppPaymentsRule
import org.thoughtcrime.securesms.testing.SignalActivityRule
/**
* Entry-path coverage for the donations remote megaphone: the megaphone shown on the conversation list
* that carries a "Donate" action into the one-time checkout flow.
*
* The megaphone renders through [MegaphoneComponent], so this renders that composable directly (rather
* than launching the full [org.thoughtcrime.securesms.MainActivity]) and asserts that tapping Donate
* runs the production [RemoteMegaphoneRepository] action, which navigates to [CheckoutFlowActivity]
* with [InAppPaymentType.ONE_TIME_DONATION]. The sustainer gating is covered by [DonationMegaphoneGatingTest].
*/
@RunWith(AndroidJUnit4::class)
class DonationMegaphoneTest {
@get:Rule
val harness = SignalActivityRule()
@get:Rule
val iapRule = InAppPaymentsRule()
@get:Rule
val composeRule = createComposeRule()
private val context: Context get() = InstrumentationRegistry.getInstrumentation().targetContext
@Before
fun setUp() {
SignalDatabase.remoteMegaphones.debugRemoveAll()
}
@Test
fun donateMegaphone_donateClick_opensOneTimeCheckout() {
val record = donateMegaphoneRecord(conditionalId = null)
SignalDatabase.remoteMegaphones.insert(record)
val controller = RecordingMegaphoneActionController()
composeRule.setContent {
SignalTheme {
MegaphoneComponent(buildDonateMegaphone(record), controller)
}
}
composeRule.onNodeWithText(record.primaryActionText!!).performClick()
val intent = controller.navigationIntent
assertThat(intent).isNotNull()
assertThat(intent!!.component?.className).isEqualTo(CheckoutFlowActivity::class.java.name)
val type = intent.extras!!.getSerializableCompat(CheckoutFlowActivity.ARG_IN_APP_PAYMENT_TYPE, InAppPaymentType::class.java)
assertThat(type).isEqualTo(InAppPaymentType.ONE_TIME_DONATION)
}
/** Mirrors [Megaphones.buildRemoteMegaphone]: wires the primary button to the real repository action. */
private fun buildDonateMegaphone(record: RemoteMegaphoneRecord): Megaphone {
return Megaphone.Builder(Megaphones.Event.REMOTE_MEGAPHONE, Megaphone.Style.BASIC)
.setTitle(record.title)
.setBody(record.body)
.setActionButton(record.primaryActionText!!) { _, controller ->
RemoteMegaphoneRepository.getAction(record.primaryActionId!!).run(context, controller, record)
}
.build()
}
private class RecordingMegaphoneActionController : MegaphoneActionController {
var navigationIntent: Intent? = null
private set
override fun onMegaphoneNavigationRequested(intent: Intent) {
navigationIntent = intent
}
override fun onMegaphoneNavigationRequested(intent: Intent, requestCode: Int) {
navigationIntent = intent
}
override fun onMegaphoneToastRequested(string: String) = Unit
override fun getMegaphoneActivity(): Activity = throw UnsupportedOperationException("not used")
override fun onMegaphoneSnooze(event: Megaphones.Event) = Unit
override fun onMegaphoneCompleted(event: Megaphones.Event) = Unit
override fun onMegaphoneDialogFragmentRequested(dialogFragment: DialogFragment) = Unit
}
}
@@ -0,0 +1,54 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.megaphone
import android.net.Uri
import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.RemoteMegaphoneRecord
import org.thoughtcrime.securesms.recipients.Recipient
import java.util.UUID
import java.util.concurrent.TimeUnit
fun donateMegaphoneRecord(conditionalId: String?): RemoteMegaphoneRecord {
val now = System.currentTimeMillis()
return RemoteMegaphoneRecord(
priority = 100,
uuid = UUID.randomUUID().toString(),
countries = null,
minimumVersion = 1,
doNotShowBefore = now - TimeUnit.DAYS.toMillis(2),
doNotShowAfter = now + TimeUnit.DAYS.toMillis(28),
showForNumberOfDays = 30,
conditionalId = conditionalId,
primaryActionId = RemoteMegaphoneRecord.ActionId.DONATE,
secondaryActionId = RemoteMegaphoneRecord.ActionId.SNOOZE,
imageUrl = null,
title = "Donate Test",
body = "Donate body test.",
primaryActionText = "Donate",
secondaryActionText = "Snooze"
)
}
fun donorBadge(): Badge {
return Badge(
id = "test-donor-badge",
category = Badge.Category.Donor,
name = "Signal Sustainer",
description = "",
imageUrl = Uri.EMPTY,
imageDensity = "xxhdpi",
expirationTimestamp = System.currentTimeMillis() + TimeUnit.DAYS.toMillis(30),
visible = true,
duration = TimeUnit.DAYS.toMillis(30)
)
}
fun setSelfBadges(badges: List<Badge>) {
SignalDatabase.recipients.setBadges(Recipient.self().id, badges)
Recipient.self().fresh()
}
@@ -26,6 +26,7 @@ import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.testing.MessageContentFuzzer
import org.thoughtcrime.securesms.testing.SignalActivityRule
import org.thoughtcrime.securesms.util.MediaUtil
import org.thoughtcrime.securesms.util.TextSecurePreferences
import org.whispersystems.signalservice.api.push.SignalServiceAddress
import org.whispersystems.signalservice.internal.push.AddressableMessage
import org.whispersystems.signalservice.internal.push.AttachmentPointer
@@ -51,6 +52,14 @@ class SyncMessageProcessorTest_attachmentBackfill {
originalDeviceId = SignalStore.account.deviceId
// Make this device a linked device so backfill response handling activates.
SignalStore.account.deviceId = 2
// Prevent AttachmentDownloadJob onAdded from async changing the attachment state.
TextSecurePreferences.getSharedPreferences(harness.application)
.edit()
.putStringSet(TextSecurePreferences.MEDIA_DOWNLOAD_WIFI_PREF, emptySet())
.putStringSet(TextSecurePreferences.MEDIA_DOWNLOAD_MOBILE_PREF, emptySet())
.putStringSet(TextSecurePreferences.MEDIA_DOWNLOAD_ROAMING_PREF, emptySet())
.commit()
}
@After
@@ -7,43 +7,90 @@ package org.thoughtcrime.securesms.testing
import androidx.test.platform.app.InstrumentationRegistry
import io.mockk.every
import org.json.JSONObject
import org.junit.rules.ExternalResource
import org.signal.core.util.JsonUtils
import org.signal.network.NetworkResult
import org.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.thoughtcrime.securesms.testing.endpoints.DonationResponses
import org.thoughtcrime.securesms.testing.endpoints.DonationTestServer
import org.thoughtcrime.securesms.testing.endpoints.EndpointResponse
import org.thoughtcrime.securesms.testing.endpoints.MockEndpoints
import org.thoughtcrime.securesms.testing.endpoints.delete
import org.thoughtcrime.securesms.testing.endpoints.get
import org.thoughtcrime.securesms.testing.endpoints.ok
import org.thoughtcrime.securesms.testing.endpoints.post
import org.thoughtcrime.securesms.testing.endpoints.put
import org.whispersystems.signalservice.internal.push.WhoAmIResponse
/**
* Sets up some common infrastructure for on-device InAppPayment testing
* Sets up common infrastructure for on-device InAppPayment testing.
*
* The Signal-service edge is driven through the shared [MockEndpoints.responder] (the real
* [org.whispersystems.signalservice.api.donations.DonationsApi] runs against these handlers), while
* account/archive lookups that are not websocket-backed here stay mocked via mockk. A scenario can
* override any default by registering a more specific handler after this rule runs (last-registered
* wins).
*/
class InAppPaymentsRule : ExternalResource() {
override fun before() {
initialiseConfigurationResponse()
initialisePutSubscription()
MockEndpoints.responder.clear()
registerDonationServiceDefaults()
initialiseSetArchiveBackupId()
initialiseSetAccountAttributes()
initialiseAccountAndSubscriptionLookups()
initialiseAccountLookups()
}
private fun initialiseConfigurationResponse() {
val assets = InstrumentationRegistry.getInstrumentation().context.resources.assets
val response = assets.open("inAppPaymentsTests/configuration.json").use { stream ->
NetworkResult.Success(JsonUtils.fromJson(stream, SubscriptionsConfiguration::class.java))
}
AppDependencies.donationsApi.apply {
every { getDonationsConfiguration(any()) } returns response
}
override fun after() {
MockEndpoints.responder.clear()
}
private fun initialisePutSubscription() {
AppDependencies.donationsApi.apply {
every { putSubscription(any()) } returns NetworkResult.Success(Unit)
every { createSubscriber(any(), any()) } returns NetworkResult.Success(Unit)
/**
* Default happy-path responses for the Signal donations endpoints. Registered generic-to-specific
* so more specific paths registered later win.
*/
private fun registerDonationServiceDefaults() {
val responder = MockEndpoints.responder
// Subscriber lifecycle: create/put/update-level (PUT), cancel (DELETE) all succeed by default.
responder.put("/v1/subscription/") { ok() }
responder.delete("/v1/subscription/") { ok() }
// getSubscription defaults to "no active subscription".
responder.get("/v1/subscription/") { ok(DonationResponses.emptySubscription()) }
// Donations configuration (registered after the generic GET so it wins for its path).
val configuration = InstrumentationRegistry.getInstrumentation().context.resources.assets
.open("inAppPaymentsTests/configuration.json")
.use { it.readBytes().decodeToString() }
responder.get("/v1/subscription/configuration") { ok(configuration) }
// SEPA bank-transfer mandate text (registered after the generic GET so it wins for its path).
responder.get("/v1/subscription/bank_mandate/") { ok(DonationResponses.bankMandate()) }
// One-time (boost) payment intent.
responder.post("/v1/subscription/boost/create") { ok(DonationResponses.stripeClientSecret()) }
// Recurring payment method setup + default payment method + level are keyed by subscriber id in
// the middle of the path, so match on a distinctive suffix.
responder.register({ it.method == "POST" && it.path.contains("/create_payment_method") && !it.path.contains("paypal") }) {
ok(DonationResponses.setupIntentClientSecret())
}
responder.register({ it.method == "POST" && it.path.contains("/default_payment_method") }) { ok() }
// Receipt credential submission (boost + recurring) — mint a valid credential from the client's request.
responder.register({ it.method == "POST" && it.path.contains("/receipt_credentials") }) { request ->
mintReceiptCredential(request.bodyString)
}
// Redemption.
responder.post("/v1/donation/redeem-receipt") { ok() }
}
private fun mintReceiptCredential(requestBody: String): EndpointResponse {
val requestBase64 = JSONObject(requestBody).getString("receiptCredentialRequest")
val minted = DonationTestServer.issueReceiptCredential(requestBase64)
return ok(DonationResponses.receiptCredential(minted))
}
private fun initialiseSetArchiveBackupId() {
@@ -59,23 +106,12 @@ class InAppPaymentsRule : ExternalResource() {
}
/**
* 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.
* whoAmI is served by the (still mocked) account API rather than the websocket responder, so
* background jobs that resolve the account during a test hit a handled path instead of throwing.
*/
private fun initialiseAccountAndSubscriptionLookups() {
private fun initialiseAccountLookups() {
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))
}
}
}
@@ -16,6 +16,7 @@ import org.signal.benchmark.setup.Harness
import org.signal.benchmark.setup.OtherClient
import org.signal.core.util.ThreadUtil
import org.signal.core.util.logging.Log
import org.signal.network.websocket.WebSocketRequestMessage
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.TestDbUtils
import org.thoughtcrime.securesms.dependencies.AppDependencies
@@ -23,7 +24,6 @@ import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.recipients.Recipient
import org.whispersystems.signalservice.internal.push.Envelope
import org.whispersystems.signalservice.internal.websocket.BenchmarkWebSocketConnection
import org.signal.network.websocket.WebSocketRequestMessage
import kotlin.random.Random
/**
@@ -78,7 +78,6 @@ class NoOpJob(parameters: Parameters) : Job(parameters) {
StoryOnboardingDownloadJob.KEY
)
fun replaceFactories(factories: Map<String, Job.Factory<*>>): Map<String, Job.Factory<*>> =
factories.mapValues { if (it.key in STARTUP_NETWORK_JOB_KEYS) Factory() else it.value }
fun replaceFactories(factories: Map<String, Job.Factory<*>>): Map<String, Job.Factory<*>> = factories.mapValues { if (it.key in STARTUP_NETWORK_JOB_KEYS) Factory() else it.value }
}
}
@@ -8,10 +8,10 @@ package org.whispersystems.signalservice.internal.websocket
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.subjects.BehaviorSubject
import org.signal.core.util.JsonUtils
import org.signal.network.websocket.WebSocketRequestMessage
import org.signal.network.websocket.WebSocketResponseMessage
import org.signal.network.websocket.WebsocketResponse
import org.signal.core.util.JsonUtils
import org.thoughtcrime.securesms.util.SignalTrace
import org.whispersystems.signalservice.api.websocket.WebSocketConnectionState
import org.whispersystems.signalservice.internal.push.SendMessageResponse
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -76,6 +76,7 @@ import androidx.lifecycle.createSavedStateHandle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
import androidx.navigation3.ui.NavDisplay
@@ -673,23 +674,23 @@ class MainActivity :
primaryContent = {
when (mainNavigationState.currentListLocation) {
MainNavigationListLocation.CHATS, MainNavigationListLocation.ARCHIVE -> {
NavDisplay(
NavDisplay<NavKey>(
backStack = mainNavigationViewModel.chatsBackStackEntries,
onBack = { mainNavigationViewModel.popChatsDetailLocation() },
transitionSpec = TransitionSpecs.HorizontalSlide.transitionSpec,
popTransitionSpec = TransitionSpecs.HorizontalSlide.popTransitionSpec,
predictivePopTransitionSpec = TransitionSpecs.HorizontalSlide.predictivePopTransitionSpec,
transitionSpec = { TransitionSpecs.HorizontalSlide.transitionSpec },
popTransitionSpec = { TransitionSpecs.HorizontalSlide.popTransitionSpec },
predictivePopTransitionSpec = { TransitionSpecs.HorizontalSlide.predictivePopTransitionSpec },
entryProvider = entryProvider { chatsNavEntries(convoTransitionState) }
)
}
MainNavigationListLocation.CALLS -> {
NavDisplay(
NavDisplay<NavKey>(
backStack = mainNavigationViewModel.callsBackStackEntries,
onBack = { mainNavigationViewModel.popCallsDetailLocation() },
transitionSpec = TransitionSpecs.HorizontalSlide.transitionSpec,
popTransitionSpec = TransitionSpecs.HorizontalSlide.popTransitionSpec,
predictivePopTransitionSpec = TransitionSpecs.HorizontalSlide.predictivePopTransitionSpec,
transitionSpec = { TransitionSpecs.HorizontalSlide.transitionSpec },
popTransitionSpec = { TransitionSpecs.HorizontalSlide.popTransitionSpec },
predictivePopTransitionSpec = { TransitionSpecs.HorizontalSlide.predictivePopTransitionSpec },
entryDecorators = listOf(
rememberSaveableStateHolderNavEntryDecorator(),
rememberViewModelStoreNavEntryDecorator()
@@ -699,12 +700,12 @@ class MainActivity :
}
MainNavigationListLocation.STORIES -> {
NavDisplay(
NavDisplay<NavKey>(
backStack = mainNavigationViewModel.storiesBackStackEntries,
onBack = { mainNavigationViewModel.popStoriesDetailLocation() },
transitionSpec = TransitionSpecs.HorizontalSlide.transitionSpec,
popTransitionSpec = TransitionSpecs.HorizontalSlide.popTransitionSpec,
predictivePopTransitionSpec = TransitionSpecs.HorizontalSlide.predictivePopTransitionSpec,
transitionSpec = { TransitionSpecs.HorizontalSlide.transitionSpec },
popTransitionSpec = { TransitionSpecs.HorizontalSlide.popTransitionSpec },
predictivePopTransitionSpec = { TransitionSpecs.HorizontalSlide.predictivePopTransitionSpec },
entryDecorators = listOf(
rememberSaveableStateHolderNavEntryDecorator(),
rememberViewModelStoreNavEntryDecorator()
@@ -255,6 +255,9 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
}
private Intent getTransferOrRestoreIntent() {
if (Environment.USE_NEW_REGISTRATION) {
return org.signal.registration.RegistrationActivity.createIntent(this, MainActivity.clearTop(this));
}
Intent intent = RestoreActivity.getRestoreIntent(this);
return getRoutedIntent(intent, MainActivity.clearTop(this));
}
@@ -79,7 +79,13 @@ class SystemContactsEntrypointViewModel : ViewModel() {
}
private fun getDestinationForSyncAdapter(context: Context, intent: Intent): DestinationAndBody {
context.contentResolver.query(intent.data!!, null, null, null, null).use { cursor ->
val uri = intent.data
if (uri == null || uri.authority != ContactsContract.AUTHORITY) {
Log.w(TAG, "Ignoring content URI with an unexpected authority.")
return DestinationAndBody("", "")
}
context.contentResolver.query(uri, null, null, null, null).use { cursor ->
if (cursor != null && cursor.moveToNext()) {
return DestinationAndBody(cursor.getString(cursor.getColumnIndexOrThrow(ContactsContract.RawContacts.Data.DATA1)), "")
}
@@ -102,6 +102,7 @@ import org.thoughtcrime.securesms.database.KeyValueDatabase
import org.thoughtcrime.securesms.database.KyberPreKeyTable
import org.thoughtcrime.securesms.database.OneTimePreKeyTable
import org.thoughtcrime.securesms.database.SearchTable
import org.thoughtcrime.securesms.database.SessionTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.SignedPreKeyTable
import org.thoughtcrime.securesms.database.StickerTable
@@ -1126,7 +1127,7 @@ object BackupRepository {
}
return frameReader.use { reader ->
import(reader, selfData, cancellationSignal = { false })
import(reader, selfData, backupMode = BackupMode.LOCAL, cancellationSignal = { false })
}
}
@@ -1157,7 +1158,7 @@ object BackupRepository {
}
return frameReader.use { reader ->
import(reader, selfData, cancellationSignal)
import(reader, selfData, backupMode = BackupMode.REMOTE, cancellationSignal = cancellationSignal)
}
} catch (e: IOException) {
Log.w(TAG, "Unable to restore signal backup", e)
@@ -1185,7 +1186,7 @@ object BackupRepository {
)
return frameReader.use { reader ->
import(reader, selfData, cancellationSignal)
import(reader, selfData, backupMode = BackupMode.LINK_SYNC, cancellationSignal = cancellationSignal)
}
}
@@ -1211,7 +1212,7 @@ object BackupRepository {
}
return frameReader.use { reader ->
import(reader, selfData, cancellationSignal)
import(reader, selfData, backupMode = BackupMode.REMOTE, cancellationSignal = cancellationSignal)
}
}
@@ -1227,13 +1228,14 @@ object BackupRepository {
val frameReader = PlainTextBackupReader(inputStreamFactory(), length)
return frameReader.use { reader ->
import(reader, selfData, cancellationSignal)
import(reader, selfData, backupMode = BackupMode.PLAINTEXT_EXPORT, cancellationSignal = cancellationSignal)
}
}
private fun import(
frameReader: BackupImportReader,
selfData: SelfData,
backupMode: BackupMode,
cancellationSignal: () -> Boolean
): ImportResult {
val stopwatch = Stopwatch("import")
@@ -1291,7 +1293,16 @@ object BackupRepository {
}
Log.d(TAG, "[import] --- Recreating all tables ---")
val skipTables = setOf(KyberPreKeyTable.TABLE_NAME, OneTimePreKeyTable.TABLE_NAME, SignedPreKeyTable.TABLE_NAME)
val skipTables = buildSet {
add(KyberPreKeyTable.TABLE_NAME)
add(OneTimePreKeyTable.TABLE_NAME)
add(SignedPreKeyTable.TABLE_NAME)
// Preserve the session established with the primary during linking
if (backupMode.isLinkAndSync) {
add(SessionTable.TABLE_NAME)
}
}
val tableMetadata = SignalDatabase.rawDatabase.getAllTableDefinitions().filter { !it.name.startsWith(SearchTable.FTS_TABLE_NAME + "_") }
for (table in tableMetadata) {
if (skipTables.contains(table.name)) {
@@ -1664,6 +1675,19 @@ object BackupRepository {
}
}
/**
* Stores the remote backup's last-modified time in [BackupValues.lastBackupTime], (404/401 clear it to 0).
*/
fun refreshBackupFileTimestamp(): NetworkResult<ZonedDateTime> {
return getBackupFileLastModified().also { result ->
when (result) {
is NetworkResult.Success -> SignalStore.backup.lastBackupTime = result.result.toMillis()
is NetworkResult.StatusCodeError if (result.code == 404 || result.code == 401) -> SignalStore.backup.lastBackupTime = 0L
else -> Log.w(TAG, "Failed to refresh last backup time from remote: ${result::class.simpleName}")
}
}
}
/**
* Returns an object with details about the remote backup state.
*/
@@ -6,6 +6,7 @@
package org.thoughtcrime.securesms.backup.v2
import org.signal.core.util.LongSerializer
import org.signal.libsignal.zkgroup.backups.BackupLevel
/**
* Serializable enum value for what we think a user's current backup tier is.
@@ -18,6 +19,13 @@ enum class MessageBackupTier(val value: Int) {
FREE(0),
PAID(1);
fun toBackupLevel(): Long {
return when (this) {
FREE -> BackupLevel.FREE.value.toLong()
PAID -> BackupLevel.PAID.value.toLong()
}
}
companion object Serializer : LongSerializer<MessageBackupTier?> {
override fun serialize(data: MessageBackupTier?): Long {
return data?.value?.toLong() ?: -1
@@ -26,5 +34,13 @@ enum class MessageBackupTier(val value: Int) {
override fun deserialize(data: Long): MessageBackupTier? {
return entries.firstOrNull { it.value == data.toInt() }
}
fun fromBackupLevel(backupLevel: Long?): MessageBackupTier? {
return when (backupLevel) {
BackupLevel.FREE.value.toLong() -> FREE
BackupLevel.PAID.value.toLong() -> PAID
else -> null
}
}
}
}
@@ -26,6 +26,7 @@ import org.signal.core.util.toJson
import org.signal.libsignal.crypto.Aes256Ctr32
import org.thoughtcrime.securesms.backup.LocalExportProgress
import org.thoughtcrime.securesms.backup.v2.BackupRepository
import org.thoughtcrime.securesms.backup.v2.ImportResult
import org.thoughtcrime.securesms.database.AttachmentTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.keyvalue.SignalStore
@@ -269,13 +270,18 @@ object LocalArchiver {
val mainStreamLength = snapshotFileSystem.mainLength() ?: return ArchiveResult.failure(RestoreFailure.MainStream)
BackupRepository.importLocal(
val importResult = BackupRepository.importLocal(
mainStreamFactory = { snapshotFileSystem.mainInputStream()!! },
mainStreamLength = mainStreamLength,
selfData = selfData,
backupId = backupId,
messageBackupKey = messageBackupKey
)
if (importResult is ImportResult.Failure) {
Log.w(TAG, "Local backup import failed")
return RestoreResult.failure(RestoreFailure.ImportFailed)
}
} finally {
metadataStream?.close()
}
@@ -350,6 +356,7 @@ object LocalArchiver {
data object MainStream : RestoreFailure
data object Cancelled : RestoreFailure
data object BackupIdMissing : RestoreFailure
data object ImportFailed : RestoreFailure
data class VersionMismatch(val backupVersion: Int, val supportedVersion: Int) : RestoreFailure
}
@@ -16,7 +16,6 @@ import org.signal.core.models.database.AttachmentId
import org.signal.core.util.UuidUtil
import org.signal.core.util.logging.Log
import org.signal.core.util.toByteArray
import org.signal.libsignal.zkgroup.backups.BackupLevel
import org.signal.mediasend.SentMediaQuality
import org.thoughtcrime.securesms.backup.v2.ExportState
import org.thoughtcrime.securesms.backup.v2.ImportState
@@ -127,7 +126,7 @@ object AccountDataArchiveProcessor {
hasCompletedUsernameOnboarding = signalStore.uiHintValues.hasCompletedUsernameOnboarding(),
customChatColors = db.chatColorsTable.getSavedChatColors().toRemoteChatColors().also { colors -> exportState.customChatColorIds.addAll(colors.map { it.id }) },
optimizeOnDeviceStorage = signalStore.backupValues.optimizeStorage && signalStore.backupValues.backupTier == MessageBackupTier.PAID,
backupTier = signalStore.backupValues.backupTier.toRemoteBackupTier(),
backupTier = signalStore.backupValues.backupTier?.toBackupLevel(),
defaultSentMediaQuality = signalStore.settingsValues.sentMediaQuality.toRemoteSentMediaQuality(),
autoDownloadSettings = AccountData.AutoDownloadSettings(
images = getRemoteAutoDownloadOption("image", mobileAutoDownload, wifiAutoDownload),
@@ -277,7 +276,7 @@ object AccountDataArchiveProcessor {
SignalStore.story.userHasSeenGroupStoryEducationSheet = settings.hasSeenGroupStoryEducationSheet
SignalStore.story.viewedReceiptsEnabled = settings.storyViewReceiptsEnabled ?: settings.readReceipts
SignalStore.backup.optimizeStorage = settings.optimizeOnDeviceStorage
SignalStore.backup.backupTier = settings.backupTier?.toLocalBackupTier()
SignalStore.backup.backupTier = MessageBackupTier.fromBackupLevel(settings.backupTier)
SignalStore.settings.sentMediaQuality = settings.defaultSentMediaQuality.toLocalSentMediaQuality()
SignalStore.settings.setTheme(settings.appTheme.toLocalTheme())
SignalStore.settings.setCallDataMode(settings.callsUseLessDataSetting.toLocalCallDataMode())
@@ -456,22 +455,6 @@ object AccountDataArchiveProcessor {
}
}
private fun MessageBackupTier?.toRemoteBackupTier(): Long? {
return when (this) {
MessageBackupTier.FREE -> BackupLevel.FREE.value.toLong()
MessageBackupTier.PAID -> BackupLevel.PAID.value.toLong()
null -> null
}
}
private fun Long?.toLocalBackupTier(): MessageBackupTier? {
return when (this) {
BackupLevel.FREE.value.toLong() -> MessageBackupTier.FREE
BackupLevel.PAID.value.toLong() -> MessageBackupTier.PAID
else -> null
}
}
private fun SentMediaQuality.toRemoteSentMediaQuality(): AccountData.SentMediaQuality {
return when (this) {
SentMediaQuality.STANDARD -> AccountData.SentMediaQuality.STANDARD
@@ -110,6 +110,9 @@ object CallLinks {
} catch (_: UnsupportedEncodingException) {
Log.w(TAG, "Invalid url: $url")
return null
} catch (_: IllegalArgumentException) {
Log.w(TAG, "Invalid url: $url")
return null
}
val key = fragmentQuery[ROOT_KEY]
@@ -48,11 +48,15 @@ class SignalProgressDialog private constructor(
}
fun hide() {
dialog.hide()
if (dialog.window?.decorView?.isAttachedToWindow == true) {
dialog.hide()
}
}
fun dismiss() {
dialog.dismiss()
if (dialog.window?.decorView?.isAttachedToWindow == true) {
dialog.dismiss()
}
}
companion object {
@@ -90,7 +90,11 @@ class AppSettingsActivity : DSLSettingsActivity(), GooglePayComponent {
is AppSettingsRoute.BackupsRoute.Backups -> AppSettingsFragmentDirections.actionDirectToBackupsSettingsFragment().setLaunchCheckoutFlow(appSettingsRoute.launchCheckoutFlow)
AppSettingsRoute.Invite -> AppSettingsFragmentDirections.actionDirectToInviteFragment()
AppSettingsRoute.DataAndStorageRoute.DataAndStorage -> AppSettingsFragmentDirections.actionDirectToStoragePreferenceFragment()
AppSettingsRoute.AccountRoute.Account -> AppSettingsFragmentDirections.actionDirectToAccountSettingsFragment()
AppSettingsRoute.AccountRoute.Account -> if (SignalStore.account.isPrimaryDevice) {
AppSettingsFragmentDirections.actionDirectToAccountSettingsFragment()
} else {
AppSettingsFragmentDirections.actionDirectToLinkedDeviceAccountSettingsFragment()
}
else -> error("Unsupported start location: ${appSettingsRoute?.javaClass?.name}")
}
}
@@ -76,6 +76,7 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaym
import org.thoughtcrime.securesms.components.settings.app.subscription.completed.InAppPaymentsBottomSheetDelegate
import org.thoughtcrime.securesms.compose.rememberStatusBarColorNestedScrollModifier
import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.profiles.ProfileName
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.CommunicationActions
@@ -96,7 +97,11 @@ class AppSettingsFragment : ComposeFragment(), Callbacks {
appSettingsRouter.currentRoute.collect { route ->
when (route) {
is AppSettingsRoute.BackupsRoute.Remote -> findNavController().safeNavigate(R.id.action_appSettingsFragment_to_remoteBackupsSettingsFragment)
is AppSettingsRoute.AccountRoute.Account -> findNavController().safeNavigate(R.id.action_appSettingsFragment_to_accountSettingsFragment)
is AppSettingsRoute.AccountRoute.Account -> if (SignalStore.account.isPrimaryDevice) {
findNavController().safeNavigate(R.id.action_appSettingsFragment_to_accountSettingsFragment)
} else {
findNavController().safeNavigate(R.id.action_appSettingsFragment_to_linkedDeviceAccountSettingsFragment)
}
is AppSettingsRoute.LinkDeviceRoute.LinkDevice -> findNavController().safeNavigate(R.id.action_appSettingsFragment_to_linkDeviceFragment)
is AppSettingsRoute.DonationsRoute.Donations -> findNavController().safeNavigate(R.id.action_appSettingsFragment_to_manageDonationsFragment)
is AppSettingsRoute.AppearanceRoute.Appearance -> findNavController().safeNavigate(R.id.action_appSettingsFragment_to_appearanceSettingsFragment)
@@ -292,17 +297,17 @@ private fun AppSettingsContent(
BackupFailureState.NONE -> Unit
}
if (state.isPrimaryDevice) {
item {
Rows.TextRow(
text = stringResource(R.string.AccountSettingsFragment__account),
icon = painterResource(CoreUiR.drawable.symbol_person_circle_24),
onClick = {
callbacks.navigate(AppSettingsRoute.AccountRoute.Account)
}
)
}
item {
Rows.TextRow(
text = stringResource(R.string.AccountSettingsFragment__account),
icon = painterResource(CoreUiR.drawable.symbol_person_circle_24),
onClick = {
callbacks.navigate(AppSettingsRoute.AccountRoute.Account)
}
)
}
if (state.isPrimaryDevice) {
item {
Rows.TextRow(
text = stringResource(R.string.preferences__linked_devices),
@@ -412,20 +417,20 @@ private fun AppSettingsContent(
)
}
if (state.isPrimaryDevice) {
item {
Rows.TextRow(
icon = SignalIcons.Backup.imageVector,
text = stringResource(R.string.preferences_chats__backups),
onClick = {
callbacks.navigate(AppSettingsRoute.BackupsRoute.Backups())
},
onLongClick = {
callbacks.copyRemoteBackupsSubscriberIdToClipboard()
},
enabled = isRegisteredAndUpToDate
)
}
item {
Rows.TextRow(
icon = SignalIcons.Backup.imageVector,
text = stringResource(R.string.preferences_chats__backups),
onClick = {
callbacks.navigate(AppSettingsRoute.BackupsRoute.Backups())
},
onLongClick = if (state.isPrimaryDevice) {
{ callbacks.copyRemoteBackupsSubscriberIdToClipboard() }
} else {
null
},
enabled = isRegisteredAndUpToDate
)
}
item {
@@ -0,0 +1,142 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.account
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.fragment.app.FragmentManager
import org.signal.core.ui.BottomSheetUtil
import org.signal.core.ui.compose.BottomSheets
import org.signal.core.ui.compose.ComposeBottomSheetDialogFragment
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.SignalIcons
import org.signal.core.ui.compose.horizontalGutters
import org.thoughtcrime.securesms.R
/**
* Bottom sheet shown from the linked-device Account settings screen explaining what linked devices
* are and that account management lives on the primary device.
*/
class LinkedDeviceAccountLearnMoreBottomSheet : ComposeBottomSheetDialogFragment() {
override val peekHeightPercentage: Float = 0.75f
companion object {
@JvmStatic
fun show(fragmentManager: FragmentManager) {
LinkedDeviceAccountLearnMoreBottomSheet()
.show(fragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
}
}
@Composable
override fun SheetContent() {
LinkedDeviceAccountLearnMoreSheet()
}
}
@Composable
private fun LinkedDeviceAccountLearnMoreSheet() {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxWidth()
.horizontalGutters()
.padding(bottom = 48.dp)
) {
BottomSheets.Handle()
Icon(
imageVector = SignalIcons.Devices.imageVector,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier
.padding(top = 16.dp, bottom = 24.dp)
.clip(RoundedCornerShape(50))
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.12f))
.padding(horizontal = 20.dp, vertical = 6.dp)
.size(40.dp)
)
val linkedDevices = stringResource(R.string.LinkedDeviceAccountLearnMoreBottomSheet__linked_devices)
val text = stringResource(R.string.LinkedDeviceAccountLearnMoreBottomSheet__linked_devices_let_you_access)
val annotatedText = remember(text, linkedDevices) {
buildAnnotatedString {
val start = text.indexOf(linkedDevices)
if (start >= 0) {
append(text.substring(0, start))
withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) {
append(linkedDevices)
}
append(text.substring(start + linkedDevices.length))
} else {
append(text)
}
}
}
Text(
text = annotatedText,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 20.dp)
)
BulletRow(stringResource(R.string.LinkedDeviceAccountLearnMoreBottomSheet__you_can_link_a_desktop))
BulletRow(stringResource(R.string.LinkedDeviceAccountLearnMoreBottomSheet__your_primary_device_manages))
BulletRow(stringResource(R.string.LinkedDeviceAccountLearnMoreBottomSheet__some_account_settings))
}
}
@Composable
private fun BulletRow(text: String) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 12.dp),
verticalAlignment = Alignment.Top
) {
Text(
text = "\u2022",
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(end = 8.dp)
)
Text(
text = text,
style = MaterialTheme.typography.bodyLarge
)
}
}
@DayNightPreviews
@Composable
private fun LinkedDeviceAccountLearnMoreSheetPreview() {
Previews.BottomSheetContentPreview {
LinkedDeviceAccountLearnMoreSheet()
}
}
@@ -0,0 +1,29 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.account
sealed interface LinkedDeviceAccountSettingsEvent {
/** The user tapped "Learn more" on the linked-device callout. */
data object LearnMoreClicked : LinkedDeviceAccountSettingsEvent
/** The user tapped the navigation (back) icon. */
data object NavigateBackClicked : LinkedDeviceAccountSettingsEvent
/** The user tapped the "Delete app data" row. */
data object DeleteAppDataClicked : LinkedDeviceAccountSettingsEvent
/** The user confirmed the delete in the confirmation dialog. */
data object DeleteConfirmed : LinkedDeviceAccountSettingsEvent
/** The user dismissed the delete confirmation dialog. */
data object DeleteDismissed : LinkedDeviceAccountSettingsEvent
/** The fragment reported that clearing application data failed. */
data object DataWipeFailed : LinkedDeviceAccountSettingsEvent
/** The fragment has handled the current [LinkedDeviceAccountSettingsState.oneTimeEvent]. */
data object ConsumeOneTimeEvent : LinkedDeviceAccountSettingsEvent
}
@@ -0,0 +1,220 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.account
import android.widget.Toast
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.fragment.app.viewModels
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.ComposeFragment
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Dialogs
import org.signal.core.ui.compose.Dividers
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.Rows
import org.signal.core.ui.compose.Scaffolds
import org.signal.core.ui.compose.SignalIcons
import org.signal.core.ui.compose.theme.SignalTheme
import org.signal.core.util.ServiceUtil
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.app.account.LinkedDeviceAccountSettingsState.OneTimeEvent
import org.thoughtcrime.securesms.compose.rememberStatusBarColorNestedScrollModifier
import org.thoughtcrime.securesms.dependencies.AppDependencies
/**
* Account settings shown when the current device is a linked (non-primary) device. Account
* management lives on the primary device, so this screen only surfaces an informational callout
* and the ability to delete the Signal data stored on this device.
*/
class LinkedDeviceAccountSettingsFragment : ComposeFragment() {
private val viewModel: LinkedDeviceAccountSettingsViewModel by viewModels()
@Composable
override fun FragmentContent() {
val state by viewModel.state.collectAsStateWithLifecycle()
LaunchedEffect(state.oneTimeEvent) {
viewModel.onEvent(LinkedDeviceAccountSettingsEvent.ConsumeOneTimeEvent)
when (state.oneTimeEvent) {
OneTimeEvent.OpenLearnMore -> LinkedDeviceAccountLearnMoreBottomSheet.show(childFragmentManager)
OneTimeEvent.NavigateBack -> requireActivity().onBackPressedDispatcher.onBackPressed()
OneTimeEvent.WipeData -> {
if (!ServiceUtil.getActivityManager(AppDependencies.application).clearApplicationUserData()) {
viewModel.onEvent(LinkedDeviceAccountSettingsEvent.DataWipeFailed)
}
}
OneTimeEvent.DeleteFailed -> Toast.makeText(requireContext(), R.string.preferences_account_delete_all_data_failed, Toast.LENGTH_LONG).show()
null -> Unit
}
}
LinkedDeviceAccountSettingsScreen(
state = state,
onEvent = viewModel::onEvent
)
}
}
@Composable
private fun LinkedDeviceAccountSettingsScreen(
state: LinkedDeviceAccountSettingsState,
onEvent: (LinkedDeviceAccountSettingsEvent) -> Unit
) {
Scaffolds.Settings(
title = stringResource(R.string.AccountSettingsFragment__account),
onNavigationClick = { onEvent(LinkedDeviceAccountSettingsEvent.NavigateBackClicked) },
navigationIcon = SignalIcons.ArrowStart.imageVector
) { paddingValues ->
LazyColumn(
modifier = Modifier
.padding(paddingValues)
.then(rememberStatusBarColorNestedScrollModifier())
) {
item {
LinkedDeviceCallout(onLearnMoreClick = { onEvent(LinkedDeviceAccountSettingsEvent.LearnMoreClicked) })
}
item {
Dividers.Default()
}
item {
Rows.TextRow(
text = stringResource(R.string.LinkedDeviceAccountSettingsFragment__delete_app_data),
label = stringResource(R.string.LinkedDeviceAccountSettingsFragment__this_will_remove_all_data),
onClick = { onEvent(LinkedDeviceAccountSettingsEvent.DeleteAppDataClicked) }
)
}
}
}
if (state.showDeleteConfirmationDialog) {
DeleteAppDataConfirmationDialog(
onConfirm = { onEvent(LinkedDeviceAccountSettingsEvent.DeleteConfirmed) },
onDismiss = { onEvent(LinkedDeviceAccountSettingsEvent.DeleteDismissed) }
)
}
if (state.deleting) {
Dialogs.IndeterminateProgressDialog()
}
}
@Composable
private fun LinkedDeviceCallout(
onLearnMoreClick: () -> Unit
) {
Row(
modifier = Modifier
.padding(horizontal = 24.dp, vertical = 12.dp)
.fillMaxWidth()
.background(color = SignalTheme.colors.colorSurface2, shape = RoundedCornerShape(12.dp))
.padding(20.dp)
) {
Icon(
imageVector = SignalIcons.Devices.imageVector,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurface
)
Spacer(modifier = Modifier.width(24.dp))
Column {
Text(
text = stringResource(R.string.LinkedDeviceAccountSettingsFragment__this_is_a_linked_device),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringResource(R.string.LinkedDeviceAccountSettingsFragment__to_manage_your_account_settings),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(12.dp))
Buttons.Small(
onClick = onLearnMoreClick,
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.secondaryContainer, contentColor = MaterialTheme.colorScheme.onSecondaryContainer)
) {
Text(text = stringResource(R.string.LearnMoreTextView_learn_more))
}
}
}
}
@Composable
private fun DeleteAppDataConfirmationDialog(
onConfirm: () -> Unit,
onDismiss: () -> Unit
) {
Dialogs.SimpleAlertDialog(
title = stringResource(R.string.LinkedDeviceAccountSettingsFragment__delete_app_data_question),
body = stringResource(R.string.LinkedDeviceAccountSettingsFragment__this_will_delete_all_data_and_messages),
confirm = stringResource(R.string.delete),
confirmColor = MaterialTheme.colorScheme.error,
onConfirm = onConfirm,
dismiss = stringResource(android.R.string.cancel),
onDismiss = onDismiss
)
}
@DayNightPreviews
@Composable
private fun LinkedDeviceAccountSettingsScreenPreview() {
Previews.Preview {
LinkedDeviceAccountSettingsScreen(
state = LinkedDeviceAccountSettingsState(),
onEvent = {}
)
}
}
@DayNightPreviews
@Composable
private fun LinkedDeviceAccountSettingsScreenDeleteConfirmationPreview() {
Previews.Preview {
LinkedDeviceAccountSettingsScreen(
state = LinkedDeviceAccountSettingsState(showDeleteConfirmationDialog = true),
onEvent = {}
)
}
}
@DayNightPreviews
@Composable
private fun LinkedDeviceAccountSettingsScreenDeletingPreview() {
Previews.Preview {
LinkedDeviceAccountSettingsScreen(
state = LinkedDeviceAccountSettingsState(deleting = true),
onEvent = {}
)
}
}
@@ -0,0 +1,20 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.account
data class LinkedDeviceAccountSettingsState(
val showDeleteConfirmationDialog: Boolean = false,
val deleting: Boolean = false,
val oneTimeEvent: OneTimeEvent? = null
) {
/** One-time side effects the fragment must carry out (they need Android/fragment context). Consumed via [LinkedDeviceAccountSettingsEvent.ConsumeOneTimeEvent]. */
sealed interface OneTimeEvent {
data object OpenLearnMore : OneTimeEvent
data object NavigateBack : OneTimeEvent
data object WipeData : OneTimeEvent
data object DeleteFailed : OneTimeEvent
}
}
@@ -0,0 +1,58 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.account
import androidx.annotation.VisibleForTesting
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.withContext
import org.signal.core.ui.compose.EventDrivenViewModel
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.components.settings.app.account.LinkedDeviceAccountSettingsState.OneTimeEvent
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.linkdevice.LinkDeviceRepository
/**
* Drives the account settings screen shown on a linked (non-primary) device. Owns the screen state
* and, on delete, makes a best-effort attempt to unlink this device from the server before the
* fragment wipes local data.
*/
class LinkedDeviceAccountSettingsViewModel(
private val selfDeviceId: () -> Int = { SignalStore.account.deviceId },
private val removeDevice: suspend (Int) -> Boolean = LinkDeviceRepository::removeDevice
) : EventDrivenViewModel<LinkedDeviceAccountSettingsEvent>(TAG) {
companion object {
private val TAG = Log.tag(LinkedDeviceAccountSettingsViewModel::class)
}
private val _state = MutableStateFlow(LinkedDeviceAccountSettingsState())
val state: StateFlow<LinkedDeviceAccountSettingsState> = _state.asStateFlow()
override suspend fun processEvent(event: LinkedDeviceAccountSettingsEvent) {
applyEvent(_state.value, event) { _state.value = it }
}
@VisibleForTesting
suspend fun applyEvent(state: LinkedDeviceAccountSettingsState, event: LinkedDeviceAccountSettingsEvent, stateEmitter: (LinkedDeviceAccountSettingsState) -> Unit) {
when (event) {
LinkedDeviceAccountSettingsEvent.LearnMoreClicked -> stateEmitter(state.copy(oneTimeEvent = OneTimeEvent.OpenLearnMore))
LinkedDeviceAccountSettingsEvent.NavigateBackClicked -> stateEmitter(state.copy(oneTimeEvent = OneTimeEvent.NavigateBack))
LinkedDeviceAccountSettingsEvent.DeleteAppDataClicked -> stateEmitter(state.copy(showDeleteConfirmationDialog = true))
LinkedDeviceAccountSettingsEvent.DeleteDismissed -> stateEmitter(state.copy(showDeleteConfirmationDialog = false))
LinkedDeviceAccountSettingsEvent.DeleteConfirmed -> {
val deletingState = state.copy(showDeleteConfirmationDialog = false, deleting = true)
stateEmitter(deletingState)
withContext(Dispatchers.IO) { removeDevice(selfDeviceId()) }
stateEmitter(deletingState.copy(oneTimeEvent = OneTimeEvent.WipeData))
}
LinkedDeviceAccountSettingsEvent.DataWipeFailed -> stateEmitter(state.copy(deleting = false, oneTimeEvent = OneTimeEvent.DeleteFailed))
LinkedDeviceAccountSettingsEvent.ConsumeOneTimeEvent -> stateEmitter(state.copy(oneTimeEvent = null))
}
}
}
@@ -35,6 +35,11 @@ import androidx.compose.ui.platform.LocalLocale
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.withLink
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.fragment.app.viewModels
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -58,6 +63,7 @@ import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsType
import org.thoughtcrime.securesms.components.settings.app.subscription.MessageBackupsCheckoutLauncher.createBackupsCheckoutLauncher
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.DateUtils
import org.thoughtcrime.securesms.util.Environment
import org.thoughtcrime.securesms.util.navigation.safeNavigate
@@ -86,7 +92,7 @@ class BackupsSettingsFragment : ComposeFragment() {
findNavController().safeNavigate(R.id.action_backupsSettingsFragment_to_remoteBackupsSettingsFragment)
}
if (savedInstanceState == null && args.launchCheckoutFlow) {
if (savedInstanceState == null && args.launchCheckoutFlow && SignalStore.account.isPrimaryDevice) {
checkoutLauncher.launch(null)
}
}
@@ -103,7 +109,9 @@ class BackupsSettingsFragment : ComposeFragment() {
is BackupState.Error -> Unit
BackupState.None -> {
checkoutLauncher.launch(null)
if (!state.isLinkedDevice) {
checkoutLauncher.launch(null)
}
}
else -> {
@@ -112,13 +120,14 @@ class BackupsSettingsFragment : ComposeFragment() {
}
},
onOnDeviceBackupsRowClick = {
if (SignalStore.backup.newLocalBackupsEnabled || (Environment.Backups.isNewFormatSupportedForLocalBackup() && !SignalStore.settings.isBackupEnabled)) {
if (state.isLinkedDevice || SignalStore.backup.newLocalBackupsEnabled || (Environment.Backups.isNewFormatSupportedForLocalBackup() && !SignalStore.settings.isBackupEnabled)) {
findNavController().safeNavigate(R.id.action_backupsSettingsFragment_to_localBackupsFragment)
} else {
findNavController().safeNavigate(R.id.action_backupsSettingsFragment_to_backupsPreferenceFragment)
}
},
onBackupTierInternalOverrideChanged = { viewModel.onBackupTierInternalOverrideChanged(it) }
onBackupTierInternalOverrideChanged = { viewModel.onBackupTierInternalOverrideChanged(it) },
onLearnMoreClick = { CommunicationActions.openBrowserLink(requireContext(), "https://support.signal.org/hc/articles/360007059752") }
)
}
}
@@ -129,7 +138,8 @@ private fun BackupsSettingsContent(
onNavigationClick: () -> Unit = {},
onBackupsRowClick: () -> Unit = {},
onOnDeviceBackupsRowClick: () -> Unit = {},
onBackupTierInternalOverrideChanged: (MessageBackupTier?) -> Unit = {}
onBackupTierInternalOverrideChanged: (MessageBackupTier?) -> Unit = {},
onLearnMoreClick: () -> Unit = {}
) {
Scaffolds.Settings(
title = stringResource(R.string.preferences_chats__backups),
@@ -166,21 +176,29 @@ private fun BackupsSettingsContent(
}
item {
val displayActionButton = !backupsSettingsState.isLinkedDevice
when (backupsSettingsState.backupState) {
is BackupState.LocalStore -> {
LocalStoreBackupRow(
backupState = backupsSettingsState.backupState,
lastBackupAt = backupsSettingsState.lastBackupAt,
onBackupsRowClick = onBackupsRowClick
onBackupsRowClick = onBackupsRowClick,
displayActionButton = displayActionButton
)
OtherWaysToBackUpHeading()
}
is BackupState.Inactive -> {
InactiveBackupsRow(
onBackupsRowClick = onBackupsRowClick
)
if (backupsSettingsState.isLinkedDevice) {
BackupsOffLinkedDeviceRow(onLearnMoreClick = onLearnMoreClick)
} else {
InactiveBackupsRow(
onBackupsRowClick = onBackupsRowClick,
displayActionButton = true
)
}
OtherWaysToBackUpHeading()
}
@@ -189,23 +207,30 @@ private fun BackupsSettingsContent(
ActiveBackupsRow(
backupState = backupsSettingsState.backupState,
onBackupsRowClick = onBackupsRowClick,
lastBackupAt = backupsSettingsState.lastBackupAt
lastBackupAt = backupsSettingsState.lastBackupAt,
displayActionButton = true
)
OtherWaysToBackUpHeading()
}
BackupState.None -> {
NeverEnabledBackupsRow(
onBackupsRowClick = onBackupsRowClick
)
if (backupsSettingsState.isLinkedDevice) {
BackupsOffLinkedDeviceRow(onLearnMoreClick = onLearnMoreClick)
} else {
NeverEnabledBackupsRow(
onBackupsRowClick = onBackupsRowClick,
displayActionButton = true
)
}
OtherWaysToBackUpHeading()
}
is BackupState.Error -> {
WaitingForNetworkRow(
onBackupsRowClick = onBackupsRowClick
onBackupsRowClick = onBackupsRowClick,
displayActionButton = displayActionButton
)
OtherWaysToBackUpHeading()
@@ -213,7 +238,8 @@ private fun BackupsSettingsContent(
BackupState.NotFound -> {
NotFoundBackupRow(
onBackupsRowClick = onBackupsRowClick
onBackupsRowClick = onBackupsRowClick,
displayActionButton = displayActionButton
)
OtherWaysToBackUpHeading()
@@ -221,7 +247,8 @@ private fun BackupsSettingsContent(
is BackupState.Pending -> {
PendingBackupRow(
onBackupsRowClick = onBackupsRowClick
onBackupsRowClick = onBackupsRowClick,
displayActionButton = displayActionButton
)
OtherWaysToBackUpHeading()
@@ -231,7 +258,8 @@ private fun BackupsSettingsContent(
ActiveBackupsRow(
backupState = backupsSettingsState.backupState,
lastBackupAt = backupsSettingsState.lastBackupAt,
onBackupsRowClick = onBackupsRowClick
onBackupsRowClick = onBackupsRowClick,
displayActionButton = displayActionButton
)
OtherWaysToBackUpHeading()
@@ -262,7 +290,8 @@ private fun OtherWaysToBackUpHeading() {
@Composable
private fun NeverEnabledBackupsRow(
onBackupsRowClick: () -> Unit = {}
onBackupsRowClick: () -> Unit = {},
displayActionButton: Boolean = true
) {
Rows.TextRow(
modifier = Modifier.wrapContentHeight(),
@@ -291,13 +320,15 @@ private fun NeverEnabledBackupsRow(
style = MaterialTheme.typography.bodyMedium
)
Buttons.MediumTonal(
onClick = onBackupsRowClick,
modifier = Modifier.padding(top = 12.dp)
) {
Text(
text = stringResource(R.string.BackupsSettingsFragment_set_up)
)
if (displayActionButton) {
Buttons.MediumTonal(
onClick = onBackupsRowClick,
modifier = Modifier.padding(top = 12.dp)
) {
Text(
text = stringResource(R.string.BackupsSettingsFragment_set_up)
)
}
}
}
}
@@ -305,12 +336,57 @@ private fun NeverEnabledBackupsRow(
}
@Composable
private fun WaitingForNetworkRow(onBackupsRowClick: () -> Unit = {}) {
private fun BackupsOffLinkedDeviceRow(
onLearnMoreClick: () -> Unit = {}
) {
val description = buildAnnotatedString {
append(stringResource(R.string.BackupsSettingsFragment__automatic_backups_get_started_on_your_phone))
append(" ")
withLink(LinkAnnotation.Clickable(tag = "learn-more") { onLearnMoreClick() }) {
withStyle(SpanStyle(color = MaterialTheme.colorScheme.primary)) {
append(stringResource(R.string.RemoteBackupsSettingsFragment__learn_more))
}
}
}
Rows.TextRow(
modifier = Modifier.wrapContentHeight(),
icon = {
Box(
modifier = Modifier
.padding(top = 12.dp)
.align(Alignment.Top)
) {
Icon(
painter = SignalIcons.Backup.painter,
contentDescription = null
)
}
},
text = {
Column {
Text(
text = stringResource(R.string.RemoteBackupsSettingsFragment__signal_backups),
style = MaterialTheme.typography.bodyLarge
)
Text(
text = description,
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodyMedium
)
}
}
)
}
@Composable
private fun WaitingForNetworkRow(onBackupsRowClick: () -> Unit = {}, displayActionButton: Boolean = true) {
Rows.TextRow(
text = {
Column {
Text(text = stringResource(R.string.RemoteBackupsSettingsFragment__waiting_for_network))
ViewSettingsButton(onBackupsRowClick)
ViewSettingsButton(onBackupsRowClick, displayActionButton)
}
},
icon = {
@@ -321,7 +397,8 @@ private fun WaitingForNetworkRow(onBackupsRowClick: () -> Unit = {}) {
@Composable
private fun InactiveBackupsRow(
onBackupsRowClick: () -> Unit = {}
onBackupsRowClick: () -> Unit = {},
displayActionButton: Boolean = true
) {
Rows.TextRow(
text = {
@@ -336,7 +413,7 @@ private fun InactiveBackupsRow(
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
ViewSettingsButton(onBackupsRowClick)
ViewSettingsButton(onBackupsRowClick, displayActionButton)
}
},
icon = {
@@ -354,7 +431,8 @@ private fun InactiveBackupsRow(
@Composable
private fun NotFoundBackupRow(
onBackupsRowClick: () -> Unit = {}
onBackupsRowClick: () -> Unit = {},
displayActionButton: Boolean = true
) {
Rows.TextRow(
modifier = Modifier.wrapContentHeight(),
@@ -382,7 +460,7 @@ private fun NotFoundBackupRow(
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
ViewSettingsButton(onBackupsRowClick)
ViewSettingsButton(onBackupsRowClick, displayActionButton)
}
}
)
@@ -390,7 +468,8 @@ private fun NotFoundBackupRow(
@Composable
private fun PendingBackupRow(
onBackupsRowClick: () -> Unit = {}
onBackupsRowClick: () -> Unit = {},
displayActionButton: Boolean = true
) {
Rows.TextRow(
modifier = Modifier.wrapContentHeight(),
@@ -418,14 +497,18 @@ private fun PendingBackupRow(
style = MaterialTheme.typography.bodyMedium
)
ViewSettingsButton(onBackupsRowClick)
ViewSettingsButton(onBackupsRowClick, displayActionButton)
}
}
)
}
@Composable
private fun ViewSettingsButton(onClick: () -> Unit) {
private fun ViewSettingsButton(onClick: () -> Unit, visible: Boolean = true) {
if (!visible) {
return
}
Buttons.MediumTonal(
onClick = onClick,
modifier = Modifier.padding(top = 12.dp)
@@ -440,7 +523,8 @@ private fun ViewSettingsButton(onClick: () -> Unit) {
private fun LocalStoreBackupRow(
backupState: BackupState.LocalStore,
lastBackupAt: Duration,
onBackupsRowClick: () -> Unit
onBackupsRowClick: () -> Unit,
displayActionButton: Boolean = true
) {
Rows.TextRow(
modifier = Modifier.wrapContentHeight(),
@@ -475,7 +559,7 @@ private fun LocalStoreBackupRow(
)
LastBackedUpText(lastBackupAt)
ViewSettingsButton(onBackupsRowClick)
ViewSettingsButton(onBackupsRowClick, displayActionButton)
}
}
)
@@ -485,7 +569,8 @@ private fun LocalStoreBackupRow(
private fun ActiveBackupsRow(
backupState: BackupState.WithTypeAndRenewalTime,
lastBackupAt: Duration,
onBackupsRowClick: () -> Unit = {}
onBackupsRowClick: () -> Unit = {},
displayActionButton: Boolean = true
) {
Rows.TextRow(
modifier = Modifier.wrapContentHeight(),
@@ -552,7 +637,7 @@ private fun ActiveBackupsRow(
LastBackedUpText(lastBackupAt)
ViewSettingsButton(onBackupsRowClick)
ViewSettingsButton(onBackupsRowClick, displayActionButton)
}
}
)
@@ -644,7 +729,8 @@ private fun BackupsSettingsContentPreview() {
renewalTime = 0.seconds,
price = FiatMoney(BigDecimal.valueOf(4), Currency.getInstance("CAD"))
),
lastBackupAt = 0.seconds
lastBackupAt = 0.seconds,
isLinkedDevice = false
)
)
}
@@ -652,14 +738,21 @@ private fun BackupsSettingsContentPreview() {
@DayNightPreviews
@Composable
private fun BackupsSettingsContentBackupTierInternalOverridePreview() {
private fun BackupsSettingsContentLinkedDevicePreview() {
Previews.Preview {
BackupsSettingsContent(
backupsSettingsState = BackupsSettingsState(
backupState = BackupState.None,
showBackupTierInternalOverride = true,
backupTierInternalOverride = null,
lastBackupAt = 0.seconds
backupState = BackupState.ActivePaid(
messageBackupsType = MessageBackupsType.Paid(
pricePerMonth = FiatMoney(BigDecimal.valueOf(2.99), Currency.getInstance("USD")),
storageAllowanceBytes = 1_000_000,
mediaTtl = 30.days
),
renewalTime = 0.seconds,
price = FiatMoney(BigDecimal.valueOf(2.99), Currency.getInstance("USD"))
),
lastBackupAt = 0.seconds,
isLinkedDevice = true
)
)
}
@@ -758,3 +851,11 @@ private fun NeverEnabledBackupsRowPreview() {
NeverEnabledBackupsRow()
}
}
@DayNightPreviews
@Composable
private fun BackupsOffLinkedDeviceRowPreview() {
Previews.Preview {
BackupsOffLinkedDeviceRow()
}
}
@@ -17,5 +17,6 @@ data class BackupsSettingsState(
val backupState: BackupState,
val lastBackupAt: Duration = SignalStore.backup.lastBackupTime.milliseconds,
val showBackupTierInternalOverride: Boolean = false,
val backupTierInternalOverride: MessageBackupTier? = null
val backupTierInternalOverride: MessageBackupTier? = null,
val isLinkedDevice: Boolean = SignalStore.account.isLinkedDevice
)
@@ -15,6 +15,7 @@ import kotlinx.coroutines.launch
import org.signal.core.util.concurrent.SignalDispatchers
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.backup.DeletionState
import org.thoughtcrime.securesms.backup.v2.BackupRepository
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.keyvalue.SignalStore
@@ -44,7 +45,7 @@ class BackupsSettingsViewModel : ViewModel() {
it.copy(
backupState = enabledState,
lastBackupAt = SignalStore.backup.lastBackupTime.milliseconds,
showBackupTierInternalOverride = Environment.IS_STAGING,
showBackupTierInternalOverride = Environment.IS_STAGING && SignalStore.account.isPrimaryDevice,
backupTierInternalOverride = SignalStore.backup.backupTierInternalOverride
)
}
@@ -59,6 +60,12 @@ class BackupsSettingsViewModel : ViewModel() {
}
}
}
if (SignalStore.account.isLinkedDevice) {
viewModelScope.launch(Dispatchers.IO) {
BackupRepository.refreshBackupFileTimestamp()
}
}
}
fun onBackupTierInternalOverrideChanged(tier: MessageBackupTier?) {
@@ -452,7 +452,8 @@ private fun RemoteBackupsSettingsContent(
state = state.backupState,
onLearnMoreClick = contentCallbacks::onLearnMoreAboutLostSubscription,
onRenewClick = contentCallbacks::onRenewLostSubscription,
isRenewEnabled = backupDeleteState.isIdle()
isRenewEnabled = backupDeleteState.isIdle(),
isLinkedDevice = state.isLinkedDevice
)
}
@@ -464,7 +465,8 @@ private fun RemoteBackupsSettingsContent(
onBackupTypeActionButtonClicked = contentCallbacks::onBackupTypeActionClick,
isPaidTierPricingAvailable = state.isPaidTierPricingAvailable,
isGooglePlayServicesAvailable = state.isGooglePlayServicesAvailable,
buttonsEnabled = backupDeleteState.isIdle()
buttonsEnabled = backupDeleteState.isIdle(),
isLinkedDevice = state.isLinkedDevice
)
}
@@ -473,13 +475,16 @@ private fun RemoteBackupsSettingsContent(
title = stringResource(R.string.RemoteBackupsSettingsFragment__your_subscription_was_not_found),
onRenewClick = contentCallbacks::onRenewLostSubscription,
onLearnMoreClick = contentCallbacks::onLearnMoreAboutLostSubscription,
isRenewEnabled = backupDeleteState.isIdle()
isRenewEnabled = backupDeleteState.isIdle(),
isLinkedDevice = state.isLinkedDevice
)
}
}
}
if (backupDeleteState != DeletionState.NONE && backupDeleteState != DeletionState.CLEAR_LOCAL_STATE) {
if (state.isLinkedDevice) {
appendReducedBackupDetailsItems(state)
} else if (backupDeleteState != DeletionState.NONE && backupDeleteState != DeletionState.CLEAR_LOCAL_STATE) {
appendBackupDeletionItems(
backupDeleteState = backupDeleteState,
backupRestoreState = backupRestoreState,
@@ -973,12 +978,58 @@ private fun LazyListScope.appendBackupDetailsItems(
}
}
private fun LazyListScope.appendReducedBackupDetailsItems(
state: RemoteBackupsSettingsState
) {
item {
Dividers.Default()
}
item {
Texts.SectionHeader(text = stringResource(id = R.string.RemoteBackupsSettingsFragment__backup_details))
}
item {
ReadOnlyLastBackupRow(lastBackupTimestamp = state.lastBackupTimestamp)
}
}
@Composable
private fun ReadOnlyLastBackupRow(
lastBackupTimestamp: Long
) {
val label = if (lastBackupTimestamp > 0) {
val context = LocalContext.current
val day = remember(lastBackupTimestamp) {
DateUtils.getDayPrecisionTimeString(context, Locale.getDefault(), lastBackupTimestamp)
}
val time = remember(lastBackupTimestamp) {
DateUtils.getOnlyTimeString(context, lastBackupTimestamp)
}
stringResource(
id = R.string.RemoteBackupsSettingsFragment__your_phone_s,
stringResource(id = R.string.RemoteBackupsSettingsFragment__s_at_s, day, time)
)
} else {
stringResource(id = R.string.RemoteBackupsSettingsFragment__never)
}
Rows.TextRow(
text = stringResource(id = R.string.RemoteBackupsSettingsFragment__last_backup),
label = label
)
}
@Composable
private fun BackupCard(
backupState: BackupState.WithTypeAndRenewalTime,
isPaidTierPricingAvailable: Boolean,
isGooglePlayServicesAvailable: Boolean,
buttonsEnabled: Boolean,
isLinkedDevice: Boolean = false,
onBackupTypeActionButtonClicked: (MessageBackupTier) -> Unit = {}
) {
val messageBackupsType = backupState.messageBackupsType
@@ -1070,7 +1121,21 @@ private fun BackupCard(
)
}
if (backupState.isActive() && isPaidTierPricingAvailable && isGooglePlayServicesAvailable) {
if (isLinkedDevice) {
val primaryDeviceText = when (backupState) {
is BackupState.ActivePaid -> stringResource(R.string.RemoteBackupsSettingsFragment__you_can_manage_or_cancel_your_subscription_on_your_primary_device)
is BackupState.Canceled -> stringResource(R.string.RemoteBackupsSettingsFragment__you_can_manage_or_renew_your_subscription_on_your_primary_device)
is BackupState.ActiveFree -> stringResource(R.string.RemoteBackupsSettingsFragment__you_can_manage_or_upgrade_your_subscription_on_your_primary_device)
else -> stringResource(R.string.RemoteBackupsSettingsFragment__you_can_manage_or_upgrade_backups_on_your_primary_device)
}
Text(
text = primaryDeviceText,
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(top = 12.dp)
)
} else if (backupState.isActive() && isPaidTierPricingAvailable && isGooglePlayServicesAvailable) {
val buttonText = when (messageBackupsType) {
is MessageBackupsType.Paid -> stringResource(R.string.RemoteBackupsSettingsFragment__manage_or_cancel)
is MessageBackupsType.Free -> stringResource(R.string.RemoteBackupsSettingsFragment__upgrade)
@@ -1270,6 +1335,7 @@ private fun PendingCard(
private fun SubscriptionNotFoundCard(
title: String,
isRenewEnabled: Boolean,
isLinkedDevice: Boolean = false,
onRenewClick: () -> Unit = {},
onLearnMoreClick: () -> Unit = {}
) {
@@ -1308,38 +1374,47 @@ private fun SubscriptionNotFoundCard(
}
}
Row(
horizontalArrangement = spacedBy(16.dp)
) {
Buttons.MediumTonal(
onClick = onRenewClick,
colors = ButtonDefaults.filledTonalButtonColors().copy(
containerColor = SignalTheme.colors.colorTransparent5,
contentColor = colorResource(CoreUiR.color.signal_light_colorOnSurface)
),
modifier = Modifier
.padding(top = 24.dp)
.weight(1f)
if (isLinkedDevice) {
Text(
text = stringResource(R.string.RemoteBackupsSettingsFragment__you_can_manage_or_upgrade_backups_on_your_primary_device),
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(top = 12.dp)
)
} else {
Row(
horizontalArrangement = spacedBy(16.dp)
) {
Text(
text = stringResource(R.string.RemoteBackupsSettingsFragment__renew)
)
}
Buttons.MediumTonal(
onClick = onRenewClick,
colors = ButtonDefaults.filledTonalButtonColors().copy(
containerColor = SignalTheme.colors.colorTransparent5,
contentColor = colorResource(CoreUiR.color.signal_light_colorOnSurface)
),
modifier = Modifier
.padding(top = 24.dp)
.weight(1f)
) {
Text(
text = stringResource(R.string.RemoteBackupsSettingsFragment__renew)
)
}
Buttons.MediumTonal(
onClick = onLearnMoreClick,
enabled = isRenewEnabled,
colors = ButtonDefaults.filledTonalButtonColors().copy(
containerColor = SignalTheme.colors.colorTransparent5,
contentColor = colorResource(CoreUiR.color.signal_light_colorOnSurface)
),
modifier = Modifier
.padding(top = 24.dp)
.weight(1f)
) {
Text(
text = stringResource(R.string.RemoteBackupsSettingsFragment__learn_more)
)
Buttons.MediumTonal(
onClick = onLearnMoreClick,
enabled = isRenewEnabled,
colors = ButtonDefaults.filledTonalButtonColors().copy(
containerColor = SignalTheme.colors.colorTransparent5,
contentColor = colorResource(CoreUiR.color.signal_light_colorOnSurface)
),
modifier = Modifier
.padding(top = 24.dp)
.weight(1f)
) {
Text(
text = stringResource(R.string.RemoteBackupsSettingsFragment__learn_more)
)
}
}
}
}
@@ -1349,6 +1424,7 @@ private fun SubscriptionNotFoundCard(
private fun SubscriptionMismatchMissingGooglePlayCard(
state: BackupState.SubscriptionMismatchMissingGooglePlay,
isRenewEnabled: Boolean,
isLinkedDevice: Boolean = false,
onRenewClick: () -> Unit = {},
onLearnMoreClick: () -> Unit = {}
) {
@@ -1357,6 +1433,7 @@ private fun SubscriptionMismatchMissingGooglePlayCard(
SubscriptionNotFoundCard(
title = pluralStringResource(R.plurals.RemoteBackupsSettingsFragment__your_subscription_on_this_device_is_valid, days.toInt(), days),
isRenewEnabled = isRenewEnabled,
isLinkedDevice = isLinkedDevice,
onRenewClick = onRenewClick,
onLearnMoreClick = onLearnMoreClick
)
@@ -1847,6 +1924,64 @@ private fun RemoteBackupsSettingsInternalUserContentPreview() {
}
}
@DayNightPreviews
@Composable
private fun RemoteBackupsSettingsLinkedDevicePaidContentPreview() {
Previews.Preview {
RemoteBackupsSettingsContent(
state = RemoteBackupsSettingsState(
backupsEnabled = true,
isLinkedDevice = true,
lastBackupTimestamp = -1,
canBackUpUsingCellular = false,
canRestoreUsingCellular = false,
dialog = RemoteBackupsSettingsState.Dialog.NONE,
snackbar = RemoteBackupsSettingsState.Snackbar.NONE,
backupMediaSize = 2300000,
backupState = BackupState.ActivePaid(
messageBackupsType = MessageBackupsType.Paid(
pricePerMonth = FiatMoney(BigDecimal.valueOf(2.99), Currency.getInstance("USD")),
storageAllowanceBytes = 1_000_000,
mediaTtl = 30.days
),
price = FiatMoney(BigDecimal.valueOf(2.99), Currency.getInstance("USD")),
renewalTime = 1_752_710_400.seconds
)
),
statusBarColorNestedScrollConnection = null,
backupDeleteState = DeletionState.NONE,
backupRestoreState = BackupRestoreState.None,
contentCallbacks = ContentCallbacks.Empty,
backupProgress = null
)
}
}
@DayNightPreviews
@Composable
private fun RemoteBackupsSettingsLinkedDeviceNotFoundContentPreview() {
Previews.Preview {
RemoteBackupsSettingsContent(
state = RemoteBackupsSettingsState(
backupsEnabled = true,
isLinkedDevice = true,
lastBackupTimestamp = -1,
canBackUpUsingCellular = false,
canRestoreUsingCellular = false,
dialog = RemoteBackupsSettingsState.Dialog.NONE,
snackbar = RemoteBackupsSettingsState.Snackbar.NONE,
backupMediaSize = 2300000,
backupState = BackupState.NotFound
),
statusBarColorNestedScrollConnection = null,
backupDeleteState = DeletionState.NONE,
backupRestoreState = BackupRestoreState.None,
contentCallbacks = ContentCallbacks.Empty,
backupProgress = null
)
}
}
@DayNightPreviews
@Composable
private fun RedemptionErrorAlertPreview() {
@@ -1875,10 +2010,8 @@ private fun ErrorCardPreview() {
@Composable
private fun PendingCardPreview() {
Previews.Preview {
val locale = LocalLocale.current.platformLocale
PendingCard(
price = FiatMoney(BigDecimal.TEN, Currency.getInstance(locale))
price = FiatMoney(BigDecimal.TEN, Currency.getInstance(Locale.US))
)
}
}
@@ -35,7 +35,8 @@ data class RemoteBackupsSettingsState(
val backupCreationError: BackupValues.BackupCreationError? = null,
val lastMessageCutoffTime: Long = 0,
val freeTierMediaRetentionDays: Int = -1,
val isGooglePlayServicesAvailable: Boolean = false
val isGooglePlayServicesAvailable: Boolean = false,
val isLinkedDevice: Boolean = false
) {
data class BackupMediaDetails(
@@ -79,7 +79,8 @@ class RemoteBackupsSettingsViewModel : ViewModel() {
internalUser = RemoteConfig.internalUser,
includeDebuglog = SignalStore.internal.includeDebuglogInBackup.takeIf { RemoteConfig.internalUser },
backupCreationError = SignalStore.backup.backupCreationError,
lastMessageCutoffTime = SignalStore.backup.lastUsedMessageCutoffTime
lastMessageCutoffTime = SignalStore.backup.lastUsedMessageCutoffTime,
isLinkedDevice = SignalStore.account.isLinkedDevice
)
)
@@ -92,6 +93,14 @@ class RemoteBackupsSettingsViewModel : ViewModel() {
private var forQuickRestore = false
init {
if (state.value.isLinkedDevice) {
initLinkedDevice()
} else {
initPrimaryDevice()
}
}
private fun initPrimaryDevice() {
ArchiveUploadProgress.triggerUpdate()
viewModelScope.launch(Dispatchers.IO) {
@@ -199,6 +208,29 @@ class RemoteBackupsSettingsViewModel : ViewModel() {
}
}
/**
* Render remote backups as read-only and refresh the last-backup time from the CDN.
*/
private fun initLinkedDevice() {
viewModelScope.launch(Dispatchers.IO) {
BackupStateObserver(viewModelScope, useDatabaseFallbackOnNetworkError = true).backupState.collect { backupState ->
_state.update {
it.copy(backupState = backupState)
}
}
}
viewModelScope.launch(Dispatchers.Default) {
SignalStore.backup.lastBackupTimeFlow.collect { lastBackupTime ->
_state.update { it.copy(lastBackupTimestamp = lastBackupTime) }
}
}
viewModelScope.launch(Dispatchers.IO) {
BackupRepository.refreshBackupFileTimestamp()
}
}
fun setCanBackUpUsingCellular(canBackUpUsingCellular: Boolean) {
SignalStore.backup.backupWithCellular = canBackUpUsingCellular
_state.update {
@@ -259,6 +291,10 @@ class RemoteBackupsSettingsViewModel : ViewModel() {
}
fun refresh() {
if (state.value.isLinkedDevice) {
return
}
viewModelScope.launch(Dispatchers.IO) {
val id = SignalDatabase.inAppPayments.getLatestInAppPaymentByType(InAppPaymentType.RECURRING_BACKUP)?.id
@@ -588,19 +588,6 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
dividerPref()
sectionHeaderPref(DSLSettingsText.from("Media"))
switchPref(
title = DSLSettingsText.from("Enable HEVC Encoding for HD Videos"),
summary = DSLSettingsText.from("Videos sent in \"HD\" quality will be encoded in HEVC on compatible devices."),
isChecked = state.hevcEncoding,
onClick = {
viewModel.setHevcEncoding(!state.hevcEncoding)
}
)
dividerPref()
sectionHeaderPref(DSLSettingsText.from("Conversations and Shortcuts"))
clickPref(
@@ -28,7 +28,6 @@ data class InternalSettingsState(
val pnpInitialized: Boolean,
val useConversationItemV2ForMedia: Boolean,
val hasPendingOneTimeDonation: Boolean,
val hevcEncoding: Boolean,
val forceSplitPane: Boolean,
val forceSinglePane: Boolean,
val useNewMediaActivity: Boolean,
@@ -152,11 +152,6 @@ class InternalSettingsViewModel(private val repository: InternalSettingsReposito
refresh()
}
fun setHevcEncoding(enabled: Boolean) {
SignalStore.internal.hevcEncoding = enabled
refresh()
}
fun addSampleReleaseNote(callToAction: String = "action") {
repository.addSampleReleaseNote(callToAction)
}
@@ -242,7 +237,6 @@ class InternalSettingsViewModel(private val repository: InternalSettingsReposito
pnpInitialized = SignalStore.misc.hasPniInitializedDevices,
useConversationItemV2ForMedia = SignalStore.internal.useConversationItemV2Media,
hasPendingOneTimeDonation = SignalStore.inAppPayments.getPendingOneTimeDonation() != null,
hevcEncoding = SignalStore.internal.hevcEncoding,
forceSplitPane = SignalStore.internal.forceSplitPane,
forceSinglePane = SignalStore.internal.forceSinglePane,
useNewMediaActivity = SignalStore.internal.useNewMediaActivity,
@@ -71,6 +71,8 @@ class AdvancedPrivacySettingsViewModel(
fun setAllowAutomaticVerification(enabled: Boolean) {
SignalStore.settings.automaticVerificationEnabled = enabled
SignalStore.misc.hasKeyTransparencyFailure = false
SignalStore.misc.hasSeenKeyTransparencyFailure = false
refresh()
viewModelScope.launch(SignalDispatchers.Default) {
if (!enabled) {
@@ -67,7 +67,9 @@ class ExpireTimerSettingsFragment : ComposeFragment() {
if (state.isGroupCreate) {
requireActivity().setResult(Activity.RESULT_OK, Intent().putExtra(FOR_RESULT_VALUE, saveState.result))
}
requireActivity().onNavigateUp()
if (!NavHostFragment.findNavController(this).popBackStack()) {
requireActivity().onNavigateUp()
}
}
is ProcessState.Failure -> {
@@ -133,6 +133,7 @@ class ManageStorageSettingsViewModel : ViewModel() {
private suspend fun getOnDeviceStorageOptimizationState(): OnDeviceStorageOptimizationState {
return when {
!SignalStore.account.isPrimaryDevice -> OnDeviceStorageOptimizationState.FEATURE_NOT_AVAILABLE
!SignalStore.backup.areBackupsEnabled || !BackupUpgradeAvailabilityChecker.isUpgradeAvailable(AppDependencies.application) -> OnDeviceStorageOptimizationState.FEATURE_NOT_AVAILABLE
SignalStore.backup.backupTier != MessageBackupTier.PAID -> OnDeviceStorageOptimizationState.REQUIRES_PAID_TIER
SignalStore.backup.optimizeStorage -> OnDeviceStorageOptimizationState.ENABLED
@@ -9,6 +9,7 @@ import android.content.Context
import android.content.Intent
import android.os.Parcelable
import androidx.activity.result.contract.ActivityResultContract
import androidx.annotation.VisibleForTesting
import androidx.fragment.app.Fragment
import io.reactivex.rxjava3.subjects.PublishSubject
import io.reactivex.rxjava3.subjects.Subject
@@ -26,7 +27,8 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.GooglePay
class CheckoutFlowActivity : FragmentWrapperActivity(), GooglePayComponent {
companion object {
private const val ARG_IN_APP_PAYMENT_TYPE = "in_app_payment_type"
@VisibleForTesting
const val ARG_IN_APP_PAYMENT_TYPE = "in_app_payment_type"
const val RESULT_DATA = "result_data"
fun createIntent(context: Context, inAppPaymentType: InAppPaymentType): Intent {
@@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.components.settings.app.subscription.donate
import android.text.SpannableStringBuilder
import android.view.View
import android.view.ViewGroup
import androidx.annotation.VisibleForTesting
import androidx.appcompat.widget.Toolbar
import androidx.core.content.ContextCompat
import androidx.core.os.bundleOf
@@ -78,7 +79,8 @@ class DonateToSignalFragment :
companion object {
private const val ARG = "in_app_payment_type"
@VisibleForTesting
const val ARG = "in_app_payment_type"
@JvmStatic
fun create(inAppPaymentType: InAppPaymentType): DialogFragment {
@@ -0,0 +1,29 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.transfer
/**
* Test tags for the bank-transfer (SEPA) and iDEAL Compose entry forms, so instrumentation tests can
* drive them reliably. Follows the central-object convention used by the registration module's
* `TestTags`.
*/
object DonationTransferTestTags {
// SEPA bank transfer mandate
const val SEPA_MANDATE_CONTINUE_BUTTON = "sepa_mandate_continue_button"
// SEPA bank transfer details
const val SEPA_DETAILS_LIST = "sepa_details_list"
const val SEPA_IBAN_FIELD = "sepa_iban_field"
const val SEPA_NAME_FIELD = "sepa_name_field"
const val SEPA_EMAIL_FIELD = "sepa_email_field"
const val SEPA_DONATE_BUTTON = "sepa_donate_button"
// iDEAL transfer details
const val IDEAL_DETAILS_LIST = "ideal_details_list"
const val IDEAL_NAME_FIELD = "ideal_name_field"
const val IDEAL_EMAIL_FIELD = "ideal_email_field"
const val IDEAL_DONATE_BUTTON = "ideal_donate_button"
}
@@ -32,6 +32,7 @@ import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
@@ -63,6 +64,7 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.donate.In
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.stripe.StripePaymentInProgressFragment
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.stripe.StripePaymentInProgressViewModel
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.transfer.BankTransferRequestKeys
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.transfer.DonationTransferTestTags
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.transfer.details.BankTransferDetailsViewModel.Field
import org.thoughtcrime.securesms.database.InAppPaymentTable
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
@@ -237,6 +239,7 @@ fun BankTransferDetailsContent(
modifier = Modifier
.weight(1f)
.padding(horizontal = 24.dp)
.testTag(DonationTransferTestTags.SEPA_DETAILS_LIST)
) {
item {
val learnMore = stringResource(id = R.string.BankTransferDetailsFragment__learn_more)
@@ -292,6 +295,7 @@ fun BankTransferDetailsContent(
.defaultMinSize(minHeight = 78.dp)
.onFocusChanged { onFocusChanged(Field.IBAN, it.hasFocus) }
.focusRequester(focusRequester)
.testTag(DonationTransferTestTags.SEPA_IBAN_FIELD)
)
}
@@ -320,6 +324,7 @@ fun BankTransferDetailsContent(
.padding(top = 16.dp)
.defaultMinSize(minHeight = 78.dp)
.onFocusChanged { onFocusChanged(Field.NAME, it.hasFocus) }
.testTag(DonationTransferTestTags.SEPA_NAME_FIELD)
)
}
@@ -348,6 +353,7 @@ fun BankTransferDetailsContent(
.padding(top = 16.dp)
.defaultMinSize(minHeight = 78.dp)
.onFocusChanged { onFocusChanged(Field.EMAIL, it.hasFocus) }
.testTag(DonationTransferTestTags.SEPA_EMAIL_FIELD)
)
}
@@ -373,6 +379,7 @@ fun BankTransferDetailsContent(
modifier = Modifier
.defaultMinSize(minWidth = 220.dp)
.padding(vertical = 16.dp)
.testTag(DonationTransferTestTags.SEPA_DONATE_BUTTON)
) {
Text(text = donateLabel)
}
@@ -26,6 +26,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
@@ -60,6 +61,7 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.donate.ga
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.stripe.StripePaymentInProgressFragment
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.stripe.StripePaymentInProgressViewModel
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.transfer.BankTransferRequestKeys
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.transfer.DonationTransferTestTags
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.transfer.ideal.IdealTransferDetailsViewModel.Field
import org.thoughtcrime.securesms.database.InAppPaymentTable
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
@@ -241,6 +243,7 @@ private fun IdealTransferDetailsContent(
modifier = Modifier
.weight(1f)
.padding(horizontal = 24.dp)
.testTag(DonationTransferTestTags.IDEAL_DETAILS_LIST)
) {
item {
val learnMore = stringResource(id = R.string.IdealTransferDetailsFragment__learn_more)
@@ -283,6 +286,7 @@ private fun IdealTransferDetailsContent(
.padding(top = 16.dp)
.defaultMinSize(minHeight = 78.dp)
.onFocusChanged { onFocusChanged(Field.NAME, it.hasFocus) }
.testTag(DonationTransferTestTags.IDEAL_NAME_FIELD)
)
}
@@ -316,6 +320,7 @@ private fun IdealTransferDetailsContent(
.padding(top = 16.dp)
.defaultMinSize(minHeight = 78.dp)
.onFocusChanged { onFocusChanged(Field.EMAIL, it.hasFocus) }
.testTag(DonationTransferTestTags.IDEAL_EMAIL_FIELD)
)
}
}
@@ -327,6 +332,7 @@ private fun IdealTransferDetailsContent(
modifier = Modifier
.defaultMinSize(minWidth = 220.dp)
.padding(bottom = 16.dp)
.testTag(DonationTransferTestTags.IDEAL_DONATE_BUTTON)
) {
Text(text = donateLabel)
}
@@ -40,6 +40,7 @@ import androidx.compose.ui.Alignment.Companion.CenterHorizontally
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
@@ -58,6 +59,7 @@ import org.signal.core.ui.compose.SignalIcons
import org.signal.core.ui.compose.Texts
import org.signal.core.ui.compose.theme.SignalTheme
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.transfer.DonationTransferTestTags
import org.thoughtcrime.securesms.compose.StatusBarColorAnimator
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
import org.thoughtcrime.securesms.util.SpanUtil
@@ -267,6 +269,7 @@ fun BankTransferScreen(
.wrapContentWidth()
.padding(top = 16.dp, bottom = 16.dp)
.defaultMinSize(minWidth = 220.dp)
.testTag(DonationTransferTestTags.SEPA_MANDATE_CONTINUE_BUTTON)
) {
Text(text = if (listState.canScrollForward) stringResource(id = R.string.BankTransferMandateFragment__read_more) else stringResource(id = R.string.BankTransferMandateFragment__agree))
}
@@ -18,6 +18,7 @@ import androidx.appcompat.widget.AppCompatImageView
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -234,6 +235,7 @@ object ContactSearchModels {
) { model ->
Text(
text = if (model.empty.query.isNullOrEmpty()) stringResource(R.string.SearchFragment_no_results_empty) else stringResource(R.string.SearchFragment_no_results, model.empty.query),
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
@@ -44,6 +44,18 @@ object PlaintextExportRepository {
private val TAG = Log.tag(PlaintextExportRepository::class.java)
private const val BATCH_SIZE = 500
private val EMOJI_REGEX = Regex(
"[" +
"\\x{1F000}-\\x{1FAFF}" +
"\\x{2600}-\\x{27BF}" +
"\\x{2300}-\\x{23FF}" +
"\\x{2B00}-\\x{2BFF}" +
"\\x{FE00}-\\x{FE0F}" +
"\\x{200D}" +
"\\x{20E3}" +
"]"
)
fun export(
context: Context,
threadId: Long,
@@ -435,7 +447,13 @@ object PlaintextExportRepository {
@VisibleForTesting
internal fun sanitizeFileName(name: String): String {
return name.replace(Regex("[\\\\/:*?\"<>|]"), "_").trim().take(100)
val sanitized = name
.replace(EMOJI_REGEX, "")
.replace(Regex("[\\\\/:*?\"<>|]"), "_")
.replace(Regex("\\s+"), " ")
.trim()
.take(100)
return if (sanitized.isEmpty() || sanitized.all { it == '.' }) "chat" else sanitized
}
private fun <T> ExecutorService.submitTyped(callable: Callable<T>): Future<T> {
@@ -27,7 +27,6 @@ import android.graphics.Canvas;
import android.graphics.drawable.ColorDrawable;
import android.graphics.drawable.Drawable;
import android.net.Uri;
import android.os.AsyncTask;
import android.os.Bundle;
import android.text.TextUtils;
import android.view.LayoutInflater;
@@ -1224,33 +1223,22 @@ public class ConversationListFragment extends MainFragment implements Conversati
final Set<Long> selectedConversations = new HashSet<>(ids);
if (!selectedConversations.isEmpty()) {
new AsyncTask<Void, Void, Void>() {
private SignalProgressDialog dialog;
SignalProgressDialog progressDialog = SignalProgressDialog.show(requireActivity(),
context.getString(R.string.ConversationListFragment_deleting),
context.getResources().getQuantityString(R.plurals.ConversationListFragment_deleting_selected_conversations, conversationsCount),
true,
false);
@Override
protected void onPreExecute() {
dialog = SignalProgressDialog.show(requireActivity(),
context.getString(R.string.ConversationListFragment_deleting),
context.getResources().getQuantityString(R.plurals.ConversationListFragment_deleting_selected_conversations, conversationsCount),
true,
false);
}
@Override
protected Void doInBackground(Void... params) {
Log.d(TAG, "[handleDelete] Deleting " + selectedConversations.size() + " chats");
SignalDatabase.threads().deleteConversations(selectedConversations, true);
AppDependencies.getMessageNotifier().updateNotification(AppDependencies.getApplication());
Log.d(TAG, "[handleDelete] Delete complete");
return null;
}
@Override
protected void onPostExecute(Void result) {
dialog.dismiss();
endActionModeIfActive();
}
}.executeOnExecutor(SignalExecutors.BOUNDED);
SimpleTask.run(getViewLifecycleOwner().getLifecycle(), () -> {
Log.d(TAG, "[handleDelete] Deleting " + selectedConversations.size() + " chats");
SignalDatabase.threads().deleteConversations(selectedConversations, true);
AppDependencies.getMessageNotifier().updateNotification(AppDependencies.getApplication());
Log.d(TAG, "[handleDelete] Delete complete");
return null;
}, unused -> {
progressDialog.dismiss();
endActionModeIfActive();
});
}
});
@@ -914,7 +914,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
val threadIdResult = threads.getOrCreateThreadIdResultFor(recipient.id, recipient.isGroup)
val threadId = threadIdResult.threadId
val dateReceived = System.currentTimeMillis()
val expiresIn = if (RemoteConfig.disappearMore) threads.getExpiresIn(threadId) else 0
val expiresIn = threads.getExpiresIn(threadId)
val missed = MessageTypes.isMissedAudioCall(type) || MessageTypes.isMissedVideoCall(type)
val values = contentValuesOf(
@@ -995,7 +995,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
): MessageId {
val recipient = Recipient.resolved(groupRecipientId)
val threadId = threads.getOrCreateThreadIdFor(recipient)
val expiresIn = if (RemoteConfig.disappearMore) recipient.expiresInSeconds.seconds.inWholeMilliseconds else 0
val expiresIn = recipient.expiresInSeconds.seconds.inWholeMilliseconds
val messageId: MessageId = writableDatabase.withinTransaction { db ->
val self = Recipient.self()
val selfCreated = self.id == sender
@@ -498,6 +498,7 @@ object AppDependencies {
fun provideProfileService(profileOperations: ClientZkProfileOperations, authWebSocket: SignalWebSocket.AuthenticatedWebSocket, unauthWebSocket: SignalWebSocket.UnauthenticatedWebSocket): ProfileService
fun provideDeadlockDetector(): DeadlockDetector
fun provideClientZkReceiptOperations(signalServiceConfiguration: SignalServiceConfiguration): ClientZkReceiptOperations
fun provideOkHttpClient(): OkHttpClient
fun provideScheduledMessageManager(): ScheduledMessageManager
fun providePinnedMessageManager(): PinnedMessageManager
fun provideLibsignalNetwork(config: SignalServiceConfiguration): Network
@@ -9,6 +9,7 @@ import androidx.annotation.NonNull;
import androidx.annotation.VisibleForTesting;
import androidx.media3.exoplayer.ExoPlayer;
import okhttp3.OkHttpClient;
import org.jetbrains.annotations.NotNull;
import org.signal.billing.BillingFactory;
import org.signal.core.models.ServiceId.ACI;
@@ -209,6 +210,7 @@ public class ApplicationDependencyProvider implements AppDependencies.Provider {
protocolStore.aci(),
new SignalProtocolAddress(pushServiceSocket.getCredentialsProvider().getAci().getLibSignalServiceId(),
pushServiceSocket.getCredentialsProvider().getDeviceId()),
ReentrantSessionLock.INSTANCE,
PreKeyBatcher.INSTANCE
)
);
@@ -541,6 +543,14 @@ public class ApplicationDependencyProvider implements AppDependencies.Provider {
return provideClientZkOperations(signalServiceConfiguration).getReceiptOperations();
}
@Override
public @NonNull OkHttpClient provideOkHttpClient() {
return new OkHttpClient.Builder()
.addInterceptor(new StandardUserAgentInterceptor())
.dns(SignalServiceNetworkAccess.DNS)
.build();
}
@Override
public @NonNull BillingApi provideBillingApi() {
return BillingFactory.create(GooglePlayBillingDependencies.INSTANCE, Environment.Backups.supportsGooglePlayBilling());
@@ -37,7 +37,6 @@ import org.thoughtcrime.securesms.groups.GroupsV2Authorization
import org.thoughtcrime.securesms.groups.GroupsV2AuthorizationMemoryValueCache
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.messages.IncomingMessageObserver
import org.thoughtcrime.securesms.net.StandardUserAgentInterceptor
import org.thoughtcrime.securesms.payments.Payments
import org.thoughtcrime.securesms.push.SignalServiceNetworkAccess
import org.thoughtcrime.securesms.push.SignalServiceTrustStore
@@ -244,10 +243,7 @@ class NetworkDependenciesModule(
}
val okHttpClient: OkHttpClient by lazy {
OkHttpClient.Builder()
.addInterceptor(StandardUserAgentInterceptor())
.dns(SignalServiceNetworkAccess.DNS)
.build()
provider.provideOkHttpClient()
}
val signalOkHttpClient: OkHttpClient by lazy {
@@ -135,7 +135,7 @@ class CheckKeyTransparencyJob private constructor(
keyTransparencyStore = KeyTransparencyStore
)
Log.i(TAG, "Key transparency complete, result: $result. Included username in check: ${Recipient.self().usernameSyncMessagesCapability.isSupported}, next check time: ${SignalStore.misc.nextKeyTransparencyTime}")
Log.i(TAG, "Key transparency complete, result: $result. Included username in check: ${Recipient.self().usernameSyncMessagesCapability.isSupported}, discoverability: ${SignalStore.phoneNumberPrivacy.phoneNumberDiscoverabilityMode}, next check time: ${SignalStore.misc.nextKeyTransparencyTime}")
return when (result) {
is RequestResult.Success -> {
SignalStore.misc.hasKeyTransparencyFailure = false
@@ -144,9 +144,10 @@ class CheckKeyTransparencyJob private constructor(
}
is RequestResult.NonSuccess -> {
if (!showFailure) {
if (!showFailure && !SignalStore.misc.hasKeyTransparencyFailure) {
Log.w(TAG, "Verification failure. Enqueuing this job again to run again a day.")
StorageSyncJob.forRemoteChange()
AppDependencies.jobManager.add(StorageSyncJob.forRemoteChange())
AppDependencies.jobManager.add(RefreshAttributesJob())
enqueueFollowingFailure()
} else {
Log.w(TAG, "Second verification failure. Showing failure sheet.")
@@ -186,6 +186,10 @@ class RestoreLocalAttachmentJob private constructor(
} catch (e: IOException) {
Log.w(TAG, "Experienced an exception while trying to read attachment.", e)
return Result.retry(defaultBackoff())
} catch (e: SecurityException) {
Log.w(TAG, "Lost access to the backup directory. Unable to restore attachment.", e)
SignalStore.backup.localRestoreDirectoryError = true
return Result.failure()
}
return Result.success()
@@ -30,7 +30,6 @@ class InternalValues internal constructor(store: KeyValueStore) : SignalStoreVal
const val LAST_SCROLL_POSITION: String = "internal.last_scroll_position"
const val CONVERSATION_ITEM_V2_MEDIA: String = "internal.conversation_item_v2_media"
const val WEB_SOCKET_SHADOWING_STATS: String = "internal.web_socket_shadowing_stats"
const val ENCODE_HEVC: String = "internal.hevc_encoding"
const val FORCE_SPLIT_PANE_ON_COMPACT_LANDSCAPE: String = "internal.force.split.pane.on.compact.landscape.ui"
const val FORCE_SINGLE_PANE_ON_ALL_DEVICES: String = "internal.force_single_pane_on_all_devices"
const val SHOW_ARCHIVE_STATE_HINT: String = "internal.show_archive_state_hint"
@@ -171,8 +170,6 @@ class InternalValues internal constructor(store: KeyValueStore) : SignalStoreVal
*/
var callingUseInputVoiceComm by booleanValue(CALLING_USE_INPUT_VOICE_COMM, true).defaultForExternalUsers()
var hevcEncoding by booleanValue(ENCODE_HEVC, false).defaultForExternalUsers()
var lastScrollPosition: Int by integerValue(LAST_SCROLL_POSITION, 0).defaultForExternalUsers()
var useConversationItemV2Media by booleanValue(CONVERSATION_ITEM_V2_MEDIA, false).defaultForExternalUsers()
@@ -133,6 +133,14 @@ class SignalStore(context: Application, private val store: KeyValueStore) {
instance!!.store.resetCache()
}
/**
* Swaps out the singleton for a test instance. Pass null to clear it. Should only be used for testing!
*/
@VisibleForTesting
fun testInject(instance: SignalStore?) {
Companion.instance = instance
}
/**
* Restoring a backup changes the underlying disk values, so the cache needs to be reset.
*/
@@ -51,7 +51,7 @@ object LinkDeviceRepository {
private val TAG = Log.tag(LinkDeviceRepository::class)
private const val DECRYPTION_INFO = "deviceCreatedAt"
fun removeDevice(deviceId: Int): Boolean {
suspend fun removeDevice(deviceId: Int): Boolean {
return when (val result = AppDependencies.linkDeviceApi.removeDevice(deviceId)) {
is NetworkResult.Success -> {
LinkedDeviceInactiveCheckJob.enqueue()
@@ -7,12 +7,13 @@ import org.signal.mediasend.SentMediaQuality
import org.signal.mediasend.edit.video.VideoTrimData
import org.thoughtcrime.securesms.conversation.MessageSendType
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.mms.PushMediaConstraints
import org.thoughtcrime.securesms.mms.TranscodingConfigProvider
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.stories.Stories
import org.thoughtcrime.securesms.util.MediaUtil
import org.thoughtcrime.securesms.util.RemoteConfig
import org.thoughtcrime.securesms.video.TranscodingPreset
import org.thoughtcrime.securesms.video.TranscodingConfig
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
data class MediaSelectionState(
@@ -31,13 +32,12 @@ data class MediaSelectionState(
val cameraFirstCapture: Media? = null,
val isStory: Boolean,
val storySendRequirements: Stories.MediaTransform.SendRequirements = Stories.MediaTransform.SendRequirements.CAN_NOT_SEND,
val suppressEmptyError: Boolean = true
val suppressEmptyError: Boolean = true,
val transcodingConfigs: List<TranscodingConfig.QualityTier> = TranscodingConfigProvider.getConfigsForMediaQuality(SentMediaQuality.fromCode(quality.code))
) {
val isVideoTrimmingVisible: Boolean = focusedMedia != null && MediaUtil.isVideoType(focusedMedia.contentType) && MediaConstraints.isVideoTranscodeAvailable() && !focusedMedia.isVideoGif
val transcodingPreset: TranscodingPreset = PushMediaConstraints(SentMediaQuality.fromCode(quality.code)).videoTranscodingSettings
val maxSelection = RemoteConfig.maxAttachmentCount
val canSend = !isSent && selectedMedia.isNotEmpty()
@@ -46,11 +46,11 @@ data class MediaSelectionState(
return editorStateMap[uri] as? VideoTrimData ?: VideoTrimData()
}
fun calculateMaxVideoDurationUs(maxFileSize: Long): Long {
fun calculateMaxVideoDurationUs(maxFileSize: Long, videoDuration: Duration): Long {
return if (isStory && !MediaConstraints.isVideoTranscodeAvailable()) {
Stories.MAX_VIDEO_DURATION_MILLIS
} else {
transcodingPreset.calculateMaxVideoUploadDurationInSeconds(maxFileSize).seconds.inWholeMicroseconds
TranscodingConfig.calculateMaxVideoUploadDurationInSeconds(transcodingConfigs, videoDuration, maxFileSize).seconds.inWholeMicroseconds
}
}
@@ -35,6 +35,7 @@ import org.thoughtcrime.securesms.conversation.MessageStyler
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.mediasend.MediaSendActivityResult
import org.thoughtcrime.securesms.mms.PushMediaConstraints
import org.thoughtcrime.securesms.mms.TranscodingConfigProvider
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.scribbles.ImageEditorFragment
import org.thoughtcrime.securesms.stories.Stories
@@ -42,6 +43,7 @@ import org.thoughtcrime.securesms.util.MediaUtil
import org.thoughtcrime.securesms.util.livedata.Store
import java.util.Collections
import kotlin.math.max
import kotlin.time.Duration.Companion.microseconds
import kotlin.time.Duration.Companion.milliseconds
/**
@@ -166,11 +168,11 @@ class MediaSelectionViewModel(
.subscribe { filterResult ->
if (filterResult.filteredMedia.isNotEmpty()) {
store.update {
val maxDuration = it.calculateMaxVideoDurationUs(getMediaConstraints().getEditorVideoMaxSize())
val initializedVideoEditorStates = filterResult.filteredMedia.filterNot { media -> it.editorStateMap.containsKey(media.uri) }
.filter { media -> MediaUtil.isNonGifVideo(media) }
.associate { video: Media ->
val duration = video.duration.milliseconds.inWholeMicroseconds
val maxDuration = it.calculateMaxVideoDurationUs(getMediaConstraints().getEditorVideoMaxSize(), video.duration.milliseconds)
if (duration < maxDuration) {
video.uri to VideoTrimData(false, duration, 0, duration)
} else {
@@ -330,7 +332,7 @@ class MediaSelectionViewModel(
return
}
store.update { it.copy(quality = sentMediaQuality, isPreUploadEnabled = false) }
store.update { it.copy(quality = sentMediaQuality, isPreUploadEnabled = false, transcodingConfigs = TranscodingConfigProvider.getConfigsForMediaQuality(sentMediaQuality)) }
repository.uploadRepository.cancelAllUploads()
store.state.selectedMedia.forEach { mediaItem ->
@@ -360,7 +362,7 @@ class MediaSelectionViewModel(
val durationEdited = clampedStartTime > 0 || endTimeUs < totalDurationUs
val isEntireDuration = startTimeUs == 0L && endTimeUs == totalDurationUs
val endMoved = !isEntireDuration && data.endTimeUs != endTimeUs
val maxVideoDurationUs: Long = it.calculateMaxVideoDurationUs(getMediaConstraints().getEditorVideoMaxSize())
val maxVideoDurationUs: Long = it.calculateMaxVideoDurationUs(getMediaConstraints().getEditorVideoMaxSize(), (endTimeUs - clampedStartTime).microseconds)
val preserveStartTime = unedited || !endMoved
val videoTrimData = VideoTrimData(durationEdited, totalDurationUs, clampedStartTime, endTimeUs)
val updatedData = clampToMaxClipDuration(videoTrimData, maxVideoDurationUs, preserveStartTime)
@@ -69,11 +69,13 @@ import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import org.thoughtcrime.securesms.util.fragments.requireListener
import org.thoughtcrime.securesms.util.views.TouchInterceptingFrameLayout
import org.thoughtcrime.securesms.util.visible
import org.thoughtcrime.securesms.video.TranscodingConfig
import org.thoughtcrime.securesms.video.TranscodingQuality
import java.io.IOException
import java.util.Locale
import java.util.concurrent.TimeUnit
import kotlin.math.roundToInt
import kotlin.time.Duration.Companion.microseconds
import org.signal.core.ui.R as CoreUiR
/**
@@ -584,7 +586,7 @@ class MediaReviewFragment : Fragment(R.layout.v2_media_review_fragment), Schedul
val size: Long = tryGetUriSize(requireContext(), uri, Long.MAX_VALUE)
val maxSend = sharedViewModel.getMediaConstraints().getEditorVideoMaxSize()
if (size > maxSend) {
videoTimeLine.setTimeLimit(state.transcodingPreset.calculateMaxVideoUploadDurationInSeconds(maxSend), TimeUnit.SECONDS)
videoTimeLine.setTimeLimit(TranscodingConfig.calculateMaxVideoUploadDurationInSeconds(state.transcodingConfigs, state.getOrCreateVideoTrimData(uri).totalInputDurationUs.microseconds, maxSend), TimeUnit.SECONDS)
}
if (state.isTouchEnabled) {
@@ -602,7 +604,7 @@ class MediaReviewFragment : Fragment(R.layout.v2_media_review_fragment), Schedul
videoSizeHint.text = if (state.isVideoTrimmingVisible) {
val seconds = trimData.getDuration().inWholeSeconds
val bytes = TranscodingQuality.createFromPreset(state.transcodingPreset, trimData.getDuration().inWholeMilliseconds).byteCountEstimate
val bytes = TranscodingQuality.createFromQualityTiers(state.transcodingConfigs, trimData.getDuration().inWholeMilliseconds).byteCountEstimate
String.format(Locale.getDefault(), "%d:%02d • %s", seconds / 60, seconds % 60, bytes.bytes.toUnitString())
} else {
null
@@ -46,6 +46,7 @@ import org.thoughtcrime.securesms.scribbles.ImageEditorFragment
import org.thoughtcrime.securesms.stories.Stories
import org.thoughtcrime.securesms.util.MediaUtil
import org.thoughtcrime.securesms.util.RemoteConfig
import org.thoughtcrime.securesms.video.TranscodingConfig
import java.io.InputStream
import java.util.concurrent.TimeUnit
import kotlin.coroutines.resume
@@ -129,9 +130,9 @@ object MediaSendV3Repository : MediaSendRepository {
}
}
override fun getMaxVideoDurationUs(quality: SentMediaQuality, maxFileSizeBytes: Long): Long {
val preset = PushMediaConstraints(quality).videoTranscodingSettings
return preset.calculateMaxVideoUploadDurationInSeconds(maxFileSizeBytes).seconds.inWholeMicroseconds
override fun getMaxVideoDurationUs(quality: SentMediaQuality, maxFileSizeBytes: Long, duration: Duration): Long {
val config = PushMediaConstraints(quality).videoTranscodingSettings
return TranscodingConfig.calculateMaxVideoUploadDurationInSeconds(config, duration, maxFileSizeBytes).seconds.inWholeMicroseconds
}
override fun getVideoMaxSizeBytes(): Long {
@@ -122,7 +122,7 @@ import kotlin.time.Duration.Companion.seconds
object DataMessageProcessor {
private const val BODY_RANGE_PROCESSING_LIMIT = 250
internal const val BODY_RANGE_PROCESSING_LIMIT = 250
private const val POLL_QUESTION_CHARACTER_LIMIT = 200
private const val POLL_CHARACTER_LIMIT = 100
private const val POLL_OPTIONS_LIMIT = 10
@@ -791,7 +791,8 @@ object DataMessageProcessor {
return null
}
val bodyRanges: BodyRangeList? = message.bodyRanges.filter { Util.allAreNull(it.mentionAci, it.mentionAciBinary) }.toList().toBodyRangeList()
val cappedBodyRanges: List<BodyRange> = message.bodyRanges.take(BODY_RANGE_PROCESSING_LIMIT)
val bodyRanges: BodyRangeList? = cappedBodyRanges.filter { Util.allAreNull(it.mentionAci, it.mentionAciBinary) }.toList().toBodyRangeList()
val mediaMessage = IncomingMessage(
type = MessageType.NORMAL,
@@ -805,7 +806,7 @@ object DataMessageProcessor {
body = message.body,
groupId = groupId,
quote = quoteModel,
mentions = getMentions(message.bodyRanges),
mentions = getMentions(cappedBodyRanges),
serverGuid = UuidUtil.getStringUUID(envelope.serverGuid, envelope.serverGuidBinary),
messageRanges = bodyRanges
)
@@ -124,7 +124,8 @@ object EditMessageProcessor {
message: DataMessage,
targetMessage: MmsMessageRecord
): InsertResult? {
val messageRanges: BodyRangeList? = message.bodyRanges.filter { Util.allAreNull(it.mentionAci, it.mentionAciBinary) }.toList().toBodyRangeList()
val cappedBodyRanges = message.bodyRanges.take(DataMessageProcessor.BODY_RANGE_PROCESSING_LIMIT)
val messageRanges: BodyRangeList? = cappedBodyRanges.filter { Util.allAreNull(it.mentionAci, it.mentionAciBinary) }.toList().toBodyRangeList()
val targetQuote = targetMessage.quote
val quote: QuoteModel? = if (targetQuote != null && (message.quote != null || (targetMessage.parentStoryId != null && message.storyContext != null))) {
QuoteModel(
@@ -159,7 +160,7 @@ object EditMessageProcessor {
parentStoryId = targetMessage.parentStoryId,
sharedContacts = emptyList(),
linkPreviews = DataMessageProcessor.getLinkPreviews(message.preview, message.body ?: "", false),
mentions = DataMessageProcessor.getMentions(message.bodyRanges),
mentions = DataMessageProcessor.getMentions(cappedBodyRanges),
serverGuid = UuidUtil.getStringUUID(envelope.serverGuid, envelope.serverGuidBinary),
messageRanges = messageRanges
)
@@ -159,6 +159,8 @@ object MessageDecryptor {
SignalTrace.endSection()
val endTimeNanos = System.nanoTime()
val hadSealedSenderSource = Util.allAreNull(envelope.sourceServiceId, envelope.sourceServiceIdBinary)
val envelope = if (cipherResult?.metadata?.sourceServiceId != null) {
envelope.newBuilder()
.sourceServiceIdBinary(cipherResult.metadata.sourceServiceId.toByteString())
@@ -173,7 +175,7 @@ object MessageDecryptor {
return Result.Ignore(envelope, serverDeliveredTimestamp, followUpOperations.toUnmodifiableList())
}
if (cipherResult.metadata.sourceServiceId is PNI && (envelope.sourceServiceId == null && envelope.sourceServiceIdBinary == null)) {
if (cipherResult.metadata.sourceServiceId is PNI && hadSealedSenderSource) {
Log.w(TAG, "${logPrefix(envelope)} Invalid message! Sealed sender used for a PNI.")
return Result.Ignore(envelope, serverDeliveredTimestamp, followUpOperations.toUnmodifiableList())
}
@@ -1,19 +1,18 @@
package org.thoughtcrime.securesms.messages.protocol
import org.signal.core.models.ServiceId
import org.signal.libsignal.protocol.IdentityKey
import org.signal.libsignal.protocol.IdentityKeyPair
import org.signal.libsignal.protocol.SignalProtocolAddress
import org.signal.libsignal.protocol.state.IdentityKeyStore
import org.signal.libsignal.protocol.state.IdentityKeyStore.IdentityChange
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.whispersystems.signalservice.api.SignalServiceAccountDataStore
/**
* An in-memory identity key store that is intended to be used temporarily while decrypting messages.
*/
class BufferedIdentityKeyStore(
private val selfServiceId: ServiceId,
private val selfIdentityKeyPair: IdentityKeyPair,
private val selfRegistrationId: Int
) : IdentityKeyStore {
@@ -45,8 +44,12 @@ class BufferedIdentityKeyStore(
}
override fun isTrustedIdentity(address: SignalProtocolAddress, identityKey: IdentityKey, direction: IdentityKeyStore.Direction): Boolean {
if (address.name == selfServiceId.toString()) {
return identityKey == selfIdentityKeyPair.publicKey
val isSelf = address.name == SignalStore.account.aci?.toString() ||
address.name == SignalStore.account.pni?.toString() ||
address.name == SignalStore.account.e164
if (isSelf) {
return identityKey == SignalStore.account.aciIdentityKey.publicKey
}
return when (direction) {
@@ -26,9 +26,9 @@ import java.util.UUID
class BufferedSignalServiceAccountDataStore(selfServiceId: ServiceId) : SignalServiceAccountDataStore {
private val identityStore: BufferedIdentityKeyStore = if (selfServiceId == SignalStore.account.pni) {
BufferedIdentityKeyStore(selfServiceId, SignalStore.account.pniIdentityKey, SignalStore.account.pniRegistrationId)
BufferedIdentityKeyStore(SignalStore.account.pniIdentityKey, SignalStore.account.pniRegistrationId)
} else {
BufferedIdentityKeyStore(selfServiceId, SignalStore.account.aciIdentityKey, SignalStore.account.registrationId)
BufferedIdentityKeyStore(SignalStore.account.aciIdentityKey, SignalStore.account.registrationId)
}
private val oneTimePreKeyStore: BufferedOneTimePreKeyStore = BufferedOneTimePreKeyStore(selfServiceId)
@@ -11,24 +11,25 @@ import org.signal.mediasend.MediaConstraints;
import org.signal.mediasend.SentMediaQuality;
import org.thoughtcrime.securesms.dependencies.AppDependencies;
import org.thoughtcrime.securesms.jobs.AttachmentUploadJob;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.util.LocaleRemoteConfig;
import org.thoughtcrime.securesms.util.RemoteConfig;
import org.thoughtcrime.securesms.video.TranscodingPreset;
import org.thoughtcrime.securesms.video.videoconverter.utils.DeviceCapabilities;
import org.thoughtcrime.securesms.video.TranscodingConfig;
import org.whispersystems.signalservice.api.crypto.AttachmentCipherStreamUtil;
import java.util.Arrays;
import java.util.List;
public class PushMediaConstraints extends MediaConstraints {
private static final int KB = 1024;
private static final int MB = 1024 * KB;
private final MediaConfig currentConfig;
private final MediaConfig currentConfig;
private final SentMediaQuality sentMediaQuality;
public PushMediaConstraints(@Nullable SentMediaQuality sentMediaQuality) {
currentConfig = getCurrentConfig(AppDependencies.getApplication(), sentMediaQuality);
this.currentConfig = getCurrentConfig(AppDependencies.getApplication(), sentMediaQuality);
this.sentMediaQuality = sentMediaQuality;
}
@Override
@@ -95,8 +96,8 @@ public class PushMediaConstraints extends MediaConstraints {
}
@Override
public TranscodingPreset getVideoTranscodingSettings() {
return currentConfig.videoPreset;
public List<TranscodingConfig.QualityTier> getVideoTranscodingSettings() {
return sentMediaQuality == SentMediaQuality.HIGH ? currentConfig.videoConfigs.getHigh() : currentConfig.videoConfigs.getStandard();
}
private static @NonNull MediaConfig getCurrentConfig(@NonNull Context context, @Nullable SentMediaQuality sentMediaQuality) {
@@ -105,44 +106,37 @@ public class PushMediaConstraints extends MediaConstraints {
}
if (sentMediaQuality == SentMediaQuality.HIGH) {
if (DeviceCapabilities.canEncodeHevc() && (RemoteConfig.useHevcEncoder() || SignalStore.internal().getHevcEncoding())) {
return MediaConfig.LEVEL_3_H265;
} else {
return MediaConfig.LEVEL_3;
}
return MediaConfig.LEVEL_3;
}
return LocaleRemoteConfig.getMediaQualityLevel().orElse(MediaConfig.getDefault(context));
}
public enum MediaConfig {
LEVEL_1_LOW_MEMORY(true, 1, MB, new int[] { 768, 512 }, 70, TranscodingPreset.LEVEL_1),
LEVEL_1_LOW_MEMORY(true, 1, MB, new int[] { 768, 512 }, 70),
LEVEL_1(false, 1, MB, new int[] { 1600, 1024, 768, 512 }, 70, TranscodingPreset.LEVEL_1),
LEVEL_2(false, 2, (int) (1.5 * MB), new int[] { 2048, 1600, 1024, 768, 512 }, 75, TranscodingPreset.LEVEL_2),
LEVEL_3(false, 3, (int) (3 * MB), new int[] { 4096, 3072, 2048, 1600, 1024, 768, 512 }, 75, TranscodingPreset.LEVEL_3),
/** Experimental H265 level */
LEVEL_3_H265(false, 4, 3 * MB, new int[] { 4096, 3072, 2048, 1600, 1024, 768, 512 }, 75, TranscodingPreset.LEVEL_3_H265);
LEVEL_1(false, 1, MB, new int[] { 1600, 1024, 768, 512 }, 70),
LEVEL_2(false, 2, (int) (1.5 * MB), new int[] { 2048, 1600, 1024, 768, 512 }, 75),
LEVEL_3(false, 3, (int) (3 * MB), new int[] { 4096, 3072, 2048, 1600, 1024, 768, 512 }, 75);
private final boolean isLowMemory;
private final int level;
private final int maxImageFileSize;
private final int[] imageSizeTargets;
private final int qualitySetting;
private final TranscodingPreset videoPreset;
private final boolean isLowMemory;
private final int level;
private final int maxImageFileSize;
private final int[] imageSizeTargets;
private final int qualitySetting;
private final TranscodingConfig.TranscodeConfig videoConfigs;
MediaConfig(boolean isLowMemory,
int level,
int maxImageFileSize,
@NonNull int[] imageSizeTargets,
@IntRange(from = 0, to = 100) int qualitySetting,
TranscodingPreset videoPreset)
@IntRange(from = 0, to = 100) int qualitySetting)
{
this.isLowMemory = isLowMemory;
this.level = level;
this.maxImageFileSize = maxImageFileSize;
this.imageSizeTargets = imageSizeTargets;
this.qualitySetting = qualitySetting;
this.videoPreset = videoPreset;
this.videoConfigs = TranscodingConfigProvider.getAllConfigs();
}
public int getMaxImageFileSize() {
@@ -157,10 +151,6 @@ public class PushMediaConstraints extends MediaConstraints {
return qualitySetting;
}
public TranscodingPreset getVideoPreset() {
return videoPreset;
}
public static @Nullable MediaConfig forLevel(int level) {
boolean isLowMemory = Util.isLowMemory(AppDependencies.getApplication());
@@ -0,0 +1,27 @@
package org.thoughtcrime.securesms.mms
import com.google.i18n.phonenumbers.PhoneNumberUtil
import org.signal.mediasend.SentMediaQuality
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.RemoteConfig
import org.thoughtcrime.securesms.video.TranscodingConfig
/**
* Gets corresponding configs depending on locale and sent media quality
*/
object TranscodingConfigProvider {
@JvmStatic
fun getAllConfigs(): TranscodingConfig.TranscodeConfig {
val countryCode = PhoneNumberUtil.getInstance().parse(SignalStore.account.e164, "").countryCode
return TranscodingConfig.getTranscodeConfig(RemoteConfig.transcodeConfig, countryCode)
}
@JvmStatic
fun getConfigsForMediaQuality(quality: SentMediaQuality): List<TranscodingConfig.QualityTier> {
val config = getAllConfigs()
return when (quality) {
SentMediaQuality.STANDARD -> config.standard
SentMediaQuality.HIGH -> config.high
}
}
}
@@ -3,6 +3,10 @@ package org.thoughtcrime.securesms.profiles;
import org.thoughtcrime.securesms.jobs.AttachmentUploadJob;
import org.signal.mediasend.MediaConstraints;
import org.thoughtcrime.securesms.video.TranscodingConfig;
import java.util.Collections;
import java.util.List;
public class ProfileMediaConstraints extends MediaConstraints {
@Override
@@ -49,4 +53,9 @@ public class ProfileMediaConstraints extends MediaConstraints {
public long getMaxAttachmentSize() {
return AttachmentUploadJob.getMaxPlaintextSize();
}
@Override
public List<TranscodingConfig.QualityTier> getVideoTranscodingSettings() {
return Collections.emptyList();
}
}
@@ -660,7 +660,7 @@ class Recipient(
}
private fun getUnknownDisplayName(context: Context): String {
return if (registered == RegisteredState.NOT_REGISTERED) {
return if (!isResolving && registered == RegisteredState.NOT_REGISTERED) {
context.getString(R.string.Recipient_deleted_account)
} else {
context.getString(R.string.Recipient_unknown)
@@ -78,9 +78,9 @@ fun TransferAccountNavHost(
entries = entries,
onBack = { viewModel.goBack() },
modifier = modifier,
transitionSpec = TransitionSpecs.HorizontalSlide.transitionSpec,
popTransitionSpec = TransitionSpecs.HorizontalSlide.popTransitionSpec,
predictivePopTransitionSpec = TransitionSpecs.HorizontalSlide.predictivePopTransitionSpec
transitionSpec = { TransitionSpecs.HorizontalSlide.transitionSpec },
popTransitionSpec = { TransitionSpecs.HorizontalSlide.popTransitionSpec },
predictivePopTransitionSpec = { TransitionSpecs.HorizontalSlide.predictivePopTransitionSpec }
)
}
@@ -72,7 +72,7 @@ fun RestoreLocalBackupNavDisplay(
Box(modifier = Modifier.fillMaxSize()) {
NavDisplay(
backStack = backstack,
sceneStrategy = bottomSheetStrategy,
sceneStrategies = listOf(bottomSheetStrategy),
entryProvider = entryProvider {
entry<RestoreLocalBackupNavKey.SelectLocalBackupTypeScreen> {
SelectLocalBackupTypeScreen(
@@ -591,10 +591,11 @@ class AppRegistrationNetworkController(
val aci = SignalStore.account.aci ?: return@withContext RequestResult.ApplicationError(IllegalStateException("ACI not available"))
val currentTime = System.currentTimeMillis()
val messageBackupKey = aep.deriveMessageBackupKey()
val messageCredential = SignalStore.backup.messageCredentials.byDay.getForCurrentTime(currentTime.milliseconds)
val access = if (messageCredential != null) {
ArchiveServiceAccess(messageCredential, SignalStore.backup.messageBackupKey)
ArchiveServiceAccess(messageCredential, messageBackupKey)
} else {
when (val credResult = SignalNetwork.archive.getServiceCredentials(currentTime)) {
is NetworkResult.Success -> {
@@ -602,7 +603,7 @@ class AppRegistrationNetworkController(
SignalStore.backup.messageCredentials.clearOlderThan(currentTime)
val credential = SignalStore.backup.messageCredentials.byDay.getForCurrentTime(currentTime.milliseconds)
?: return@withContext RequestResult.ApplicationError(IllegalStateException("Failed to obtain backup credentials after fetch"))
ArchiveServiceAccess(credential, SignalStore.backup.messageBackupKey)
ArchiveServiceAccess(credential, messageBackupKey)
}
is NetworkResult.StatusCodeError -> return@withContext RequestResult.ApplicationError(IllegalStateException("Failed to fetch backup credentials: ${credResult.code}"))
is NetworkResult.NetworkError -> return@withContext RequestResult.RetryableNetworkError(credResult.exception)
@@ -729,7 +730,7 @@ class AppRegistrationNetworkController(
val messageCredential = SignalStore.backup.messageCredentials.byDay.getForCurrentTime(currentTime.milliseconds)
?: return@withContext RequestResult.ApplicationError(IllegalStateException("No message credential available"))
val access = ArchiveServiceAccess(messageCredential, SignalStore.backup.messageBackupKey)
val access = ArchiveServiceAccess(messageCredential, aep.deriveMessageBackupKey())
val cdnCredentials = when (val cdnResult = SignalNetwork.archive.getCdnReadCredentials(cdn, aci, access)) {
is NetworkResult.Success -> cdnResult.result.headers
@@ -6,75 +6,117 @@
package org.thoughtcrime.securesms.registration.v2
import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.annotation.VisibleForTesting
import androidx.core.app.NotificationManagerCompat
import androidx.core.net.toUri
import androidx.documentfile.provider.DocumentFile
import com.google.common.io.CountingInputStream
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okio.ByteString.Companion.toByteString
import org.greenrobot.eventbus.EventBus
import org.greenrobot.eventbus.Subscribe
import org.greenrobot.eventbus.ThreadMode
import org.signal.archive.LocalBackupRestoreProgress
import org.signal.core.models.AccountEntropyPool
import org.signal.core.models.MasterKey
import org.signal.core.models.ServiceId
import org.signal.core.models.ServiceId.ACI
import org.signal.core.models.ServiceId.PNI
import org.signal.core.models.backup.MediaRootBackupKey
import org.signal.core.models.backup.MessageBackupKey
import org.signal.core.util.AppUtil
import org.signal.core.util.Result
import org.signal.core.util.StreamUtil
import org.signal.core.util.crypto.AttachmentSecretProvider
import org.signal.core.util.getLength
import org.signal.core.util.logging.Log
import org.signal.libsignal.protocol.IdentityKeyPair
import org.signal.libsignal.protocol.state.KyberPreKeyRecord
import org.signal.libsignal.protocol.state.SignedPreKeyRecord
import org.signal.libsignal.zkgroup.profiles.ProfileKey
import org.signal.registration.PreExistingRegistrationData
import org.signal.registration.RestoreDecision
import org.signal.registration.StorageController
import org.signal.registration.StoredProfileData
import org.signal.registration.proto.AccountData
import org.signal.registration.proto.RegistrationData
import org.signal.registration.screens.localbackuprestore.LocalBackupInfo
import org.signal.registration.screens.messagesync.LinkAndSyncProgress
import org.signal.registration.screens.remotebackuprestore.RemoteBackupRestoreProgress
import org.thoughtcrime.securesms.backup.BackupEvent
import org.thoughtcrime.securesms.backup.BackupPassphrase
import org.thoughtcrime.securesms.backup.FullBackupImporter
import org.thoughtcrime.securesms.backup.v2.BackupRepository
import org.thoughtcrime.securesms.backup.v2.RemoteRestoreResult
import org.thoughtcrime.securesms.backup.v2.RestoreV2Event
import org.thoughtcrime.securesms.backup.v2.local.ArchiveFileSystem
import org.thoughtcrime.securesms.backup.v2.local.LocalArchiver
import org.thoughtcrime.securesms.backup.v2.local.SnapshotFileSystem
import org.thoughtcrime.securesms.crypto.AppAttachmentSecretStore
import org.thoughtcrime.securesms.crypto.PreKeyUtil
import org.thoughtcrime.securesms.crypto.ProfileKeyUtil
import org.thoughtcrime.securesms.crypto.SenderKeyUtil
import org.thoughtcrime.securesms.crypto.storage.PreKeyMetadataStore
import org.thoughtcrime.securesms.crypto.storage.SignalServiceAccountDataStoreImpl
import org.thoughtcrime.securesms.database.IdentityTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.databaseprotos.LinkedDeviceInfo
import org.thoughtcrime.securesms.database.model.databaseprotos.LocalRegistrationMetadata
import org.thoughtcrime.securesms.database.model.databaseprotos.RestoreDecisionState
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobmanager.runJobBlocking
import org.thoughtcrime.securesms.jobs.CheckKeyTransparencyJob
import org.thoughtcrime.securesms.jobs.DirectoryRefreshJob
import org.thoughtcrime.securesms.jobs.LocalBackupRestoreMediaJob
import org.thoughtcrime.securesms.jobs.PreKeysSyncJob
import org.thoughtcrime.securesms.jobs.RefreshOwnProfileJob
import org.thoughtcrime.securesms.jobs.RotateCertificateJob
import org.thoughtcrime.securesms.keyvalue.Completed
import org.thoughtcrime.securesms.keyvalue.NewAccount
import org.thoughtcrime.securesms.keyvalue.PhoneNumberPrivacyValues
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.keyvalue.Skipped
import org.thoughtcrime.securesms.keyvalue.isDecisionPending
import org.thoughtcrime.securesms.notifications.NotificationIds
import org.thoughtcrime.securesms.pin.SvrRepository
import org.thoughtcrime.securesms.profiles.AvatarHelper
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.registration.data.RegistrationRepository
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.registration.util.RegistrationUtil
import org.thoughtcrime.securesms.service.DirectoryRefreshListener
import org.thoughtcrime.securesms.service.LocalBackupListener
import org.thoughtcrime.securesms.service.RotateSignedPreKeyListener
import org.thoughtcrime.securesms.util.BackupUtil
import org.thoughtcrime.securesms.util.TextSecurePreferences
import org.whispersystems.signalservice.api.link.TransferArchiveResponse
import java.io.File
import java.io.IOException
import java.time.LocalDateTime
import java.time.ZoneId
import java.time.ZoneOffset
import kotlin.jvm.optionals.getOrNull
import kotlin.time.Duration.Companion.seconds
/**
* Implementation of [StorageController] that bridges to the app's existing storage infrastructure.
*/
class AppRegistrationStorageController(private val context: Context) : StorageController {
/**
* Restarts the process-wide network stack after account data is applied. Overridable only so tests can avoid
* touching the real, suite-shared network module; production must never replace it.
*/
@VisibleForTesting
internal var restartNetwork: () -> Unit = {
AppDependencies.resetNetwork()
AppDependencies.startNetwork()
}
companion object {
private val TAG = Log.tag(AppRegistrationStorageController::class)
private const val TEMP_PROTO_FILENAME = "registration-in-progress.proto"
@@ -174,100 +216,44 @@ class AppRegistrationStorageController(private val context: Context) : StorageCo
override suspend fun commitRegistrationData() = withContext(Dispatchers.IO) {
val data = readInProgressRegistrationData()
val accountData = data.accountData
// The account's master key is always the one derived from the AEP, which we expect to have by the time we commit.
// Restore it up-front so any master-key-derived state we touch below resolves against the correct value rather
// than lazily generating a new AEP.
val accountEntropyPool: AccountEntropyPool? = data.accountEntropyPool.takeIf { it.isNotEmpty() }?.let { AccountEntropyPool(it) }
if (accountEntropyPool != null) {
if (data.linkedDeviceData != null) {
SignalStore.account.setAccountEntropyPoolFromPrimaryDevice(accountEntropyPool)
} else {
SignalStore.account.restoreAccountEntropyPool(accountEntropyPool)
}
}
val masterKey: MasterKey? = applyAccountEntropyPool(data)
val masterKey: MasterKey? = accountEntropyPool?.deriveMasterKey()
// Build LocalRegistrationMetadata if we have enough data for account setup
if (data.e164.isNotEmpty() && data.aci.isNotEmpty() && data.pni.isNotEmpty() && data.servicePassword.isNotEmpty()) {
val profileKey = RegistrationRepository.getProfileKey(data.e164)
val metadata = LocalRegistrationMetadata.Builder().apply {
if (data.aciIdentityKeyPair.size > 0) {
aciIdentityKeyPair = data.aciIdentityKeyPair
}
if (data.pniIdentityKeyPair.size > 0) {
pniIdentityKeyPair = data.pniIdentityKeyPair
}
if (data.aciSignedPreKey.size > 0) {
aciSignedPreKey = data.aciSignedPreKey
}
if (data.pniSignedPreKey.size > 0) {
pniSignedPreKey = data.pniSignedPreKey
}
if (data.aciLastResortKyberPreKey.size > 0) {
aciLastRestoreKyberPreKey = data.aciLastResortKyberPreKey
}
if (data.pniLastResortKyberPreKey.size > 0) {
pniLastRestoreKyberPreKey = data.pniLastResortKyberPreKey
}
aci = data.aci
pni = data.pni
e164 = data.e164
this.servicePassword = data.servicePassword
this.profileKey = profileKey.serialize().toByteString()
hasPin = data.pin.isNotEmpty()
if (data.pin.isNotEmpty()) {
pin = data.pin
masterKey?.let { this.masterKey = it.serialize().toByteString() }
}
fcmEnabled = SignalStore.account.fcmEnabled
fcmToken = SignalStore.account.fcmToken ?: ""
reglockEnabled = data.registrationLockEnabled
data.linkedDeviceData?.let { linkData ->
linkedDeviceInfo = LinkedDeviceInfo(
deviceId = linkData.deviceId,
deviceName = linkData.deviceName,
ephemeralBackupKey = linkData.ephemeralBackupKey,
accountEntropyPool = data.accountEntropyPool,
mediaRootBackupKey = linkData.mediaRootBackupKey
)
}
}.build()
SignalStore.account.registrationId = data.aciRegistrationId
SignalStore.account.pniRegistrationId = data.pniRegistrationId
// TODO [greyson] Should probably move this stuff into this file as we get closer to being done
RegistrationRepository.registerAccountLocally(context, metadata)
SignalStore.registration.localRegistrationMetadata = metadata
data.linkedDeviceData?.readReceipts?.let { TextSecurePreferences.setReadReceiptsEnabled(context, it) }
// We only want to apply account data a single time
val svrStateUpdated = if (!data.accountDataCommitted && accountData != null && accountData.isComplete()) {
applyAccountData(
accountData = accountData,
pin = data.pin,
registrationLockEnabled = data.registrationLockEnabled,
masterKey = masterKey
)
updateInProgressRegistrationData { accountDataCommitted = true }
true
} else {
false
}
// Handle PIN/master key
if (data.pin.isNotEmpty() && masterKey != null && data.linkedDeviceData == null) {
SvrRepository.onRegistrationComplete(
masterKey,
data.pin,
true,
data.registrationLockEnabled,
data.accountEntropyPool.isNotEmpty()
)
} else if (data.pinOptedOut && data.linkedDeviceData == null) {
if (data.pin.isNotEmpty() && masterKey != null && accountData?.linkedDeviceData == null) {
// We call this same function in applyAccountData, so just avoiding double-calls
if (!svrStateUpdated) {
SvrRepository.onRegistrationComplete(
masterKey = masterKey,
userPin = data.pin,
hasPinToRestore = true,
setRegistrationLockEnabled = data.registrationLockEnabled,
restoredAEP = data.accountEntropyPool.isNotEmpty()
)
}
} else if (data.pinOptedOut && accountData?.linkedDeviceData == null) {
Log.i(TAG, "[commitRegistrationData] User opted out of creating a PIN. Applying opt-out.")
SvrRepository.optOutOfPin(rotateAep = false)
}
// The temporaryMasterKey is the one-time key restored from SVR during re-registration. The account's own master key
// is always the AEP-derived one above, so this is retained separately as the initial-restore key (used for the
// first storage service sync + recovery password). It must be set last, as onRegistrationComplete will have cleared
// the initial-restore key after recognizing the AEP-derived master key as our own.
if (data.temporaryMasterKey.size > 0) {
SignalStore.svr.masterKeyForInitialDataRestore = MasterKey(data.temporaryMasterKey.toByteArray())
// This must be set last, as SvrRepository.onRegistrationComplete will have cleared the initial-restore key after recognizing the AEP-derived master key as our own.
if (data.masterKeyForInitialDataRestore.size > 0) {
SignalStore.svr.masterKeyForInitialDataRestore = MasterKey(data.masterKeyForInitialDataRestore.toByteArray())
}
RegistrationUtil.maybeMarkRegistrationComplete()
@@ -285,84 +271,195 @@ class AppRegistrationStorageController(private val context: Context) : StorageCo
}
}
override fun restoreLocalBackupV1(uri: Uri, passphrase: String): Flow<LocalBackupRestoreProgress> = flow {
// TODO [greyson] better progress
Log.d(TAG, "Starting V1 local backup restore from: $uri")
override fun restoreLocalBackupV1(rootUri: Uri, backupUri: Uri, passphrase: String): Flow<LocalBackupRestoreProgress> = callbackFlow {
Log.d(TAG, "Starting V1 local backup restore from: $backupUri")
emit(LocalBackupRestoreProgress.Preparing)
trySend(LocalBackupRestoreProgress.Preparing)
try {
if (!FullBackupImporter.validatePassphrase(context, uri, passphrase)) {
emit(LocalBackupRestoreProgress.Error(IllegalArgumentException("Invalid passphrase")))
return@flow
// The importer only reports a running frame count with no total, so we track bytes read from the backup file against
// its size to produce a real progress fraction, sampling the counting stream on each frame-progress event.
val totalBytes = context.contentResolver.getLength(backupUri) ?: 0L
var countingStream: CountingInputStream? = null
val subscriber = object {
@Subscribe(threadMode = ThreadMode.POSTING)
fun onBackupEvent(event: BackupEvent) {
if (event.type == BackupEvent.Type.PROGRESS) {
trySend(LocalBackupRestoreProgress.InProgress(bytesRead = countingStream?.count ?: 0L, totalBytes = totalBytes))
}
}
val database = SignalDatabase.backupDatabase
FullBackupImporter.importFile(
context,
AttachmentSecretProvider.getInstance(context, AppAttachmentSecretStore).getOrCreateAttachmentSecret(),
database,
uri,
passphrase,
SignalStore.registration.localRegistrationMetadata != null
)
SignalDatabase.runPostBackupRestoreTasks(database)
emit(LocalBackupRestoreProgress.Complete)
Log.d(TAG, "V1 restore complete.")
} catch (e: FullBackupImporter.DatabaseDowngradeException) {
Log.w(TAG, "V1 restore failed: database downgrade", e)
emit(LocalBackupRestoreProgress.Error(e))
} catch (e: Exception) {
Log.w(TAG, "V1 restore failed", e)
emit(LocalBackupRestoreProgress.Error(e))
}
}.flowOn(Dispatchers.IO)
override fun restoreLocalBackupV2(rootUri: Uri, backupUri: Uri, aep: AccountEntropyPool): Flow<LocalBackupRestoreProgress> = flow {
// TODO [greyson] better progress
EventBus.getDefault().register(subscriber)
launch(Dispatchers.IO) {
try {
if (!FullBackupImporter.validatePassphrase(context, backupUri, passphrase)) {
Log.w(TAG, "V1 restore failed: incorrect passphrase")
trySend(LocalBackupRestoreProgress.IncorrectCredential)
return@launch
}
// If this flow has already committed an account locally, keep its fresh key material out of the restore.
val inProgressData = readInProgressRegistrationData()
val excludeKeyTables = inProgressData.accountDataCommitted
val database = SignalDatabase.backupDatabase
val inputStream = context.contentResolver.openInputStream(backupUri) ?: throw IOException("Unable to open backup stream for $backupUri")
CountingInputStream(inputStream).use { counting ->
countingStream = counting
FullBackupImporter.importFile(
context,
AttachmentSecretProvider.getInstance(context, AppAttachmentSecretStore).getOrCreateAttachmentSecret(),
database,
counting,
passphrase,
excludeKeyTables
)
}
SignalDatabase.runPostBackupRestoreTasks(database)
// The importer writes the restored key-value store straight to disk, bypassing the in-memory SignalStore cache.
// Reset it so the state we read below reflects the restored values rather than stale pre-restore ones.
SignalStore.onPostBackupRestore()
// A post-registration restore clobbers parts of SignalStore.account with the backup's values -- V1 backups
// carry the identity keys and AEP. Re-apply the frozen account data to heal the committed registration.
val committedAccountData = inProgressData.accountData
if (inProgressData.accountDataCommitted && committedAccountData != null) {
Log.i(TAG, "V1 restore ran after an account was committed. Re-applying committed account data.")
val masterKey = applyAccountEntropyPool(inProgressData)
applyAccountData(committedAccountData, pin = inProgressData.pin, registrationLockEnabled = inProgressData.registrationLockEnabled, masterKey = masterKey)
}
reenableLegacyLocalBackups(rootUri, passphrase)
trySend(readRestoredLocalBackupState(includePreRegistrationKeys = true))
Log.d(TAG, "V1 restore complete.")
} catch (e: FullBackupImporter.DatabaseDowngradeException) {
Log.w(TAG, "V1 restore failed: database downgrade", e)
trySend(LocalBackupRestoreProgress.Error(e))
} catch (e: Exception) {
Log.w(TAG, "V1 restore failed", e)
trySend(LocalBackupRestoreProgress.Error(e))
} finally {
channel.close()
}
}
awaitClose {
EventBus.getDefault().unregister(subscriber)
}
}
override suspend fun verifyLocalBackupKey(backupUri: Uri, aep: AccountEntropyPool): Boolean = withContext(Dispatchers.IO) {
val backupDir = DocumentFile.fromTreeUri(context, backupUri)
if (backupDir == null || !backupDir.canRead()) {
Log.w(TAG, "[verifyLocalBackupKey] Could not open backup directory.")
return@withContext false
}
LocalArchiver.canDecryptMainArchive(SnapshotFileSystem(context, backupDir), aep.deriveMessageBackupKey())
}
override fun restoreLocalBackupV2(rootUri: Uri, backupUri: Uri, aep: AccountEntropyPool): Flow<LocalBackupRestoreProgress> = callbackFlow {
Log.d(TAG, "Starting V2 local backup restore from backup=$backupUri, root=$rootUri")
emit(LocalBackupRestoreProgress.Preparing)
trySend(LocalBackupRestoreProgress.Preparing)
try {
val backupDir = DocumentFile.fromTreeUri(context, backupUri)
if (backupDir == null || !backupDir.canRead()) {
emit(LocalBackupRestoreProgress.Error(IllegalStateException("Could not open backup directory")))
return@flow
}
val selfAci = SignalStore.account.aci
val selfPni = SignalStore.account.pni
val selfE164 = SignalStore.account.e164
if (selfAci == null || selfPni == null || selfE164 == null) {
emit(LocalBackupRestoreProgress.Error(IllegalStateException("Account not registered, cannot restore V2 backup")))
return@flow
}
val selfData = BackupRepository.SelfData(selfAci, selfPni, selfE164, ProfileKeyUtil.getSelfProfileKey())
val messageBackupKey = aep.deriveMessageBackupKey()
val snapshotFileSystem = SnapshotFileSystem(context, backupDir)
when (val result = LocalArchiver.import(snapshotFileSystem, selfData, messageBackupKey)) {
is Result.Success -> {
emit(LocalBackupRestoreProgress.Complete)
Log.d(TAG, "V2 restore complete.")
}
is Result.Failure -> {
Log.w(TAG, "V2 restore failed: ${result.failure}")
emit(LocalBackupRestoreProgress.Error(IOException("V2 restore failed: ${result.failure}")))
// The import posts RestoreV2Event frame-progress events as it reads through the main archive stream, tracking bytes
// read against the stream length. Bridge those into InProgress so the UI can show a real percentage.
val subscriber = object {
@Subscribe(threadMode = ThreadMode.POSTING)
fun onRestoreEvent(event: RestoreV2Event) {
if (event.type == RestoreV2Event.Type.PROGRESS_RESTORE) {
trySend(LocalBackupRestoreProgress.InProgress(bytesRead = event.count.inWholeBytes, totalBytes = event.estimatedTotalCount.inWholeBytes))
}
}
} catch (e: Exception) {
Log.w(TAG, "V2 restore failed", e)
emit(LocalBackupRestoreProgress.Error(e))
}
}.flowOn(Dispatchers.IO)
EventBus.getDefault().register(subscriber)
launch(Dispatchers.IO) {
try {
val backupDir = DocumentFile.fromTreeUri(context, backupUri)
if (backupDir == null || !backupDir.canRead()) {
trySend(LocalBackupRestoreProgress.Error(IllegalStateException("Could not open backup directory")))
return@launch
}
val selfAci = SignalStore.account.aci
val selfPni = SignalStore.account.pni
val selfE164 = SignalStore.account.e164
if (selfAci == null || selfPni == null || selfE164 == null) {
trySend(LocalBackupRestoreProgress.Error(IllegalStateException("Account not registered, cannot restore V2 backup")))
return@launch
}
val selfData = BackupRepository.SelfData(selfAci, selfPni, selfE164, ProfileKeyUtil.getSelfProfileKey())
val messageBackupKey = aep.deriveMessageBackupKey()
val snapshotFileSystem = SnapshotFileSystem(context, backupDir)
if (!LocalArchiver.canDecryptMainArchive(snapshotFileSystem, messageBackupKey)) {
Log.w(TAG, "V2 restore failed: recovery key cannot decrypt backup")
trySend(LocalBackupRestoreProgress.IncorrectCredential)
return@launch
}
when (val result = LocalArchiver.import(snapshotFileSystem, selfData, messageBackupKey)) {
is Result.Success -> {
AppDependencies.jobManager.add(LocalBackupRestoreMediaJob.create(rootUri))
// The entered recovery key decrypted the backup the user chose to restore, so it always becomes the
// account's AEP -- even if the backup was made by a different account.
SignalStore.account.restoreAccountEntropyPool(aep)
updateInProgressRegistrationData { this.accountEntropyPool = aep.value }
// Re-enable new-style local backups pointing at the restored location, so the user keeps getting backups.
// Skip it if the folder is the SignalBackups directory itself, since it can't be reused as a destination.
val archiveFileSystem = ArchiveFileSystem.openForRestore(context, rootUri)
if (archiveFileSystem != null && !archiveFileSystem.isRootedAtSignalBackups) {
SignalStore.backup.newLocalBackupsDirectory = rootUri.toString()
SignalStore.backup.newLocalBackupsEnabled = true
LocalBackupListener.setNextBackupTimeToIntervalFromNow(context)
LocalBackupListener.schedule(context)
} else {
Log.w(TAG, "V2 local backup directory can't be reused as a destination; not re-enabling local backups.")
}
trySend(readRestoredLocalBackupState())
Log.d(TAG, "V2 restore complete.")
}
is Result.Failure -> {
Log.w(TAG, "V2 restore failed: ${result.failure}")
trySend(LocalBackupRestoreProgress.Error(IOException("V2 restore failed: ${result.failure}")))
}
}
} catch (e: Exception) {
Log.w(TAG, "V2 restore failed", e)
trySend(LocalBackupRestoreProgress.Error(e))
} finally {
channel.close()
}
}
awaitClose {
EventBus.getDefault().unregister(subscriber)
}
}
override suspend fun scanLocalBackupFolder(folderUri: Uri): List<LocalBackupInfo> = withContext(Dispatchers.IO) {
// Persist access immediately, while the picker's transient grant is still alive. Restore jobs read from this
// folder long after the registration activity (and its grant) are gone.
try {
val takeFlags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
context.contentResolver.takePersistableUriPermission(folderUri, takeFlags)
} catch (e: SecurityException) {
Log.w(TAG, "Unable to take persistable permission for backup folder", e)
}
val folder = DocumentFile.fromTreeUri(context, folderUri) ?: return@withContext emptyList()
val children = folder.listFiles()
@@ -386,7 +483,11 @@ class AppRegistrationStorageController(private val context: Context) : StorageCo
val match = MODERN_BACKUP_PATTERN.matchEntire(name) ?: continue
val (year, month, day, hour, minute, second) = match.destructured
try {
// V2 snapshot folders are named in UTC (see ArchiveFileSystem.createSnapshot), so convert to the device zone for display.
val date = LocalDateTime.of(year.toInt(), month.toInt(), day.toInt(), hour.toInt(), minute.toInt(), second.toInt())
.atOffset(ZoneOffset.UTC)
.atZoneSameInstant(ZoneId.systemDefault())
.toLocalDateTime()
backups.add(
LocalBackupInfo(
type = LocalBackupInfo.BackupType.V2,
@@ -483,7 +584,7 @@ class AppRegistrationStorageController(private val context: Context) : StorageCo
}
override fun restoreLinkAndSyncBackup(cdn: Int, key: String): Flow<LinkAndSyncProgress> = callbackFlow {
val ephemeralBackupKeyBytes = SignalStore.registration.localRegistrationMetadata?.linkedDeviceInfo?.ephemeralBackupKey?.toByteArray()
val ephemeralBackupKeyBytes = readInProgressRegistrationData().accountData?.linkedDeviceData?.ephemeralBackupKey?.toByteArray()
if (ephemeralBackupKeyBytes == null) {
Log.i(TAG, "[restoreLinkAndSyncBackup] No ephemeral backup key present; nothing to restore.")
@@ -544,4 +645,198 @@ class AppRegistrationStorageController(private val context: Context) : StorageCo
previousUri?.let { AppDependencies.blobs.delete(context, it) }
Unit
}
/**
* The account's master key is always the one derived from the AEP, which we expect to have by the time we commit.
* Restore it up-front so any master-key-derived state we touch afterwards resolves against the correct value rather
* than lazily generating a new AEP. Returns the AEP-derived master key, if an AEP is present.
*/
private fun applyAccountEntropyPool(data: RegistrationData): MasterKey? {
val accountEntropyPool = data.accountEntropyPool.takeIf { it.isNotEmpty() }?.let { AccountEntropyPool(it) } ?: return null
if (data.accountData?.linkedDeviceData != null) {
SignalStore.account.setAccountEntropyPoolFromPrimaryDevice(accountEntropyPool)
} else {
SignalStore.account.restoreAccountEntropyPool(accountEntropyPool)
}
return accountEntropyPool.deriveMasterKey()
}
private fun AccountData.isComplete(): Boolean {
return e164.isNotEmpty() && aci.isNotEmpty() && pni.isNotEmpty() && servicePassword.isNotEmpty()
}
/**
* Applies the one-time [AccountData] to permanent storage, registering the account locally. This runs once per
* registration, guarded by [RegistrationData.accountDataCommitted] -- the only re-application is after a
* post-registration V1 backup restore, which clobbers parts of [SignalStore.account] and re-applies this frozen
* data to heal them.
*/
private suspend fun applyAccountData(accountData: AccountData, pin: String, registrationLockEnabled: Boolean, masterKey: MasterKey?) {
Log.i(TAG, "[applyAccountData] Registering account locally.")
SignalStore.account.registrationId = accountData.aciRegistrationId
SignalStore.account.pniRegistrationId = accountData.pniRegistrationId
accountData.linkedDeviceData?.let {
SignalStore.account.deviceId = it.deviceId
SignalStore.account.deviceName = it.deviceName
}
val aciIdentityKeyPair = IdentityKeyPair(accountData.aciIdentityKeyPair.toByteArray())
val pniIdentityKeyPair = IdentityKeyPair(accountData.pniIdentityKeyPair.toByteArray())
SignalStore.account.restoreAciIdentityKeyFromBackup(aciIdentityKeyPair.publicKey.serialize(), aciIdentityKeyPair.privateKey.serialize())
SignalStore.account.restorePniIdentityKeyFromBackup(pniIdentityKeyPair.publicKey.serialize(), pniIdentityKeyPair.privateKey.serialize())
val aci = ACI.parseOrThrow(accountData.aci)
val pni = PNI.parseOrThrow(accountData.pni)
val isAciChanged = SignalStore.account.aci != aci
SignalStore.account.setAci(aci)
SignalStore.account.setPni(pni)
AppDependencies.resetProtocolStores()
AppDependencies.protocolStore.aci().sessions().archiveAllSessions()
AppDependencies.protocolStore.pni().sessions().archiveAllSessions()
SenderKeyUtil.clearAllState()
val aciProtocolStore = AppDependencies.protocolStore.aci()
val pniProtocolStore = AppDependencies.protocolStore.pni()
storeSignedAndLastResortPreKeys(aciProtocolStore, SignalStore.account.aciPreKeys, SignedPreKeyRecord(accountData.aciSignedPreKey.toByteArray()), KyberPreKeyRecord(accountData.aciLastResortKyberPreKey.toByteArray()))
storeSignedAndLastResortPreKeys(pniProtocolStore, SignalStore.account.pniPreKeys, SignedPreKeyRecord(accountData.pniSignedPreKey.toByteArray()), KyberPreKeyRecord(accountData.pniLastResortKyberPreKey.toByteArray()))
val profileKey = getOrCreateProfileKey(accountData.e164)
val recipientTable = SignalDatabase.recipients
val selfId = recipientTable.getAndPossiblyMergePnpVerified(aci, pni, accountData.e164)
recipientTable.setProfileSharing(selfId, true)
recipientTable.markRegisteredOrThrow(selfId, aci)
recipientTable.linkIdsForSelf(aci, pni, accountData.e164)
recipientTable.setProfileKey(selfId, profileKey)
AppDependencies.recipientCache.clearSelf()
SignalStore.account.setE164(accountData.e164)
val now = System.currentTimeMillis()
saveOwnIdentityKey(selfId, aci, aciProtocolStore, now)
saveOwnIdentityKey(selfId, pni, pniProtocolStore, now)
accountData.linkedDeviceData?.mediaRootBackupKey?.let {
SignalStore.backup.mediaRootBackupKey = MediaRootBackupKey(it.toByteArray())
}
SignalStore.account.setServicePassword(accountData.servicePassword)
SignalStore.account.setRegistered(registered = true, isAciChanged = isAciChanged)
TextSecurePreferences.setPromptedPushRegistration(context, true)
TextSecurePreferences.setUnauthorizedReceived(context, false)
NotificationManagerCompat.from(context).cancel(NotificationIds.UNREGISTERED_NOTIFICATION_ID)
SvrRepository.onRegistrationComplete(
masterKey = if (pin.isNotEmpty()) masterKey else null,
userPin = pin.takeIf { it.isNotEmpty() },
hasPinToRestore = pin.isNotEmpty(),
setRegistrationLockEnabled = registrationLockEnabled,
restoredAEP = SignalStore.account.restoredAccountEntropyPool
)
restartNetwork()
PreKeysSyncJob.enqueue()
recipientTable.clearSelfKeyTransparencyData()
CheckKeyTransparencyJob.enqueueIfNecessary(addDelay = true)
val jobManager = AppDependencies.jobManager
if (accountData.linkedDeviceData == null) {
jobManager.add(DirectoryRefreshJob(false))
jobManager.add(RotateCertificateJob())
DirectoryRefreshListener.schedule(context)
RotateSignedPreKeyListener.schedule(context)
} else {
SignalStore.account.isMultiDevice = true
jobManager.runJobBlocking(RefreshOwnProfileJob(), 30.seconds)
jobManager.add(RotateCertificateJob())
RotateSignedPreKeyListener.schedule(context)
}
accountData.linkedDeviceData?.readReceipts?.let { TextSecurePreferences.setReadReceiptsEnabled(context, it) }
}
private fun getOrCreateProfileKey(e164: String): ProfileKey {
val existing = SignalDatabase.recipients.getByE164(e164).getOrNull()?.let { ProfileKeyUtil.profileKeyOrNull(SignalDatabase.recipients.getRecord(it).profileKey) }
return existing ?: ProfileKeyUtil.createNew().also { Log.i(TAG, "[commitRegistrationData] No profile key found, created a new one") }
}
private fun saveOwnIdentityKey(selfId: RecipientId, serviceId: ServiceId, protocolStore: SignalServiceAccountDataStoreImpl, now: Long) {
protocolStore.identities().saveIdentityWithoutSideEffects(
selfId,
serviceId,
protocolStore.identityKeyPair.publicKey,
IdentityTable.VerifiedStatus.VERIFIED,
true,
now,
true
)
}
private fun storeSignedAndLastResortPreKeys(protocolStore: SignalServiceAccountDataStoreImpl, metadataStore: PreKeyMetadataStore, signedPreKey: SignedPreKeyRecord, lastResortKyberPreKey: KyberPreKeyRecord) {
PreKeyUtil.storeSignedPreKey(protocolStore, metadataStore, signedPreKey)
metadataStore.isSignedPreKeyRegistered = true
metadataStore.activeSignedPreKeyId = signedPreKey.id
metadataStore.lastSignedPreKeyRotationTime = System.currentTimeMillis()
PreKeyUtil.storeLastResortKyberPreKey(protocolStore, metadataStore, lastResortKyberPreKey)
metadataStore.lastResortKyberPreKeyId = lastResortKyberPreKey.id
metadataStore.lastResortKyberPreKeyRotationTime = System.currentTimeMillis()
}
/**
* Persists the restored backup folder as the backup directory and re-enables scheduled local backups, so the user
* keeps getting backups after restoring. Best-effort: a failure here must not fail the restore itself.
*/
private fun reenableLegacyLocalBackups(rootUri: Uri, passphrase: String) {
try {
BackupPassphrase.set(context, passphrase)
val takeFlags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
context.contentResolver.takePersistableUriPermission(rootUri, takeFlags)
SignalStore.settings.setSignalBackupDirectory(rootUri)
if (BackupUtil.canUserAccessBackupDirectory(context)) {
LocalBackupListener.setNextBackupTimeToIntervalFromNow(context)
SignalStore.settings.isBackupEnabled = true
LocalBackupListener.schedule(context)
} else {
Log.w(TAG, "Can't access restored backup directory; not re-enabling local backups.")
}
} catch (e: Exception) {
Log.w(TAG, "Failed to re-enable local backups after V1 restore.", e)
}
}
private fun readRestoredLocalBackupState(includePreRegistrationKeys: Boolean = false): LocalBackupRestoreProgress.Complete {
val restoredPin = SignalStore.svr.pin?.takeIf { it.isNotBlank() }
val restoredProfileKey = SignalStore.account.aci
?.let { SignalDatabase.recipients.getByAci(it).getOrNull() }
?.let { SignalDatabase.recipients.getRecord(it).profileKey }
?.let { ProfileKey(it) }
val restoredAccountEntropyPool = if (includePreRegistrationKeys) SignalStore.account.accountEntropyPoolOrNull else null
val restoredAciIdentityKey = if (includePreRegistrationKeys && SignalStore.account.hasAciIdentityKey()) SignalStore.account.aciIdentityKey else null
val restoredPniIdentityKey = if (includePreRegistrationKeys && SignalStore.account.hasPniIdentityKey()) SignalStore.account.pniIdentityKey else null
return LocalBackupRestoreProgress.Complete(
restoredSvrPin = restoredPin,
restoredProfileKey = restoredProfileKey,
restoredAccountEntropyPool = restoredAccountEntropyPool,
restoredAciIdentityKey = restoredAciIdentityKey,
restoredPniIdentityKey = restoredPniIdentityKey
)
}
}
@@ -183,8 +183,8 @@ object StorageSyncHelper {
backupTier = when {
SignalStore.account.isLinkedDevice -> null
SignalStore.backup.areBackupsEnabled && SignalStore.backup.backupTier != null -> getBackupLevelValue(SignalStore.backup.backupTier!!)
SignalStore.backup.backupTierInternalOverride != null -> getBackupLevelValue(SignalStore.backup.backupTierInternalOverride!!)
SignalStore.backup.areBackupsEnabled && SignalStore.backup.backupTier != null -> SignalStore.backup.backupTier!!.toBackupLevel()
SignalStore.backup.backupTierInternalOverride != null -> SignalStore.backup.backupTierInternalOverride!!.toBackupLevel()
else -> null
}
@@ -213,14 +213,6 @@ object StorageSyncHelper {
return accountRecord.toSignalAccountRecord(StorageId.forAccount(storageId)).toSignalStorageRecord()
}
// TODO: Currently we don't have access to the private values of the BackupLevel. Update when it becomes available.
private fun getBackupLevelValue(tier: MessageBackupTier): Long {
return when (tier) {
MessageBackupTier.FREE -> 200
MessageBackupTier.PAID -> 201
}
}
private fun getNotificationProfileManualOverride(): AccountRecord.NotificationProfileManualOverride? {
val profile = SignalDatabase.notificationProfiles.getProfile(SignalStore.notificationProfile.manuallyEnabledProfile)
return if (profile != null && profile.deletedTimestampMs == 0L) {
@@ -296,6 +288,13 @@ object StorageSyncHelper {
setSubscriber(remoteBackupsSubscriber)
}
if (SignalStore.account.isLinkedDevice) {
val remoteBackupTier = MessageBackupTier.fromBackupLevel(update.new.proto.backupTier)
if (remoteBackupTier != SignalStore.backup.backupTier) {
SignalStore.backup.backupTier = remoteBackupTier
}
}
if (update.new.proto.subscriptionManuallyCancelled && !update.old.proto.subscriptionManuallyCancelled) {
SignalStore.inAppPayments.updateLocalStateForManualCancellation(InAppPaymentSubscriberRecord.Type.DONATION)
}
@@ -23,7 +23,7 @@ object Environment {
}
@JvmField
val USE_NEW_REGISTRATION: Boolean = isInternal()
val USE_NEW_REGISTRATION: Boolean = true
@JvmField
val IS_LINK_AND_SYNC_AVAILABLE: Boolean = BuildConfig.DEBUG
@@ -1157,14 +1157,6 @@ object RemoteConfig {
hotSwappable = true
)
@JvmStatic
@get:JvmName("useHevcEncoder")
val useHevcEncoder: Boolean by remoteBoolean(
key = "android.useHevcEncoder",
defaultValue = false,
hotSwappable = false
)
@JvmStatic
@get:JvmName("useMessageSendRestFallback")
val useMessageSendRestFallback: Boolean by remoteBoolean(
@@ -1414,5 +1406,14 @@ object RemoteConfig {
hotSwappable = true
)
/** A json string representing possible transcoding configurations for videos */
@JvmStatic
@get:JvmName("transcodeConfig")
val transcodeConfig: String by remoteString(
key = "client.attachments.videoTranscodingConfiguration",
defaultValue = "",
hotSwappable = true
)
// endregion
}
@@ -32,7 +32,10 @@ object UriUtil {
}
} else if (ContentResolver.SCHEME_CONTENT == uri.scheme) {
val authority = uri.authority ?: return false
return !authority.startsWith(context.packageName)
// Protect against URI's like "content://0@org.thoughtcrime.securesms.part/..."
val normalizedAuthority = authority.substringAfterLast('@')
return !normalizedAuthority.startsWith(context.packageName)
} else {
return true
}
@@ -43,6 +43,13 @@
app:exitAnim="@anim/fragment_open_exit"
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit" />
<action
android:id="@+id/action_appSettingsFragment_to_linkedDeviceAccountSettingsFragment"
app:destination="@id/linkedDeviceAccountSettingsFragment"
app:enterAnim="@anim/fragment_open_enter"
app:exitAnim="@anim/fragment_open_exit"
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit" />
<action
android:id="@+id/action_appSettingsFragment_to_remoteBackupsSettingsFragment"
app:destination="@id/remoteBackupsSettingsFragment"
@@ -211,6 +218,12 @@
app:popExitAnim="@anim/fragment_close_exit" />
</fragment>
<fragment
android:id="@+id/linkedDeviceAccountSettingsFragment"
android:name="org.thoughtcrime.securesms.components.settings.app.account.LinkedDeviceAccountSettingsFragment"
android:label="linked_device_account_settings_fragment"
tools:layout="@layout/dsl_settings_fragment" />
<include app:graph="@navigation/app_settings_change_number" />
<fragment
@@ -855,6 +868,16 @@
app:popUpTo="@id/app_settings"
app:popUpToInclusive="true" />
<action
android:id="@+id/action_direct_to_linkedDeviceAccountSettingsFragment"
app:destination="@id/linkedDeviceAccountSettingsFragment"
app:enterAnim="@anim/fragment_open_enter"
app:exitAnim="@anim/fragment_open_exit"
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit"
app:popUpTo="@id/app_settings"
app:popUpToInclusive="true" />
<!-- endregion -->
<!-- Labs Settings -->
+50 -12
View File
@@ -616,7 +616,7 @@
<!-- Action shown to allow a user to re-register as they are no longer registered -->
<string name="ConversationFragment__reregister_signal">Herregistreer Signal</string>
<!-- Action shown in the conversation input area to allow a user to re-link this device as it is no longer linked -->
<string name="ConversationFragment__relink_signal">Re-link</string>
<string name="ConversationFragment__relink_signal">Herkoppel</string>
<!-- Label for a button displayed in the conversation toolbar to return to the previous screen. -->
<string name="ConversationFragment__content_description_back_button">Navigeer terug.</string>
<!-- Label for a button displayed in the conversation toolbar to open the main screen of the app. -->
@@ -3492,9 +3492,9 @@
<!-- Action in reminder banner that will take user to re-register -->
<string name="UnauthorizedReminder_reregister_action">Herregistreer toestel</string>
<!-- Message shown in a reminder banner when this linked device is no longer linked to a primary device -->
<string name="UnauthorizedReminder_this_device_is_no_longer_linked_relink_to_continue_messaging">This device is no longer linked. Re-link Signal with your phone to continue messaging.</string>
<string name="UnauthorizedReminder_this_device_is_no_longer_linked_relink_to_continue_messaging">Hierdie toestel is nie meer gekoppel nie. Herkoppel Signal aan jou foon om voort te gaan om boodskappe te stuur.</string>
<!-- Action in reminder banner that will take the user to re-link this device -->
<string name="UnauthorizedReminder_relink_action">Re-link device</string>
<string name="UnauthorizedReminder_relink_action">Herkoppel toestel</string>
<!-- Push notification when the app is forcibly logged out by the server. -->
<string name="LoggedOutNotification_you_have_been_logged_out">Jy is op hierdie toestel uit Signal uitgeteken.</string>
@@ -5107,6 +5107,32 @@
<!-- Error message shown when we fail to delete the data for some unknown reason -->
<string name="preferences_account_delete_all_data_failed">Kon nie data uitvee nie</string>
<!-- LinkedDeviceAccountSettingsFragment -->
<!-- Title of an informational card shown on the account settings screen of a linked device -->
<string name="LinkedDeviceAccountSettingsFragment__this_is_a_linked_device">This is a linked device</string>
<!-- Body of an informational card explaining that account settings must be managed from the primary device -->
<string name="LinkedDeviceAccountSettingsFragment__to_manage_your_account_settings">To manage your account settings, open Signal on your primary device.</string>
<!-- Title of a settings row that deletes all Signal data from this linked device -->
<string name="LinkedDeviceAccountSettingsFragment__delete_app_data">Delete app data</string>
<!-- Subtitle of the delete app data row explaining what it does -->
<string name="LinkedDeviceAccountSettingsFragment__this_will_remove_all_data">This will remove all data from the Signal app on this device.</string>
<!-- Title of the confirmation dialog shown when deleting app data from a linked device -->
<string name="LinkedDeviceAccountSettingsFragment__delete_app_data_question">Delete app data?</string>
<!-- Message of the confirmation dialog explaining that only this device\'s data is deleted, not the account -->
<string name="LinkedDeviceAccountSettingsFragment__this_will_delete_all_data_and_messages">This will delete all data and messages from the Signal app on this device. Your Signal account and data on your primary device or other linked devices will not be deleted. The app will close after this process is complete.</string>
<!-- LinkedDeviceAccountLearnMoreBottomSheet -->
<!-- Emphasized lead-in phrase shown in bold at the start of the learn more sheet body. Must appear verbatim within LinkedDeviceAccountLearnMoreBottomSheet__linked_devices_let_you_access. -->
<string name="LinkedDeviceAccountLearnMoreBottomSheet__linked_devices">Linked Devices</string>
<!-- Body text of the linked devices learn more sheet. The leading \"Linked Devices\" is shown in bold. -->
<string name="LinkedDeviceAccountLearnMoreBottomSheet__linked_devices_let_you_access">Linked Devices let you access your Signal account and messages securely on any of your additional devices.</string>
<!-- Bullet point describing what devices can be linked -->
<string name="LinkedDeviceAccountLearnMoreBottomSheet__you_can_link_a_desktop">You can link a desktop, tablet or additional phone</string>
<!-- Bullet point describing that the primary device manages linked devices -->
<string name="LinkedDeviceAccountLearnMoreBottomSheet__your_primary_device_manages">Your primary device manages what\'s linked, you can link up to 5 devices.</string>
<!-- Bullet point describing that some settings are only available on the primary device -->
<string name="LinkedDeviceAccountLearnMoreBottomSheet__some_account_settings">Some account settings are managed on your primary device only, including account settings and backups.</string>
<!-- TransferOrRestoreFragment -->
<string name="TransferOrRestoreFragment__transfer_or_restore_account">Dra rekening oor of herstel dit.</string>
<string name="TransferOrRestoreFragment__if_you_have_previously_registered_a_signal_account">As jy voorheen \'n Signal-rekening geregistreer het, kan jy jou rekening en boodskappe oordra of herstel</string>
@@ -8246,11 +8272,11 @@
<!-- Text describing how to restore a backup displayed on the on-device backups screen -->
<string name="OnDeviceBackupsScreen__to_restore_a_backup">"Om \'n rugsteun te herwin, installeer \'n nuwe kopie van Signal. Maak die toepassing oop, tik op \"Herwin rugsteun\" en vind dan die rugsteunlêer."</string>
<!-- Body of a dialog shown when turning on on-device backups while "Optimize Signal storage" is enabled, warning that optimized media won\'t be included -->
<string name="OnDeviceBackupsScreen__you_have_optimize_signal_storage_on">You have \"Optimize Signal storage\" on. If you want to include all media in your on-device backup, turn the setting off.</string>
<string name="OnDeviceBackupsScreen__you_have_optimize_signal_storage_on">Jou \"Optimaliseer Signal-stoorruimte\" is aangeskakel. As jy alle media in jou rugsteun op die toestel wil insluit, skakel hierdie instelling af.</string>
<!-- Dialog action that takes the user to the "Optimize Signal storage" setting -->
<string name="OnDeviceBackupsScreen__view_setting">View setting</string>
<string name="OnDeviceBackupsScreen__view_setting">Besigtig instellings</string>
<!-- Dialog action to proceed with turning on on-device backups without turning off "Optimize Signal storage" -->
<string name="OnDeviceBackupsScreen__continue_without_turning_off">Continue without turning off</string>
<string name="OnDeviceBackupsScreen__continue_without_turning_off">Gaan voort sonder om af te skakel</string>
<!-- Title of a megaphone shown to prompt the user to verify their recovery key -->
<string name="VerifyBackupKey__title">Verifieer jou herwinsleutel</string>
@@ -8635,9 +8661,9 @@
<!-- Dialog message for paid tier pending dialog -->
<string name="ManageStorageSettingsFragment__storage_optimization_can_only_be_used">Stoorruimte-optimalisering kan slegs met die betaalde vlak van Signal se Veilige Rugsteun gebruik word. Jou rugsteun-intekening word nog verwerk en is nog nie aktief nie. Probeer asseblief later weer.</string>
<!-- Title of a dialog shown when turning on "Optimize Signal storage" while on-device backups are enabled -->
<string name="ManageStorageSettingsFragment__media_will_be_removed_from_your_on_device_backup">Media will be removed from your on-device backup</string>
<string name="ManageStorageSettingsFragment__media_will_be_removed_from_your_on_device_backup">Media sal van jou rugsteun op die toestel verwyder word</string>
<!-- Body of a dialog shown when turning on "Optimize Signal storage" while on-device backups are enabled -->
<string name="ManageStorageSettingsFragment__turning_on_optimize_signal_storage_will_offload">Turning on \"Optimize Signal storage\" will offload old media from your device. You can download it again any time. It will also remove old media from your on-device backup.</string>
<string name="ManageStorageSettingsFragment__turning_on_optimize_signal_storage_will_offload">As jy \"Optimaliseer Signal-stoorruimte\" aanskakel, sal dit ou media van jou toestel aflaai. Jy kan dit enige tyd weer oplaai. Dit sal ook ou media uit jou rugsteun op die toestel verwyder.</string>
<!-- Dialog action to confirm turning on "Optimize Signal storage" -->
<string name="ManageStorageSettingsFragment__turn_on">Skakel aan</string>
@@ -8722,6 +8748,8 @@
<string name="BackupsSettingsFragment__subscription_canceled">Intekening gekanselleer</string>
<!-- Subtitle for row for no backup ever created -->
<string name="BackupsSettingsFragment_automatic_backups_with_signals">Outomatiese rugsteun met Signal se veilige end-tot-end-geënkripteerde stoorruimtediens.</string>
<!-- Subtitle shown on a linked device when backups are off, directing the user to set them up on their phone -->
<string name="BackupsSettingsFragment__automatic_backups_get_started_on_your_phone">Automatic backups with Signal\'s secure end-to-end encrypted storage service. Get started on your phone.</string>
<!-- Subtitle for row for backups that are active but subscription not found -->
<string name="BackupsSettingsFragment_subscription_not_found_on_this_device">"Intekening nie op hierdie toestel gevind nie."</string>
<!-- Action button label to set up backups -->
@@ -8825,6 +8853,16 @@
<string name="RemoteBackupsSettingsFragment__s_at_s">%1$s om %2$s</string>
<!-- Subtext describing when a backup has never taken place. -->
<string name="RemoteBackupsSettingsFragment__never">Nooit</string>
<!-- Subtext on a linked device describing when the primary device last backed up. Placeholder is the formatted date and time. -->
<string name="RemoteBackupsSettingsFragment__your_phone_s">Your phone · %1$s</string>
<!-- Message shown on a linked device with a free backup plan explaining that the subscription is managed on the primary device -->
<string name="RemoteBackupsSettingsFragment__you_can_manage_or_upgrade_your_subscription_on_your_primary_device">You can manage or upgrade your Signal Secure Backups subscription on your primary device.</string>
<!-- Message shown on a linked device with a paid backup plan explaining that the subscription is managed on the primary device -->
<string name="RemoteBackupsSettingsFragment__you_can_manage_or_cancel_your_subscription_on_your_primary_device">You can manage or cancel your Signal Secure Backups subscription on your primary device.</string>
<!-- Message shown on a linked device with a canceled backup subscription explaining that the subscription is managed on the primary device -->
<string name="RemoteBackupsSettingsFragment__you_can_manage_or_renew_your_subscription_on_your_primary_device">You can manage or renew your Signal Secure Backups subscription on your primary device.</string>
<!-- Message shown on a linked device when backups are off or the subscription was not found, explaining that backups are managed on the primary device -->
<string name="RemoteBackupsSettingsFragment__you_can_manage_or_upgrade_backups_on_your_primary_device">You can manage or upgrade Signal Secure Backups on your primary device.</string>
<!-- Button label to perform a backup immediately -->
<string name="RemoteBackupsSettingsFragment__back_up_now">Rugsteun nou</string>
<!-- Dialog title when confirming whether to turn off and deleting backups -->
@@ -9888,11 +9926,11 @@
<!-- Header for a section showing all group member -->
<string name="MemberSearchFragment__search_members">Soek lede</string>
<!-- Header for a section showing all group members who are contacts -->
<string name="MemberSearchFragment__search_contacts">Search contacts</string>
<string name="MemberSearchFragment__search_contacts">Soek deur kontakte</string>
<!-- Header for a section showing all group member who are admins -->
<string name="MemberSearchFragment__search_admins">Search admins</string>
<string name="MemberSearchFragment__search_admins">Soek deur admins</string>
<!-- Dialog title to filter search by -->
<string name="MemberSearchFragment__filter">Filter by</string>
<string name="MemberSearchFragment__filter">Filtreer volgens</string>
<!-- Dialog option for all group members -->
<string name="MemberSearchFragment__filter_all_members">Alle lede</string>
<!-- Dialog option for group admins -->
@@ -9900,7 +9938,7 @@
<!-- Dialog option for your contacts -->
<string name="MemberSearchFragment__filter_contacts">Kontakte</string>
<!-- Button to invite members via a group link -->
<string name="MemberSearchFragment__invite_via">Invite via group link</string>
<string name="MemberSearchFragment__invite_via">Nooi via groepskakel</string>
<!-- Title of the sheet shown when a local backup restore could not be completed -->
<string name="CouldNotCompleteBackupRestoreSheet__title">Kan nie rugsteun herwin nie</string>
+47 -9
View File
@@ -5639,6 +5639,32 @@
<!-- Error message shown when we fail to delete the data for some unknown reason -->
<string name="preferences_account_delete_all_data_failed">فشلت عملية حذف البيانات</string>
<!-- LinkedDeviceAccountSettingsFragment -->
<!-- Title of an informational card shown on the account settings screen of a linked device -->
<string name="LinkedDeviceAccountSettingsFragment__this_is_a_linked_device">This is a linked device</string>
<!-- Body of an informational card explaining that account settings must be managed from the primary device -->
<string name="LinkedDeviceAccountSettingsFragment__to_manage_your_account_settings">To manage your account settings, open Signal on your primary device.</string>
<!-- Title of a settings row that deletes all Signal data from this linked device -->
<string name="LinkedDeviceAccountSettingsFragment__delete_app_data">Delete app data</string>
<!-- Subtitle of the delete app data row explaining what it does -->
<string name="LinkedDeviceAccountSettingsFragment__this_will_remove_all_data">This will remove all data from the Signal app on this device.</string>
<!-- Title of the confirmation dialog shown when deleting app data from a linked device -->
<string name="LinkedDeviceAccountSettingsFragment__delete_app_data_question">Delete app data?</string>
<!-- Message of the confirmation dialog explaining that only this device\'s data is deleted, not the account -->
<string name="LinkedDeviceAccountSettingsFragment__this_will_delete_all_data_and_messages">This will delete all data and messages from the Signal app on this device. Your Signal account and data on your primary device or other linked devices will not be deleted. The app will close after this process is complete.</string>
<!-- LinkedDeviceAccountLearnMoreBottomSheet -->
<!-- Emphasized lead-in phrase shown in bold at the start of the learn more sheet body. Must appear verbatim within LinkedDeviceAccountLearnMoreBottomSheet__linked_devices_let_you_access. -->
<string name="LinkedDeviceAccountLearnMoreBottomSheet__linked_devices">Linked Devices</string>
<!-- Body text of the linked devices learn more sheet. The leading \"Linked Devices\" is shown in bold. -->
<string name="LinkedDeviceAccountLearnMoreBottomSheet__linked_devices_let_you_access">Linked Devices let you access your Signal account and messages securely on any of your additional devices.</string>
<!-- Bullet point describing what devices can be linked -->
<string name="LinkedDeviceAccountLearnMoreBottomSheet__you_can_link_a_desktop">You can link a desktop, tablet or additional phone</string>
<!-- Bullet point describing that the primary device manages linked devices -->
<string name="LinkedDeviceAccountLearnMoreBottomSheet__your_primary_device_manages">Your primary device manages what\'s linked, you can link up to 5 devices.</string>
<!-- Bullet point describing that some settings are only available on the primary device -->
<string name="LinkedDeviceAccountLearnMoreBottomSheet__some_account_settings">Some account settings are managed on your primary device only, including account settings and backups.</string>
<!-- TransferOrRestoreFragment -->
<string name="TransferOrRestoreFragment__transfer_or_restore_account">نقل أو استعادة الحساب</string>
<string name="TransferOrRestoreFragment__if_you_have_previously_registered_a_signal_account">إذا سبق لك التسجيل بحساب سيجنال، يمكنك نقل أو استعادة حسابك وكذلك رسائلك.</string>
@@ -8998,11 +9024,11 @@
<!-- Text describing how to restore a backup displayed on the on-device backups screen -->
<string name="OnDeviceBackupsScreen__to_restore_a_backup">"لاستعادة نسخة احتياطية، ثبِّت نسخة جديدة من سيجنال. افتح التطبيق وانقر على \"استعادة النسخة الاحتياطية\"، ثم حدِّد مكان ملف النسخة الاحتياطية."</string>
<!-- Body of a dialog shown when turning on on-device backups while "Optimize Signal storage" is enabled, warning that optimized media won\'t be included -->
<string name="OnDeviceBackupsScreen__you_have_optimize_signal_storage_on">You have \"Optimize Signal storage\" on. If you want to include all media in your on-device backup, turn the setting off.</string>
<string name="OnDeviceBackupsScreen__you_have_optimize_signal_storage_on">إعداد \"تحسين مساحة التخزين على سيجنال\" مُفعَّل. إذا كُنتَ ترغب في تضمين جميع ملفات الوسائط في النسخة الاحتياطية على الجهاز، يُرجى إيقاف تشغيل هذا الإعداد.</string>
<!-- Dialog action that takes the user to the "Optimize Signal storage" setting -->
<string name="OnDeviceBackupsScreen__view_setting">View setting</string>
<string name="OnDeviceBackupsScreen__view_setting">إظهار الإعدادات</string>
<!-- Dialog action to proceed with turning on on-device backups without turning off "Optimize Signal storage" -->
<string name="OnDeviceBackupsScreen__continue_without_turning_off">Continue without turning off</string>
<string name="OnDeviceBackupsScreen__continue_without_turning_off">المواصلة دون إيقاف التشغيل</string>
<!-- Title of a megaphone shown to prompt the user to verify their recovery key -->
<string name="VerifyBackupKey__title">قُم بتأكيد مفتاح الاستعادة الخاص بك</string>
@@ -9399,9 +9425,9 @@
<!-- Dialog message for paid tier pending dialog -->
<string name="ManageStorageSettingsFragment__storage_optimization_can_only_be_used">لا يمكن استخدام ميزة تحسين التخزين إلّا مع المُستوى المدفوع لنسخ سيجنال الاحتياطية. اشتراك النسخ الاحتياطية الخاص بك لا يزال قيد المعالجة ولم يتم تفعيله بعد. يُرجى المحاولة مرّة أخرى لاحقًا.</string>
<!-- Title of a dialog shown when turning on "Optimize Signal storage" while on-device backups are enabled -->
<string name="ManageStorageSettingsFragment__media_will_be_removed_from_your_on_device_backup">Media will be removed from your on-device backup</string>
<string name="ManageStorageSettingsFragment__media_will_be_removed_from_your_on_device_backup">سيتم حذف ملفات الوسائط من النسخة الاحتياطية على الجهاز لديك.</string>
<!-- Body of a dialog shown when turning on "Optimize Signal storage" while on-device backups are enabled -->
<string name="ManageStorageSettingsFragment__turning_on_optimize_signal_storage_will_offload">Turning on \"Optimize Signal storage\" will offload old media from your device. You can download it again any time. It will also remove old media from your on-device backup.</string>
<string name="ManageStorageSettingsFragment__turning_on_optimize_signal_storage_will_offload">سيؤدي تفعيل \"تحسين مساحة التخزين على سيجنال\" إلى نقل الوسائط القديمة من جهازك. يمكنك تنزيلها من جديد في أي وقت. سيؤدي هذا أيضًا إلي حذف الوسائط القديمة من النسخة الاحتياطية على الجهاز.</string>
<!-- Dialog action to confirm turning on "Optimize Signal storage" -->
<string name="ManageStorageSettingsFragment__turn_on">تشغيل</string>
@@ -9498,6 +9524,8 @@
<string name="BackupsSettingsFragment__subscription_canceled">تمَّ إلغاء الاشتراك</string>
<!-- Subtitle for row for no backup ever created -->
<string name="BackupsSettingsFragment_automatic_backups_with_signals">نسخ احتياطية تلقائية باستخدام خدمة تخزين مُشفَّرة من طرف لطرف على سيجنال.</string>
<!-- Subtitle shown on a linked device when backups are off, directing the user to set them up on their phone -->
<string name="BackupsSettingsFragment__automatic_backups_get_started_on_your_phone">Automatic backups with Signal\'s secure end-to-end encrypted storage service. Get started on your phone.</string>
<!-- Subtitle for row for backups that are active but subscription not found -->
<string name="BackupsSettingsFragment_subscription_not_found_on_this_device">"تعذَّر العثور على اشتراك في هذا الجهاز."</string>
<!-- Action button label to set up backups -->
@@ -9605,6 +9633,16 @@
<string name="RemoteBackupsSettingsFragment__s_at_s">%1$s على %2$s</string>
<!-- Subtext describing when a backup has never taken place. -->
<string name="RemoteBackupsSettingsFragment__never">أبدًا</string>
<!-- Subtext on a linked device describing when the primary device last backed up. Placeholder is the formatted date and time. -->
<string name="RemoteBackupsSettingsFragment__your_phone_s">Your phone · %1$s</string>
<!-- Message shown on a linked device with a free backup plan explaining that the subscription is managed on the primary device -->
<string name="RemoteBackupsSettingsFragment__you_can_manage_or_upgrade_your_subscription_on_your_primary_device">You can manage or upgrade your Signal Secure Backups subscription on your primary device.</string>
<!-- Message shown on a linked device with a paid backup plan explaining that the subscription is managed on the primary device -->
<string name="RemoteBackupsSettingsFragment__you_can_manage_or_cancel_your_subscription_on_your_primary_device">You can manage or cancel your Signal Secure Backups subscription on your primary device.</string>
<!-- Message shown on a linked device with a canceled backup subscription explaining that the subscription is managed on the primary device -->
<string name="RemoteBackupsSettingsFragment__you_can_manage_or_renew_your_subscription_on_your_primary_device">You can manage or renew your Signal Secure Backups subscription on your primary device.</string>
<!-- Message shown on a linked device when backups are off or the subscription was not found, explaining that backups are managed on the primary device -->
<string name="RemoteBackupsSettingsFragment__you_can_manage_or_upgrade_backups_on_your_primary_device">You can manage or upgrade Signal Secure Backups on your primary device.</string>
<!-- Button label to perform a backup immediately -->
<string name="RemoteBackupsSettingsFragment__back_up_now">إنشاء نسخة احتياطية الآن</string>
<!-- Dialog title when confirming whether to turn off and deleting backups -->
@@ -10704,11 +10742,11 @@
<!-- Header for a section showing all group member -->
<string name="MemberSearchFragment__search_members">البحث عن الأعضاء</string>
<!-- Header for a section showing all group members who are contacts -->
<string name="MemberSearchFragment__search_contacts">Search contacts</string>
<string name="MemberSearchFragment__search_contacts">البحث في جهات الاتصال</string>
<!-- Header for a section showing all group member who are admins -->
<string name="MemberSearchFragment__search_admins">Search admins</string>
<string name="MemberSearchFragment__search_admins">البحث في المُشرِفين</string>
<!-- Dialog title to filter search by -->
<string name="MemberSearchFragment__filter">Filter by</string>
<string name="MemberSearchFragment__filter">الفلترة بحسب</string>
<!-- Dialog option for all group members -->
<string name="MemberSearchFragment__filter_all_members">جميع الأعضاء</string>
<!-- Dialog option for group admins -->
@@ -10716,7 +10754,7 @@
<!-- Dialog option for your contacts -->
<string name="MemberSearchFragment__filter_contacts">جهات الاتصال</string>
<!-- Button to invite members via a group link -->
<string name="MemberSearchFragment__invite_via">Invite via group link</string>
<string name="MemberSearchFragment__invite_via">الدعوة عبر رابط المجموعة</string>
<!-- Title of the sheet shown when a local backup restore could not be completed -->
<string name="CouldNotCompleteBackupRestoreSheet__title">تعذَّرت استعادة النسخة الاحتياطية</string>
+50 -12
View File
@@ -616,7 +616,7 @@
<!-- Action shown to allow a user to re-register as they are no longer registered -->
<string name="ConversationFragment__reregister_signal">Signal-da təkrar qeydiyyatdan keçin</string>
<!-- Action shown in the conversation input area to allow a user to re-link this device as it is no longer linked -->
<string name="ConversationFragment__relink_signal">Re-link</string>
<string name="ConversationFragment__relink_signal">Yenidən əlaqələndir</string>
<!-- Label for a button displayed in the conversation toolbar to return to the previous screen. -->
<string name="ConversationFragment__content_description_back_button">Geri qayıt.</string>
<!-- Label for a button displayed in the conversation toolbar to open the main screen of the app. -->
@@ -3492,9 +3492,9 @@
<!-- Action in reminder banner that will take user to re-register -->
<string name="UnauthorizedReminder_reregister_action">Cihazı təkrar qeydiyyatdan keçirin</string>
<!-- Message shown in a reminder banner when this linked device is no longer linked to a primary device -->
<string name="UnauthorizedReminder_this_device_is_no_longer_linked_relink_to_continue_messaging">This device is no longer linked. Re-link Signal with your phone to continue messaging.</string>
<string name="UnauthorizedReminder_this_device_is_no_longer_linked_relink_to_continue_messaging">Bu cihaz artıq qoşulu deyil. Mesajlaşmağa davam etmək üçün Signal-ı yenidən telefonunuzla əlaqələndirin.</string>
<!-- Action in reminder banner that will take the user to re-link this device -->
<string name="UnauthorizedReminder_relink_action">Re-link device</string>
<string name="UnauthorizedReminder_relink_action">Cihazı yenidən əlaqələndir</string>
<!-- Push notification when the app is forcibly logged out by the server. -->
<string name="LoggedOutNotification_you_have_been_logged_out">Bu cihazda Signal tətbiqindən çıxmısınız.</string>
@@ -5107,6 +5107,32 @@
<!-- Error message shown when we fail to delete the data for some unknown reason -->
<string name="preferences_account_delete_all_data_failed">Verilənləri silmək mümkün olmadı</string>
<!-- LinkedDeviceAccountSettingsFragment -->
<!-- Title of an informational card shown on the account settings screen of a linked device -->
<string name="LinkedDeviceAccountSettingsFragment__this_is_a_linked_device">This is a linked device</string>
<!-- Body of an informational card explaining that account settings must be managed from the primary device -->
<string name="LinkedDeviceAccountSettingsFragment__to_manage_your_account_settings">To manage your account settings, open Signal on your primary device.</string>
<!-- Title of a settings row that deletes all Signal data from this linked device -->
<string name="LinkedDeviceAccountSettingsFragment__delete_app_data">Delete app data</string>
<!-- Subtitle of the delete app data row explaining what it does -->
<string name="LinkedDeviceAccountSettingsFragment__this_will_remove_all_data">This will remove all data from the Signal app on this device.</string>
<!-- Title of the confirmation dialog shown when deleting app data from a linked device -->
<string name="LinkedDeviceAccountSettingsFragment__delete_app_data_question">Delete app data?</string>
<!-- Message of the confirmation dialog explaining that only this device\'s data is deleted, not the account -->
<string name="LinkedDeviceAccountSettingsFragment__this_will_delete_all_data_and_messages">This will delete all data and messages from the Signal app on this device. Your Signal account and data on your primary device or other linked devices will not be deleted. The app will close after this process is complete.</string>
<!-- LinkedDeviceAccountLearnMoreBottomSheet -->
<!-- Emphasized lead-in phrase shown in bold at the start of the learn more sheet body. Must appear verbatim within LinkedDeviceAccountLearnMoreBottomSheet__linked_devices_let_you_access. -->
<string name="LinkedDeviceAccountLearnMoreBottomSheet__linked_devices">Linked Devices</string>
<!-- Body text of the linked devices learn more sheet. The leading \"Linked Devices\" is shown in bold. -->
<string name="LinkedDeviceAccountLearnMoreBottomSheet__linked_devices_let_you_access">Linked Devices let you access your Signal account and messages securely on any of your additional devices.</string>
<!-- Bullet point describing what devices can be linked -->
<string name="LinkedDeviceAccountLearnMoreBottomSheet__you_can_link_a_desktop">You can link a desktop, tablet or additional phone</string>
<!-- Bullet point describing that the primary device manages linked devices -->
<string name="LinkedDeviceAccountLearnMoreBottomSheet__your_primary_device_manages">Your primary device manages what\'s linked, you can link up to 5 devices.</string>
<!-- Bullet point describing that some settings are only available on the primary device -->
<string name="LinkedDeviceAccountLearnMoreBottomSheet__some_account_settings">Some account settings are managed on your primary device only, including account settings and backups.</string>
<!-- TransferOrRestoreFragment -->
<string name="TransferOrRestoreFragment__transfer_or_restore_account">Hesabınızı köçürün və ya geri yükləyin</string>
<string name="TransferOrRestoreFragment__if_you_have_previously_registered_a_signal_account">Daha əvvəl bir Signal hesabını qeydiyyata almısınızsa, hesabınızı və mesajlarınızı köçürə və ya geri yükləyə bilərsiniz.</string>
@@ -8246,11 +8272,11 @@
<!-- Text describing how to restore a backup displayed on the on-device backups screen -->
<string name="OnDeviceBackupsScreen__to_restore_a_backup">"Ehtiyat nüsxəni bərpa etmək üçün Signal-ın yeni versiyasını quraşdırın. Tətbiqi açıb, \"Ehtiyat nüsxəni bərpa et\" seçiminə toxunun, sonra ehtiyat nüsxə faylını tapın."</string>
<!-- Body of a dialog shown when turning on on-device backups while "Optimize Signal storage" is enabled, warning that optimized media won\'t be included -->
<string name="OnDeviceBackupsScreen__you_have_optimize_signal_storage_on">You have \"Optimize Signal storage\" on. If you want to include all media in your on-device backup, turn the setting off.</string>
<string name="OnDeviceBackupsScreen__you_have_optimize_signal_storage_on">\"Signal yaddaşını optimallaşdır\" funksiyası açıqdır. Cihazdaxili ehtiyat nüsxədəki bütün media fayllarını əhatə etmək üçün parametrləri söndürün.</string>
<!-- Dialog action that takes the user to the "Optimize Signal storage" setting -->
<string name="OnDeviceBackupsScreen__view_setting">View setting</string>
<string name="OnDeviceBackupsScreen__view_setting">Parametrlərə bax</string>
<!-- Dialog action to proceed with turning on on-device backups without turning off "Optimize Signal storage" -->
<string name="OnDeviceBackupsScreen__continue_without_turning_off">Continue without turning off</string>
<string name="OnDeviceBackupsScreen__continue_without_turning_off">Söndürmədən davam et</string>
<!-- Title of a megaphone shown to prompt the user to verify their recovery key -->
<string name="VerifyBackupKey__title">Bərpa şifrənizi yoxlayın</string>
@@ -8635,9 +8661,9 @@
<!-- Dialog message for paid tier pending dialog -->
<string name="ManageStorageSettingsFragment__storage_optimization_can_only_be_used">Yaddaş optimallaşdırması yalnız Signal-ın təhlükəsiz ehtiyat nüsxələrinin ödənişli səviyyəsi ilə istifadə oluna bilər. Ehtiyat nüsxə abunəliyiniz hələ də emal olunur və aktiv deyil. Daha sonra yenidən cəhd edin.</string>
<!-- Title of a dialog shown when turning on "Optimize Signal storage" while on-device backups are enabled -->
<string name="ManageStorageSettingsFragment__media_will_be_removed_from_your_on_device_backup">Media will be removed from your on-device backup</string>
<string name="ManageStorageSettingsFragment__media_will_be_removed_from_your_on_device_backup">Media faylı cihazdaxili ehtiyat nüsxədən silinəcək</string>
<!-- Body of a dialog shown when turning on "Optimize Signal storage" while on-device backups are enabled -->
<string name="ManageStorageSettingsFragment__turning_on_optimize_signal_storage_will_offload">Turning on \"Optimize Signal storage\" will offload old media from your device. You can download it again any time. It will also remove old media from your on-device backup.</string>
<string name="ManageStorageSettingsFragment__turning_on_optimize_signal_storage_will_offload">\"Signal yaddaşını optimallaşdır\" funksiyasını açsanız, bu, cihazınızdan köhnə media faylların köçürülməsinə səbəb olacaq. Bunu istədiyiniz vaxt yenidən endirə bilərsiniz. Bu addım həmçinin cihazdaxili ehtiyat nüsxədən köhnə media faylını siləcək.</string>
<!-- Dialog action to confirm turning on "Optimize Signal storage" -->
<string name="ManageStorageSettingsFragment__turn_on"></string>
@@ -8722,6 +8748,8 @@
<string name="BackupsSettingsFragment__subscription_canceled">Abunəlik ləğv edildi</string>
<!-- Subtitle for row for no backup ever created -->
<string name="BackupsSettingsFragment_automatic_backups_with_signals">Signal-ın təhlükəsiz tam şəkildə şifrələnmiş saxlama xidməti ilə avtomatik çıxarılan ehtiyat nüsxələr.</string>
<!-- Subtitle shown on a linked device when backups are off, directing the user to set them up on their phone -->
<string name="BackupsSettingsFragment__automatic_backups_get_started_on_your_phone">Automatic backups with Signal\'s secure end-to-end encrypted storage service. Get started on your phone.</string>
<!-- Subtitle for row for backups that are active but subscription not found -->
<string name="BackupsSettingsFragment_subscription_not_found_on_this_device">"Bu cihazda abunəlik tapılmadı."</string>
<!-- Action button label to set up backups -->
@@ -8825,6 +8853,16 @@
<string name="RemoteBackupsSettingsFragment__s_at_s">%1$s, %2$s radəsində</string>
<!-- Subtext describing when a backup has never taken place. -->
<string name="RemoteBackupsSettingsFragment__never">Heç vaxt</string>
<!-- Subtext on a linked device describing when the primary device last backed up. Placeholder is the formatted date and time. -->
<string name="RemoteBackupsSettingsFragment__your_phone_s">Your phone · %1$s</string>
<!-- Message shown on a linked device with a free backup plan explaining that the subscription is managed on the primary device -->
<string name="RemoteBackupsSettingsFragment__you_can_manage_or_upgrade_your_subscription_on_your_primary_device">You can manage or upgrade your Signal Secure Backups subscription on your primary device.</string>
<!-- Message shown on a linked device with a paid backup plan explaining that the subscription is managed on the primary device -->
<string name="RemoteBackupsSettingsFragment__you_can_manage_or_cancel_your_subscription_on_your_primary_device">You can manage or cancel your Signal Secure Backups subscription on your primary device.</string>
<!-- Message shown on a linked device with a canceled backup subscription explaining that the subscription is managed on the primary device -->
<string name="RemoteBackupsSettingsFragment__you_can_manage_or_renew_your_subscription_on_your_primary_device">You can manage or renew your Signal Secure Backups subscription on your primary device.</string>
<!-- Message shown on a linked device when backups are off or the subscription was not found, explaining that backups are managed on the primary device -->
<string name="RemoteBackupsSettingsFragment__you_can_manage_or_upgrade_backups_on_your_primary_device">You can manage or upgrade Signal Secure Backups on your primary device.</string>
<!-- Button label to perform a backup immediately -->
<string name="RemoteBackupsSettingsFragment__back_up_now">İndi Arxivləşdir</string>
<!-- Dialog title when confirming whether to turn off and deleting backups -->
@@ -9888,11 +9926,11 @@
<!-- Header for a section showing all group member -->
<string name="MemberSearchFragment__search_members">Üzvləri axtar</string>
<!-- Header for a section showing all group members who are contacts -->
<string name="MemberSearchFragment__search_contacts">Search contacts</string>
<string name="MemberSearchFragment__search_contacts">Kontaktları axtar</string>
<!-- Header for a section showing all group member who are admins -->
<string name="MemberSearchFragment__search_admins">Search admins</string>
<string name="MemberSearchFragment__search_admins">Adminləri axtar</string>
<!-- Dialog title to filter search by -->
<string name="MemberSearchFragment__filter">Filter by</string>
<string name="MemberSearchFragment__filter">Buna görə filtrlə:</string>
<!-- Dialog option for all group members -->
<string name="MemberSearchFragment__filter_all_members">Bütün üzvlər</string>
<!-- Dialog option for group admins -->
@@ -9900,7 +9938,7 @@
<!-- Dialog option for your contacts -->
<string name="MemberSearchFragment__filter_contacts">Əlaqələr</string>
<!-- Button to invite members via a group link -->
<string name="MemberSearchFragment__invite_via">Invite via group link</string>
<string name="MemberSearchFragment__invite_via">Qrup keçidi vasitəsilə dəvət et</string>
<!-- Title of the sheet shown when a local backup restore could not be completed -->
<string name="CouldNotCompleteBackupRestoreSheet__title">Ehtiyat nüsxəni bərpa etmək mümkün deyil</string>
+47 -9
View File
@@ -5373,6 +5373,32 @@
<!-- Error message shown when we fail to delete the data for some unknown reason -->
<string name="preferences_account_delete_all_data_failed">Не атрымалася выдаліць даныя</string>
<!-- LinkedDeviceAccountSettingsFragment -->
<!-- Title of an informational card shown on the account settings screen of a linked device -->
<string name="LinkedDeviceAccountSettingsFragment__this_is_a_linked_device">This is a linked device</string>
<!-- Body of an informational card explaining that account settings must be managed from the primary device -->
<string name="LinkedDeviceAccountSettingsFragment__to_manage_your_account_settings">To manage your account settings, open Signal on your primary device.</string>
<!-- Title of a settings row that deletes all Signal data from this linked device -->
<string name="LinkedDeviceAccountSettingsFragment__delete_app_data">Delete app data</string>
<!-- Subtitle of the delete app data row explaining what it does -->
<string name="LinkedDeviceAccountSettingsFragment__this_will_remove_all_data">This will remove all data from the Signal app on this device.</string>
<!-- Title of the confirmation dialog shown when deleting app data from a linked device -->
<string name="LinkedDeviceAccountSettingsFragment__delete_app_data_question">Delete app data?</string>
<!-- Message of the confirmation dialog explaining that only this device\'s data is deleted, not the account -->
<string name="LinkedDeviceAccountSettingsFragment__this_will_delete_all_data_and_messages">This will delete all data and messages from the Signal app on this device. Your Signal account and data on your primary device or other linked devices will not be deleted. The app will close after this process is complete.</string>
<!-- LinkedDeviceAccountLearnMoreBottomSheet -->
<!-- Emphasized lead-in phrase shown in bold at the start of the learn more sheet body. Must appear verbatim within LinkedDeviceAccountLearnMoreBottomSheet__linked_devices_let_you_access. -->
<string name="LinkedDeviceAccountLearnMoreBottomSheet__linked_devices">Linked Devices</string>
<!-- Body text of the linked devices learn more sheet. The leading \"Linked Devices\" is shown in bold. -->
<string name="LinkedDeviceAccountLearnMoreBottomSheet__linked_devices_let_you_access">Linked Devices let you access your Signal account and messages securely on any of your additional devices.</string>
<!-- Bullet point describing what devices can be linked -->
<string name="LinkedDeviceAccountLearnMoreBottomSheet__you_can_link_a_desktop">You can link a desktop, tablet or additional phone</string>
<!-- Bullet point describing that the primary device manages linked devices -->
<string name="LinkedDeviceAccountLearnMoreBottomSheet__your_primary_device_manages">Your primary device manages what\'s linked, you can link up to 5 devices.</string>
<!-- Bullet point describing that some settings are only available on the primary device -->
<string name="LinkedDeviceAccountLearnMoreBottomSheet__some_account_settings">Some account settings are managed on your primary device only, including account settings and backups.</string>
<!-- TransferOrRestoreFragment -->
<string name="TransferOrRestoreFragment__transfer_or_restore_account">Перанесці або аднавіць уліковы запіс</string>
<string name="TransferOrRestoreFragment__if_you_have_previously_registered_a_signal_account">Калі вы раней ужо зарэгістравалі ўліковы запіс Signal, вы можаце перанесці або аднавіць як яго, так і свае паведамленні</string>
@@ -8622,11 +8648,11 @@
<!-- Text describing how to restore a backup displayed on the on-device backups screen -->
<string name="OnDeviceBackupsScreen__to_restore_a_backup">"Каб аднавіць рэзервовую копію, усталюйце новую копію Signal. Адкрыйце праграму і націсніце «Аднавіць рэзервовую копію», потым выберыце файл рэзервовай копіі."</string>
<!-- Body of a dialog shown when turning on on-device backups while "Optimize Signal storage" is enabled, warning that optimized media won\'t be included -->
<string name="OnDeviceBackupsScreen__you_have_optimize_signal_storage_on">You have \"Optimize Signal storage\" on. If you want to include all media in your on-device backup, turn the setting off.</string>
<string name="OnDeviceBackupsScreen__you_have_optimize_signal_storage_on">У вас уключана опцыя «Аптымізаваць сховішча Signal». Калі вы жадаеце ўключыць усе медыяфайлы ў рэзервовую копію на прыладзе, адключыце гэту опцыю.</string>
<!-- Dialog action that takes the user to the "Optimize Signal storage" setting -->
<string name="OnDeviceBackupsScreen__view_setting">View setting</string>
<string name="OnDeviceBackupsScreen__view_setting">Прагледзець налады</string>
<!-- Dialog action to proceed with turning on on-device backups without turning off "Optimize Signal storage" -->
<string name="OnDeviceBackupsScreen__continue_without_turning_off">Continue without turning off</string>
<string name="OnDeviceBackupsScreen__continue_without_turning_off">Працягнуць, не выключаючы</string>
<!-- Title of a megaphone shown to prompt the user to verify their recovery key -->
<string name="VerifyBackupKey__title">Праверце свой код для аднаўлення</string>
@@ -9017,9 +9043,9 @@
<!-- Dialog message for paid tier pending dialog -->
<string name="ManageStorageSettingsFragment__storage_optimization_can_only_be_used">Аптымізацыю сховішча можна зрабіць толькі на платнай аснове бяспечнага рэзервовага капіравання Signal. Ваша падпіска на рэзервовае капіраванне яшчэ апрацоўваецца і пакуль не актыўная. Калі ласка, паўтарыце спробу пазней.</string>
<!-- Title of a dialog shown when turning on "Optimize Signal storage" while on-device backups are enabled -->
<string name="ManageStorageSettingsFragment__media_will_be_removed_from_your_on_device_backup">Media will be removed from your on-device backup</string>
<string name="ManageStorageSettingsFragment__media_will_be_removed_from_your_on_device_backup">Медыяфайлы будуць выдалены з рэзервовай копіі на прыладзе</string>
<!-- Body of a dialog shown when turning on "Optimize Signal storage" while on-device backups are enabled -->
<string name="ManageStorageSettingsFragment__turning_on_optimize_signal_storage_will_offload">Turning on \"Optimize Signal storage\" will offload old media from your device. You can download it again any time. It will also remove old media from your on-device backup.</string>
<string name="ManageStorageSettingsFragment__turning_on_optimize_signal_storage_will_offload">Калі вы ўключыце опцыю «Аптымізаваць сховішча Signal», усе старыя медыяфайлы будуць выгружаны з прылады. Вы ў любы час можаце спампаваць іх нанова. Праз гэта таксама старыя медыяфайлы будуць выдалены з рэзервовай копіі на прыладзе.</string>
<!-- Dialog action to confirm turning on "Optimize Signal storage" -->
<string name="ManageStorageSettingsFragment__turn_on">Уключыць</string>
@@ -9110,6 +9136,8 @@
<string name="BackupsSettingsFragment__subscription_canceled">Падпіска скасавана</string>
<!-- Subtitle for row for no backup ever created -->
<string name="BackupsSettingsFragment_automatic_backups_with_signals">Аўтаматычнае рэзервовае капіраванне з дапамогай скразнога зашыфраванага сэрвісу захоўвання даных Signal.</string>
<!-- Subtitle shown on a linked device when backups are off, directing the user to set them up on their phone -->
<string name="BackupsSettingsFragment__automatic_backups_get_started_on_your_phone">Automatic backups with Signal\'s secure end-to-end encrypted storage service. Get started on your phone.</string>
<!-- Subtitle for row for backups that are active but subscription not found -->
<string name="BackupsSettingsFragment_subscription_not_found_on_this_device">"На гэтай прыладзе падпіска не знойдзена."</string>
<!-- Action button label to set up backups -->
@@ -9215,6 +9243,16 @@
<string name="RemoteBackupsSettingsFragment__s_at_s">%1$s у %2$s</string>
<!-- Subtext describing when a backup has never taken place. -->
<string name="RemoteBackupsSettingsFragment__never">Ніколі</string>
<!-- Subtext on a linked device describing when the primary device last backed up. Placeholder is the formatted date and time. -->
<string name="RemoteBackupsSettingsFragment__your_phone_s">Your phone · %1$s</string>
<!-- Message shown on a linked device with a free backup plan explaining that the subscription is managed on the primary device -->
<string name="RemoteBackupsSettingsFragment__you_can_manage_or_upgrade_your_subscription_on_your_primary_device">You can manage or upgrade your Signal Secure Backups subscription on your primary device.</string>
<!-- Message shown on a linked device with a paid backup plan explaining that the subscription is managed on the primary device -->
<string name="RemoteBackupsSettingsFragment__you_can_manage_or_cancel_your_subscription_on_your_primary_device">You can manage or cancel your Signal Secure Backups subscription on your primary device.</string>
<!-- Message shown on a linked device with a canceled backup subscription explaining that the subscription is managed on the primary device -->
<string name="RemoteBackupsSettingsFragment__you_can_manage_or_renew_your_subscription_on_your_primary_device">You can manage or renew your Signal Secure Backups subscription on your primary device.</string>
<!-- Message shown on a linked device when backups are off or the subscription was not found, explaining that backups are managed on the primary device -->
<string name="RemoteBackupsSettingsFragment__you_can_manage_or_upgrade_backups_on_your_primary_device">You can manage or upgrade Signal Secure Backups on your primary device.</string>
<!-- Button label to perform a backup immediately -->
<string name="RemoteBackupsSettingsFragment__back_up_now">Пачаць рэзервовае капіраванне</string>
<!-- Dialog title when confirming whether to turn off and deleting backups -->
@@ -10296,11 +10334,11 @@
<!-- Header for a section showing all group member -->
<string name="MemberSearchFragment__search_members">Шукаць удзельнікаў</string>
<!-- Header for a section showing all group members who are contacts -->
<string name="MemberSearchFragment__search_contacts">Search contacts</string>
<string name="MemberSearchFragment__search_contacts">Пошук кантактаў</string>
<!-- Header for a section showing all group member who are admins -->
<string name="MemberSearchFragment__search_admins">Search admins</string>
<string name="MemberSearchFragment__search_admins">Пошук адміністратараў</string>
<!-- Dialog title to filter search by -->
<string name="MemberSearchFragment__filter">Filter by</string>
<string name="MemberSearchFragment__filter">Адфільтраваць па</string>
<!-- Dialog option for all group members -->
<string name="MemberSearchFragment__filter_all_members">Усе ўдзельнікі</string>
<!-- Dialog option for group admins -->
@@ -10308,7 +10346,7 @@
<!-- Dialog option for your contacts -->
<string name="MemberSearchFragment__filter_contacts">Кантакты</string>
<!-- Button to invite members via a group link -->
<string name="MemberSearchFragment__invite_via">Invite via group link</string>
<string name="MemberSearchFragment__invite_via">Запрасіць праз спасылку на групу</string>
<!-- Title of the sheet shown when a local backup restore could not be completed -->
<string name="CouldNotCompleteBackupRestoreSheet__title">Не атрымалася аднавіць рэзервовую копію</string>
+48 -10
View File
@@ -5107,6 +5107,32 @@
<!-- Error message shown when we fail to delete the data for some unknown reason -->
<string name="preferences_account_delete_all_data_failed">Неуспешно изтриване на данни</string>
<!-- LinkedDeviceAccountSettingsFragment -->
<!-- Title of an informational card shown on the account settings screen of a linked device -->
<string name="LinkedDeviceAccountSettingsFragment__this_is_a_linked_device">This is a linked device</string>
<!-- Body of an informational card explaining that account settings must be managed from the primary device -->
<string name="LinkedDeviceAccountSettingsFragment__to_manage_your_account_settings">To manage your account settings, open Signal on your primary device.</string>
<!-- Title of a settings row that deletes all Signal data from this linked device -->
<string name="LinkedDeviceAccountSettingsFragment__delete_app_data">Delete app data</string>
<!-- Subtitle of the delete app data row explaining what it does -->
<string name="LinkedDeviceAccountSettingsFragment__this_will_remove_all_data">This will remove all data from the Signal app on this device.</string>
<!-- Title of the confirmation dialog shown when deleting app data from a linked device -->
<string name="LinkedDeviceAccountSettingsFragment__delete_app_data_question">Delete app data?</string>
<!-- Message of the confirmation dialog explaining that only this device\'s data is deleted, not the account -->
<string name="LinkedDeviceAccountSettingsFragment__this_will_delete_all_data_and_messages">This will delete all data and messages from the Signal app on this device. Your Signal account and data on your primary device or other linked devices will not be deleted. The app will close after this process is complete.</string>
<!-- LinkedDeviceAccountLearnMoreBottomSheet -->
<!-- Emphasized lead-in phrase shown in bold at the start of the learn more sheet body. Must appear verbatim within LinkedDeviceAccountLearnMoreBottomSheet__linked_devices_let_you_access. -->
<string name="LinkedDeviceAccountLearnMoreBottomSheet__linked_devices">Linked Devices</string>
<!-- Body text of the linked devices learn more sheet. The leading \"Linked Devices\" is shown in bold. -->
<string name="LinkedDeviceAccountLearnMoreBottomSheet__linked_devices_let_you_access">Linked Devices let you access your Signal account and messages securely on any of your additional devices.</string>
<!-- Bullet point describing what devices can be linked -->
<string name="LinkedDeviceAccountLearnMoreBottomSheet__you_can_link_a_desktop">You can link a desktop, tablet or additional phone</string>
<!-- Bullet point describing that the primary device manages linked devices -->
<string name="LinkedDeviceAccountLearnMoreBottomSheet__your_primary_device_manages">Your primary device manages what\'s linked, you can link up to 5 devices.</string>
<!-- Bullet point describing that some settings are only available on the primary device -->
<string name="LinkedDeviceAccountLearnMoreBottomSheet__some_account_settings">Some account settings are managed on your primary device only, including account settings and backups.</string>
<!-- TransferOrRestoreFragment -->
<string name="TransferOrRestoreFragment__transfer_or_restore_account">Прехвърли или възстанови акаунт</string>
<string name="TransferOrRestoreFragment__if_you_have_previously_registered_a_signal_account">Ако преди сте регистрирали акаунт в Signal, можете да прехвърлите или възстановите акаунта си и съобщенията</string>
@@ -8246,11 +8272,11 @@
<!-- Text describing how to restore a backup displayed on the on-device backups screen -->
<string name="OnDeviceBackupsScreen__to_restore_a_backup">"За да възстановите резервно копие, инсталирайте ново копие на Signal. Отворете приложението и докоснете „Възстановяване на резервно копие“, след което открийте файла с резервното копие."</string>
<!-- Body of a dialog shown when turning on on-device backups while "Optimize Signal storage" is enabled, warning that optimized media won\'t be included -->
<string name="OnDeviceBackupsScreen__you_have_optimize_signal_storage_on">You have \"Optimize Signal storage\" on. If you want to include all media in your on-device backup, turn the setting off.</string>
<string name="OnDeviceBackupsScreen__you_have_optimize_signal_storage_on">„Оптимизация на хранилището на Signal“ е включено. Ако искате да включите всички мултимедийни файлове във вашето резервно копие на устройството, изключете настройката.</string>
<!-- Dialog action that takes the user to the "Optimize Signal storage" setting -->
<string name="OnDeviceBackupsScreen__view_setting">View setting</string>
<string name="OnDeviceBackupsScreen__view_setting">Преглед на настройката</string>
<!-- Dialog action to proceed with turning on on-device backups without turning off "Optimize Signal storage" -->
<string name="OnDeviceBackupsScreen__continue_without_turning_off">Continue without turning off</string>
<string name="OnDeviceBackupsScreen__continue_without_turning_off">Продължаване без изключване</string>
<!-- Title of a megaphone shown to prompt the user to verify their recovery key -->
<string name="VerifyBackupKey__title">Потвърдете своя ключ за възстановяване</string>
@@ -8635,9 +8661,9 @@
<!-- Dialog message for paid tier pending dialog -->
<string name="ManageStorageSettingsFragment__storage_optimization_can_only_be_used">Оптимизацията на съхранението може да се използва само с платената версия на „Сигурни резервни копия на Signal“. Абонаментът ви за резервни копия все още се обработва и още не е активен. Моля, опитайте отново по-късно.</string>
<!-- Title of a dialog shown when turning on "Optimize Signal storage" while on-device backups are enabled -->
<string name="ManageStorageSettingsFragment__media_will_be_removed_from_your_on_device_backup">Media will be removed from your on-device backup</string>
<string name="ManageStorageSettingsFragment__media_will_be_removed_from_your_on_device_backup">Мултимедийните файлове ще бъдат премахнати от вашето резервно копие на устройството</string>
<!-- Body of a dialog shown when turning on "Optimize Signal storage" while on-device backups are enabled -->
<string name="ManageStorageSettingsFragment__turning_on_optimize_signal_storage_will_offload">Turning on \"Optimize Signal storage\" will offload old media from your device. You can download it again any time. It will also remove old media from your on-device backup.</string>
<string name="ManageStorageSettingsFragment__turning_on_optimize_signal_storage_will_offload">Ако включите „Оптимизация на хранилището на Signal“, това ще разтовари старите мултимедийни файлове от вашето устройство. Можете да ги изтеглите отново по всяко време. Това също ще премахне старите мултимедийни файлове от вашето резервно копие на устройството.</string>
<!-- Dialog action to confirm turning on "Optimize Signal storage" -->
<string name="ManageStorageSettingsFragment__turn_on">Включване</string>
@@ -8722,6 +8748,8 @@
<string name="BackupsSettingsFragment__subscription_canceled">Абонаментът е отменен</string>
<!-- Subtitle for row for no backup ever created -->
<string name="BackupsSettingsFragment_automatic_backups_with_signals">Автоматични резервни копия, криптирани от край до край със защитената услуга за съхранение на Signal.</string>
<!-- Subtitle shown on a linked device when backups are off, directing the user to set them up on their phone -->
<string name="BackupsSettingsFragment__automatic_backups_get_started_on_your_phone">Automatic backups with Signal\'s secure end-to-end encrypted storage service. Get started on your phone.</string>
<!-- Subtitle for row for backups that are active but subscription not found -->
<string name="BackupsSettingsFragment_subscription_not_found_on_this_device">"Абонаментът не е открит на това устройство."</string>
<!-- Action button label to set up backups -->
@@ -8825,6 +8853,16 @@
<string name="RemoteBackupsSettingsFragment__s_at_s">%1$s за %2$s</string>
<!-- Subtext describing when a backup has never taken place. -->
<string name="RemoteBackupsSettingsFragment__never">Никога</string>
<!-- Subtext on a linked device describing when the primary device last backed up. Placeholder is the formatted date and time. -->
<string name="RemoteBackupsSettingsFragment__your_phone_s">Your phone · %1$s</string>
<!-- Message shown on a linked device with a free backup plan explaining that the subscription is managed on the primary device -->
<string name="RemoteBackupsSettingsFragment__you_can_manage_or_upgrade_your_subscription_on_your_primary_device">You can manage or upgrade your Signal Secure Backups subscription on your primary device.</string>
<!-- Message shown on a linked device with a paid backup plan explaining that the subscription is managed on the primary device -->
<string name="RemoteBackupsSettingsFragment__you_can_manage_or_cancel_your_subscription_on_your_primary_device">You can manage or cancel your Signal Secure Backups subscription on your primary device.</string>
<!-- Message shown on a linked device with a canceled backup subscription explaining that the subscription is managed on the primary device -->
<string name="RemoteBackupsSettingsFragment__you_can_manage_or_renew_your_subscription_on_your_primary_device">You can manage or renew your Signal Secure Backups subscription on your primary device.</string>
<!-- Message shown on a linked device when backups are off or the subscription was not found, explaining that backups are managed on the primary device -->
<string name="RemoteBackupsSettingsFragment__you_can_manage_or_upgrade_backups_on_your_primary_device">You can manage or upgrade Signal Secure Backups on your primary device.</string>
<!-- Button label to perform a backup immediately -->
<string name="RemoteBackupsSettingsFragment__back_up_now">Архивиране сега</string>
<!-- Dialog title when confirming whether to turn off and deleting backups -->
@@ -9888,19 +9926,19 @@
<!-- Header for a section showing all group member -->
<string name="MemberSearchFragment__search_members">Търсене на членове</string>
<!-- Header for a section showing all group members who are contacts -->
<string name="MemberSearchFragment__search_contacts">Search contacts</string>
<string name="MemberSearchFragment__search_contacts">Търсене на контакти</string>
<!-- Header for a section showing all group member who are admins -->
<string name="MemberSearchFragment__search_admins">Search admins</string>
<string name="MemberSearchFragment__search_admins">Търсене на администратори</string>
<!-- Dialog title to filter search by -->
<string name="MemberSearchFragment__filter">Filter by</string>
<string name="MemberSearchFragment__filter">Филтриране по</string>
<!-- Dialog option for all group members -->
<string name="MemberSearchFragment__filter_all_members">Всеки член</string>
<string name="MemberSearchFragment__filter_all_members">Всички членове</string>
<!-- Dialog option for group admins -->
<string name="MemberSearchFragment__filter_admins">Администратори</string>
<!-- Dialog option for your contacts -->
<string name="MemberSearchFragment__filter_contacts">Контакти</string>
<!-- Button to invite members via a group link -->
<string name="MemberSearchFragment__invite_via">Invite via group link</string>
<string name="MemberSearchFragment__invite_via">Поканете чрез линк за групата</string>
<!-- Title of the sheet shown when a local backup restore could not be completed -->
<string name="CouldNotCompleteBackupRestoreSheet__title">Неуспешно възстановяване на резервно копие</string>
+47 -9
View File
@@ -5107,6 +5107,32 @@
<!-- Error message shown when we fail to delete the data for some unknown reason -->
<string name="preferences_account_delete_all_data_failed">ডেটা মুছে ফেলা সফল হয়নি</string>
<!-- LinkedDeviceAccountSettingsFragment -->
<!-- Title of an informational card shown on the account settings screen of a linked device -->
<string name="LinkedDeviceAccountSettingsFragment__this_is_a_linked_device">This is a linked device</string>
<!-- Body of an informational card explaining that account settings must be managed from the primary device -->
<string name="LinkedDeviceAccountSettingsFragment__to_manage_your_account_settings">To manage your account settings, open Signal on your primary device.</string>
<!-- Title of a settings row that deletes all Signal data from this linked device -->
<string name="LinkedDeviceAccountSettingsFragment__delete_app_data">Delete app data</string>
<!-- Subtitle of the delete app data row explaining what it does -->
<string name="LinkedDeviceAccountSettingsFragment__this_will_remove_all_data">This will remove all data from the Signal app on this device.</string>
<!-- Title of the confirmation dialog shown when deleting app data from a linked device -->
<string name="LinkedDeviceAccountSettingsFragment__delete_app_data_question">Delete app data?</string>
<!-- Message of the confirmation dialog explaining that only this device\'s data is deleted, not the account -->
<string name="LinkedDeviceAccountSettingsFragment__this_will_delete_all_data_and_messages">This will delete all data and messages from the Signal app on this device. Your Signal account and data on your primary device or other linked devices will not be deleted. The app will close after this process is complete.</string>
<!-- LinkedDeviceAccountLearnMoreBottomSheet -->
<!-- Emphasized lead-in phrase shown in bold at the start of the learn more sheet body. Must appear verbatim within LinkedDeviceAccountLearnMoreBottomSheet__linked_devices_let_you_access. -->
<string name="LinkedDeviceAccountLearnMoreBottomSheet__linked_devices">Linked Devices</string>
<!-- Body text of the linked devices learn more sheet. The leading \"Linked Devices\" is shown in bold. -->
<string name="LinkedDeviceAccountLearnMoreBottomSheet__linked_devices_let_you_access">Linked Devices let you access your Signal account and messages securely on any of your additional devices.</string>
<!-- Bullet point describing what devices can be linked -->
<string name="LinkedDeviceAccountLearnMoreBottomSheet__you_can_link_a_desktop">You can link a desktop, tablet or additional phone</string>
<!-- Bullet point describing that the primary device manages linked devices -->
<string name="LinkedDeviceAccountLearnMoreBottomSheet__your_primary_device_manages">Your primary device manages what\'s linked, you can link up to 5 devices.</string>
<!-- Bullet point describing that some settings are only available on the primary device -->
<string name="LinkedDeviceAccountLearnMoreBottomSheet__some_account_settings">Some account settings are managed on your primary device only, including account settings and backups.</string>
<!-- TransferOrRestoreFragment -->
<string name="TransferOrRestoreFragment__transfer_or_restore_account">অ্যাকাউন্ট ট্রান্সফার করুন বা পুনরুদ্ধার করুন</string>
<string name="TransferOrRestoreFragment__if_you_have_previously_registered_a_signal_account">আপনি আগে একটি Signal অ্যাকাউন্টে রেজিস্টার করলে, আপনি আপনার অ্যাকাউন্ট বা মেসেজ ট্রান্সফার বা পুনরুদ্ধার করতে পারবেন</string>
@@ -8246,11 +8272,11 @@
<!-- Text describing how to restore a backup displayed on the on-device backups screen -->
<string name="OnDeviceBackupsScreen__to_restore_a_backup">"ব্যাকআপ পুনর্বহাল করতে, Signal-এর একটি নতুন কপি ইনস্টল করুন। অ্যাপটি খুলুন ও ব্যাকআপ পুনর্বহাল করতে ট্যাপ করুন, তারপর ব্যাকআপ ফাইলটি শনাক্ত করুন।"</string>
<!-- Body of a dialog shown when turning on on-device backups while "Optimize Signal storage" is enabled, warning that optimized media won\'t be included -->
<string name="OnDeviceBackupsScreen__you_have_optimize_signal_storage_on">You have \"Optimize Signal storage\" on. If you want to include all media in your on-device backup, turn the setting off.</string>
<string name="OnDeviceBackupsScreen__you_have_optimize_signal_storage_on">আপনার \"Signal স্টোরেজ অপ্টিমাইজ করুন\" চালু আছে। আপনি যদি আপনার ডিভাইসের ব্যাকআপে সব মিডিয়া অন্তর্ভুক্ত করতে চান, তাহলে সেটিংসটি বন্ধ করুন।</string>
<!-- Dialog action that takes the user to the "Optimize Signal storage" setting -->
<string name="OnDeviceBackupsScreen__view_setting">View setting</string>
<string name="OnDeviceBackupsScreen__view_setting">সেটিংস দেখুন</string>
<!-- Dialog action to proceed with turning on on-device backups without turning off "Optimize Signal storage" -->
<string name="OnDeviceBackupsScreen__continue_without_turning_off">Continue without turning off</string>
<string name="OnDeviceBackupsScreen__continue_without_turning_off">বন্ধ না করেই চালিয়ে যান</string>
<!-- Title of a megaphone shown to prompt the user to verify their recovery key -->
<string name="VerifyBackupKey__title">আপনার পুনরুদ্ধার \'কি\' যাচাই করুন</string>
@@ -8635,9 +8661,9 @@
<!-- Dialog message for paid tier pending dialog -->
<string name="ManageStorageSettingsFragment__storage_optimization_can_only_be_used">স্টোরেজ অপ্টিমাইজেশন শুধুমাত্র Signal নিরাপদ ব্যাকআপের পেইড টিয়ারের ক্ষেত্রে ব্যবহার করা যেতে পারে। আপনার ব্যাকআপ সাবস্ক্রিপশন এখনো প্রক্রিয়া করা হচ্ছে এবং এখনো সক্রিয় নয়। অনুগ্রহ করে পরে আবার চেষ্টা করুন।</string>
<!-- Title of a dialog shown when turning on "Optimize Signal storage" while on-device backups are enabled -->
<string name="ManageStorageSettingsFragment__media_will_be_removed_from_your_on_device_backup">Media will be removed from your on-device backup</string>
<string name="ManageStorageSettingsFragment__media_will_be_removed_from_your_on_device_backup">আপনার ডিভাইসের ব্যাকআপ থেকে মিডিয়া মুছে ফেলা হবে</string>
<!-- Body of a dialog shown when turning on "Optimize Signal storage" while on-device backups are enabled -->
<string name="ManageStorageSettingsFragment__turning_on_optimize_signal_storage_will_offload">Turning on \"Optimize Signal storage\" will offload old media from your device. You can download it again any time. It will also remove old media from your on-device backup.</string>
<string name="ManageStorageSettingsFragment__turning_on_optimize_signal_storage_will_offload">\"Signal স্টোরেজ অপ্টিমাইজ করুন\" চালু করলে আপনার ডিভাইস থেকে পুরানো মিডিয়া অফলোড হয়ে যাবে। আপনি যেকোনো সময় এটি আবার ডাউনলোড করতে পারবেন। এটি আপনার ডিভাইসের ব্যাকআপ থেকে পুরোনো মিডিয়া ফাইলগুলোও মুছে ফেলবে।</string>
<!-- Dialog action to confirm turning on "Optimize Signal storage" -->
<string name="ManageStorageSettingsFragment__turn_on">চালু করুন</string>
@@ -8722,6 +8748,8 @@
<string name="BackupsSettingsFragment__subscription_canceled">সাবস্ক্রিপশন বাতিল করা হয়েছে</string>
<!-- Subtitle for row for no backup ever created -->
<string name="BackupsSettingsFragment_automatic_backups_with_signals">Signal-এর সুরক্ষিত এন্ড-টু-এন্ড এনক্রিপ্ট করা স্টোরেজ পরিষেবা সহ স্বয়ংক্রিয় ব্যাকআপ।</string>
<!-- Subtitle shown on a linked device when backups are off, directing the user to set them up on their phone -->
<string name="BackupsSettingsFragment__automatic_backups_get_started_on_your_phone">Automatic backups with Signal\'s secure end-to-end encrypted storage service. Get started on your phone.</string>
<!-- Subtitle for row for backups that are active but subscription not found -->
<string name="BackupsSettingsFragment_subscription_not_found_on_this_device">"এই ডিভাইসে সাবস্ক্রিপশন পাওয়া যায়নি।"</string>
<!-- Action button label to set up backups -->
@@ -8825,6 +8853,16 @@
<string name="RemoteBackupsSettingsFragment__s_at_s">%2$s-তে %1$s</string>
<!-- Subtext describing when a backup has never taken place. -->
<string name="RemoteBackupsSettingsFragment__never">কখনোই না</string>
<!-- Subtext on a linked device describing when the primary device last backed up. Placeholder is the formatted date and time. -->
<string name="RemoteBackupsSettingsFragment__your_phone_s">Your phone · %1$s</string>
<!-- Message shown on a linked device with a free backup plan explaining that the subscription is managed on the primary device -->
<string name="RemoteBackupsSettingsFragment__you_can_manage_or_upgrade_your_subscription_on_your_primary_device">You can manage or upgrade your Signal Secure Backups subscription on your primary device.</string>
<!-- Message shown on a linked device with a paid backup plan explaining that the subscription is managed on the primary device -->
<string name="RemoteBackupsSettingsFragment__you_can_manage_or_cancel_your_subscription_on_your_primary_device">You can manage or cancel your Signal Secure Backups subscription on your primary device.</string>
<!-- Message shown on a linked device with a canceled backup subscription explaining that the subscription is managed on the primary device -->
<string name="RemoteBackupsSettingsFragment__you_can_manage_or_renew_your_subscription_on_your_primary_device">You can manage or renew your Signal Secure Backups subscription on your primary device.</string>
<!-- Message shown on a linked device when backups are off or the subscription was not found, explaining that backups are managed on the primary device -->
<string name="RemoteBackupsSettingsFragment__you_can_manage_or_upgrade_backups_on_your_primary_device">You can manage or upgrade Signal Secure Backups on your primary device.</string>
<!-- Button label to perform a backup immediately -->
<string name="RemoteBackupsSettingsFragment__back_up_now">এখনি ব্যাকআপ করুন</string>
<!-- Dialog title when confirming whether to turn off and deleting backups -->
@@ -9888,11 +9926,11 @@
<!-- Header for a section showing all group member -->
<string name="MemberSearchFragment__search_members">সদস্য খুঁজুন</string>
<!-- Header for a section showing all group members who are contacts -->
<string name="MemberSearchFragment__search_contacts">Search contacts</string>
<string name="MemberSearchFragment__search_contacts">কন্টাক্ট খুঁজুন</string>
<!-- Header for a section showing all group member who are admins -->
<string name="MemberSearchFragment__search_admins">Search admins</string>
<string name="MemberSearchFragment__search_admins">অ্যাডমিনদের খুঁজুন</string>
<!-- Dialog title to filter search by -->
<string name="MemberSearchFragment__filter">Filter by</string>
<string name="MemberSearchFragment__filter">যার ভিত্তিতে ফিল্টার করবেন:</string>
<!-- Dialog option for all group members -->
<string name="MemberSearchFragment__filter_all_members">সকল সদস্য</string>
<!-- Dialog option for group admins -->
@@ -9900,7 +9938,7 @@
<!-- Dialog option for your contacts -->
<string name="MemberSearchFragment__filter_contacts">পরিচিতিসমূহ</string>
<!-- Button to invite members via a group link -->
<string name="MemberSearchFragment__invite_via">Invite via group link</string>
<string name="MemberSearchFragment__invite_via">গ্রুপ লিংকের মাধ্যমে আমন্ত্রণ জানান</string>
<!-- Title of the sheet shown when a local backup restore could not be completed -->
<string name="CouldNotCompleteBackupRestoreSheet__title">ব্যাকআপ পুনর্বহাল করা যায়নি</string>
+50 -12
View File
@@ -642,7 +642,7 @@
<!-- Action shown to allow a user to re-register as they are no longer registered -->
<string name="ConversationFragment__reregister_signal">Ponovo registrirajte Signal</string>
<!-- Action shown in the conversation input area to allow a user to re-link this device as it is no longer linked -->
<string name="ConversationFragment__relink_signal">Re-link</string>
<string name="ConversationFragment__relink_signal">Ponovo poveži</string>
<!-- Label for a button displayed in the conversation toolbar to return to the previous screen. -->
<string name="ConversationFragment__content_description_back_button">Navigiraj nazad.</string>
<!-- Label for a button displayed in the conversation toolbar to open the main screen of the app. -->
@@ -3704,9 +3704,9 @@
<!-- Action in reminder banner that will take user to re-register -->
<string name="UnauthorizedReminder_reregister_action">Ponovo registrirajte uređaj</string>
<!-- Message shown in a reminder banner when this linked device is no longer linked to a primary device -->
<string name="UnauthorizedReminder_this_device_is_no_longer_linked_relink_to_continue_messaging">This device is no longer linked. Re-link Signal with your phone to continue messaging.</string>
<string name="UnauthorizedReminder_this_device_is_no_longer_linked_relink_to_continue_messaging">Ovaj uređaj više nije povezan. Ponovo povežite Signal s telefonom da nastavite slati poruke.</string>
<!-- Action in reminder banner that will take the user to re-link this device -->
<string name="UnauthorizedReminder_relink_action">Re-link device</string>
<string name="UnauthorizedReminder_relink_action">Ponovo poveži uređaj</string>
<!-- Push notification when the app is forcibly logged out by the server. -->
<string name="LoggedOutNotification_you_have_been_logged_out">Odjavljeni ste sa Signala na ovom uređaju.</string>
@@ -5373,6 +5373,32 @@
<!-- Error message shown when we fail to delete the data for some unknown reason -->
<string name="preferences_account_delete_all_data_failed">Brisanje podataka nije uspjelo</string>
<!-- LinkedDeviceAccountSettingsFragment -->
<!-- Title of an informational card shown on the account settings screen of a linked device -->
<string name="LinkedDeviceAccountSettingsFragment__this_is_a_linked_device">This is a linked device</string>
<!-- Body of an informational card explaining that account settings must be managed from the primary device -->
<string name="LinkedDeviceAccountSettingsFragment__to_manage_your_account_settings">To manage your account settings, open Signal on your primary device.</string>
<!-- Title of a settings row that deletes all Signal data from this linked device -->
<string name="LinkedDeviceAccountSettingsFragment__delete_app_data">Delete app data</string>
<!-- Subtitle of the delete app data row explaining what it does -->
<string name="LinkedDeviceAccountSettingsFragment__this_will_remove_all_data">This will remove all data from the Signal app on this device.</string>
<!-- Title of the confirmation dialog shown when deleting app data from a linked device -->
<string name="LinkedDeviceAccountSettingsFragment__delete_app_data_question">Delete app data?</string>
<!-- Message of the confirmation dialog explaining that only this device\'s data is deleted, not the account -->
<string name="LinkedDeviceAccountSettingsFragment__this_will_delete_all_data_and_messages">This will delete all data and messages from the Signal app on this device. Your Signal account and data on your primary device or other linked devices will not be deleted. The app will close after this process is complete.</string>
<!-- LinkedDeviceAccountLearnMoreBottomSheet -->
<!-- Emphasized lead-in phrase shown in bold at the start of the learn more sheet body. Must appear verbatim within LinkedDeviceAccountLearnMoreBottomSheet__linked_devices_let_you_access. -->
<string name="LinkedDeviceAccountLearnMoreBottomSheet__linked_devices">Linked Devices</string>
<!-- Body text of the linked devices learn more sheet. The leading \"Linked Devices\" is shown in bold. -->
<string name="LinkedDeviceAccountLearnMoreBottomSheet__linked_devices_let_you_access">Linked Devices let you access your Signal account and messages securely on any of your additional devices.</string>
<!-- Bullet point describing what devices can be linked -->
<string name="LinkedDeviceAccountLearnMoreBottomSheet__you_can_link_a_desktop">You can link a desktop, tablet or additional phone</string>
<!-- Bullet point describing that the primary device manages linked devices -->
<string name="LinkedDeviceAccountLearnMoreBottomSheet__your_primary_device_manages">Your primary device manages what\'s linked, you can link up to 5 devices.</string>
<!-- Bullet point describing that some settings are only available on the primary device -->
<string name="LinkedDeviceAccountLearnMoreBottomSheet__some_account_settings">Some account settings are managed on your primary device only, including account settings and backups.</string>
<!-- TransferOrRestoreFragment -->
<string name="TransferOrRestoreFragment__transfer_or_restore_account">Prenesi ili vrati račun</string>
<string name="TransferOrRestoreFragment__if_you_have_previously_registered_a_signal_account">Ako imate odranije registrovan Signal račun, možete prenijeti ili vratiti svoj račun i poruke</string>
@@ -8622,11 +8648,11 @@
<!-- Text describing how to restore a backup displayed on the on-device backups screen -->
<string name="OnDeviceBackupsScreen__to_restore_a_backup">"Da biste vratili sigurnosnu kopiju, instalirajte novu kopiju Signala. Otvorite aplikaciju i dodirnite Vrati sigurnosnu kopiju, a zatim pronađite folder sigurnosne kopije."</string>
<!-- Body of a dialog shown when turning on on-device backups while "Optimize Signal storage" is enabled, warning that optimized media won\'t be included -->
<string name="OnDeviceBackupsScreen__you_have_optimize_signal_storage_on">You have \"Optimize Signal storage\" on. If you want to include all media in your on-device backup, turn the setting off.</string>
<string name="OnDeviceBackupsScreen__you_have_optimize_signal_storage_on">Uključena je opcija \"Optimiziraj pohranu Signala\". Ako želite uključiti sve medijske sadržaje u sigurnosnu kopiju na uređaju, isključite ovu postavku.</string>
<!-- Dialog action that takes the user to the "Optimize Signal storage" setting -->
<string name="OnDeviceBackupsScreen__view_setting">View setting</string>
<string name="OnDeviceBackupsScreen__view_setting">Prikaži postavku</string>
<!-- Dialog action to proceed with turning on on-device backups without turning off "Optimize Signal storage" -->
<string name="OnDeviceBackupsScreen__continue_without_turning_off">Continue without turning off</string>
<string name="OnDeviceBackupsScreen__continue_without_turning_off">Nastavi bez isključivanja</string>
<!-- Title of a megaphone shown to prompt the user to verify their recovery key -->
<string name="VerifyBackupKey__title">Potvrdite svoj ključ za oporavak</string>
@@ -9017,9 +9043,9 @@
<!-- Dialog message for paid tier pending dialog -->
<string name="ManageStorageSettingsFragment__storage_optimization_can_only_be_used">Optimizacija pohrane može se koristiti samo s plaćenim nivoom usluge Signal Secure Backups. Vaša pretplata na sigurnosne kopije se još uvijek obrađuje i još nije aktivna. Pokušajte ponovo kasnije.</string>
<!-- Title of a dialog shown when turning on "Optimize Signal storage" while on-device backups are enabled -->
<string name="ManageStorageSettingsFragment__media_will_be_removed_from_your_on_device_backup">Media will be removed from your on-device backup</string>
<string name="ManageStorageSettingsFragment__media_will_be_removed_from_your_on_device_backup">Medijski sadržaji će biti uklonjeni iz sigurnosne kopije na uređaju.</string>
<!-- Body of a dialog shown when turning on "Optimize Signal storage" while on-device backups are enabled -->
<string name="ManageStorageSettingsFragment__turning_on_optimize_signal_storage_will_offload">Turning on \"Optimize Signal storage\" will offload old media from your device. You can download it again any time. It will also remove old media from your on-device backup.</string>
<string name="ManageStorageSettingsFragment__turning_on_optimize_signal_storage_will_offload">Uključivanjem opcije \"Optimiziraj pohranu Signala\" stari medijski sadržaji bit će uklonjeni s uređaja. Možete ga ponovo preuzeti u bilo kojem trenutku. Time će se stari medijski sadržaji ukloniti i iz sigurnosne kopije na uređaju.</string>
<!-- Dialog action to confirm turning on "Optimize Signal storage" -->
<string name="ManageStorageSettingsFragment__turn_on">Uključi</string>
@@ -9110,6 +9136,8 @@
<string name="BackupsSettingsFragment__subscription_canceled">Pretplata je otkazana</string>
<!-- Subtitle for row for no backup ever created -->
<string name="BackupsSettingsFragment_automatic_backups_with_signals">Automatsko kreiranje rezervnih kopija sa Signalovim sigurnom sveobuhvatnom uslugom pohranjivanja.</string>
<!-- Subtitle shown on a linked device when backups are off, directing the user to set them up on their phone -->
<string name="BackupsSettingsFragment__automatic_backups_get_started_on_your_phone">Automatic backups with Signal\'s secure end-to-end encrypted storage service. Get started on your phone.</string>
<!-- Subtitle for row for backups that are active but subscription not found -->
<string name="BackupsSettingsFragment_subscription_not_found_on_this_device">"Pretplata nije pronađena na ovom uređaju."</string>
<!-- Action button label to set up backups -->
@@ -9215,6 +9243,16 @@
<string name="RemoteBackupsSettingsFragment__s_at_s">%1$s u %2$s</string>
<!-- Subtext describing when a backup has never taken place. -->
<string name="RemoteBackupsSettingsFragment__never">Nikad</string>
<!-- Subtext on a linked device describing when the primary device last backed up. Placeholder is the formatted date and time. -->
<string name="RemoteBackupsSettingsFragment__your_phone_s">Your phone · %1$s</string>
<!-- Message shown on a linked device with a free backup plan explaining that the subscription is managed on the primary device -->
<string name="RemoteBackupsSettingsFragment__you_can_manage_or_upgrade_your_subscription_on_your_primary_device">You can manage or upgrade your Signal Secure Backups subscription on your primary device.</string>
<!-- Message shown on a linked device with a paid backup plan explaining that the subscription is managed on the primary device -->
<string name="RemoteBackupsSettingsFragment__you_can_manage_or_cancel_your_subscription_on_your_primary_device">You can manage or cancel your Signal Secure Backups subscription on your primary device.</string>
<!-- Message shown on a linked device with a canceled backup subscription explaining that the subscription is managed on the primary device -->
<string name="RemoteBackupsSettingsFragment__you_can_manage_or_renew_your_subscription_on_your_primary_device">You can manage or renew your Signal Secure Backups subscription on your primary device.</string>
<!-- Message shown on a linked device when backups are off or the subscription was not found, explaining that backups are managed on the primary device -->
<string name="RemoteBackupsSettingsFragment__you_can_manage_or_upgrade_backups_on_your_primary_device">You can manage or upgrade Signal Secure Backups on your primary device.</string>
<!-- Button label to perform a backup immediately -->
<string name="RemoteBackupsSettingsFragment__back_up_now">Kreiraj kopiju sada</string>
<!-- Dialog title when confirming whether to turn off and deleting backups -->
@@ -10296,11 +10334,11 @@
<!-- Header for a section showing all group member -->
<string name="MemberSearchFragment__search_members">Pretraži članove</string>
<!-- Header for a section showing all group members who are contacts -->
<string name="MemberSearchFragment__search_contacts">Search contacts</string>
<string name="MemberSearchFragment__search_contacts">Pretraži kontakte</string>
<!-- Header for a section showing all group member who are admins -->
<string name="MemberSearchFragment__search_admins">Search admins</string>
<string name="MemberSearchFragment__search_admins">Pretraži administratore</string>
<!-- Dialog title to filter search by -->
<string name="MemberSearchFragment__filter">Filter by</string>
<string name="MemberSearchFragment__filter">Filtriraj po</string>
<!-- Dialog option for all group members -->
<string name="MemberSearchFragment__filter_all_members">Svi članovi</string>
<!-- Dialog option for group admins -->
@@ -10308,7 +10346,7 @@
<!-- Dialog option for your contacts -->
<string name="MemberSearchFragment__filter_contacts">Kontakti</string>
<!-- Button to invite members via a group link -->
<string name="MemberSearchFragment__invite_via">Invite via group link</string>
<string name="MemberSearchFragment__invite_via">Pozovi putem linka grupe</string>
<!-- Title of the sheet shown when a local backup restore could not be completed -->
<string name="CouldNotCompleteBackupRestoreSheet__title">Nije moguće vratiti sigurnosnu kopiju</string>

Some files were not shown because too many files have changed in this diff Show More