Compare commits

...

141 Commits

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

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

Fixes #14811

Closes signalapp/Signal-Android#14867
2026-07-07 11:20:56 -03:00
Greyson Parrelli 6893a0abc0 Fix lifecycle of regV5 scratch storage. 2026-07-06 15:54:16 -04:00
Greyson Parrelli 74b431d988 Move some usages of Dispatchers.IO to Dispatchers.Default. 2026-07-06 15:29:06 -04:00
dependabot[bot] f6c7824050 ci: bump docker/build-push-action from 6.18.0 to 7.3.0
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-06 15:28:38 -04:00
dependabot[bot] b3904982a5 ci: bump docker/setup-buildx-action from 3.11.1 to 4.2.0
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-06 15:28:00 -04:00
Alex Hart 5f1a7397e1 Fix donation test. 2026-07-06 15:04:40 -03:00
Greyson Parrelli 55941d3ccc Use static timezone for RemoteRestoreScreenScreenshotTests. 2026-07-06 13:39:28 -03:00
Michelle Tang 0b92bc41fe Add invite and add member to group search. 2026-07-06 13:39:28 -03:00
Greyson Parrelli 2373f567d8 Include all build reports and test results in Github CI artifacts. 2026-07-06 13:39:28 -03:00
Greyson Parrelli 7e81240d4d Add a gradle task for firebase test lab. 2026-07-06 13:39:28 -03:00
Greyson Parrelli 5cf61665d4 Add unit test coverage for regV5 screens. 2026-07-06 13:39:28 -03:00
Greyson Parrelli 9814c520e2 Add screenshot tests for all of regV5. 2026-07-06 13:39:28 -03:00
Cody Henthorne 6664716930 Fix crash tapping not-yet-restored media in conversation settings shared media rail. 2026-07-06 13:39:25 -03:00
Greyson Parrelli 6194acfdba Fix some edge-to-edge stuff with regV5. 2026-07-06 13:39:25 -03:00
Greyson Parrelli 8db4a5238c Make regV5 available for internal users. 2026-07-06 13:39:25 -03:00
Greyson Parrelli 60192c4b46 Fix icons and restore options in regV5. 2026-07-06 13:39:25 -03:00
Cody Henthorne 512df1ec1b Implement phone friendly link device registration flow. 2026-07-06 13:39:25 -03:00
Michelle Tang a849af6880 Add more features to group member search. 2026-07-06 13:39:25 -03:00
Greyson Parrelli 3d0a743fe3 Constrain recipient search bar placeholder to a single line. 2026-07-06 13:39:25 -03:00
Greyson Parrelli f5e065d050 Cleanup regV5 backstack. 2026-07-06 13:39:25 -03:00
Greyson Parrelli 7e26badb4c Support automatic SMS fill in regV5. 2026-07-06 13:39:24 -03:00
Greyson Parrelli da8af40e4f Allow 'next monday' schedule for all users. 2026-07-06 13:39:24 -03:00
Greyson Parrelli 364f2baf4b Label scheduled-message times beyond tomorrow with their weekday. 2026-07-06 13:39:24 -03:00
Greyson Parrelli 97a0f7b659 Fix dependency initialization order. 2026-07-06 13:39:24 -03:00
Alex Hart 310e90be84 Harden donation checkout instrumentation tests. 2026-07-06 13:39:24 -03:00
Cody Henthorne b462ef2149 Allow banners to reactively update their enabled state. 2026-07-06 13:39:24 -03:00
Cody Henthorne cd5ead4981 Update unregistered UI for linked devices. 2026-07-06 13:39:24 -03:00
Cody Henthorne 9be6f488c4 Add warning dialogs when mixing local backups and optimize media. 2026-07-06 13:39:23 -03:00
Alex Hart 5a6b0cbfef Bump version to 8.18.2 2026-07-06 13:35:58 -03:00
Alex Hart 86169b7368 Update translations and other static files. 2026-07-06 13:31:40 -03:00
Michelle Tang d4d8b54232 Disable disappearing calls. 2026-07-06 11:20:02 -04:00
Michelle Tang e74ed9dbf3 Bump version to 8.18.1 2026-07-02 14:55:25 -04:00
Michelle Tang e1687715d3 Update baseline profile. 2026-07-02 14:44:57 -04:00
Michelle Tang 447f29521b Update translations and other static files. 2026-07-02 14:36:19 -04:00
Greyson Parrelli c029d12b22 Fix long-text attachments failing media constraint check when sending long messages. 2026-07-02 12:03:24 -03:00
Michelle Tang 417c0f98ad Bump version to 8.18.0 2026-07-01 15:45:37 -04:00
Michelle Tang be41a24c0c Update baseline profile. 2026-07-01 15:34:34 -04:00
Michelle Tang b95815c963 Update translations and other static files. 2026-07-01 15:25:17 -04:00
Michelle Tang 15d86c9f13 Enable KT. 2026-07-01 15:11:49 -04:00
Michelle Tang 5c233daf38 Enable disappearing calls. 2026-07-01 15:11:49 -04:00
Greyson Parrelli b91ee27620 Don't enable TLS 1.3 on devices that don't support it. 2026-07-01 15:11:49 -04:00
Jeffrey Starke c03ae68ef2 Fix system navigation bar color on older Android versions. 2026-07-01 15:11:49 -04:00
Alex Hart b6f6d1e42f Add spinner API support for inserting attachments. 2026-07-01 15:11:49 -04:00
Greyson Parrelli db899e92f5 Add contact support capability to regV5. 2026-07-01 15:11:49 -04:00
Greyson Parrelli 99b89743a7 Fix default text field colorings. 2026-07-01 15:11:49 -04:00
Cody Henthorne 2bc2ae2867 Change optimize on-device storage to Optimize Signal storage. 2026-07-01 15:11:48 -04:00
Michelle Tang 300e5bf6f6 Various fixes for disappearing calls. 2026-07-01 15:11:48 -04:00
Michelle Tang 17d6fe918d Enable 1:1 polls. 2026-07-01 15:11:48 -04:00
Greyson Parrelli e71c4734bd Fix regV5 QR code animation. 2026-07-01 15:11:48 -04:00
Jim Gustafson 7d39ad04e3 Disable custom audio config by default for internal users. 2026-07-01 15:11:48 -04:00
Cody Henthorne 1ee4c590b7 Bump java and kotlin target to JDK 21. 2026-07-01 15:10:24 -04:00
Greyson Parrelli 4a0325ea55 Fix restoration of PIN during remote restore in regV5. 2026-07-01 15:10:24 -04:00
Cody Henthorne c4faafa9ae Use device specific API for linked devices updating capabilities. 2026-07-01 15:10:24 -04:00
Cody Henthorne ee11d1fd3b Harden and improve change number flow. 2026-07-01 15:10:24 -04:00
Greyson Parrelli c1c6db2b3b Update dockerfile to latest ubuntu snapshot. 2026-07-01 15:10:24 -04:00
Alex Hart 5372814468 Hook in QR Scans in new media send flow. 2026-07-01 15:10:23 -04:00
Alex Hart e5ed84f08c Update field access properties. 2026-07-01 15:10:23 -04:00
Alex Hart ab90969b0e Show donation thanks sheet reliably after redirect-based (iDEAL/Wero) donations. 2026-07-01 15:10:23 -04:00
Greyson Parrelli ebd64305bb Add button to help enter AEP from password manager in regV5. 2026-07-01 15:10:23 -04:00
Alex Hart f3b7368043 Add donation permit failure tests and surface pre-charge errors as payment setup errors. 2026-07-01 15:10:23 -04:00
Cody Henthorne a7de53333d Enable linked device flow for debug builds. 2026-07-01 15:10:23 -04:00
Cody Henthorne 36898a1e3a Remove extra option on device link screen. 2026-07-01 15:10:23 -04:00
Michelle Tang ec18fb4317 Add group search membership. 2026-07-01 15:10:23 -04:00
Greyson Parrelli dd671a8489 Fix empty state in story archive. 2026-07-01 15:10:22 -04:00
dependabot[bot] bcb5c37ed6 ci: bump the actions group.
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-01 15:10:22 -04:00
Greyson Parrelli a4a6117788 Improve remote restore test coverage in regV5. 2026-07-01 15:10:22 -04:00
Greyson Parrelli bd42444e00 Improve device transfer test coverage in regV5. 2026-07-01 15:10:22 -04:00
Greyson Parrelli b10cd9c5d3 Ensure device transfer flag is reset when re-registering. 2026-07-01 15:10:22 -04:00
Greyson Parrelli b87abdabfa Improve PIN loading spinners in regV5. 2026-07-01 15:10:22 -04:00
Cody Henthorne 91965d205a Add device linking support to RegV5. 2026-07-01 15:10:22 -04:00
Greyson Parrelli 55b5997a4e Improve UX for missing SVR data in regV5. 2026-07-01 15:10:21 -04:00
Greyson Parrelli bdb6b7d5f1 Use a more obvious error code for DeviceTransferBlockingInterceptor. 2026-07-01 15:10:21 -04:00
Greyson Parrelli 97740b34eb Ensure tha regV5 is used for re-reg. 2026-07-01 15:10:21 -04:00
Jeffrey Starke 5ed1bdc26a Migrate calls tab to nav 3. 2026-07-01 15:10:21 -04:00
Greyson Parrelli 7505ef1834 Fix CDN not being copied on duplicate attachment. 2026-07-01 15:10:21 -04:00
Greyson Parrelli 07afbb5a8f Hide edit option for messages with undownloaded long-text attachments. 2026-07-01 15:10:21 -04:00
Greyson Parrelli 613a07eb2c Optimize single name collision check. 2026-07-01 15:10:21 -04:00
Greyson Parrelli 0943030efa Split update into two statements to better utilize indices. 2026-07-01 15:10:21 -04:00
Greyson Parrelli 5b266081db Preserve video duration when populating media metadata. 2026-07-01 15:10:21 -04:00
Greyson Parrelli 8536504318 Stop prompting system password restore on PIN entry in regV5. 2026-07-01 15:10:20 -04:00
Greyson Parrelli 495b5ef9be Fix BackupSubscriptionCheckJobTest. 2026-07-01 15:10:20 -04:00
Greyson Parrelli bfdc628dd2 Fix skip button during PIN entry in regV5. 2026-07-01 15:10:20 -04:00
Greyson Parrelli 80aa521e42 Add proxy setting to regV5. 2026-07-01 15:10:18 -04:00
Greyson Parrelli 9f1c8961f0 Deduplicate sticker search results when querying by emoji. 2026-07-01 15:09:54 -04:00
Greyson Parrelli 8eeffff923 Enable regV5 on debug builds. 2026-07-01 15:09:54 -04:00
Greyson Parrelli e07a3e05bf Store restoreDecision separately in regV5. 2026-07-01 15:09:54 -04:00
Alex Hart 2c35a6a3fe Add ability to take a photo in media-send feature module. 2026-06-29 12:13:01 -04:00
Greyson Parrelli 248a787d74 Keep the universalSealedSender setting on re-reg in regV5. 2026-06-29 12:13:01 -04:00
Cody Henthorne e76a584bb7 Add additional InvalidSessionException checking to 1:1 send flows. 2026-06-29 12:13:01 -04:00
Greyson Parrelli 5a9cf128a0 Fix string in regV5 captcha screen. 2026-06-29 12:13:01 -04:00
Greyson Parrelli c7ac7087f7 Fix updateVerificationMetadata task to account for IDE tooling. 2026-06-29 12:13:01 -04:00
jeffrey-signal 9e2be2796b Fix gradle sync failure. 2026-06-29 12:13:01 -04:00
adel-signal 3f850375c1 Update RingRTC version to v2.69.5 2026-06-29 12:13:01 -04:00
Greyson Parrelli c91b739c1b Improve bubble UI for sticker replies. 2026-06-29 12:13:01 -04:00
Greyson Parrelli 600acb82bd Move improved notification management to Labs. 2026-06-29 12:13:00 -04:00
Greyson Parrelli 7d6a31b254 Allow sending sticker replies behind a Labs flag. 2026-06-29 12:13:00 -04:00
Greyson Parrelli 56378c7166 Make lower hand suggestions available for external users. 2026-06-29 12:13:00 -04:00
Greyson Parrelli 987f5158d0 Add docker caching to speed up CI task. 2026-06-29 12:13:00 -04:00
Greyson Parrelli 76a1d89954 Update verification metadata task to trim unused entries. 2026-06-29 12:13:00 -04:00
Michelle Tang 72fd5d30df Fix typo. 2026-06-29 12:13:00 -04:00
Greyson Parrelli f74f0e5109 MaybeMarkRegistrationComplete for regV5. 2026-06-29 12:13:00 -04:00
Greyson Parrelli 6e03627c47 Fix notification prompts in regV5. 2026-06-29 12:12:59 -04:00
Michelle Tang 837598657d Bump version to 8.17.3 2026-06-29 12:02:37 -04:00
Michelle Tang cf5aa92f84 Update baseline profile. 2026-06-29 11:57:52 -04:00
Michelle Tang 1613adf088 Update translations and other static files. 2026-06-29 11:46:34 -04:00
Cody Henthorne cc01d39240 Fix missing notifications for missed one-to-one calls. 2026-06-29 10:17:43 -04:00
Cody Henthorne b29bae50af Attempt to recover from bad change number state. 2026-06-29 09:45:36 -04:00
929 changed files with 35189 additions and 29186 deletions
+11 -7
View File
@@ -22,12 +22,12 @@ jobs:
submodules: true
lfs: true
- name: set up JDK 17
uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5
- name: set up JDK 21
uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5
# gh api repos/actions/setup-java/commits/v5 --jq '.sha'
with:
distribution: temurin
java-version: 17
java-version: 21
- name: Validate Gradle Wrapper
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # v6
@@ -42,15 +42,16 @@ jobs:
# Required to persist the Gradle configuration cache across runs.
cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
# Pull requests run the fast custom linter (ci); pushes to main / 8.x branches run the full
# Android lint (qa).
# Pull requests run the fast custom linter (ciRemote); pushes to main / 8.x branches run the
# full Android lint (qaRemote). Both include screenshot validation, which is deliberately kept
# out of the local qa/ci tasks because screenshot rendering is host-OS dependent.
- name: Build with Gradle
env:
SIGNAL_BUILD_CACHE_URL: ${{ secrets.SIGNAL_BUILD_CACHE_URL }}
SIGNAL_BUILD_CACHE_USER: ${{ secrets.SIGNAL_BUILD_CACHE_USER }}
SIGNAL_BUILD_CACHE_PASSWORD: ${{ secrets.SIGNAL_BUILD_CACHE_PASSWORD }}
SIGNAL_BUILD_CACHE_PUSH: ${{ startsWith(github.ref, 'refs/heads/8.') }}
run: ./gradlew ${{ github.event_name == 'pull_request' && 'ci' || 'qa' }}
run: ./gradlew ${{ github.event_name == 'pull_request' && 'ciRemote' || 'qaRemote' }}
- name: Archive reports for failed build
if: ${{ failure() }}
@@ -58,4 +59,7 @@ jobs:
# gh api repos/actions/upload-artifact/commits/v7 --jq '.sha'
with:
name: reports
path: '*/build/reports'
path: |
**/build/reports
**/build/test-results
if-no-files-found: ignore
+4 -4
View File
@@ -22,12 +22,12 @@ jobs:
submodules: true
ref: ${{ github.event.pull_request.base.sha }}
- name: set up JDK 17
uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5
- name: set up JDK 21
uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5
# gh api repos/actions/setup-java/commits/v5 --jq '.sha'
with:
distribution: temurin
java-version: 17
java-version: 21
- name: Set up Gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6
@@ -47,7 +47,7 @@ jobs:
- name: Cache base apk
id: cache-base
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v5
# gh api repos/actions/cache/commits/v5 --jq '.sha'
with:
path: diffuse-base.apk
+9 -3
View File
@@ -13,9 +13,15 @@ jobs:
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
# gh api repos/actions/checkout/commits/v6 --jq '.sha'
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: Build image
run: |
cd reproducible-builds
docker build -t signal-android .
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: reproducible-builds
tags: signal-android
load: true
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Test build
run: docker run --memory=12g --rm -v "$(pwd)":/project -w /project signal-android ./gradlew --no-daemon --max-workers=1 -Dorg.gradle.jvmargs="-Xmx4g -XX:MaxMetaspaceSize=512m" -Dkotlin.compiler.execution.strategy=in-process bundlePlayProdRelease
+1 -1
View File
@@ -1,2 +1,2 @@
java openjdk-17.0.2
java openjdk-21.0.2
uv latest
+157 -2
View File
@@ -2,7 +2,10 @@
import com.android.build.api.artifact.ArtifactTransformationRequest
import com.android.build.api.artifact.SingleArtifact
import com.android.build.api.variant.HasAndroidTest
import org.gradle.api.tasks.testing.logging.TestExceptionFormat
import org.gradle.process.ExecOperations
import org.gradle.work.DisableCachingByDefault
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import org.jetbrains.kotlin.gradle.dsl.KotlinAndroidProjectExtension
import java.time.Instant
@@ -10,6 +13,7 @@ import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
import java.util.Locale
import java.util.Properties
import javax.inject.Inject
plugins {
alias(libs.plugins.android.application)
@@ -28,8 +32,8 @@ plugins {
val staticIps = Properties().apply { file("static-ips.properties").reader().use { load(it) } }
staticIps.stringPropertyNames().forEach { rootProject.extra[it] = staticIps.getProperty(it) }
val canonicalVersionCode = 1712
val canonicalVersionName = "8.17.2"
val canonicalVersionCode = 1719
val canonicalVersionName = "8.19.2"
val currentHotfixVersion = 0
val maxHotfixVersions = 100
@@ -598,6 +602,43 @@ androidComponents {
}
variant.sources.assets?.addGeneratedSourceDirectory(taskProvider) { it.outputDir }
}
onVariants(selector().withName("playProdDebug")) { variant ->
val androidTest = (variant as? HasAndroidTest)?.androidTest ?: return@onVariants
tasks.register<FirebaseTestLabTask>("firebaseTestLab") {
group = "Verification"
description = "Runs the ${variant.name} instrumentation tests on Firebase Test Lab via the gcloud CLI. Run a single class with -Pftl.class=<fqcn>[#method]; override other defaults with -Pftl.* properties."
appApkDirectory.set(variant.artifacts.get(SingleArtifact.APK))
testApkDirectory.set(androidTest.artifacts.get(SingleArtifact.APK))
val deviceOverride = project.providers.gradleProperty("ftl.devices").orNull
devices.set(
deviceOverride?.split(";")?.map { it.trim() }?.filter { it.isNotEmpty() }
?: listOf("model=Pixel2.arm,version=31,locale=en,orientation=portrait")
)
useOrchestrator.set(true)
environmentVariables.set(mapOf("clearPackageData" to "true"))
testTimeout.set(project.providers.gradleProperty("ftl.timeout").getOrElse("30m"))
numFlakyTestAttempts.set(project.providers.gradleProperty("ftl.numFlakyTestAttempts").map { it.toInt() }.getOrElse(1))
gcloudProject.set(project.providers.gradleProperty("ftl.project"))
resultsBucket.set(project.providers.gradleProperty("ftl.resultsBucket"))
resultsDir.set(project.providers.gradleProperty("ftl.resultsDir"))
val testClass = project.providers.gradleProperty("ftl.class").orNull?.takeIf { it.isNotBlank() }
testTargets.set(
if (testClass != null) "class $testClass" else project.providers.gradleProperty("ftl.testTargets").orNull
)
gcloudExecutable.set(project.providers.gradleProperty("ftl.gcloud").getOrElse("gcloud"))
extraArgs.set(
project.providers.gradleProperty("ftl.extraArgs").orNull
?.split(" ")?.map { it.trim() }?.filter { it.isNotEmpty() }
?: emptyList()
)
}
}
}
baselineProfile {
@@ -670,6 +711,7 @@ dependencies {
implementation(libs.androidx.navigation.compose)
implementation(libs.androidx.navigation3.runtime)
implementation(libs.androidx.navigation3.ui)
implementation(libs.androidx.lifecycle.viewmodel.navigation3)
implementation(libs.androidx.lifecycle.viewmodel.ktx)
implementation(libs.androidx.lifecycle.livedata.ktx)
implementation(libs.androidx.lifecycle.process)
@@ -791,6 +833,15 @@ dependencies {
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
androidTestImplementation(testLibs.androidx.test.ext.junit)
androidTestImplementation(testLibs.espresso.core)
androidTestImplementation(testLibs.espresso.contrib) {
// espresso-contrib transitively pulls the full checkerframework jar (only its annotations are needed),
// whose MANIFEST.MF collides with other test dependencies during androidTest resource merging.
exclude(group = "org.checkerframework", module = "checker")
// accessibility-test-framework drags in an ancient com.google.protobuf:protobuf-lite:3.0.1 whose
// GeneratedMessageLite wins the merged dex and lacks registerDefaultInstance(Class, GeneratedMessageLite),
// crashing tests at runtime. We only use RecyclerViewActions from contrib, not the accessibility checks.
exclude(group = "com.google.android.apps.common.testing.accessibility.framework")
}
androidTestImplementation(testLibs.androidx.test.core)
androidTestImplementation(testLibs.androidx.test.core.ktx)
androidTestImplementation(testLibs.androidx.test.ext.junit.ktx)
@@ -974,6 +1025,110 @@ abstract class CopyBenchmarkBackupTask : DefaultTask() {
}
}
/**
* Runs an instrumentation test suite on Firebase Test Lab by shelling out to `gcloud firebase test android run`.
*
* The `gcloud` CLI must be installed and authenticated (`gcloud auth login` and a configured project, or an
* activated service account) before invoking this task.
*/
@DisableCachingByDefault(because = "Executes tests on remote devices; results must never be served from the build cache")
abstract class FirebaseTestLabTask
@Inject
constructor(
private val execOperations: ExecOperations
) : DefaultTask() {
@get:InputFiles
@get:PathSensitive(PathSensitivity.RELATIVE)
abstract val appApkDirectory: DirectoryProperty
@get:InputFiles
@get:PathSensitive(PathSensitivity.RELATIVE)
abstract val testApkDirectory: DirectoryProperty
@get:Input
abstract val devices: ListProperty<String>
@get:Input
abstract val useOrchestrator: Property<Boolean>
@get:Input
abstract val environmentVariables: MapProperty<String, String>
@get:Input
abstract val testTimeout: Property<String>
@get:Input
abstract val numFlakyTestAttempts: Property<Int>
@get:Input
@get:Optional
abstract val gcloudProject: Property<String>
@get:Input
@get:Optional
abstract val resultsBucket: Property<String>
@get:Input
@get:Optional
abstract val resultsDir: Property<String>
@get:Input
@get:Optional
abstract val testTargets: Property<String>
@get:Input
abstract val gcloudExecutable: Property<String>
@get:Input
abstract val extraArgs: ListProperty<String>
@TaskAction
fun run() {
val appApk = findApk(appApkDirectory.get().asFile, "app")
val testApk = findApk(testApkDirectory.get().asFile, "instrumentation test")
val arguments = mutableListOf(
gcloudExecutable.get(),
"firebase", "test", "android", "run",
"--type", "instrumentation",
"--app", appApk.absolutePath,
"--test", testApk.absolutePath,
"--timeout", testTimeout.get(),
"--num-flaky-test-attempts", numFlakyTestAttempts.get().toString()
)
devices.get().forEach { device ->
arguments += listOf("--device", device)
}
if (useOrchestrator.get()) {
arguments += "--use-orchestrator"
}
val environment = environmentVariables.get()
if (environment.isNotEmpty()) {
arguments += "--environment-variables"
arguments += environment.entries.joinToString(",") { "${it.key}=${it.value}" }
}
gcloudProject.orNull?.takeIf { it.isNotBlank() }?.let { arguments += listOf("--project", it) }
resultsBucket.orNull?.takeIf { it.isNotBlank() }?.let { arguments += listOf("--results-bucket", it) }
resultsDir.orNull?.takeIf { it.isNotBlank() }?.let { arguments += listOf("--results-dir", it) }
testTargets.orNull?.takeIf { it.isNotBlank() }?.let { arguments += listOf("--test-targets", it) }
arguments += extraArgs.get()
logger.lifecycle("Running Firebase Test Lab:\n ${arguments.joinToString(" ")}")
execOperations.exec {
commandLine(arguments)
}
}
private fun findApk(directory: File, label: String): File {
return directory.walkTopDown().firstOrNull { it.isFile && it.extension == "apk" }
?: throw GradleException("No $label APK found under ${directory.absolutePath}. Was the assemble task run?")
}
}
abstract class RenameApkTask : DefaultTask() {
@get:InputFiles
abstract val apkFolder: DirectoryProperty
@@ -0,0 +1,82 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.subscription.donate
import androidx.compose.ui.test.junit4.v2.createEmptyComposeRule
import androidx.test.core.app.ActivityScenario
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.ViewActions
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.matcher.RootMatchers.isDialog
import androidx.test.espresso.matcher.ViewMatchers.isClickable
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.espresso.matcher.ViewMatchers.withText
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.hamcrest.Matchers.allOf
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.signal.core.util.deleteAll
import org.signal.donations.InAppPaymentType
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.database.InAppPaymentTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.testing.GooglePayTestRule
import org.thoughtcrime.securesms.testing.InAppPaymentsRule
import org.thoughtcrime.securesms.testing.RxTestSchedulerRule
import org.thoughtcrime.securesms.testing.SignalActivityRule
import org.thoughtcrime.securesms.testing.actions.scrollToDescendant
@Suppress("ClassName")
@RunWith(AndroidJUnit4::class)
class CheckoutFlowActivityTest__OneTimeDonations {
@get:Rule
val harness = SignalActivityRule(othersCount = 10)
@get:Rule
val iapRule = InAppPaymentsRule()
@get:Rule
val rxRule = RxTestSchedulerRule()
@get:Rule
val googlePayRule = GooglePayTestRule()
@get:Rule
val composeRule = createEmptyComposeRule()
private val intent = CheckoutFlowActivity.createIntent(InstrumentationRegistry.getInstrumentation().targetContext, InAppPaymentType.ONE_TIME_DONATION)
@Before
fun setUp() {
SignalDatabase.inAppPayments.writableDatabase.deleteAll(InAppPaymentTable.TABLE_NAME)
startJobLoopForTests()
}
@Test
fun givenPermitAcquisitionFails_whenIDonateOnce_thenIExpectPaymentSetupErrorDialog() {
failDonationPermitAcquisition()
val scenario = ActivityScenario.launch<CheckoutFlowActivity>(intent)
rxRule.defaultTestScheduler.triggerActions()
scrollToDescendant(R.id.recycler, withId(R.id.boost_1), rxRule.defaultTestScheduler)
onView(allOf(withId(R.id.boost_1), isClickable())).perform(ViewActions.click())
rxRule.defaultTestScheduler.triggerActions()
scrollToDescendant(R.id.recycler, withText(R.string.DonateToSignalFragment__continue), rxRule.defaultTestScheduler)
onView(withText(R.string.DonateToSignalFragment__continue)).perform(ViewActions.click())
rxRule.defaultTestScheduler.triggerActions()
scenario.selectGooglePay(composeRule, rxRule.defaultTestScheduler, InAppPaymentType.ONE_TIME_DONATION)
awaitDialog(rxRule.defaultTestScheduler, R.string.DonationsErrors__error_processing_payment)
onView(withText(R.string.DonationsErrors__your_payment)).inRoot(isDialog()).check(matches(isDisplayed()))
}
}
@@ -1,9 +1,11 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.donate
import androidx.compose.ui.test.junit4.v2.createEmptyComposeRule
import androidx.test.core.app.ActivityScenario
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.ViewActions
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.matcher.RootMatchers.isDialog
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import androidx.test.espresso.matcher.ViewMatchers.isNotEnabled
import androidx.test.espresso.matcher.ViewMatchers.isSelected
@@ -12,21 +14,32 @@ import androidx.test.espresso.matcher.ViewMatchers.withText
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import io.mockk.every
import io.mockk.mockkObject
import io.mockk.unmockkObject
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.signal.core.util.deleteAll
import org.signal.donations.InAppPaymentType
import org.signal.libsignal.net.RequestResult
import org.signal.network.NetworkResult
import org.signal.network.exceptions.NonSuccessfulResponseCodeException
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.permits.DonationPermits
import org.thoughtcrime.securesms.database.InAppPaymentTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.testing.GooglePayTestRule
import org.thoughtcrime.securesms.testing.InAppPaymentsRule
import org.thoughtcrime.securesms.testing.RxTestSchedulerRule
import org.thoughtcrime.securesms.testing.SignalActivityRule
import org.thoughtcrime.securesms.testing.actions.RecyclerViewScrollToBottomAction
import org.thoughtcrime.securesms.testing.actions.scrollToDescendant
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription
import org.whispersystems.signalservice.api.subscriptions.SubscriberId
import java.math.BigDecimal
@@ -46,19 +59,37 @@ class CheckoutFlowActivityTest__RecurringDonations {
@get:Rule
val rxRule = RxTestSchedulerRule()
@get:Rule
val googlePayRule = GooglePayTestRule()
@get:Rule
val composeRule = createEmptyComposeRule()
private val intent = CheckoutFlowActivity.createIntent(InstrumentationRegistry.getInstrumentation().targetContext, InAppPaymentType.RECURRING_DONATION)
@Before
fun setUp() {
SignalDatabase.inAppPayments.writableDatabase.deleteAll(InAppPaymentTable.TABLE_NAME)
startJobLoopForTests()
}
@After
fun tearDown() {
unmockkObject(DonationPermits)
}
@Test
fun givenRecurringDonations_whenILoadScreen_thenIExpectMonthlySelected() {
ActivityScenario.launch<CheckoutFlowActivity>(intent)
scrollToDescendant(R.id.recycler, withId(R.id.monthly), rxRule.defaultTestScheduler)
onView(withId(R.id.monthly)).check(matches(isSelected()))
}
@Test
fun givenNoCurrentDonation_whenILoadScreen_thenIExpectContinueButton() {
ActivityScenario.launch<CheckoutFlowActivity>(intent)
onView(withId(R.id.recycler)).perform(RecyclerViewScrollToBottomAction)
onView(withText("Continue")).check(matches(isDisplayed()))
scrollToDescendant(R.id.recycler, withText(R.string.DonateToSignalFragment__continue), rxRule.defaultTestScheduler)
onView(withText(R.string.DonateToSignalFragment__continue)).check(matches(isDisplayed()))
}
@Test
@@ -69,8 +100,9 @@ class CheckoutFlowActivityTest__RecurringDonations {
rxRule.defaultTestScheduler.triggerActions()
onView(withId(R.id.recycler)).perform(RecyclerViewScrollToBottomAction)
scrollToDescendant(R.id.recycler, withText(R.string.SubscribeFragment__update_subscription), rxRule.defaultTestScheduler)
onView(withText(R.string.SubscribeFragment__update_subscription)).check(matches(isDisplayed()))
scrollToDescendant(R.id.recycler, withText(R.string.SubscribeFragment__cancel_subscription), rxRule.defaultTestScheduler)
onView(withText(R.string.SubscribeFragment__cancel_subscription)).check(matches(isDisplayed()))
}
@@ -82,7 +114,7 @@ class CheckoutFlowActivityTest__RecurringDonations {
rxRule.defaultTestScheduler.triggerActions()
onView(withId(R.id.recycler)).perform(RecyclerViewScrollToBottomAction)
scrollToDescendant(R.id.recycler, withText(R.string.SubscribeFragment__cancel_subscription), rxRule.defaultTestScheduler)
onView(withText(R.string.SubscribeFragment__cancel_subscription)).check(matches(isDisplayed()))
onView(withText(R.string.SubscribeFragment__cancel_subscription)).perform(ViewActions.click())
onView(withText(R.string.SubscribeFragment__confirm_cancellation)).check(matches(isDisplayed()))
@@ -97,11 +129,48 @@ class CheckoutFlowActivityTest__RecurringDonations {
rxRule.defaultTestScheduler.triggerActions()
onView(withId(R.id.recycler)).perform(RecyclerViewScrollToBottomAction)
scrollToDescendant(R.id.recycler, withText(R.string.SubscribeFragment__update_subscription), rxRule.defaultTestScheduler)
onView(withText(R.string.SubscribeFragment__update_subscription)).check(matches(isDisplayed()))
onView(withText(R.string.SubscribeFragment__update_subscription)).check(matches(isNotEnabled()))
}
@Test
fun givenSubscriberPermitAcquisitionFails_whenISubscribe_thenIExpectPaymentSetupErrorDialog() {
failDonationPermitAcquisition()
val scenario = ActivityScenario.launch<CheckoutFlowActivity>(intent)
rxRule.defaultTestScheduler.triggerActions()
scrollToDescendant(R.id.recycler, withText(R.string.DonateToSignalFragment__continue), rxRule.defaultTestScheduler)
onView(withText(R.string.DonateToSignalFragment__continue)).perform(ViewActions.click())
rxRule.defaultTestScheduler.triggerActions()
scenario.selectGooglePay(composeRule, rxRule.defaultTestScheduler, InAppPaymentType.RECURRING_DONATION)
awaitDialog(rxRule.defaultTestScheduler, R.string.DonationsErrors__error_processing_payment)
onView(withText(R.string.DonationsErrors__your_payment)).inRoot(isDialog()).check(matches(isDisplayed()))
}
@Test
fun givenSubscriptionPaymentMethodPermitRejected_whenISubscribe_thenIExpectPaymentSetupErrorDialog() {
AppDependencies.donationPermitsRepository.clearPermits()
mockkObject(DonationPermits)
every { DonationPermits.getDonationPermit() } returns RequestResult.Success("permit")
every { AppDependencies.donationsApi.createStripeSubscriptionPaymentMethod(any(), any(), any()) } returns NetworkResult.StatusCodeError(NonSuccessfulResponseCodeException(402))
val scenario = ActivityScenario.launch<CheckoutFlowActivity>(intent)
rxRule.defaultTestScheduler.triggerActions()
scrollToDescendant(R.id.recycler, withText(R.string.DonateToSignalFragment__continue), rxRule.defaultTestScheduler)
onView(withText(R.string.DonateToSignalFragment__continue)).perform(ViewActions.click())
rxRule.defaultTestScheduler.triggerActions()
scenario.selectGooglePay(composeRule, rxRule.defaultTestScheduler, InAppPaymentType.RECURRING_DONATION)
awaitDialog(rxRule.defaultTestScheduler, R.string.DonationsErrors__error_processing_payment)
onView(withText(R.string.DonationsErrors__your_payment)).inRoot(isDialog()).check(matches(isDisplayed()))
}
private fun initialiseActiveSubscription() {
val currency = Currency.getInstance("USD")
val subscriber = InAppPaymentSubscriberRecord(
@@ -0,0 +1,109 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.subscription.donate
import android.app.Activity
import android.content.Intent
import androidx.compose.ui.test.junit4.ComposeTestRule
import androidx.compose.ui.test.onAllNodesWithTag
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick
import androidx.test.core.app.ActivityScenario
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.matcher.RootMatchers.isDialog
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import androidx.test.espresso.matcher.ViewMatchers.withText
import io.mockk.every
import io.reactivex.rxjava3.schedulers.TestScheduler
import org.signal.donations.InAppPaymentType
import org.signal.libsignal.net.RequestResult
import org.signal.network.rest.RestStatusCodeError
import org.thoughtcrime.securesms.SignalInstrumentationApplicationContext
import org.thoughtcrime.securesms.components.settings.app.subscription.GooglePayComponent
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewaySelectorTestTags
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.testing.flushUntil
/**
* Forces real donation-permit acquisition to fail at the issuer, exercising the permit code path through
* [org.thoughtcrime.securesms.components.settings.app.subscription.permits.DonationPermits].
*/
fun failDonationPermitAcquisition(statusCode: Int = 500) {
AppDependencies.donationPermitsRepository.clearPermits()
every { AppDependencies.donationsApi.createDonationPermits(any()) } returns RequestResult.NonSuccess(RestStatusCodeError(statusCode, emptyMap(), null))
}
/**
* The instrumentation app stubs [org.thoughtcrime.securesms.ApplicationContext.beginJobLoop] to a no-op, so enqueued
* jobs never run. The checkout pipeline drives a real setup job through the JobManager, so the loop must be started.
*/
fun startJobLoopForTests() {
(AppDependencies.application as SignalInstrumentationApplicationContext).beginJobLoopForTests()
}
/**
* Selects Google Pay in the Compose gateway selector and feeds the stubbed Google Pay result back into the
* checkout, navigating to the payment-in-progress screen where the pipeline runs.
*
* The gateway selector is populated by Rx work on [scheduler], so we [flushUntil] the button is present
* rather than sleeping. Selecting Google Pay dismisses the gateway sheet and hands a fragment result back to
* the checkout, which runs `launchGooglePay` -> `provideGatewayRequestForGooglePay` on [scheduler]; only then
* does the checkout's subscriber consume a [GooglePayComponent.googlePayResultPublisher] emission
* (`consumeGatewayRequestForGooglePay` returns null until then, and the publisher is a hot PublishSubject that
* drops earlier emissions). So we [flushUntil] the sheet has dismissed — a real signal that the click was
* fully processed — before dispatching the result, rather than pumping a fixed number of times and racing.
*/
fun ActivityScenario<CheckoutFlowActivity>.selectGooglePay(
composeRule: ComposeTestRule,
scheduler: TestScheduler,
inAppPaymentType: InAppPaymentType
) {
scheduler.flushUntil {
composeRule.onAllNodesWithTag(GatewaySelectorTestTags.GOOGLE_PAY_BUTTON).fetchSemanticsNodes().isNotEmpty()
}
composeRule.onNodeWithTag(GatewaySelectorTestTags.GOOGLE_PAY_BUTTON).performClick()
scheduler.flushUntil {
// Once the gateway sheet dismisses there is no Compose hierarchy left, so fetchSemanticsNodes throws
// rather than returning empty; treat both the empty list and the missing hierarchy as "sheet gone".
runCatching {
composeRule.onAllNodesWithTag(GatewaySelectorTestTags.GOOGLE_PAY_BUTTON).fetchSemanticsNodes().isEmpty()
}.getOrDefault(true)
}
onActivity { activity ->
(activity as GooglePayComponent).googlePayResultPublisher.onNext(
GooglePayComponent.GooglePayResult(
requestCode = InAppPaymentsRepository.getGooglePayRequestCode(inAppPaymentType),
resultCode = Activity.RESULT_OK,
data = Intent()
)
)
}
}
/**
* Waits for a dialog whose title is [titleResId] to be displayed. Matches any dialog by title (error,
* confirmation, thanks, etc.) — it is not specific to error dialogs.
*
* [flushUntil] advances the Rx pipeline (which may create the payment, enqueue the real setup job, and react
* to its committed state) while yielding real time for any job to run, until the dialog renders. A single
* condition-driven pump rather than a fixed-duration wall-clock poll. Letting the Espresso check throw (rather
* than collapsing it to a boolean) lets [flushUntil] chain the last matcher failure as the timeout cause.
*/
fun awaitDialog(
scheduler: TestScheduler,
titleResId: Int,
timeoutMillis: Long = 15_000
) {
scheduler.flushUntil(timeoutMillis) {
onView(withText(titleResId)).inRoot(isDialog()).check(matches(isDisplayed()))
true
}
}
@@ -11,6 +11,7 @@ import assertk.assertions.isEmpty
import assertk.assertions.isEqualTo
import assertk.assertions.isNotEmpty
import assertk.assertions.isNotEqualTo
import assertk.assertions.isNull
import assertk.assertions.isTrue
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
@@ -219,6 +220,26 @@ class AttachmentTableTest {
assertThat(SignalDatabase.attachments.getAttachment(attachmentId)!!.archiveTransferState).isEqualTo(AttachmentTable.ArchiveTransferState.NONE)
}
@Test
fun resetArchiveTransferStateForLocalBackupMedia_onlyResetsLocalBackupMedia() {
// Given one archive-finished attachment restored from a local backup, and one that wasn't
val localBackupMessageId = SignalDatabase.messages.insertMessageInbox(createIncomingMessage(serverTime = 0.days, attachment = createArchivedAttachment(localBackupKey = Random.nextBytes(32)))).map { it.messageId }.get()
val localBackupAttachmentId = SignalDatabase.attachments.getAttachmentsForMessage(localBackupMessageId).first().attachmentId
val nonLocalBackupMessageId = SignalDatabase.messages.insertMessageInbox(createIncomingMessage(serverTime = 1.days, attachment = createArchivedAttachment())).map { it.messageId }.get()
val nonLocalBackupAttachmentId = SignalDatabase.attachments.getAttachmentsForMessage(nonLocalBackupMessageId).first().attachmentId
SignalDatabase.attachments.setArchiveTransferState(localBackupAttachmentId, AttachmentTable.ArchiveTransferState.FINISHED)
SignalDatabase.attachments.setArchiveTransferState(nonLocalBackupAttachmentId, AttachmentTable.ArchiveTransferState.FINISHED)
val resetCount = SignalDatabase.attachments.resetArchiveTransferStateForLocalBackupMedia()
// Only the local-backup attachment is reset
assertThat(resetCount).isEqualTo(1)
assertThat(SignalDatabase.attachments.getAttachment(localBackupAttachmentId)!!.archiveTransferState).isEqualTo(AttachmentTable.ArchiveTransferState.NONE)
assertThat(SignalDatabase.attachments.getAttachment(nonLocalBackupAttachmentId)!!.archiveTransferState).isEqualTo(AttachmentTable.ArchiveTransferState.FINISHED)
}
@Test
fun given10NewerAnd10OlderAttachments_whenIGetEachBatch_thenIExpectProperBucketing() {
val now = System.currentTimeMillis().milliseconds
@@ -418,6 +439,39 @@ class AttachmentTableTest {
assertThat(dbAttachment2.archiveTransferState).isEqualTo(AttachmentTable.ArchiveTransferState.FINISHED)
}
@Test
fun givenLocalBackupRestore_whenIFinalizeAttachment_thenIExpectArchiveStateNoneSoItGetsUploaded() {
val data = byteArrayOf(1, 2, 3, 4, 5)
val attachment = createAttachmentPointer("remote-key-1".toByteArray(), data.size)
val messageResult = SignalDatabase.messages.insertMessageInbox(createIncomingMessage(serverTime = 0.days, attachment = attachment)).get()
val attachmentId = messageResult.insertedAttachments!![attachment]!!
SignalDatabase.attachments.setTransferState(messageResult.messageId, attachmentId, AttachmentTable.TRANSFER_PROGRESS_STARTED)
// Data is restored from a local backup file, not the archive CDN
SignalDatabase.attachments.finalizeAttachmentAfterDownload(messageResult.messageId, attachmentId, ByteArrayInputStream(data), archiveRestore = true, restoredFromArchiveCdn = false)
val result = SignalDatabase.attachments.getAttachment(attachmentId)!!
assertThat(result.archiveTransferState).isEqualTo(AttachmentTable.ArchiveTransferState.NONE)
assertThat(result.archiveCdn).isNull()
}
@Test
fun givenArchiveCdnRestore_whenIFinalizeAttachment_thenIExpectArchiveStateFinished() {
val data = byteArrayOf(1, 2, 3, 4, 5)
val attachment = createAttachmentPointer("remote-key-1".toByteArray(), data.size)
val messageResult = SignalDatabase.messages.insertMessageInbox(createIncomingMessage(serverTime = 0.days, attachment = attachment)).get()
val attachmentId = messageResult.insertedAttachments!![attachment]!!
SignalDatabase.attachments.setTransferState(messageResult.messageId, attachmentId, AttachmentTable.TRANSFER_PROGRESS_STARTED)
// Data is restored directly from the archive CDN
SignalDatabase.attachments.finalizeAttachmentAfterDownload(messageResult.messageId, attachmentId, ByteArrayInputStream(data), archiveRestore = true, restoredFromArchiveCdn = true)
val result = SignalDatabase.attachments.getAttachment(attachmentId)!!
assertThat(result.archiveTransferState).isEqualTo(AttachmentTable.ArchiveTransferState.FINISHED)
}
@Test
fun givenAttachmentsWithMatchingMediaId_whenISetArchiveFinishedForMatchingMediaObjects_thenIExpectThoseAttachmentsToBeMarkedFinished() {
// GIVEN
@@ -585,7 +639,7 @@ class AttachmentTableTest {
).get()
}
private fun createArchivedAttachment(): Attachment {
private fun createArchivedAttachment(localBackupKey: ByteArray? = null): Attachment {
return ArchivedAttachment(
contentType = "image/jpeg",
size = 1024,
@@ -609,7 +663,7 @@ class AttachmentTableTest {
quoteTargetContentType = null,
uuid = UUID.randomUUID(),
fileName = null,
localBackupKey = null
localBackupKey = localBackupKey
)
}
@@ -10,9 +10,14 @@ import org.junit.Test
import org.junit.runner.RunWith
import org.signal.ringrtc.CallId
import org.signal.ringrtc.CallManager
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.testing.Flag
import org.thoughtcrime.securesms.testing.RemoteConfigForTest
import org.thoughtcrime.securesms.testing.SignalActivityRule
import org.thoughtcrime.securesms.testing.TestRemoteConfigFlag
@RemoteConfigForTest(flags = [Flag(TestRemoteConfigFlag.DISAPPEAR_MORE, "true")])
@RunWith(AndroidJUnit4::class)
class CallTableTest {
@@ -977,6 +982,80 @@ class CallTableTest {
// assertEquals(0, allCallEvents.size)
}
@Test
fun givenAMissedOneToOneCall_whenIMarkAllCallEventsRead_thenTimerShouldStart() {
val callId = 1L
val peer = harness.others[0]
insertExpiringThread(peer)
SignalDatabase.calls.insertOneToOneCall(callId, System.currentTimeMillis(), peer, CallTable.Type.AUDIO_CALL, CallTable.Direction.INCOMING, CallTable.Event.MISSED)
val readAt = System.currentTimeMillis()
SignalDatabase.calls.markAllCallEventsRead(readAt = readAt)
val call = SignalDatabase.calls.getCallById(callId, peer)
assertEquals(readAt, SignalDatabase.messages.getMessageRecord(call!!.messageId!!).expireStarted)
}
@Test
fun givenAMissedNotificationProfileOneToOneCall_whenIMarkAllCallEventsRead_thenTimerShouldStart() {
val callId = 1L
val peer = harness.others[0]
insertExpiringThread(peer)
SignalDatabase.calls.insertOneToOneCall(callId, System.currentTimeMillis(), peer, CallTable.Type.AUDIO_CALL, CallTable.Direction.INCOMING, CallTable.Event.MISSED_NOTIFICATION_PROFILE)
val readAt = System.currentTimeMillis()
SignalDatabase.calls.markAllCallEventsRead(readAt = readAt)
val call = SignalDatabase.calls.getCallById(callId, peer)
assertEquals(readAt, SignalDatabase.messages.getMessageRecord(call!!.messageId!!).expireStarted)
}
@Test
fun givenAMissedGroupCall_whenIMarkAllCallEventsRead_thenTimerShouldStart() {
val callId = 1L
SignalDatabase.recipients.setExpireMessagesForGroup(groupRecipientId, 60)
SignalDatabase.calls.insertOrUpdateGroupCallFromRingState(callId, groupRecipientId, harness.others[1], System.currentTimeMillis(), CallManager.RingUpdate.EXPIRED_REQUEST)
val readAt = System.currentTimeMillis()
SignalDatabase.calls.markAllCallEventsRead(readAt = readAt)
val call = SignalDatabase.calls.getCallById(callId, groupRecipientId)
assertEquals(readAt, SignalDatabase.messages.getMessageRecord(call!!.messageId!!).expireStarted)
}
@Test
fun givenAMissedNotificationProfileGroupCall_whenIMarkAllCallEventsRead_thenTimerShouldStart() {
val callId = 1L
val ringerAci = Recipient.resolved(harness.others[1]).requireAci()
SignalDatabase.recipients.setExpireMessagesForGroup(groupRecipientId, 60)
SignalDatabase.calls.insertOrUpdateGroupCallFromRingState(callId, groupRecipientId, ringerAci, System.currentTimeMillis(), CallManager.RingUpdate.EXPIRED_REQUEST, true)
val readAt = System.currentTimeMillis()
SignalDatabase.calls.markAllCallEventsRead(readAt = readAt)
val call = SignalDatabase.calls.getCallById(callId, groupRecipientId)
assertEquals(readAt, SignalDatabase.messages.getMessageRecord(call!!.messageId!!).expireStarted)
}
@Test
fun givenAnOutgoingOneToOneCallFromSync_whenInserted_thenTimerIsStarted() {
val callId = 1L
val peer = harness.others[0]
val timestamp = System.currentTimeMillis()
insertExpiringThread(peer)
SignalDatabase.calls.insertOneToOneCall(callId, timestamp, peer, CallTable.Type.AUDIO_CALL, CallTable.Direction.OUTGOING, CallTable.Event.OUTGOING_RING, true)
val call = SignalDatabase.calls.getCallById(callId, peer)
val message = SignalDatabase.messages.getMessageRecord(call!!.messageId!!)
assertNotEquals(0L, message.expireStarted)
}
private fun insertTwoCallEvents() {
SignalDatabase.calls.insertAcceptedGroupCall(
1,
@@ -992,4 +1071,10 @@ class CallTableTest {
2000
)
}
private fun insertExpiringThread(recipientId: RecipientId) {
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(Recipient.resolved(recipientId))
MmsHelper.insert(recipient = Recipient.resolved(recipientId), expiresIn = 30_000L, threadId = threadId)
SignalDatabase.threads.update(threadId, false)
}
}
@@ -0,0 +1,279 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.jobs
import androidx.test.ext.junit.runners.AndroidJUnit4
import assertk.assertThat
import assertk.assertions.isEqualTo
import assertk.assertions.isFalse
import assertk.assertions.isNull
import io.mockk.Runs
import io.mockk.every
import io.mockk.just
import io.mockk.mockkObject
import io.mockk.unmockkAll
import io.mockk.verify
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.signal.core.models.backup.MediaName
import org.signal.core.models.database.AttachmentId
import org.signal.core.util.Base64.decodeBase64OrThrow
import org.signal.network.NetworkResult
import org.thoughtcrime.securesms.attachments.Attachment
import org.thoughtcrime.securesms.attachments.PointerAttachment
import org.thoughtcrime.securesms.backup.v2.BackupRepository
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
import org.thoughtcrime.securesms.database.AttachmentTable
import org.thoughtcrime.securesms.database.BackupMediaSnapshotTable.MediaEntry
import org.thoughtcrime.securesms.database.MessageType
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.mms.IncomingMessage
import org.thoughtcrime.securesms.testing.SignalActivityRule
import org.thoughtcrime.securesms.util.MediaUtil
import org.whispersystems.signalservice.api.archive.ArchiveGetMediaItemsResponse
import org.whispersystems.signalservice.api.archive.ArchiveGetMediaItemsResponse.StoredMediaObject
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentPointer
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentRemoteId
import java.io.ByteArrayInputStream
import java.util.Optional
import kotlin.time.Duration
import kotlin.time.Duration.Companion.days
@RunWith(AndroidJUnit4::class)
class ArchiveAttachmentReconciliationJobTest {
@get:Rule
val harness = SignalActivityRule()
@Before
fun setUp() {
SignalStore.backup.backupTier = MessageBackupTier.PAID
SignalStore.backup.hasBackupBeenUploaded = true
SignalStore.backup.lastAttachmentReconciliationTime = System.currentTimeMillis()
SignalStore.backup.localRestoreReconcilePending = false
mockkObject(BackupRepository)
mockkObject(ArchiveCommitAttachmentDeletesJob)
every { ArchiveCommitAttachmentDeletesJob.deleteMediaObjectsFromCdn(any(), any(), any(), any()) } returns null
}
@After
fun tearDown() {
unmockkAll()
}
/**
* The core of the reconcile-first restore flow: a local restore resets everything to [AttachmentTable.ArchiveTransferState.NONE], so media that genuinely is
* on the CDN must be promoted back to FINISHED during reconciliation -- otherwise the backfill would needlessly re-upload it. This only happens while
* [localRestoreReconcilePending] is set, so it never runs in the common periodic reconciliation.
*/
@Test
fun givenLocalRestorePendingAndAttachmentOnCdn_whenIReconcile_thenIExpectItMarkedFinished() {
SignalStore.backup.localRestoreReconcilePending = true
val attachmentId = seedFinalizedAttachment("remote-key-1".toByteArray(), byteArrayOf(1, 2, 3, 4, 5))
commitSnapshotFor(attachmentId, cdn = 3)
fakeCdnContains(attachmentId, cdn = 3)
ArchiveAttachmentReconciliationJob(forced = true).run()
assertThat(SignalDatabase.attachments.getAttachment(attachmentId)!!.archiveTransferState).isEqualTo(AttachmentTable.ArchiveTransferState.FINISHED)
}
/**
* Guards the reconcile-first promotion above: outside the local-restore flow (the common periodic reconciliation), NONE media that happens to be on the CDN is
* left alone, so we don't do the expensive mark-finished scan in the common case.
*/
@Test
fun givenNoLocalRestorePendingAndNoneAttachmentOnCdn_whenIReconcile_thenItStaysNone() {
val attachmentId = seedFinalizedAttachment("remote-key-common".toByteArray(), byteArrayOf(2, 3, 4, 5, 6))
commitSnapshotFor(attachmentId, cdn = 3)
fakeCdnContains(attachmentId, cdn = 3)
ArchiveAttachmentReconciliationJob(forced = true).run()
assertThat(SignalDatabase.attachments.getAttachment(attachmentId)!!.archiveTransferState).isEqualTo(AttachmentTable.ArchiveTransferState.NONE)
}
@Test
fun givenFinishedAttachmentMissingFromCdn_whenIReconcile_thenIExpectItResetToNone() {
val attachmentId = seedFinalizedAttachment("remote-key-2".toByteArray(), byteArrayOf(6, 7, 8, 9, 10))
SignalDatabase.attachments.setArchiveTransferState(attachmentId, AttachmentTable.ArchiveTransferState.FINISHED)
commitSnapshotFor(attachmentId, cdn = 3)
fakeCdnEmpty()
ArchiveAttachmentReconciliationJob(forced = true).run()
assertThat(SignalDatabase.attachments.getAttachment(attachmentId)!!.archiveTransferState).isEqualTo(AttachmentTable.ArchiveTransferState.NONE)
}
/**
* The eventual safety net: an ordinary (non-forced) periodic reconciliation, run after the sync interval has elapsed, heals the bad state on its own -- media
* in the snapshot but missing from the CDN is reset to [AttachmentTable.ArchiveTransferState.NONE] and a re-upload is enqueued -- with no help from the
* migration or the reconcile-first flow.
*/
@Test
fun givenFinishedMediaMissingFromCdn_whenAnOrdinaryPeriodicReconciliationRuns_thenItHealsToNoneAndReUploads() {
SignalStore.backup.lastAttachmentReconciliationTime = System.currentTimeMillis() - 60.days.inWholeMilliseconds
mockkObject(BackupMessagesJob)
every { BackupMessagesJob.enqueue() } just Runs
val attachmentId = seedFinalizedAttachment("remote-key-periodic".toByteArray(), byteArrayOf(1, 2, 3, 4, 5))
SignalDatabase.attachments.setArchiveTransferState(attachmentId, AttachmentTable.ArchiveTransferState.FINISHED)
commitSnapshotFor(attachmentId, cdn = 3)
fakeCdnEmpty()
ArchiveAttachmentReconciliationJob(forced = false).run()
val healed = SignalDatabase.attachments.getAttachment(attachmentId)!!
assertThat(healed.archiveTransferState).isEqualTo(AttachmentTable.ArchiveTransferState.NONE)
assertThat(healed.archiveCdn).isNull()
verify(exactly = 1) { BackupMessagesJob.enqueue() }
}
/**
* Reconciliation must only repair genuinely-broken state. Media that is actually present on the CDN stays [AttachmentTable.ArchiveTransferState.FINISHED], so
* we never needlessly reset (and therefore re-upload) media that was correctly archived.
*/
@Test
fun givenFinishedMediaStillOnCdn_whenIReconcile_thenItStaysFinished() {
val attachmentId = seedFinalizedAttachment("remote-key-on-cdn".toByteArray(), byteArrayOf(6, 7, 8, 9, 10))
SignalDatabase.attachments.setArchiveTransferState(attachmentId, AttachmentTable.ArchiveTransferState.FINISHED)
commitSnapshotFor(attachmentId, cdn = 3)
fakeCdnContains(attachmentId, cdn = 3)
ArchiveAttachmentReconciliationJob(forced = true).run()
assertThat(SignalDatabase.attachments.getAttachment(attachmentId)!!.archiveTransferState).isEqualTo(AttachmentTable.ArchiveTransferState.FINISHED)
}
/**
* The other healing direction: media that is on the CDN but locally marked [AttachmentTable.ArchiveTransferState.NONE] and absent from the current snapshot is
* treated as a delete-candidate. Before deleting, reconciliation confirms it's still referenced locally and recovers it to
* [AttachmentTable.ArchiveTransferState.FINISHED] rather than deleting it from the CDN.
*/
@Test
fun givenNoneMediaOnCdnButNotInSnapshot_whenIReconcile_thenItIsRecoveredToFinished() {
val attachmentId = seedFinalizedAttachment("remote-key-flow2".toByteArray(), byteArrayOf(11, 12, 13, 14, 15))
SignalDatabase.attachments.setArchiveTransferState(attachmentId, AttachmentTable.ArchiveTransferState.NONE)
fakeCdnContains(attachmentId, cdn = 3)
ArchiveAttachmentReconciliationJob(forced = true).run()
assertThat(SignalDatabase.attachments.getAttachment(attachmentId)!!.archiveTransferState).isEqualTo(AttachmentTable.ArchiveTransferState.FINISHED)
}
@Test
fun givenFirstEverReconciliation_whenIForceIt_thenItStillRunsAndRepairs() {
SignalStore.backup.lastAttachmentReconciliationTime = -1
val attachmentId = seedFinalizedAttachment("remote-key-3".toByteArray(), byteArrayOf(11, 12, 13, 14, 15))
SignalDatabase.attachments.setArchiveTransferState(attachmentId, AttachmentTable.ArchiveTransferState.FINISHED)
commitSnapshotFor(attachmentId, cdn = 3)
fakeCdnEmpty()
ArchiveAttachmentReconciliationJob(forced = true).run()
assertThat(SignalDatabase.attachments.getAttachment(attachmentId)!!.archiveTransferState).isEqualTo(AttachmentTable.ArchiveTransferState.NONE)
}
@Test
fun givenLocalRestoreReconcilePending_whenReconcileCompletes_thenIExpectFlagCleared() {
SignalStore.backup.localRestoreReconcilePending = true
fakeCdnEmpty()
ArchiveAttachmentReconciliationJob(forced = true).run()
assertThat(SignalStore.backup.localRestoreReconcilePending).isFalse()
}
private fun seedFinalizedAttachment(remoteKey: ByteArray, data: ByteArray): AttachmentId {
val attachment = createAttachmentPointer(remoteKey, data.size)
val messageResult = SignalDatabase.messages.insertMessageInbox(createIncomingMessage(serverTime = 0.days, attachment = attachment)).get()
val attachmentId = messageResult.insertedAttachments!![attachment]!!
SignalDatabase.attachments.setTransferState(messageResult.messageId, attachmentId, AttachmentTable.TRANSFER_PROGRESS_STARTED)
SignalDatabase.attachments.finalizeAttachmentAfterDownload(messageResult.messageId, attachmentId, ByteArrayInputStream(data))
return attachmentId
}
private fun commitSnapshotFor(attachmentId: AttachmentId, cdn: Int) {
val attachment = SignalDatabase.attachments.getAttachment(attachmentId)!!
val plaintextHash = attachment.dataHash!!.decodeBase64OrThrow()
val remoteKey = attachment.remoteKey!!.decodeBase64OrThrow()
val mediaId = MediaName.fromPlaintextHashAndRemoteKey(plaintextHash, remoteKey).toMediaId(SignalStore.backup.mediaRootBackupKey).encode()
SignalDatabase.backupMediaSnapshots.writePendingMediaEntries(
listOf(MediaEntry(mediaId = mediaId, cdn = cdn, plaintextHash = plaintextHash, remoteKey = remoteKey, isThumbnail = false))
)
SignalDatabase.backupMediaSnapshots.commitPendingRows()
}
private fun fakeCdnContains(attachmentId: AttachmentId, cdn: Int) {
val attachment = SignalDatabase.attachments.getAttachment(attachmentId)!!
val plaintextHash = attachment.dataHash!!.decodeBase64OrThrow()
val remoteKey = attachment.remoteKey!!.decodeBase64OrThrow()
val mediaId = MediaName.fromPlaintextHashAndRemoteKey(plaintextHash, remoteKey).toMediaId(SignalStore.backup.mediaRootBackupKey).encode()
every { BackupRepository.listRemoteMediaObjects(any(), any()) } returns NetworkResult.Success(
ArchiveGetMediaItemsResponse(
storedMediaObjects = listOf(StoredMediaObject(cdn = cdn, mediaId = mediaId, objectLength = attachment.size)),
backupDir = null,
mediaDir = null,
cursor = null
)
)
}
private fun fakeCdnEmpty() {
every { BackupRepository.listRemoteMediaObjects(any(), any()) } returns NetworkResult.Success(
ArchiveGetMediaItemsResponse(storedMediaObjects = emptyList(), backupDir = null, mediaDir = null, cursor = null)
)
}
private fun createIncomingMessage(serverTime: Duration, attachment: Attachment): IncomingMessage {
return IncomingMessage(
type = MessageType.NORMAL,
from = harness.others[0],
body = null,
sentTimeMillis = serverTime.inWholeMilliseconds,
serverTimeMillis = serverTime.inWholeMilliseconds,
receivedTimeMillis = serverTime.inWholeMilliseconds,
attachments = listOf(attachment)
)
}
private fun createAttachmentPointer(key: ByteArray, size: Int): Attachment {
return PointerAttachment.forPointer(
pointer = Optional.of(
SignalServiceAttachmentPointer(
cdnNumber = 3,
remoteId = SignalServiceAttachmentRemoteId.V4("asdf"),
contentType = MediaUtil.IMAGE_JPEG,
key = key,
size = Optional.of(size),
preview = Optional.empty(),
width = 2,
height = 2,
digest = Optional.of(byteArrayOf()),
incrementalDigest = Optional.empty(),
incrementalMacChunkSize = 0,
fileName = Optional.of("file.jpg"),
voiceNote = false,
isBorderless = false,
isGif = false,
caption = Optional.empty(),
blurHash = Optional.empty(),
uploadTimestamp = 0,
uuid = null
)
)
).get()
}
}
@@ -88,6 +88,7 @@ class BackupSubscriptionCheckJobTest {
every { RecurringInAppPaymentRepository.getActiveSubscriptionSync(InAppPaymentSubscriberRecord.Type.BACKUP) } returns NetworkResult.Success(
createActiveSubscription()
)
every { RecurringInAppPaymentRepository.ensureSubscriberIdSync(any(), any(), any()) } returns Unit
mockkObject(BackupRepository)
every { BackupRepository.getBackupTier() } answers {
@@ -49,7 +49,6 @@ import java.util.concurrent.TimeUnit
*/
@RunWith(AndroidJUnit4::class)
class MainNavigationLaunchTest {
@get:Rule
val harness = SignalActivityRule(othersCount = 2)
@@ -89,7 +88,8 @@ class MainNavigationLaunchTest {
appendLine("fragments observed: ${recorder.allCreated}")
appendLine("activity fragments: ${launched.activity.supportFragmentManager.fragments.map { it::class.simpleName }}")
appendLine("vm.currentListLocation: ${vm.mainNavigationState.value.currentListLocation}")
appendLine("vm.earlyNavigationDetailLocationRequested: ${vm.earlyNavigationDetailLocationRequested}")
appendLine("vm.detailLocation: ${vm.detailLocation.value}")
appendLine("vm.chatsBackStackEntries: ${vm.chatsBackStackEntries.toList()}")
}
}
throw IllegalStateException("${e.message}\n$state", e)
@@ -254,8 +254,9 @@ class MainNavigationLaunchTest {
"starts handling it on cold launch, update or delete this test. Got: ${recorder.allCreated}"
}
val vm = runOnMainSync { launched.activity.mainNavigationViewModel() }
check(vm.earlyNavigationDetailLocationRequested == null) {
"Expected no early detail to be staged, got ${vm.earlyNavigationDetailLocationRequested}"
val staged = runOnMainSync { vm.chatsBackStackEntries.filterNot { it is MainNavigationDetailLocation.Empty } }
check(staged.isEmpty()) {
"Expected no detail to be staged on the chats back stack, got $staged"
}
}
}
@@ -289,8 +290,9 @@ class MainNavigationLaunchTest {
"Expected default CHATS, got ${vm.mainNavigationState.value.currentListLocation}"
}
Thread.sleep(750)
check(vm.earlyNavigationDetailLocationRequested == null) {
"Expected no early detail, got ${vm.earlyNavigationDetailLocationRequested}"
val detailLocation = runOnMainSync { vm.detailLocation.value }
check(detailLocation == MainNavigationDetailLocation.Empty) {
"Expected Empty detail location, got $detailLocation"
}
check(recorder.createdArgs.isEmpty()) {
"Expected no ConversationFragment for bare launch, got ${recorder.createdArgs.size}"
@@ -236,8 +236,9 @@ class SyncMessageProcessorTest_synchronizePniChangeNumber {
}
@Test
fun skipsRedeliveryWhenPniAlreadyMatches() {
sendPniChangeNumber()
fun skipsRedeliveryWithSameServerTimestamp() {
val timestamp = messageHelper.nextStartTime()
sendPniChangeNumber(timestamp = timestamp)
val afterFirstApply = captureOriginalState()
val otherIdentity = IdentityKeyPair.generate()
@@ -247,12 +248,41 @@ class SyncMessageProcessorTest_synchronizePniChangeNumber {
identityKeyPair = otherIdentity.serialize().toByteString(),
signedPreKey = otherSignedPreKey.serialize().toByteString(),
e164 = "+15555550100",
timestamp = messageHelper.nextStartTime() + 1000
timestamp = timestamp
)
assertOriginalStatePreserved(afterFirstApply)
}
@Test
fun reappliesWhenServerTimestampIsNewer() {
sendPniChangeNumber()
val secondPniUuid = UUID.randomUUID()
val secondPni = ServiceId.PNI.from(secondPniUuid)
val secondPniBytes = UuidUtil.toByteArray(secondPniUuid).toByteString()
val secondIdentity = IdentityKeyPair.generate()
val secondSignedPreKey = PreKeyUtil.generateSignedPreKey(9999, secondIdentity.privateKey)
val secondE164 = "+15555550100"
val secondRegistrationId = 7777
sendPniChangeNumber(
identityKeyPair = secondIdentity.serialize().toByteString(),
signedPreKey = secondSignedPreKey.serialize().toByteString(),
lastResortKyberPreKey = null,
registrationId = secondRegistrationId,
e164 = secondE164,
envelopePniBinary = secondPniBytes,
timestamp = messageHelper.nextStartTime() + 1000
)
assertThat(SignalStore.account.e164).isEqualTo(secondE164)
assertThat(SignalStore.account.pni).isEqualTo(secondPni)
assertThat(SignalStore.account.pniRegistrationId).isEqualTo(secondRegistrationId)
assertThat(SignalStore.account.pniIdentityKey.publicKey.serialize().toByteString())
.isEqualTo(secondIdentity.publicKey.serialize().toByteString())
}
@Test
fun bailsWhenServerTimestampStale() {
sendPniChangeNumber()
@@ -0,0 +1,49 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.testing
import com.google.android.gms.wallet.PaymentData
import io.mockk.Runs
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.mockkConstructor
import io.mockk.unmockkConstructor
import io.reactivex.rxjava3.core.Completable
import org.junit.rules.ExternalResource
import org.signal.donations.GooglePayApi
/**
* Makes Google Pay appear available and return a fake [com.google.android.gms.wallet.PaymentData] without
* launching the real Google Pay sheet, allowing checkout to be driven to the payment pipeline in instrumentation.
*/
class GooglePayTestRule : ExternalResource() {
override fun before() {
val paymentData = mockk<PaymentData> {
every { toJson() } returns GOOGLE_PAY_PAYMENT_DATA_JSON
}
mockkConstructor(GooglePayApi::class)
every { anyConstructed<GooglePayApi>().queryIsReadyToPay() } returns Completable.complete()
every { anyConstructed<GooglePayApi>().requestPayment(any(), any(), any()) } just Runs
every { anyConstructed<GooglePayApi>().onActivityResult(any(), any(), any(), any(), any()) } answers {
arg<GooglePayApi.PaymentRequestCallback>(4).onSuccess(paymentData)
}
}
override fun after() {
unmockkConstructor(GooglePayApi::class)
}
companion object {
/**
* Minimal but well-formed Google Pay payload. [org.signal.donations.GooglePayPaymentSource] parses
* `paymentMethodData.tokenizationData.token` (itself a JSON object with an `id`) when the source is
* serialized into the setup job, so a relaxed mock that returns an empty body fails before the job runs.
*/
const val GOOGLE_PAY_PAYMENT_DATA_JSON = """{"paymentMethodData":{"tokenizationData":{"token":"{\"id\":\"tok_test\"}"}},"email":"test@signal.org"}"""
}
}
@@ -0,0 +1,57 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.testing
import android.os.SystemClock
import androidx.test.platform.app.InstrumentationRegistry
import io.reactivex.rxjava3.schedulers.TestScheduler
/**
* Timing primitives for in-app-payment instrumentation tests.
*
* The checkout flow runs against two clocks: the Rx pipeline that drives the UI (creating the payment,
* enqueuing the setup job, reacting to state changes, rendering dialogs) runs on the test's [TestScheduler]
* (virtual time), while the setup job itself executes on a live JobManager worker thread (real time). Neither
* clock can be waited on alone — the scheduler must be advanced for the pipeline to make progress, and the
* test must yield real time for the job to run and commit its result. [flushUntil] does both.
*/
/**
* Advances virtual Rx time on this [TestScheduler], drains the main looper, and yields briefly for real
* JobManager work, repeating until [condition] holds or [timeoutMs] elapses.
*
* [TestScheduler.triggerActions] runs the pipeline's scheduled work (which may enqueue a job or process a
* database update), `waitForIdleSync` renders whatever that produced, and the short sleep lets the real setup
* job run on its worker thread and commit the next state before the loop advances the scheduler again to pick
* it up. Throws once [timeoutMs] is exceeded, which means the expected UI state never materialised.
*
* [condition] may either return `false` or throw (e.g. an Espresso `check` that has not yet matched) to signal
* "not satisfied"; a thrown failure is retained and chained as the cause of the timeout [AssertionError] so a
* flake surfaces the underlying Espresso error rather than an opaque "condition not satisfied".
*/
fun TestScheduler.flushUntil(timeoutMs: Long = 15_000, condition: () -> Boolean) {
val deadline = SystemClock.uptimeMillis() + timeoutMs
var lastFailure: Throwable? = null
while (true) {
triggerActions()
InstrumentationRegistry.getInstrumentation().waitForIdleSync()
try {
if (condition()) {
return
}
} catch (t: Throwable) {
lastFailure = t
}
if (SystemClock.uptimeMillis() >= deadline) {
throw AssertionError("Condition was not satisfied within ${timeoutMs}ms of flushing the checkout pipeline.", lastFailure)
}
Thread.sleep(50)
}
}
@@ -10,8 +10,11 @@ import io.mockk.every
import org.junit.rules.ExternalResource
import org.signal.core.util.JsonUtils
import org.signal.network.NetworkResult
import org.signal.network.exceptions.NonSuccessfulResponseCodeException
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription
import org.whispersystems.signalservice.internal.push.SubscriptionsConfiguration
import org.whispersystems.signalservice.internal.push.WhoAmIResponse
/**
* Sets up some common infrastructure for on-device InAppPayment testing
@@ -21,6 +24,8 @@ class InAppPaymentsRule : ExternalResource() {
initialiseConfigurationResponse()
initialisePutSubscription()
initialiseSetArchiveBackupId()
initialiseSetAccountAttributes()
initialiseAccountAndSubscriptionLookups()
}
private fun initialiseConfigurationResponse() {
@@ -46,4 +51,31 @@ class InAppPaymentsRule : ExternalResource() {
every { triggerBackupIdReservation(any(), any(), any()) } returns NetworkResult.Success(Unit)
}
}
private fun initialiseSetAccountAttributes() {
AppDependencies.accountApi.apply {
every { setAccountAttributes(any()) } returns NetworkResult.Success(Unit)
}
}
/**
* Starting the real job loop lets background jobs unrelated to the assertion under test run against the strict
* API mocks (e.g. [org.thoughtcrime.securesms.jobs.InAppPaymentRecurringContextJob] querying whoAmI, the
* active subscription, and then submitting receipt credentials). Stub these calls so those jobs hit a handled
* path and terminate quietly instead of throwing [io.mockk.MockKException] on a job thread, which crashes the
* whole app process (the test asserts nothing, so it surfaces as "Application crashed" with no failed test).
* Tests that supply an active subscription push the job past the [getSubscription] guard to
* [submitReceiptCredentials], so that must be stubbed too. End-to-end coverage of that pipeline is tracked
* separately; here we only keep the process alive and the logs clean.
*/
private fun initialiseAccountAndSubscriptionLookups() {
AppDependencies.accountApi.apply {
every { whoAmI() } returns NetworkResult.Success(WhoAmIResponse(number = "+15555550123"))
}
AppDependencies.donationsApi.apply {
every { getSubscription(any()) } returns NetworkResult.Success(ActiveSubscription.EMPTY)
every { submitReceiptCredentials(any(), any()) } returns NetworkResult.StatusCodeError(NonSuccessfulResponseCodeException(402))
}
}
}
@@ -46,7 +46,8 @@ annotation class RawFlag(val key: String, val value: String)
*/
enum class TestRemoteConfigFlag(private val property: KProperty0<*>) {
INTERNAL_USER(RemoteConfig::internalUser),
DEFAULT_MAX_BACKOFF(RemoteConfig::defaultMaxBackoff);
DEFAULT_MAX_BACKOFF(RemoteConfig::defaultMaxBackoff),
DISAPPEAR_MORE(RemoteConfig::disappearMore);
val key: String
get() {
@@ -0,0 +1,66 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.testing.actions
import android.os.SystemClock
import android.view.View
import androidx.annotation.IdRes
import androidx.recyclerview.widget.RecyclerView
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.PerformException
import androidx.test.espresso.contrib.RecyclerViewActions
import androidx.test.espresso.matcher.ViewMatchers.hasDescendant
import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.platform.app.InstrumentationRegistry
import io.reactivex.rxjava3.schedulers.TestScheduler
import org.hamcrest.Matcher
/**
* Scrolls the [RecyclerView] with id [recyclerViewId] to the view holder whose item view matches [target], or
* whose item view contains a descendant matching [target] (e.g. a preset button inside a container row),
* binding it if necessary, then returns. Off-screen presets/buttons are brought on-screen before a click or
* assertion regardless of device size — important for Firebase Test Lab's varied screens.
*
* The DSL screens use [androidx.recyclerview.widget.ListAdapter], which diffs `submitList` on a background
* thread and posts the result to the main thread. espresso-contrib's [RecyclerViewActions.scrollTo] scans the
* adapter once and fails fast if that diff has not yet committed, so we retry within [timeoutMs], pumping the
* main looper (and, if supplied, advancing [scheduler] to run the Rx work that produces the list) between
* attempts. This is the async-diff analogue of the codebase's existing poll-until-ready test idiom; Android
* exposes no deterministic completion hook for the differ.
*/
fun scrollToDescendant(
@IdRes recyclerViewId: Int,
target: Matcher<View>,
scheduler: TestScheduler? = null,
timeoutMs: Long = 5_000
) {
val deadline = SystemClock.uptimeMillis() + timeoutMs
while (true) {
// A holder's item view may itself be the target (a bare button row) or contain it as a descendant
// (a preset within a container), so try both rather than assume one shape.
if (tryScrollTo(recyclerViewId, target) || tryScrollTo(recyclerViewId, hasDescendant(target))) {
return
}
if (SystemClock.uptimeMillis() >= deadline) {
throw AssertionError("RecyclerView (id=$recyclerViewId) never bound a holder matching $target within ${timeoutMs}ms.")
}
scheduler?.triggerActions()
InstrumentationRegistry.getInstrumentation().waitForIdleSync()
Thread.sleep(50)
}
}
private fun tryScrollTo(@IdRes recyclerViewId: Int, holderMatcher: Matcher<View>): Boolean {
return try {
onView(withId(recyclerViewId)).perform(RecyclerViewActions.scrollTo<RecyclerView.ViewHolder>(holderMatcher))
true
} catch (e: PerformException) {
false
}
}
@@ -1,34 +0,0 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.testing.actions
import android.view.View
import androidx.recyclerview.widget.RecyclerView
import androidx.test.espresso.UiController
import androidx.test.espresso.ViewAction
import androidx.test.espresso.matcher.ViewMatchers.isAssignableFrom
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import org.hamcrest.CoreMatchers.allOf
import org.hamcrest.Matcher
/**
* Scrolls the RecyclerView to the bottom position.
*
* Borrowed from [https://stackoverflow.com/a/55990445](https://stackoverflow.com/a/55990445)
*/
object RecyclerViewScrollToBottomAction : ViewAction {
override fun getDescription(): String = "scroll RecyclerView to bottom"
override fun getConstraints(): Matcher<View> = allOf(isAssignableFrom(RecyclerView::class.java), isDisplayed())
override fun perform(uiController: UiController?, view: View?) {
val recyclerView = view as RecyclerView
val itemCount = recyclerView.adapter?.itemCount
val position = itemCount?.minus(1) ?: 0
recyclerView.scrollToPosition(position)
uiController?.loopMainThreadUntilIdle()
}
}
+8 -6
View File
@@ -438,12 +438,7 @@
android:theme="@style/Theme.Signal.DayNight.NoActionBar"
android:windowSoftInputMode="adjustResize" />
<activity
android:name=".calls.links.details.CallLinkDetailsActivity"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
android:exported="false"
android:theme="@style/Theme.Signal.DayNight.NoActionBar"
android:windowSoftInputMode="stateAlwaysHidden" />
<activity
android:name=".calls.new.NewCallActivity"
@@ -527,6 +522,13 @@
</intent-filter>
</activity>
<activity
android:name=".preferences.EditProxyActivity"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
android:exported="false"
android:theme="@style/Signal.DayNight.NoActionBar"
android:windowSoftInputMode="adjustResize" />
<activity
android:name=".stories.my.MyStoriesActivity"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -52,6 +52,7 @@ import org.signal.ringrtc.CallManager;
import org.thoughtcrime.securesms.apkupdate.ApkUpdateRefreshListener;
import org.thoughtcrime.securesms.avatar.AvatarPickerStorage;
import org.thoughtcrime.securesms.backup.v2.BackupRepository;
import org.thoughtcrime.securesms.preferences.EditProxyActivity;
import org.thoughtcrime.securesms.conversation.drafts.DraftBlobs;
import org.thoughtcrime.securesms.crypto.AppAttachmentSecretStore;
import org.thoughtcrime.securesms.crypto.DatabaseSecretProvider;
@@ -118,14 +119,16 @@ import org.thoughtcrime.securesms.service.webrtc.AndroidTelecomUtil;
import org.thoughtcrime.securesms.storage.StorageSyncHelper;
import org.thoughtcrime.securesms.util.AppStartup;
import org.thoughtcrime.securesms.util.BatterySnapshotTracker;
import org.thoughtcrime.securesms.util.CommunicationActions;
import org.thoughtcrime.securesms.util.DeviceProperties;
import org.thoughtcrime.securesms.util.DynamicTheme;
import org.thoughtcrime.securesms.util.Environment;
import org.thoughtcrime.securesms.util.PlayServicesUtil;
import org.signal.core.util.PlayServicesUtil;
import org.thoughtcrime.securesms.util.RemoteConfig;
import org.thoughtcrime.securesms.util.SignalLocalMetrics;
import org.thoughtcrime.securesms.util.SignalUncaughtExceptionHandler;
import org.thoughtcrime.securesms.util.SqlCipherLogTarget;
import org.thoughtcrime.securesms.util.SupportEmailUtil;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.VersionTracker;
import org.thoughtcrime.securesms.util.dynamiclanguage.DynamicLanguageContextWrapper;
@@ -183,9 +186,9 @@ public class ApplicationContext extends Application implements AppForegroundObse
initializeLogging();
Log.i(TAG, "onCreate()");
})
.addBlocking("security-provider", this::initializeSecurityProvider)
.addBlocking("app-dependencies", this::initializeAppDependencies)
.addBlocking("anr-detector", this::startAnrDetector)
.addBlocking("security-provider", this::initializeSecurityProvider)
.addBlocking("crash-handling", this::initializeCrashHandling)
.addBlocking("rx-init", this::initializeRx)
.addBlocking("event-bus", () -> EventBus.builder().logNoSubscriberMessages(false).installDefaultEventBus())
@@ -433,6 +436,15 @@ public class ApplicationContext extends Application implements AppForegroundObse
context -> {
context.startActivity(new Intent(context, SubmitDebugLogActivity.class));
return Unit.INSTANCE;
},
context -> {
context.startActivity(EditProxyActivity.intent(context));
return Unit.INSTANCE;
},
(context, subject) -> {
String body = SupportEmailUtil.generateSupportEmailBody(context, subject, null, null);
CommunicationActions.openEmail(context, SupportEmailUtil.getSupportEmailAddress(context), subject, body);
return Unit.INSTANCE;
}
)
);
@@ -280,7 +280,7 @@ public final class ContactSelectionListFragment extends LoggingFragment {
false,
new ContactSelectionListAdapter.ArbitraryRepository(),
new SearchRepository(requireContext().getString(R.string.note_to_self)),
new ContactSearchPagedDataSourceRepository(requireContext()),
new ContactSearchPagedDataSourceRepository(requireContext(), requireContext().getString(R.string.note_to_self)),
fixedContacts,
false
)
@@ -9,6 +9,7 @@ import android.annotation.SuppressLint
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.view.MotionEvent
import android.view.View
@@ -23,6 +24,7 @@ import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.compose.foundation.background
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.BoxWithConstraintsScope
@@ -57,10 +59,13 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.fragment.app.DialogFragment
import androidx.fragment.compose.AndroidFragment
import androidx.fragment.compose.rememberFragmentState
@@ -70,7 +75,9 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.createSavedStateHandle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
import androidx.navigation3.ui.NavDisplay
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.dialog.MaterialAlertDialogBuilder
@@ -101,6 +108,7 @@ import org.thoughtcrime.securesms.backup.v2.ArchiveRestoreProgressState
import org.thoughtcrime.securesms.backup.v2.ui.CouldNotCompleteBackupRestoreSheet
import org.thoughtcrime.securesms.backup.v2.ui.verify.VerifyBackupKeyActivity
import org.thoughtcrime.securesms.calls.YouAreAlreadyInACallSnackbar.show
import org.thoughtcrime.securesms.calls.callsNavEntries
import org.thoughtcrime.securesms.calls.log.CallLogFilter
import org.thoughtcrime.securesms.calls.log.CallLogFragment
import org.thoughtcrime.securesms.calls.new.NewCallActivity
@@ -137,7 +145,6 @@ import org.thoughtcrime.securesms.devicetransfer.olddevice.OldDeviceExitActivity
import org.thoughtcrime.securesms.groups.ui.creategroup.CreateGroupActivity
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.lock.v2.CreateSvrPinActivity
import org.thoughtcrime.securesms.main.DetailsScreenNavHost
import org.thoughtcrime.securesms.main.MainBottomChrome
import org.thoughtcrime.securesms.main.MainBottomChromeCallback
import org.thoughtcrime.securesms.main.MainBottomChromeState
@@ -157,11 +164,6 @@ import org.thoughtcrime.securesms.main.MainToolbarMode
import org.thoughtcrime.securesms.main.MainToolbarState
import org.thoughtcrime.securesms.main.MainToolbarViewModel
import org.thoughtcrime.securesms.main.Material3OnScrollHelperBinder
import org.thoughtcrime.securesms.main.callNavGraphBuilder
import org.thoughtcrime.securesms.main.navigateToDetailLocation
import org.thoughtcrime.securesms.main.rememberDetailNavHostController
import org.thoughtcrime.securesms.main.rememberFocusRequester
import org.thoughtcrime.securesms.main.storiesNavGraphBuilder
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionActivity
import org.thoughtcrime.securesms.mediasend.v3.mediaSendLauncher
import org.thoughtcrime.securesms.megaphone.Megaphone
@@ -176,14 +178,12 @@ import org.thoughtcrime.securesms.service.BackupMediaRestoreService
import org.thoughtcrime.securesms.service.KeyCachingService
import org.thoughtcrime.securesms.starred.StarredMessagesActivity
import org.thoughtcrime.securesms.stories.Stories
import org.thoughtcrime.securesms.stories.archive.StoryArchiveActivity
import org.thoughtcrime.securesms.stories.landing.StoriesLandingFragment
import org.thoughtcrime.securesms.stories.settings.StorySettingsActivity
import org.thoughtcrime.securesms.stories.storiesNavEntries
import org.thoughtcrime.securesms.util.AppStartup
import org.thoughtcrime.securesms.util.CachedInflater
import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme
import org.thoughtcrime.securesms.util.DynamicTheme
import org.thoughtcrime.securesms.util.Material3OnScrollHelper
import org.thoughtcrime.securesms.util.SplashScreenUtil
import org.thoughtcrime.securesms.util.TopToastPopup
@@ -196,6 +196,7 @@ import org.thoughtcrime.securesms.window.NavigationType
import org.thoughtcrime.securesms.window.rememberThreePaneScaffoldNavigatorDelegate
import org.whispersystems.signalservice.api.websocket.WebSocketConnectionState
import kotlin.time.Duration.Companion.minutes
import org.signal.core.ui.R as CoreUiR
class MainActivity :
PassphraseRequiredActivity(),
@@ -287,14 +288,6 @@ class MainActivity :
AppStartup.getInstance().onCriticalRenderEventStart()
enableEdgeToEdge(
navigationBarStyle = if (DynamicTheme.isDarkTheme(this)) {
SystemBarStyle.dark(0)
} else {
SystemBarStyle.light(0, 0)
}
)
super.onCreate(savedInstanceState, ready)
navigator = MainNavigator(this, mainNavigationViewModel)
@@ -507,54 +500,6 @@ class MainActivity :
mainNavigationViewModel.onSplitPaneChanged(isSplitPane)
}
val callsNavHostController = rememberDetailNavHostController(
onRequestFocus = rememberFocusRequester(
mainNavigationViewModel = mainNavigationViewModel,
currentListLocation = mainNavigationState.currentListLocation
) { it == MainNavigationListLocation.CALLS }
) {
callNavGraphBuilder(it)
}
val storiesNavHostController = rememberDetailNavHostController(
onRequestFocus = rememberFocusRequester(
mainNavigationViewModel = mainNavigationViewModel,
currentListLocation = mainNavigationState.currentListLocation
) { it == MainNavigationListLocation.STORIES }
) {
storiesNavGraphBuilder()
}
LaunchedEffect(Unit) {
fun navigateToLocation(location: MainNavigationDetailLocation) {
when (location) {
is MainNavigationDetailLocation.Empty -> {
when (mainNavigationState.currentListLocation) {
MainNavigationListLocation.CHATS, MainNavigationListLocation.ARCHIVE -> {
throw IllegalStateException("Navigation to ${mainNavigationState.currentListLocation} should be handled by ChatsBackStack.")
}
MainNavigationListLocation.CALLS -> callsNavHostController
MainNavigationListLocation.STORIES -> storiesNavHostController
}.navigateToDetailLocation(location)
}
is MainNavigationDetailLocation.Conversation, is MainNavigationDetailLocation.Chats -> {
throw IllegalStateException("Navigation to $location should be handled by ChatsBackStack.")
}
is MainNavigationDetailLocation.CallLinkDetails -> callsNavHostController.navigateToDetailLocation(location)
is MainNavigationDetailLocation.Calls -> callsNavHostController.navigateToDetailLocation(location)
is MainNavigationDetailLocation.Stories -> storiesNavHostController.navigateToDetailLocation(location)
}
}
mainNavigationViewModel.earlyNavigationDetailLocationRequested?.let { navigateToLocation(it) }
mainNavigationViewModel.clearEarlyDetailLocation()
mainNavigationViewModel.detailLocation.collect { navigateToLocation(it) }
}
val scope = rememberCoroutineScope()
BackHandler(paneExpansionState.currentAnchor == detailOnlyAnchor) {
@@ -739,16 +684,32 @@ class MainActivity :
}
MainNavigationListLocation.CALLS -> {
DetailsScreenNavHost(
navHostController = callsNavHostController,
contentLayoutData = contentLayoutData
NavDisplay(
backStack = mainNavigationViewModel.callsBackStackEntries,
onBack = { mainNavigationViewModel.popCallsDetailLocation() },
transitionSpec = TransitionSpecs.HorizontalSlide.transitionSpec,
popTransitionSpec = TransitionSpecs.HorizontalSlide.popTransitionSpec,
predictivePopTransitionSpec = TransitionSpecs.HorizontalSlide.predictivePopTransitionSpec,
entryDecorators = listOf(
rememberSaveableStateHolderNavEntryDecorator(),
rememberViewModelStoreNavEntryDecorator()
),
entryProvider = entryProvider { callsNavEntries(isSplitPane) }
)
}
MainNavigationListLocation.STORIES -> {
DetailsScreenNavHost(
navHostController = storiesNavHostController,
contentLayoutData = contentLayoutData
NavDisplay(
backStack = mainNavigationViewModel.storiesBackStackEntries,
onBack = { mainNavigationViewModel.popStoriesDetailLocation() },
transitionSpec = TransitionSpecs.HorizontalSlide.transitionSpec,
popTransitionSpec = TransitionSpecs.HorizontalSlide.popTransitionSpec,
predictivePopTransitionSpec = TransitionSpecs.HorizontalSlide.predictivePopTransitionSpec,
entryDecorators = listOf(
rememberSaveableStateHolderNavEntryDecorator(),
rememberViewModelStoreNavEntryDecorator()
),
entryProvider = entryProvider { storiesNavEntries() }
)
}
}
@@ -831,6 +792,23 @@ class MainActivity :
SignalTheme.colors.colorSurface1
}
val context = LocalContext.current
val isDarkTheme = isSystemInDarkTheme()
val navBarColor = if (isSplitPane) backgroundColor.toArgb() else ContextCompat.getColor(context, CoreUiR.color.signal_colorSurface2)
LaunchedEffect(isDarkTheme, navBarColor) {
if (Build.VERSION.SDK_INT >= 26) {
enableEdgeToEdge(
navigationBarStyle = if (isDarkTheme) {
SystemBarStyle.dark(navBarColor)
} else {
SystemBarStyle.light(navBarColor, navBarColor)
}
)
} else {
enableEdgeToEdge()
}
}
val modifier = when {
isSplitPane -> {
Modifier
@@ -1171,7 +1149,7 @@ class MainActivity :
} else if (SignalStore.internal.useNewMediaActivity) {
mediaSendLauncher.launch(
MediaSendActivityContract.Args(
isCameraFirst = false,
isCameraFirst = true,
isStory = destination == MainNavigationListLocation.STORIES
)
)
@@ -1249,11 +1227,11 @@ class MainActivity :
}
override fun onStoryPrivacyClick() {
startActivity(StorySettingsActivity.getIntent(this@MainActivity))
mainNavigationViewModel.goTo(MainNavigationDetailLocation.Stories.PrivacySettings)
}
override fun onStoryArchiveClick() {
startActivity(StoryArchiveActivity.createIntent(this@MainActivity))
mainNavigationViewModel.goTo(MainNavigationDetailLocation.Stories.Archive)
}
override fun onCloseSearchClick() {
@@ -13,9 +13,11 @@ import androidx.core.content.ContextCompat;
import androidx.fragment.app.Fragment;
import org.greenrobot.eventbus.EventBus;
import org.signal.core.util.AppForegroundObserver;
import org.signal.core.util.logging.Log;
import org.signal.core.util.tracing.Tracer;
import org.signal.devicetransfer.TransferStatus;
import org.signal.registration.RegistrationRoute;
import org.thoughtcrime.securesms.components.settings.app.changenumber.ChangeNumberLockActivity;
import org.thoughtcrime.securesms.crypto.MasterSecretUtil;
import org.thoughtcrime.securesms.dependencies.AppDependencies;
@@ -30,11 +32,10 @@ import org.thoughtcrime.securesms.profiles.edit.CreateProfileActivity;
import org.thoughtcrime.securesms.push.SignalServiceNetworkAccess;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.registration.ui.RegistrationActivity;
import org.thoughtcrime.securesms.util.Environment;
import org.thoughtcrime.securesms.restore.RestoreActivity;
import org.thoughtcrime.securesms.service.KeyCachingService;
import org.signal.core.util.AppForegroundObserver;
import org.thoughtcrime.securesms.util.AppStartup;
import org.thoughtcrime.securesms.util.Environment;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import java.util.Locale;
@@ -57,6 +58,7 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
private static final int STATE_TRANSFER_LOCKED = 9;
private static final int STATE_CHANGE_NUMBER_LOCK = 10;
private static final int STATE_TRANSFER_OR_RESTORE = 11;
private static final int STATE_RESUME_LINKING_REG = 12;
private SignalServiceNetworkAccess networkAccess;
private BroadcastReceiver clearKeyReceiver;
@@ -155,6 +157,7 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
case STATE_TRANSFER_LOCKED: return getOldDeviceTransferLockedIntent();
case STATE_CHANGE_NUMBER_LOCK: return getChangeNumberLockIntent();
case STATE_TRANSFER_OR_RESTORE: return getTransferOrRestoreIntent();
case STATE_RESUME_LINKING_REG: return getResumeLinkedRegistrationIntent();
default: return null;
}
}
@@ -168,6 +171,8 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
return STATE_UI_BLOCKING_UPGRADE;
} else if (!TextSecurePreferences.hasPromptedPushRegistration(this)) {
return STATE_WELCOME_PUSH_SCREEN;
} else if (shouldResumeLinkingRegistration()) {
return STATE_RESUME_LINKING_REG;
} else if (userCanTransferOrRestore()) {
return STATE_TRANSFER_OR_RESTORE;
} else if (SignalStore.storageService().getNeedsAccountRestore()) {
@@ -192,6 +197,14 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
RestoreDecisionStateUtil.isDecisionPending(SignalStore.registration().getRestoreDecisionState());
}
private boolean shouldResumeLinkingRegistration() {
return Environment.USE_NEW_REGISTRATION &&
SignalStore.account().isRegistered() &&
!SignalStore.account().isPrimaryDevice() &&
!SignalStore.registration().isRegistrationComplete() &&
RestoreDecisionStateUtil.isDecisionPending(SignalStore.registration().getRestoreDecisionState());
}
private boolean userMustCreateSignalPin() {
return !SignalStore.registration().isRegistrationComplete() &&
!SignalStore.svr().hasPin() &&
@@ -222,11 +235,7 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
}
private Intent getPushRegistrationIntent() {
if (Environment.USE_NEW_REGISTRATION) {
return org.signal.registration.RegistrationActivity.createIntent(this, MainActivity.clearTop(this));
} else {
return RegistrationActivity.newIntentForNewRegistration(this, getIntent());
}
return RegistrationActivity.newIntentForNewRegistration(this, getIntent());
}
private Intent getEnterSignalPinIntent() {
@@ -250,6 +259,10 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
return getRoutedIntent(intent, MainActivity.clearTop(this));
}
private Intent getResumeLinkedRegistrationIntent() {
return org.signal.registration.RegistrationActivity.createIntent(this, MainActivity.clearTop(this), RegistrationRoute.MessageSync.INSTANCE);
}
private Intent getCreateProfileNameIntent() {
Intent intent = CreateProfileActivity.getIntentForUserProfile(this);
return getRoutedIntent(intent, getIntent());
@@ -50,7 +50,7 @@ object ArchiveUploadProgress {
private val TAG = Log.tag(ArchiveUploadProgress::class)
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
private val _progress: MutableSharedFlow<Unit> = MutableSharedFlow(replay = 1)
@@ -119,7 +119,7 @@ object ArchiveUploadProgress {
updateState(notify = false) { updated }
}
.onStart { emit(uploadProgress) }
.flowOn(Dispatchers.IO)
.flowOn(Dispatchers.Default)
.shareIn(scope, SharingStarted.Eagerly, replay = 1)
init {
@@ -82,7 +82,7 @@ object ArchiveRestoreProgress {
val stateFlow: Flow<ArchiveRestoreProgressState> = store
.throttleLatest(1.seconds)
.distinctUntilChanged()
.flowOn(Dispatchers.IO)
.flowOn(Dispatchers.Default)
init {
SignalExecutors.BOUNDED.execute { update() }
@@ -646,7 +646,7 @@ object BackupRepository {
val state = SignalStore.backup.backupDownloadNotifierState ?: return null
val nextSheetDisplayTime = state.lastSheetDisplaySeconds.seconds + state.intervalSeconds.seconds
val remainingAttachmentSize = withContext(SignalDispatchers.IO) {
val remainingAttachmentSize = withContext(SignalDispatchers.Default) {
SignalDatabase.attachments.getRemainingRestorableAttachmentSize()
}
@@ -1250,6 +1250,7 @@ object BackupRepository {
return ImportResult.Failure
}
SignalStore.backup.hasInvalidBackupVersion = false
val selfId: RecipientId
var transactionSuccessful = false
try {
@@ -1321,7 +1322,7 @@ object BackupRepository {
SignalStore.backup.mediaRootBackupKey = mediaRootBackupKey
// Add back self after clearing data
val selfId: RecipientId = SignalDatabase.recipients.getAndPossiblyMerge(selfData.aci, selfData.pni, selfData.e164, pniVerified = true, changeSelf = true)
selfId = SignalDatabase.recipients.getAndPossiblyMerge(selfData.aci, selfData.pni, selfData.e164, pniVerified = true, changeSelf = true)
SignalDatabase.recipients.setProfileKey(selfId, selfData.profileKey)
SignalDatabase.recipients.setProfileSharing(selfId, true)
@@ -1532,7 +1533,7 @@ object BackupRepository {
Log.d(TAG, "[import] Finished! ${eventTimer.stop().summary}")
stopwatch.stop(TAG)
return ImportResult.Success(backupTime = header.backupTimeMs)
return ImportResult.Success(backupTime = header.backupTimeMs, selfRecipientId = selfId)
}
fun listRemoteMediaObjects(limit: Int, cursor: String? = null): NetworkResult<ArchiveGetMediaItemsResponse> {
@@ -2310,19 +2311,23 @@ object BackupRepository {
forwardSecrecyToken = forwardSecrecyToken,
cancellationSignal = cancellationSignal
)
if (result == ImportResult.Failure) {
Log.w(TAG, "[remoteRestore] Failed to import backup")
return RemoteRestoreResult.Failure
return when (result) {
is ImportResult.Failure -> {
Log.w(TAG, "[remoteRestore] Failed to import backup")
RemoteRestoreResult.Failure
}
is ImportResult.Success -> {
Log.i(TAG, "[remoteRestore] Restore successful")
BackupMediaRestoreService.resetTimeout()
AppDependencies.jobManager.add(BackupRestoreMediaJob())
RemoteRestoreResult.Success(result.selfRecipientId)
}
}
BackupMediaRestoreService.resetTimeout()
AppDependencies.jobManager.add(BackupRestoreMediaJob())
Log.i(TAG, "[remoteRestore] Restore successful")
return RemoteRestoreResult.Success
}
suspend fun restoreLinkAndSyncBackup(response: TransferArchiveResponse, ephemeralBackupKey: MessageBackupKey) {
suspend fun restoreLinkAndSyncBackup(response: TransferArchiveResponse, ephemeralBackupKey: MessageBackupKey): RemoteRestoreResult {
val context = AppDependencies.application
ArchiveRestoreProgress.onRestorePending()
@@ -2354,9 +2359,16 @@ object BackupRepository {
override fun shouldCancel() = cancellationSignal()
}
val cdn = response.cdn
val key = response.key
if (cdn == null || key == null) {
Log.w(TAG, "[restoreLinkAndSyncBackup] Response has no archive location (error=${response.error}); nothing to download.")
return RemoteRestoreResult.Failure
}
Log.i(TAG, "[restoreLinkAndSyncBackup] Downloading backup")
val tempBackupFile = AppDependencies.blobs.forNonAutoEncryptingSingleSessionOnDisk(AppDependencies.application)
when (val result = AppDependencies.signalServiceMessageReceiver.retrieveLinkAndSyncBackup(response.cdn, response.key, tempBackupFile, progressListener)) {
when (val result = AppDependencies.signalServiceMessageReceiver.retrieveLinkAndSyncBackup(cdn, key, tempBackupFile, progressListener)) {
is NetworkResult.Success -> Log.i(TAG, "[restoreLinkAndSyncBackup] Download successful")
else -> {
Log.w(TAG, "[restoreLinkAndSyncBackup] Failed to download backup file", result.getCause())
@@ -2385,16 +2397,19 @@ object BackupRepository {
cancellationSignal = cancellationSignal
)
if (result == ImportResult.Failure) {
Log.w(TAG, "[restoreLinkAndSyncBackup] Failed to import backup")
return RemoteRestoreResult.Failure
return when (result) {
is ImportResult.Failure -> {
Log.w(TAG, "[restoreLinkAndSyncBackup] Failed to import backup")
RemoteRestoreResult.Failure
}
is ImportResult.Success -> {
Log.i(TAG, "[restoreLinkAndSyncBackup] Restore successful")
BackupMediaRestoreService.resetTimeout()
AppDependencies.jobManager.add(BackupRestoreMediaJob())
RemoteRestoreResult.Success(result.selfRecipientId)
}
}
BackupMediaRestoreService.resetTimeout()
AppDependencies.jobManager.add(BackupRestoreMediaJob())
Log.i(TAG, "[restoreLinkAndSyncBackup] Restore successful")
return RemoteRestoreResult.Success
}
private fun buildDebugInfo(): ByteString {
@@ -2524,12 +2539,12 @@ data class StagedBackupKeyRotations(
)
sealed class ImportResult {
data class Success(val backupTime: Long) : ImportResult()
data class Success(val backupTime: Long, val selfRecipientId: RecipientId) : ImportResult()
data object Failure : ImportResult()
}
sealed interface RemoteRestoreResult {
data object Success : RemoteRestoreResult
data class Success(val selfRecipientId: RecipientId) : RemoteRestoreResult
data object NetworkError : RemoteRestoreResult
data object Canceled : RemoteRestoreResult
data object Failure : RemoteRestoreResult
@@ -49,7 +49,7 @@ object BackupAlertDelegate {
}
private suspend fun displayBackupDownloadNotifier(fragmentManager: FragmentManager) {
val downloadYourBackupToday = withContext(SignalDispatchers.IO) { BackupRepository.getDownloadYourBackupData() }
val downloadYourBackupToday = withContext(SignalDispatchers.Default) { BackupRepository.getDownloadYourBackupData() }
when (downloadYourBackupToday?.type) {
BackupDownloadNotifierState.Type.SHEET -> {
Log.d(TAG, "Displaying 'Download your backup today' sheet.")
@@ -145,7 +145,7 @@ class MessageBackupsFlowViewModel(
}
} catch (e: Exception) {
Log.d(TAG, "Failed to handle purchase.", e)
withContext(SignalDispatchers.IO) {
withContext(SignalDispatchers.Default) {
InAppPaymentsRepository.handlePipelineError(
inAppPaymentId = id,
error = e
@@ -297,7 +297,7 @@ class MessageBackupsFlowViewModel(
private fun validateTypeAndUpdateState(state: MessageBackupsFlowState): MessageBackupsFlowState {
return when (state.selectedMessageBackupTier!!) {
MessageBackupTier.FREE -> {
viewModelScope.launch(SignalDispatchers.IO) {
viewModelScope.launch(SignalDispatchers.Default) {
SignalDatabase.recipients.markNeedsSync(Recipient.self().id)
StorageSyncHelper.scheduleSyncForDataChange()
}
@@ -40,7 +40,7 @@ class MonthlyDonationCanceledViewModel(
private fun initializeFromInAppPaymentId(inAppPaymentId: InAppPaymentTable.InAppPaymentId) {
viewModelScope.launch {
val inAppPayment = withContext(Dispatchers.IO) {
val inAppPayment = withContext(Dispatchers.Default) {
SignalDatabase.inAppPayments.getById(inAppPaymentId)
}
@@ -8,6 +8,7 @@ package org.thoughtcrime.securesms.banner
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.runtime.Composable
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow
/**
* This class represents a banner across the top of the screen.
@@ -28,6 +29,12 @@ abstract class Banner<Model> {
*/
abstract val dataFlow: Flow<Model>
/**
* Emits whenever this banner's [enabled] state may have changed.
*/
open val stateUpdates: Flow<Unit>
get() = emptyFlow()
/**
* Composable function to display the content emitted from [dataFlow].
* You likely want to use [org.thoughtcrime.securesms.banner.ui.compose.DefaultBanner].
@@ -15,6 +15,11 @@ import androidx.compose.ui.platform.ViewCompositionStrategy
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.onStart
import org.signal.core.ui.compose.theme.SignalTheme
import org.signal.core.util.logging.Log
@@ -32,6 +37,22 @@ class BannerManager @JvmOverloads constructor(
val TAG = Log.tag(BannerManager::class)
}
private fun selectEnabledBanner(): Banner<Any>? = banners.firstOrNull { it.enabled } as Banner<Any>?
/**
* Reactively selects which [Banner] should be shown. Re-evaluates whenever any banner signals via
* [Banner.stateUpdates] that its eligibility may have changed.
*/
private val selectedBanner: Flow<Banner<Any>?> =
if (banners.isEmpty()) {
flowOf(null)
} else {
merge(*banners.map { it.stateUpdates }.toTypedArray())
.onStart { emit(Unit) }
.map { selectEnabledBanner() }
.distinctUntilChanged()
}
/**
* Re-evaluates the [Banner]s, choosing one to render (if any) and updating the view.
*/
@@ -39,19 +60,20 @@ class BannerManager @JvmOverloads constructor(
composeView.apply {
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
setContent {
val banner: Banner<Any>? = banners.firstOrNull { it.enabled } as Banner<Any>?
if (banner == null) {
val banner: Banner<Any>? by selectedBanner.collectAsStateWithLifecycle(initialValue = selectEnabledBanner())
val selected = banner
if (selected == null) {
onNoBannerShownListener()
return@setContent
}
key(banner) {
val bannerState by banner.dataFlow.collectAsStateWithLifecycle(initialValue = null)
key(selected) {
val bannerState by selected.dataFlow.collectAsStateWithLifecycle(initialValue = null)
bannerState?.let { model ->
SignalTheme {
Box {
banner.DisplayBanner(model, PaddingValues(horizontal = 12.dp, vertical = 8.dp))
selected.DisplayBanner(model, PaddingValues(horizontal = 12.dp, vertical = 8.dp))
}
}
onNewBannerShownListener()
@@ -66,16 +88,14 @@ class BannerManager @JvmOverloads constructor(
*/
@Composable
fun Banner() {
val banner: Banner<Any>? = banners.firstOrNull { it.enabled } as Banner<Any>?
if (banner == null) {
return
}
val banner: Banner<Any>? by selectedBanner.collectAsStateWithLifecycle(initialValue = selectEnabledBanner())
val selected = banner ?: return
key(banner) {
val bannerState by banner.dataFlow.collectAsStateWithLifecycle(initialValue = null)
key(selected) {
val bannerState by selected.dataFlow.collectAsStateWithLifecycle(initialValue = null)
bannerState?.let { model ->
banner.DisplayBanner(model, PaddingValues(horizontal = 12.dp, vertical = 8.dp))
selected.DisplayBanner(model, PaddingValues(horizontal = 12.dp, vertical = 8.dp))
}
}
}
@@ -9,7 +9,9 @@ import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.runtime.Composable
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import org.thoughtcrime.securesms.backup.v2.ArchiveRestoreProgress
import org.thoughtcrime.securesms.backup.v2.ArchiveRestoreProgressState
@@ -32,6 +34,12 @@ class ArchiveRestoreStatusBanner(private val listener: RestoreProgressBannerList
}
}
override val stateUpdates: Flow<Unit>
get() = ArchiveRestoreProgress.stateFlow
.map { enabled }
.distinctUntilChanged()
.map { }
@Composable
override fun DisplayBanner(model: ArchiveRestoreProgressState, contentPadding: PaddingValues) {
ArchiveRestoreStatusBanner(
@@ -40,7 +48,6 @@ class ArchiveRestoreStatusBanner(private val listener: RestoreProgressBannerList
onActionClick = listener::onActionClick,
onDismissClick = {
ArchiveRestoreProgress.clearFinishedStatus()
listener.onDismissComplete()
}
)
}
@@ -48,6 +55,5 @@ class ArchiveRestoreStatusBanner(private val listener: RestoreProgressBannerList
interface RestoreProgressBannerListener {
fun onBannerClick()
fun onActionClick(data: ArchiveRestoreProgressState)
fun onDismissComplete()
}
}
@@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.runtime.Composable
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import org.signal.core.util.bytes
import org.thoughtcrime.securesms.backup.ArchiveUploadProgress
@@ -78,6 +79,12 @@ class ArchiveUploadStatusBanner(private val listener: UploadProgressBannerListen
}
}
override val stateUpdates: Flow<Unit>
get() = ArchiveUploadProgress.progress
.map { enabled }
.distinctUntilChanged()
.map { }
@Composable
override fun DisplayBanner(model: ArchiveUploadStatusBannerViewState, contentPadding: PaddingValues) {
ArchiveUploadStatusBannerView(
@@ -92,7 +99,7 @@ class ArchiveUploadStatusBanner(private val listener: UploadProgressBannerListen
}
ArchiveUploadStatusBannerViewEvents.HideClicked -> {
SignalStore.backup.uploadBannerVisible = false
listener.onHidden()
ArchiveUploadProgress.triggerUpdate()
}
}
}
@@ -105,6 +112,5 @@ class ArchiveUploadStatusBanner(private val listener: UploadProgressBannerListen
interface UploadProgressBannerListener {
fun onBannerClick()
fun onCancelClicked()
fun onHidden()
}
}
@@ -12,7 +12,10 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.rx3.asFlow
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Previews
import org.thoughtcrime.securesms.R
@@ -21,6 +24,8 @@ import org.thoughtcrime.securesms.banner.ui.compose.Action
import org.thoughtcrime.securesms.banner.ui.compose.DefaultBanner
import org.thoughtcrime.securesms.banner.ui.compose.Importance
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.net.DeviceTransferBlockingInterceptor
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.registration.ui.RegistrationActivity
import org.thoughtcrime.securesms.util.TextSecurePreferences
@@ -35,23 +40,47 @@ class UnauthorizedBanner(val context: Context) : Banner<Unit>() {
override val dataFlow: Flow<Unit>
get() = flowOf(Unit)
override val stateUpdates: Flow<Unit>
get() = Recipient.self()
.live()
.observable()
.asFlow()
.map { enabled }
.distinctUntilChanged()
.map { }
@Composable
override fun DisplayBanner(model: Unit, contentPadding: PaddingValues) {
Banner(contentPadding)
Banner(contentPadding, SignalStore.account.isLinkedDevice)
}
}
@Composable
private fun Banner(contentPadding: PaddingValues) {
private fun Banner(contentPadding: PaddingValues, isLinkedDevice: Boolean) {
val context = LocalContext.current
DefaultBanner(
title = null,
body = stringResource(id = R.string.UnauthorizedReminder_this_is_likely_because_you_registered_your_phone_number_with_Signal_on_a_different_device),
body = stringResource(
id = if (isLinkedDevice) {
R.string.UnauthorizedReminder_this_device_is_no_longer_linked_relink_to_continue_messaging
} else {
R.string.UnauthorizedReminder_this_is_likely_because_you_registered_your_phone_number_with_Signal_on_a_different_device
}
),
importance = Importance.ERROR,
actions = listOf(
Action(R.string.UnauthorizedReminder_reregister_action) {
val registrationIntent = RegistrationActivity.newIntentForReRegistration(context)
Action(if (isLinkedDevice) R.string.UnauthorizedReminder_relink_action else R.string.UnauthorizedReminder_reregister_action) {
if (SignalStore.misc.isOldDeviceTransferLocked) {
SignalStore.misc.isOldDeviceTransferLocked = false
DeviceTransferBlockingInterceptor.getInstance().unblockNetwork()
}
val registrationIntent = if (isLinkedDevice) {
RegistrationActivity.newIntentForReLinkDevice(context)
} else {
RegistrationActivity.newIntentForReRegistration(context)
}
context.startActivity(registrationIntent)
}
),
@@ -63,6 +92,6 @@ private fun Banner(contentPadding: PaddingValues) {
@Composable
private fun BannerPreview() {
Previews.Preview {
Banner(PaddingValues(0.dp))
Banner(PaddingValues(0.dp), isLinkedDevice = false)
}
}
@@ -0,0 +1,64 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.calls
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewmodel.compose.SavedStateHandleSaveableApi
import androidx.lifecycle.viewmodel.compose.saveable
import org.thoughtcrime.securesms.calls.log.CallLogRow
import org.thoughtcrime.securesms.main.MainDetailBackStack
import org.thoughtcrime.securesms.main.MainNavigationDetailLocation
/**
* Controls the navigation stack used by the calls screen.
*/
@OptIn(SavedStateHandleSaveableApi::class)
class CallsBackStack(savedStateHandle: SavedStateHandle) : MainDetailBackStack {
companion object {
private const val KEY = "calls_back_stack"
val saver: Saver<SnapshotStateList<MainNavigationDetailLocation>, ArrayList<MainNavigationDetailLocation>> = Saver(
save = { ArrayList(it) },
restore = { mutableStateListOf(*it.toTypedArray()) }
)
}
override val entries: SnapshotStateList<MainNavigationDetailLocation> = savedStateHandle.saveable(
key = KEY,
saver = saver
) {
mutableStateListOf(MainNavigationDetailLocation.Empty)
}
val activeCallId: CallLogRow.Id?
get() = entries.asReversed().firstNotNullOfOrNull { location ->
when (location) {
is MainNavigationDetailLocation.Calls -> location.controllerKey
is MainNavigationDetailLocation.CallLinkDetails -> location.controllerKey
else -> null
}
}
/**
* Pushes an entry onto the stack.
*/
override fun push(location: MainNavigationDetailLocation) {
when {
location is MainNavigationDetailLocation.Empty || location == entries.lastOrNull() -> Unit
location.isContentRoot -> {
entries.removeAll { it !is MainNavigationDetailLocation.Empty }
entries.add(location)
}
else -> entries.add(location)
}
}
}
@@ -0,0 +1,64 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.calls
import androidx.activity.compose.LocalActivity
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.navigation3.runtime.EntryProviderScope
import androidx.navigation3.runtime.NavKey
import org.signal.core.ui.navigation.TransitionSpecs
import org.thoughtcrime.securesms.MainNavigator
import org.thoughtcrime.securesms.calls.links.EditCallLinkNameScreen
import org.thoughtcrime.securesms.calls.links.details.CallLinkDetailsScreen
import org.thoughtcrime.securesms.main.EmptyDetailScreen
import org.thoughtcrime.securesms.main.MainNavigationDetailLocation
fun EntryProviderScope<NavKey>.callsNavEntries(isSplitPane: Boolean) {
entry<MainNavigationDetailLocation.Empty> {
NoCallSelectedEntry()
}
entry<MainNavigationDetailLocation.CallLinkDetails>(
metadata = if (isSplitPane) TransitionSpecs.None.metadata else emptyMap()
) { route ->
CallLinkDetailsEntry(route)
}
entry<MainNavigationDetailLocation.Calls.CallLinks.EditCallLinkName> { route ->
EditCallLinkNameEntry(route)
}
}
@Composable
private fun NoCallSelectedEntry() {
EmptyDetailScreen()
}
@Composable
private fun CallLinkDetailsEntry(route: MainNavigationDetailLocation.CallLinkDetails) {
informNavigatorWeAreReady()
CallLinkDetailsScreen(roomId = route.callLinkRoomId)
}
@Composable
private fun EditCallLinkNameEntry(route: MainNavigationDetailLocation.Calls.CallLinks.EditCallLinkName) {
informNavigatorWeAreReady()
EditCallLinkNameScreen(
roomId = route.callLinkRoomId,
initialName = route.currentName
)
}
@Composable
private fun informNavigatorWeAreReady() {
val navigator = LocalActivity.current as? MainNavigator.NavigatorProvider
LaunchedEffect(navigator) {
navigator?.onFirstRender()
}
}
@@ -89,7 +89,8 @@ class EditCallLinkNameDialogFragment : ComposeDialogFragment() {
@Composable
fun EditCallLinkNameScreen(
roomId: CallLinkRoomId
roomId: CallLinkRoomId,
initialName: String
) {
val viewModel: CallLinkDetailsViewModel = viewModel {
CallLinkDetailsViewModel(roomId)
@@ -99,7 +100,7 @@ fun EditCallLinkNameScreen(
val lifecycleScope = LocalLifecycleOwner.current.lifecycleScope
EditCallLinkNameScreen(
initialNameValue = viewModel.nameSnapshot,
initialNameValue = initialName,
onSaveClick = {
lifecycleScope.launch {
viewModel.setName(it)
@@ -1,71 +0,0 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.calls.links.details
import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.runtime.remember
import androidx.core.os.bundleOf
import androidx.fragment.app.FragmentActivity
import org.signal.core.ui.compose.theme.SignalTheme
import org.signal.core.util.getParcelableExtraCompat
import org.thoughtcrime.securesms.calls.links.EditCallLinkNameDialogFragment
import org.thoughtcrime.securesms.main.MainNavigationCallDetailRouter
import org.thoughtcrime.securesms.main.MainNavigationDetailLocation
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId
import org.thoughtcrime.securesms.util.viewModel
class CallLinkDetailsActivity : FragmentActivity() {
companion object {
private const val ARG_ROOM_ID = "room.id"
fun createIntent(context: Context, callLinkRoomId: CallLinkRoomId): Intent {
return Intent(context, CallLinkDetailsActivity::class.java)
.putExtra(ARG_ROOM_ID, callLinkRoomId)
}
}
private val roomId: CallLinkRoomId
get() = intent.getParcelableExtraCompat(ARG_ROOM_ID, CallLinkRoomId::class.java)!!
private val viewModel: CallLinkDetailsViewModel by viewModel {
CallLinkDetailsViewModel(roomId)
}
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
setContent {
SignalTheme {
CallLinkDetailsScreen(
roomId = roomId,
viewModel = viewModel,
router = remember { Router() }
)
}
}
}
private inner class Router : MainNavigationCallDetailRouter {
override fun goToCallDetail(location: MainNavigationDetailLocation.Calls) {
when (location) {
is MainNavigationDetailLocation.Calls.CallLinks.EditCallLinkName -> {
EditCallLinkNameDialogFragment().apply {
arguments = bundleOf(EditCallLinkNameDialogFragment.ARG_NAME to viewModel.nameSnapshot)
}.show(supportFragmentManager, null)
}
}
}
override fun exitDetailLocation() = finishAfterTransition()
}
}
@@ -113,7 +113,12 @@ class DefaultCallLinkDetailsCallback(
}
override fun onEditNameClicked() {
router.goToCallDetail(MainNavigationDetailLocation.Calls.CallLinks.EditCallLinkName(callLinkRoomId = viewModel.recipientSnapshot!!.requireCallLinkRoomId()))
router.goToCallDetail(
MainNavigationDetailLocation.Calls.CallLinks.EditCallLinkName(
callLinkRoomId = viewModel.recipientSnapshot!!.requireCallLinkRoomId(),
currentName = viewModel.nameSnapshot
)
)
}
override fun onShareClicked() {
@@ -6,7 +6,6 @@
package org.thoughtcrime.securesms.calls.links.details
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.kotlin.plusAssign
import io.reactivex.rxjava3.kotlin.subscribeBy
@@ -194,10 +193,4 @@ class CallLinkDetailsViewModel(
private fun toastCouldNotDeleteCallLink() {
_state.update { it.copy(failureSnackbar = CallLinkDetailsState.FailureSnackbar.COULD_NOT_DELETE_CALL_LINK) }
}
class Factory(private val callLinkRoomId: CallLinkRoomId) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return modelClass.cast(CallLinkDetailsViewModel(callLinkRoomId)) as T
}
}
}
@@ -9,13 +9,13 @@ import org.signal.core.util.concurrent.LifecycleDisposable
import org.signal.core.util.dp
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.calls.YouAreAlreadyInACallSnackbar
import org.thoughtcrime.securesms.calls.links.details.CallLinkDetailsActivity
import org.thoughtcrime.securesms.components.menu.ActionItem
import org.thoughtcrime.securesms.components.menu.SignalContextMenu
import org.thoughtcrime.securesms.components.settings.conversation.ConversationSettingsActivity
import org.thoughtcrime.securesms.conversation.ConversationIntents
import org.thoughtcrime.securesms.database.CallTable
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId
import org.thoughtcrime.securesms.util.CommunicationActions
import org.signal.core.ui.R as CoreUiR
@@ -122,11 +122,10 @@ class CallLogContextMenu(
iconRes = CoreUiR.drawable.symbol_info_24,
title = fragment.getString(R.string.CallContextMenu__info)
) {
val intent = when {
peer.isCallLink -> CallLinkDetailsActivity.createIntent(fragment.requireContext(), peer.requireCallLinkRoomId())
else -> ConversationSettingsActivity.forCall(fragment.requireContext(), peer, messageIds)
when {
peer.isCallLink -> callbacks.goToCallLinkDetails(peer.requireCallLinkRoomId())
else -> fragment.startActivity(ConversationSettingsActivity.forCall(fragment.requireContext(), peer, messageIds))
}
fragment.startActivity(intent)
}
}
@@ -154,6 +153,7 @@ class CallLogContextMenu(
interface Callbacks {
fun startSelection(call: CallLogRow)
fun goToCallLinkDetails(roomId: CallLinkRoomId)
fun deleteCall(call: CallLogRow)
}
}
@@ -32,7 +32,6 @@ import org.signal.core.util.orNull
import org.thoughtcrime.securesms.MainNavigator
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.calls.links.create.CreateCallLinkBottomSheetDialogFragment
import org.thoughtcrime.securesms.calls.links.details.CallLinkDetailsActivity
import org.thoughtcrime.securesms.components.ProgressCardDialogFragment
import org.thoughtcrime.securesms.components.ScrollToPositionDelegate
import org.thoughtcrime.securesms.components.ViewBinderDelegate
@@ -58,6 +57,7 @@ import org.thoughtcrime.securesms.main.MainToolbarMode
import org.thoughtcrime.securesms.main.MainToolbarViewModel
import org.thoughtcrime.securesms.main.Material3OnScrollHelperBinder
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId
import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.ViewUtil
import org.thoughtcrime.securesms.util.doAfterNextLayout
@@ -242,9 +242,11 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
MainToolbarViewModel.Event.Search.Close -> {
viewModel.setSearchQuery("")
}
MainToolbarViewModel.Event.Search.Open -> {
mainToolbarViewModel.setSearchHint(R.string.SearchToolbar_search)
}
is MainToolbarViewModel.Event.Search.Query -> {
viewModel.setSearchQuery(it.query.trim())
}
@@ -325,7 +327,7 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
)
startActivity(intent)
} else {
startActivity(CallLinkDetailsActivity.createIntent(requireContext(), callLogRow.peer.requireCallLinkRoomId()))
goToCallLinkDetails(callLogRow.peer.requireCallLinkRoomId())
}
}
@@ -375,6 +377,7 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
)
}
}
CallLogRow.CanStartCall.GROUP_TERMINATED -> ConversationDialogs.displayCannotStartGroupCallDueToGroupEndedDialog(requireContext())
CallLogRow.CanStartCall.NOT_A_MEMBER -> ConversationDialogs.displayCannotStartGroupCallDueToNoLongerAMemberDialog(requireContext())
CallLogRow.CanStartCall.ADMIN_ONLY -> ConversationDialogs.displayCannotStartGroupCallDueToPermissionsDialog(requireContext())
@@ -386,6 +389,10 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
viewModel.toggleSelected(call.id)
}
override fun goToCallLinkDetails(roomId: CallLinkRoomId) {
mainNavigationViewModel.goTo(MainNavigationDetailLocation.CallLinkDetails(roomId))
}
override fun deleteCall(call: CallLogRow) {
MaterialAlertDialogBuilder(requireContext())
.setTitle(resources.getQuantityString(R.plurals.CallLogFragment__delete_d_calls, 1, 1))
@@ -53,7 +53,7 @@ class NewCallViewModel : ViewModel() {
}
private suspend fun resolveAndStartCall(id: RecipientId) {
val recipient = withContext(Dispatchers.IO) {
val recipient = withContext(Dispatchers.Default) {
Recipient.resolved(id)
}
openCall(recipient)
@@ -11,6 +11,7 @@ import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewmodel.compose.SavedStateHandleSaveableApi
import androidx.lifecycle.viewmodel.compose.saveable
import org.thoughtcrime.securesms.main.MainDetailBackStack
import org.thoughtcrime.securesms.main.MainNavigationDetailLocation
import org.thoughtcrime.securesms.recipients.RecipientId
@@ -18,7 +19,7 @@ import org.thoughtcrime.securesms.recipients.RecipientId
* Controls the navigation stack used by the chats screen.
*/
@OptIn(SavedStateHandleSaveableApi::class)
class ChatsBackStack(savedStateHandle: SavedStateHandle) {
class ChatsBackStack(savedStateHandle: SavedStateHandle) : MainDetailBackStack {
companion object {
private const val KEY = "chats_back_stack"
@@ -29,7 +30,7 @@ class ChatsBackStack(savedStateHandle: SavedStateHandle) {
)
}
val entries: SnapshotStateList<MainNavigationDetailLocation> = savedStateHandle.saveable(
override val entries: SnapshotStateList<MainNavigationDetailLocation> = savedStateHandle.saveable(
key = KEY,
saver = saver
) {
@@ -45,13 +46,10 @@ class ChatsBackStack(savedStateHandle: SavedStateHandle) {
}
}
val isEmpty: Boolean
get() = entries.singleOrNull() is MainNavigationDetailLocation.Empty
/**
* Pushes an entry onto the stack.
*/
fun push(location: MainNavigationDetailLocation) {
override fun push(location: MainNavigationDetailLocation) {
when (location) {
is MainNavigationDetailLocation.Empty, entries.lastOrNull() -> Unit
@@ -63,23 +61,4 @@ class ChatsBackStack(savedStateHandle: SavedStateHandle) {
else -> entries.add(location)
}
}
/**
* Pops the top entry off the stack. Returns true if something was popped, false if the stack is already at its root.
*/
fun pop(): Boolean {
if (entries.size <= 1) return false
entries.removeAt(entries.lastIndex)
return true
}
/**
* Resets the stack to its base empty state.
*/
fun reset() {
entries.removeAll { it !is MainNavigationDetailLocation.Empty }
if (entries.isEmpty()) {
entries.add(MainNavigationDetailLocation.Empty)
}
}
}
@@ -274,8 +274,17 @@ class DividerPreferenceViewHolder(itemView: View) : MappingViewHolder<DividerPre
class SectionHeaderPreferenceViewHolder(itemView: View) : MappingViewHolder<SectionHeaderPreference>(itemView) {
private val sectionHeader: TextView = itemView.findViewById(R.id.section_header)
private val iconEndView: ImageView = itemView.findViewById(R.id.icon_end)
override fun bind(model: SectionHeaderPreference) {
sectionHeader.text = model.title.resolve(context)
val iconEnd = model.iconEnd?.resolve(context)
iconEndView.setImageDrawable(iconEnd)
iconEndView.visible = iconEnd != null
if (model.onClick != null) {
iconEndView.setOnClickListener { model.onClick() }
}
}
}
@@ -180,7 +180,7 @@ class AppSettingsFragment : ComposeFragment(), Callbacks {
@StringRes toastSuccessStringRes: Int
) {
lifecycleScope.launch {
val subscriber = withContext(Dispatchers.IO) {
val subscriber = withContext(Dispatchers.Default) {
InAppPaymentsRepository.getSubscriber(subscriberType)
}
@@ -58,7 +58,7 @@ class BackupStateObserver(
companion object {
private val TAG = Log.tag(BackupStateObserver::class)
private val staticScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val staticScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val backupTierChangedNotifier = MutableSharedFlow<Unit>()
/**
@@ -107,13 +107,13 @@ class BackupStateObserver(
}
}
scope.launch(SignalDispatchers.IO) {
scope.launch(SignalDispatchers.Default) {
backupTierChangedNotifier.collect {
requestBackupStateRefresh()
}
}
scope.launch(SignalDispatchers.IO) {
scope.launch(SignalDispatchers.Default) {
InternetConnectionObserver.observe().asFlow()
.collect {
if (backupState.value is BackupState.Error) {
@@ -122,19 +122,19 @@ class BackupStateObserver(
}
}
scope.launch(SignalDispatchers.IO) {
scope.launch(SignalDispatchers.Default) {
InAppPaymentsRepository.observeLatestBackupPayment().collect {
requestBackupStateRefresh()
}
}
scope.launch(SignalDispatchers.IO) {
scope.launch(SignalDispatchers.Default) {
SignalStore.backup.subscriptionStateMismatchDetectedFlow.collect {
requestBackupStateRefresh()
}
}
scope.launch(SignalDispatchers.IO) {
scope.launch(SignalDispatchers.Default) {
SignalStore.backup.deletionStateFlow.collect {
requestBackupStateRefresh()
}
@@ -51,7 +51,7 @@ class BackupsSettingsViewModel : ViewModel() {
}
}
viewModelScope.launch(Dispatchers.IO) {
viewModelScope.launch(Dispatchers.Default) {
SignalStore.backup.lastBackupTimeFlow
.collect { lastBackupTime ->
internalStateFlow.update {
@@ -33,6 +33,7 @@ sealed interface LocalBackupsSettingsCallback {
fun onLearnMoreClick()
fun onLaunchBackupLocationPickerClick()
fun onTurnOffAndDeleteConfirmed()
fun onViewOptimizeStorageSettingClick()
object Empty : LocalBackupsSettingsCallback {
override fun onNavigationClick() = Unit
@@ -43,6 +44,7 @@ sealed interface LocalBackupsSettingsCallback {
override fun onLearnMoreClick() = Unit
override fun onLaunchBackupLocationPickerClick() = Unit
override fun onTurnOffAndDeleteConfirmed() = Unit
override fun onViewOptimizeStorageSettingClick() = Unit
}
}
@@ -144,4 +146,8 @@ class DefaultLocalBackupsSettingsCallback(
override fun onTurnOffAndDeleteConfirmed() {
viewModel.turnOffAndDelete(fragment.requireContext())
}
override fun onViewOptimizeStorageSettingClick() {
fragment.findNavController().safeNavigate(R.id.action_direct_to_storagePreferenceFragment)
}
}
@@ -53,6 +53,7 @@ internal fun LocalBackupsSettingsScreen(
val context = LocalContext.current
var showChooseLocationDialog by rememberSaveable { mutableStateOf(false) }
var showTurnOffAndDeleteDialog by rememberSaveable { mutableStateOf(false) }
var showOptimizeStorageWarningDialog by rememberSaveable { mutableStateOf(false) }
val learnMore = stringResource(id = R.string.BackupsPreferenceFragment__learn_more)
val restoreText = stringResource(id = R.string.OnDeviceBackupsScreen__to_restore_a_backup, learnMore).trim()
@@ -80,6 +81,14 @@ internal fun LocalBackupsSettingsScreen(
educationSheetMessage = stringResource(R.string.RemoteBackupsSettingsFragment__to_view_your_key)
)
val proceedWithTurnOn = {
if (BackupUtil.isUserSelectionRequired(context)) {
showChooseLocationDialog = true
} else {
callback.onTurnOnClick()
}
}
Scaffolds.Settings(
title = stringResource(id = R.string.RemoteBackupsSettingsFragment__on_device_backups),
navigationIcon = ImageVector.vectorResource(CoreUiR.drawable.symbol_arrow_start_24),
@@ -104,11 +113,10 @@ internal fun LocalBackupsSettingsScreen(
Buttons.MediumTonal(
onClick = {
// For the SAF-based flow, present an in-screen dialog before launching the picker.
if (BackupUtil.isUserSelectionRequired(context)) {
showChooseLocationDialog = true
if (state.optimizeStorageEnabled) {
showOptimizeStorageWarningDialog = true
} else {
callback.onTurnOnClick()
proceedWithTurnOn()
}
},
enabled = state.canTurnOn,
@@ -220,6 +228,24 @@ internal fun LocalBackupsSettingsScreen(
)
}
if (showOptimizeStorageWarningDialog) {
Dialogs.AdvancedAlertDialog(
body = stringResource(id = R.string.OnDeviceBackupsScreen__you_have_optimize_signal_storage_on),
positive = stringResource(id = R.string.OnDeviceBackupsScreen__view_setting),
neutral = stringResource(id = R.string.OnDeviceBackupsScreen__continue_without_turning_off),
negative = stringResource(id = android.R.string.cancel),
onPositive = {
showOptimizeStorageWarningDialog = false
callback.onViewOptimizeStorageSettingClick()
},
onNeutral = {
showOptimizeStorageWarningDialog = false
proceedWithTurnOn()
},
onNegative = { showOptimizeStorageWarningDialog = false }
)
}
if (showTurnOffAndDeleteDialog) {
Dialogs.SimpleAlertDialog(
title = stringResource(id = R.string.BackupDialog_delete_backups),
@@ -15,6 +15,7 @@ import org.thoughtcrime.securesms.keyvalue.protos.LocalBackupCreationProgress
data class LocalBackupsSettingsState(
val backupsEnabled: Boolean = false,
val canTurnOn: Boolean = true,
val optimizeStorageEnabled: Boolean = false,
val lastBackupLabel: String? = null,
val folderDisplayName: String? = null,
val scheduleTimeLabel: String? = null,
@@ -103,7 +103,8 @@ class LocalBackupsViewModel : ViewModel(), BackupKeyCredentialManagerHandler {
internalSettingsState.update {
it.copy(
canTurnOn = canTurnOn,
scheduleTimeLabel = backupTime
scheduleTimeLabel = backupTime,
optimizeStorageEnabled = SignalStore.backup.optimizeStorage
)
}
}
@@ -44,7 +44,7 @@ class BackupKeyDisplayViewModel : ViewModel(), BackupKeyCredentialManagerHandler
viewModelScope.launch {
internalUiState.update { it.copy(rotationState = BackupKeyRotationState.GENERATING_KEY) }
val stagedKeyRotations = withContext(SignalDispatchers.IO) {
val stagedKeyRotations = withContext(SignalDispatchers.Default) {
BackupRepository.stageBackupKeyRotations()
}
@@ -143,7 +143,7 @@ class RemoteBackupsSettingsViewModel : ViewModel() {
}
}
viewModelScope.launch(Dispatchers.IO) {
viewModelScope.launch(Dispatchers.Default) {
var optimizedRemainingBytes = 0L
while (isActive) {
if (ArchiveRestoreProgress.state.let { it.restoreState.isMediaRestoreOperation || it.restoreStatus == RestoreStatus.FINISHED }) {
@@ -194,7 +194,7 @@ class RemoteBackupsSettingsViewModel : ViewModel() {
}
}
viewModelScope.launch(Dispatchers.IO) {
viewModelScope.launch(Dispatchers.Default) {
BackupRepository.maybeFixAnyDanglingUploadProgress()
}
}
@@ -128,11 +128,14 @@ class ChangeNumberEnterCodeFragment : LoggingFragment(R.layout.fragment_change_n
binding.codeEntryLayout.resendSmsCountDown.startCountDownTo(state.nextSmsTimestamp.milliseconds)
binding.codeEntryLayout.callMeCountDown.startCountDownTo(state.nextCallTimestamp.milliseconds)
when (val outcome = state.changeNumberOutcome) {
is ChangeNumberOutcome.RecoveryPasswordWorked,
is ChangeNumberOutcome.VerificationCodeWorked -> changeNumberSuccess()
is ChangeNumberOutcome.Succeeded -> changeNumberSuccess()
is ChangeNumberOutcome.ChangeNumberRequestOutcome -> if (!state.inProgress && !outcome.result.isSuccess()) {
presentGenericError(outcome.result)
if (outcome.result is VerificationCodeRequestResult.RequestVerificationCodeRateLimited) {
Log.i(TAG, "Verification code request rate limited; staying on code entry screen.")
} else {
presentGenericError(outcome.result)
}
}
null -> Unit
@@ -158,6 +161,8 @@ class ChangeNumberEnterCodeFragment : LoggingFragment(R.layout.fragment_change_n
when (result) {
is VerificationCodeRequestResult.Success -> binding.codeEntryLayout.keyboard.displaySuccess()
is VerificationCodeRequestResult.RateLimited -> presentRateLimitedDialog()
is VerificationCodeRequestResult.RequestVerificationCodeRateLimited -> presentRateLimitedDialog(retryAfterSeconds = (result.nextSmsTimestamp - System.currentTimeMillis().milliseconds).inWholeSeconds.coerceAtLeast(0))
is VerificationCodeRequestResult.SubmitVerificationCodeRateLimited -> presentRateLimitedDialog()
is VerificationCodeRequestResult.RegistrationLocked -> presentRegistrationLocked(result.timeRemaining)
else -> presentGenericError(result)
}
@@ -113,7 +113,7 @@ class ChangeNumberRegistrationLockFragment : LoggingFragment(R.layout.fragment_c
}
private fun onStateUpdate(state: ChangeNumberState) {
if (state.changeNumberOutcome == ChangeNumberOutcome.VerificationCodeWorked) {
if (state.changeNumberOutcome == ChangeNumberOutcome.Succeeded) {
handleSuccessfulPinEntry(state.enteredPin)
}
@@ -188,8 +188,7 @@ class ChangeNumberRepository(
StorageSyncHelper.scheduleSyncForDataChange()
}
SignalStore.account.setE164(e164)
SignalStore.account.setPni(pni)
SignalStore.account.setNumberAndPniIdentity(e164, pni, pniRegistrationId, pniIdentityKeyPair)
AppDependencies.resetProtocolStores()
AppDependencies.groupsV2Authorization.clear()
@@ -197,9 +196,6 @@ class ChangeNumberRepository(
val pniProtocolStore = AppDependencies.protocolStore.pni()
val pniMetadataStore = SignalStore.account.pniPreKeys
SignalStore.account.pniRegistrationId = pniRegistrationId
SignalStore.account.setPniIdentityKeyAfterChangeNumber(pniIdentityKeyPair)
PreKeyUtil.storeSignedPreKey(pniProtocolStore, pniMetadataStore, pniSignedPreKey)
pniMetadataStore.activeSignedPreKeyId = pniSignedPreKey.id
@@ -326,6 +322,10 @@ class ChangeNumberRepository(
}
}
if (result !is NetworkResult.Success) {
result = verifyChangeAppliedDespiteError(newE164 = newE164, originalResult = result)
}
if (result is NetworkResult.StatusCodeError) {
SignalStore.misc.unlockChangeNumber()
}
@@ -336,13 +336,39 @@ class ChangeNumberRepository(
NumberChangeResult(
uuid = accountRegistrationResponse.uuid,
pni = accountRegistrationResponse.pni,
storageCapable = accountRegistrationResponse.storageCapable,
number = accountRegistrationResponse.number
)
}
)
}
/**
* The server can commit a number change but still return an error. Before trusting a non-success result, check
* with the server and see if it already reports [newE164] as our number. If it does, then the change actually
* went through, so treat it as a success instead of surfacing the error.
*/
private suspend fun verifyChangeAppliedDespiteError(newE164: String, originalResult: NetworkResult<VerifyAccountResponse>): NetworkResult<VerifyAccountResponse> {
return try {
val whoAmI = withContext(Dispatchers.IO) { whoAmI() }
if (whoAmI.number == newE164 && whoAmI.pni != null) {
Log.w(TAG, "Change number request did not succeed, but whoami reports the new number is already active. Treating the change as successful.")
NetworkResult.Success(
VerifyAccountResponse().apply {
uuid = whoAmI.aci
pni = whoAmI.pni
number = whoAmI.number
}
)
} else {
Log.i(TAG, "Change number request did not succeed and whoami does not report the new number; treating as a genuine failure.")
originalResult
}
} catch (e: IOException) {
Log.w(TAG, "Failed to query whoami while verifying change number outcome; treating as a genuine failure.", e)
originalResult
}
}
@WorkerThread
private fun createChangeNumberRequest(
sessionId: String? = null,
@@ -433,7 +459,6 @@ class ChangeNumberRepository(
data class NumberChangeResult(
val uuid: String,
val pni: String,
val storageCapable: Boolean,
val number: String
)
}
@@ -43,8 +43,7 @@ data class ChangeNumberState(
)
sealed interface ChangeNumberOutcome {
data object RecoveryPasswordWorked : ChangeNumberOutcome
data object VerificationCodeWorked : ChangeNumberOutcome
data object Succeeded : ChangeNumberOutcome
class ChangeNumberRequestOutcome(val result: VerificationCodeRequestResult) : ChangeNumberOutcome
}
@@ -89,8 +89,8 @@ class ChangeNumberVerifyFragment : LoggingFragment(R.layout.fragment_change_phon
private fun handleRequestCodeResult(changeNumberOutcome: ChangeNumberOutcome) {
Log.d(TAG, "Handling request code result: ${changeNumberOutcome.javaClass.name}")
when (changeNumberOutcome) {
is ChangeNumberOutcome.RecoveryPasswordWorked -> {
Log.i(TAG, "Successfully changed number with recovery password.")
is ChangeNumberOutcome.Succeeded -> {
Log.i(TAG, "Successfully changed number.")
changeNumberSuccess()
}
@@ -109,11 +109,27 @@ class ChangeNumberVerifyFragment : LoggingFragment(R.layout.fragment_change_phon
}
}
is VerificationCodeRequestResult.RateLimited -> {
is VerificationCodeRequestResult.RequestVerificationCodeRateLimited -> {
if (castResult.willBeAbleToRequestAgain) {
Log.i(TAG, "Verification code request rate limited; proceeding to code entry screen so the user can wait/resend rather than bailing.")
findNavController().safeNavigate(ChangeNumberVerifyFragmentDirections.actionChangePhoneNumberVerifyFragmentToChangeNumberEnterCodeFragment())
} else {
Log.i(TAG, "Verification code request rate limited with no pending resend; showing rate limit error.")
showErrorDialog(R.string.RegistrationActivity_rate_limited_to_service)
}
}
is VerificationCodeRequestResult.RateLimited,
is VerificationCodeRequestResult.SubmitVerificationCodeRateLimited -> {
Log.i(TAG, "Unable to request sms code due to rate limit")
showErrorDialog(R.string.RegistrationActivity_rate_limited_to_service)
}
is VerificationCodeRequestResult.RegistrationLocked -> {
Log.i(TAG, "Destination number is registration locked; navigating to PIN entry.")
findNavController().safeNavigate(ChangeNumberVerifyFragmentDirections.actionChangePhoneNumberVerifyFragmentToChangeNumberRegistrationLock(castResult.timeRemaining))
}
is VerificationCodeRequestResult.TokenNotAccepted -> {
Log.i(TAG, "Token was not accepted.")
showErrorDialog(R.string.RegistrationActivity_additional_verification_required)
@@ -125,11 +141,6 @@ class ChangeNumberVerifyFragment : LoggingFragment(R.layout.fragment_change_phon
}
}
}
is ChangeNumberOutcome.VerificationCodeWorked -> {
Log.i(TAG, "Successfully changed number with verification code.")
changeNumberSuccess()
}
}
}
@@ -12,14 +12,12 @@ import androidx.lifecycle.viewModelScope
import com.google.i18n.phonenumbers.NumberParseException
import com.google.i18n.phonenumbers.PhoneNumberUtil
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.signal.core.models.ServiceId
import org.signal.core.util.concurrent.SignalExecutors
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore
@@ -36,6 +34,7 @@ import org.thoughtcrime.securesms.registration.viewmodel.NumberViewState
import org.thoughtcrime.securesms.registration.viewmodel.SvrAuthCredentialSet
import org.thoughtcrime.securesms.util.dualsim.MccMncProducer
import java.io.IOException
import java.util.concurrent.atomic.AtomicBoolean
/**
* [ViewModel] for the change number flow.
@@ -50,12 +49,13 @@ class ChangeNumberViewModel : ViewModel() {
private val repository = ChangeNumberRepository()
private val store = MutableStateFlow(ChangeNumberState())
private val serialContext = SignalExecutors.SERIAL.asCoroutineDispatcher()
private val smsRetrieverReceiver: SmsRetrieverReceiver = SmsRetrieverReceiver(AppDependencies.application)
private val initialLocalNumber = SignalStore.account.e164
private val password = SignalStore.account.servicePassword!!
private val changeNumberSessionInFlight = AtomicBoolean(false)
val uiState = store.asLiveData()
val liveOldNumberState = store.map { it.oldPhoneNumber }.asLiveData()
val liveNewNumberState = store.map { it.number }.asLiveData()
@@ -261,14 +261,24 @@ class ChangeNumberViewModel : ViewModel() {
}
private suspend fun verifyCodeInternal(context: Context, pin: String?, verificationErrorHandler: (VerificationCodeRequestResult) -> Unit, numberChangeErrorHandler: (ChangeNumberResult) -> Unit) {
val sessionId = getOrCreateValidSession(context)?.sessionId ?: return bail { Log.i(TAG, "Bailing from code verification due to invalid session.") }
val registrationData = getRegistrationData(context)
val session = getOrCreateValidSession(context) ?: return bail { Log.i(TAG, "Bailing from code verification due to invalid session.") }
val sessionId = session.sessionId
val verificationResponse = RegistrationRepository.submitVerificationCode(context, sessionId, registrationData)
if (!session.verified) {
if (store.value.enteredCode == null) {
Log.w(TAG, "Session is not verified and no code is available to submit; cannot complete change number.")
handleVerificationError(VerificationCodeRequestResult.UnknownError(IllegalStateException("No verification code available for an unverified session")), verificationErrorHandler)
return bail { Log.i(TAG, "Bailing from code verification due to missing code for unverified session.") }
}
if (verificationResponse !is VerificationCodeRequestResult.Success && verificationResponse !is VerificationCodeRequestResult.AlreadyVerified) {
handleVerificationError(verificationResponse, verificationErrorHandler)
return bail { Log.i(TAG, "Bailing from code verification due to non-successful response.") }
val registrationData = getRegistrationData(context)
val verificationResponse = RegistrationRepository.submitVerificationCode(context, sessionId, registrationData)
if (verificationResponse !is VerificationCodeRequestResult.Success && verificationResponse !is VerificationCodeRequestResult.AlreadyVerified) {
handleVerificationError(verificationResponse, verificationErrorHandler)
return bail { Log.i(TAG, "Bailing from code verification due to non-successful response.") }
}
}
val result: ChangeNumberResult = if (pin == null) {
@@ -286,7 +296,7 @@ class ChangeNumberViewModel : ViewModel() {
}
if (result is ChangeNumberResult.Success) {
handleSuccessfulChangedRemoteNumber(e164 = result.numberChangeResult.number, pni = ServiceId.PNI.parseOrThrow(result.numberChangeResult.pni), changeNumberOutcome = ChangeNumberOutcome.RecoveryPasswordWorked)
handleSuccessfulChangedRemoteNumber(e164 = result.numberChangeResult.number, pni = ServiceId.PNI.parseOrThrow(result.numberChangeResult.pni), changeNumberOutcome = ChangeNumberOutcome.Succeeded)
} else {
handleChangeNumberError(result, numberChangeErrorHandler)
}
@@ -359,18 +369,26 @@ class ChangeNumberViewModel : ViewModel() {
fun initiateChangeNumberSession(context: Context, mode: RegistrationRepository.E164VerificationMode) {
Log.v(TAG, "changeNumber()")
if (!changeNumberSessionInFlight.compareAndSet(false, true)) {
Log.i(TAG, "A change number session is already in progress; ignoring duplicate request.")
return
}
store.update { it.copy(inProgress = true) }
viewModelScope.launch(Dispatchers.IO) {
val encryptionDrained = repository.ensureDecryptionsDrained() ?: false
try {
val encryptionDrained = repository.ensureDecryptionsDrained() ?: false
if (!encryptionDrained) {
return@launch bail { Log.i(TAG, "Failed to drain encryption.") }
}
if (!encryptionDrained) {
return@launch bail { Log.i(TAG, "Failed to drain encryption.") }
}
when (changeNumberWithRecoveryPassword()) {
ChangeLocalNumberOutcome.NotPerformed -> requestVerificationCode(context, mode)
ChangeLocalNumberOutcome.Success -> Log.d(TAG, "Successfully changed number using recovery password")
ChangeLocalNumberOutcome.Failure -> Log.w(TAG, "Change number failed, bailing")
when (changeNumberWithRecoveryPassword()) {
ChangeLocalNumberOutcome.NotPerformed -> requestVerificationCode(context, mode)
ChangeLocalNumberOutcome.Success -> Log.d(TAG, "Successfully changed number using recovery password")
ChangeLocalNumberOutcome.Failure -> Log.w(TAG, "Change number failed, bailing")
}
} finally {
changeNumberSessionInFlight.set(false)
}
}
}
@@ -417,7 +435,7 @@ class ChangeNumberViewModel : ViewModel() {
val result = repository.changeNumberWithRecoveryPassword(recoveryPassword = recoveryPassword, newE164 = number.e164Number)
if (result is ChangeNumberResult.Success) {
return handleSuccessfulChangedRemoteNumber(e164 = result.numberChangeResult.number, pni = ServiceId.PNI.parseOrThrow(result.numberChangeResult.pni), changeNumberOutcome = ChangeNumberOutcome.RecoveryPasswordWorked)
return handleSuccessfulChangedRemoteNumber(e164 = result.numberChangeResult.number, pni = ServiceId.PNI.parseOrThrow(result.numberChangeResult.pni), changeNumberOutcome = ChangeNumberOutcome.Succeeded)
} else if (result is ChangeNumberResult.UnknownError) {
store.update {
it.copy(inProgress = false, changeNumberOutcome = ChangeNumberOutcome.ChangeNumberRequestOutcome(VerificationCodeRequestResult.UnknownError(result.getCause())))
@@ -512,6 +530,12 @@ class ChangeNumberViewModel : ViewModel() {
return
}
if (validSession.verified) {
Log.i(TAG, "Existing session is already verified; completing the change with the verified session instead of requesting a new code.")
changeNumberWithVerifiedSession(validSession.sessionId)
return
}
val result = if (!validSession.allowedToRequestCode) {
val challenges = validSession.challengesRequested.joinToString()
Log.i(TAG, "Not allowed to request code! Remaining challenges: $challenges")
@@ -532,14 +556,72 @@ class ChangeNumberViewModel : ViewModel() {
}
if (result is VerificationCodeRequestResult.AlreadyVerified) {
Log.w(TAG, "Already verified, not handled properly in change number flow, attempt to recover")
resetLocalSessionState()
Log.i(TAG, "Session became verified while requesting a code; completing the change with the verified session.")
changeNumberWithVerifiedSession(validSession.sessionId)
return
}
Log.d(TAG, "Received result: ${result.javaClass.canonicalName}\nwith challenges: ${challengesRequested.joinToString { it.key }}")
val (nextSmsTimestamp, nextCallTimestamp) = when (result) {
is VerificationCodeRequestResult.Success -> result.nextSmsTimestamp.inWholeMilliseconds to result.nextCallTimestamp.inWholeMilliseconds
is VerificationCodeRequestResult.RequestVerificationCodeRateLimited -> result.nextSmsTimestamp.inWholeMilliseconds to result.nextCallTimestamp.inWholeMilliseconds
else -> store.value.nextSmsTimestamp to store.value.nextCallTimestamp
}
store.update {
it.copy(changeNumberOutcome = ChangeNumberOutcome.ChangeNumberRequestOutcome(result), challengesRequested = challengesRequested, inProgress = false)
it.copy(
changeNumberOutcome = ChangeNumberOutcome.ChangeNumberRequestOutcome(result),
challengesRequested = challengesRequested,
inProgress = false,
nextSmsTimestamp = nextSmsTimestamp,
nextCallTimestamp = nextCallTimestamp
)
}
}
/**
* Completes a number change using a registration session that the service already considers verified.
*/
private suspend fun changeNumberWithVerifiedSession(sessionId: String) {
Log.v(TAG, "changeNumberWithVerifiedSession()")
when (val result = repository.changeNumberWithoutRegistrationLock(sessionId = sessionId, newE164 = number.e164Number)) {
is ChangeNumberResult.Success -> {
handleSuccessfulChangedRemoteNumber(
e164 = result.numberChangeResult.number,
pni = ServiceId.PNI.parseOrThrow(result.numberChangeResult.pni),
changeNumberOutcome = ChangeNumberOutcome.Succeeded
)
}
is ChangeNumberResult.RegistrationLocked -> {
if (result.svr2Credentials != null) {
Log.i(TAG, "Destination number is registration locked; prompting for PIN.")
store.update {
it.copy(
inProgress = false,
svr2Credentials = result.svr2Credentials,
svr3Credentials = result.svr3Credentials,
lockedTimeRemaining = result.timeRemaining,
changeNumberOutcome = ChangeNumberOutcome.ChangeNumberRequestOutcome(
VerificationCodeRequestResult.RegistrationLocked(result.getCause(), result.timeRemaining, result.svr2Credentials, result.svr3Credentials)
)
)
}
} else {
Log.w(TAG, "Destination number is registration locked but SVR credentials were missing, cannot prompt for PIN.")
store.update {
it.copy(inProgress = false, changeNumberOutcome = ChangeNumberOutcome.ChangeNumberRequestOutcome(VerificationCodeRequestResult.UnknownError(result.getCause())))
}
}
}
else -> {
Log.w(TAG, "Unable to complete change number with verified session.", result.getCause())
store.update {
it.copy(inProgress = false, changeNumberOutcome = ChangeNumberOutcome.ChangeNumberRequestOutcome(VerificationCodeRequestResult.UnknownError(result.getCause())))
}
}
}
}
@@ -24,7 +24,7 @@ class ChatFoldersViewModel : ViewModel() {
val state = internalState.asStateFlow()
fun loadCurrentFolders(context: Context) {
viewModelScope.launch(Dispatchers.IO) {
viewModelScope.launch(Dispatchers.Default) {
val folders = ChatFoldersRepository.getCurrentFolders()
val suggestedFolders = getSuggestedFolders(context, folders)
@@ -93,7 +93,7 @@ class ChatFoldersViewModel : ViewModel() {
}
fun setCurrentFolder(folder: ChatFolderRecord) {
viewModelScope.launch(Dispatchers.IO) {
viewModelScope.launch(Dispatchers.Default) {
val includedRecipients = folder.includedChats.mapNotNull { threadId ->
SignalDatabase.threads.getRecipientForThreadId(threadId)
}
@@ -150,7 +150,7 @@ class ChatFoldersViewModel : ViewModel() {
}
fun deleteFolder(context: Context) {
viewModelScope.launch(Dispatchers.IO) {
viewModelScope.launch(Dispatchers.Default) {
ChatFoldersRepository.deleteFolder(internalState.value.originalFolder.folderRecord)
loadCurrentFolders(context)
@@ -167,7 +167,7 @@ class ChatFoldersViewModel : ViewModel() {
}
fun createFolder(context: Context, folder: ChatFolderRecord? = null) {
viewModelScope.launch(Dispatchers.IO) {
viewModelScope.launch(Dispatchers.Default) {
val currentFolder = if (folder != null) ChatFolder(folder) else internalState.value.currentFolder
ChatFoldersRepository.createFolder(currentFolder.folderRecord, currentFolder.includedRecipients, currentFolder.excludedRecipients)
loadCurrentFolders(context)
@@ -192,13 +192,13 @@ class ChatFoldersViewModel : ViewModel() {
val updatedFolders = state.value.folders.mapIndexed { index, chatFolderRecord ->
chatFolderRecord.copy(position = index)
}
viewModelScope.launch(Dispatchers.IO) {
viewModelScope.launch(Dispatchers.Default) {
ChatFoldersRepository.updatePositions(updatedFolders)
}
}
fun updateFolder(context: Context) {
viewModelScope.launch(Dispatchers.IO) {
viewModelScope.launch(Dispatchers.Default) {
val currentFolder = internalState.value.currentFolder
ChatFoldersRepository.updateFolder(currentFolder.folderRecord, currentFolder.includedRecipients, currentFolder.excludedRecipients)
loadCurrentFolders(context)
@@ -210,7 +210,7 @@ class ChatFoldersViewModel : ViewModel() {
}
fun setPendingChats() {
viewModelScope.launch(Dispatchers.IO) {
viewModelScope.launch(Dispatchers.Default) {
val currentFolder = internalState.value.currentFolder
val includedChats = currentFolder.includedRecipients.map { recipient -> recipient.id }.toMutableSet()
val excludedChats = currentFolder.excludedRecipients.map { recipient -> recipient.id }.toMutableSet()
@@ -304,7 +304,7 @@ class ChatFoldersViewModel : ViewModel() {
}
fun savePendingChats() {
viewModelScope.launch(Dispatchers.IO) {
viewModelScope.launch(Dispatchers.Default) {
val updatedFolder = internalState.value.currentFolder
val includedChatIds = internalState.value.pendingIncludedRecipients
val excludedChatIds = internalState.value.pendingExcludedRecipients
@@ -346,7 +346,7 @@ class ChatFoldersViewModel : ViewModel() {
fun setCurrentFolderId(folderId: Long) {
if (folderId != -1L) {
viewModelScope.launch(Dispatchers.IO) {
viewModelScope.launch(Dispatchers.Default) {
val folder = ChatFoldersRepository.getFolder(folderId)
setCurrentFolder(folder)
}
@@ -6,12 +6,12 @@ import androidx.lifecycle.ViewModelProvider
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import org.signal.core.util.PlayServicesUtil
import org.signal.mediasend.SentMediaQuality
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.keyvalue.SettingsValues.ForceWebsocketMode
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.messages.IncomingMessageObserver
import org.thoughtcrime.securesms.util.PlayServicesUtil
import org.thoughtcrime.securesms.util.TextSecurePreferences
import org.thoughtcrime.securesms.webrtc.CallDataMode
@@ -336,7 +336,7 @@ class InternalBackupPlaygroundViewModel : ViewModel() {
viewModelScope.launch {
when (val result = BackupRepository.restoreRemoteBackup()) {
RemoteRestoreResult.Success -> {
is RemoteRestoreResult.Success -> {
_state.value = _state.value.copy(statusMessage = "Import complete!")
ThreadUtil.runOnMain { afterDbRestoreCallback() }
}
@@ -352,7 +352,7 @@ class InternalBackupPlaygroundViewModel : ViewModel() {
}
fun loadStats() {
viewModelScope.launch(Dispatchers.IO) {
viewModelScope.launch(Dispatchers.Default) {
launch {
var stats = SignalDatabase.attachments.debugGetAttachmentStats()
@@ -398,7 +398,7 @@ class InternalBackupPlaygroundViewModel : ViewModel() {
return@withContext false
}
suspend fun clearLocalMediaBackupState() = withContext(Dispatchers.IO) {
suspend fun clearLocalMediaBackupState() = withContext(Dispatchers.Default) {
SignalDatabase.attachments.clearAllArchiveData()
}
@@ -41,7 +41,7 @@ class DataSeedingPlaygroundViewModel(application: Application) : AndroidViewMode
val state: StateFlow<DataSeedingPlaygroundState> = _state.asStateFlow()
fun loadThreads() {
viewModelScope.launch(Dispatchers.IO) {
viewModelScope.launch(Dispatchers.Default) {
try {
val threads = mutableListOf<ThreadWithRecipient>()
val cursor: Cursor = SignalDatabase.threads.getRecentConversationList(
@@ -42,7 +42,7 @@ class InternalIssuesViewModel(application: Application) : AndroidViewModel(appli
private fun load() {
viewModelScope.launch {
allIssues = withContext(Dispatchers.IO) { issues.getRecent() }
allIssues = withContext(Dispatchers.Default) { issues.getRecent() }
_state.update { it.copy(loading = false, notificationPriority = SignalStore.internal.issueNotificationPriority).withVisibleIssues() }
}
}
@@ -54,7 +54,7 @@ class InternalIssuesViewModel(application: Application) : AndroidViewModel(appli
private fun clearAll() {
viewModelScope.launch {
withContext(Dispatchers.IO) { issues.clear() }
withContext(Dispatchers.Default) { issues.clear() }
allIssues = emptyList()
_state.update { it.copy(nameFilter = null, expandedIds = emptySet()).withVisibleIssues() }
}
@@ -27,7 +27,7 @@ class InternalSqlitePlaygroundViewModel : ViewModel() {
get() = _queryResults
fun onQuerySubmitted(query: String) {
viewModelScope.launch(Dispatchers.IO) {
viewModelScope.launch(Dispatchers.Default) {
_queryResults.value = null
val startTime = System.nanoTime()
@@ -10,6 +10,7 @@ sealed interface LabsSettingsEvents {
data class ToggleStoryArchive(val enabled: Boolean) : LabsSettingsEvents
data class ToggleIncognito(val enabled: Boolean) : LabsSettingsEvents
data class ToggleBetterSearch(val enabled: Boolean) : LabsSettingsEvents
data class ToggleAutoLowerHand(val enabled: Boolean) : LabsSettingsEvents
data class ToggleStarredMessages(val enabled: Boolean) : LabsSettingsEvents
data class ToggleStickerReplies(val enabled: Boolean) : LabsSettingsEvents
data class ToggleMuteBreakthroughNotifications(val enabled: Boolean) : LabsSettingsEvents
}
@@ -125,15 +125,6 @@ private fun LabsSettingsContent(
)
}
item {
Rows.ToggleRow(
checked = state.autoLowerHand,
text = "Auto Lower Hand Suggestion",
label = "Show a prompt to lower your raised hand when you are speaking in a group call.",
onCheckChanged = { onEvent(LabsSettingsEvents.ToggleAutoLowerHand(it)) }
)
}
item {
Rows.ToggleRow(
checked = state.starredMessages,
@@ -142,6 +133,24 @@ private fun LabsSettingsContent(
onCheckChanged = { onEvent(LabsSettingsEvents.ToggleStarredMessages(it)) }
)
}
item {
Rows.ToggleRow(
checked = state.stickerReplies,
text = "Sticker Replies",
label = "Keep a pending quote when sending a sticker so the sticker is sent as a reply. Normally the quote is dropped when you send a sticker.",
onCheckChanged = { onEvent(LabsSettingsEvents.ToggleStickerReplies(it)) }
)
}
item {
Rows.ToggleRow(
checked = state.muteBreakthroughNotifications,
text = "Improved Notification Management",
label = "Adds per-conversation controls to let calls and replies break through mute. New options in the sounds & notifications settings for a chat.",
onCheckChanged = { onEvent(LabsSettingsEvents.ToggleMuteBreakthroughNotifications(it)) }
)
}
}
}
}
@@ -13,6 +13,7 @@ data class LabsSettingsState(
val storyArchive: Boolean = false,
val incognito: Boolean = false,
val betterSearch: Boolean = false,
val autoLowerHand: Boolean = false,
val starredMessages: Boolean = false
val starredMessages: Boolean = false,
val stickerReplies: Boolean = false,
val muteBreakthroughNotifications: Boolean = false
)
@@ -33,15 +33,18 @@ class LabsSettingsViewModel : ViewModel() {
SignalStore.labs.betterSearch = event.enabled
_state.value = _state.value.copy(betterSearch = event.enabled)
}
is LabsSettingsEvents.ToggleAutoLowerHand -> {
SignalStore.labs.autoLowerHand = event.enabled
_state.value = _state.value.copy(autoLowerHand = event.enabled)
}
is LabsSettingsEvents.ToggleStarredMessages -> {
SignalStore.labs.starredMessages = event.enabled
_state.value = _state.value.copy(starredMessages = event.enabled)
}
is LabsSettingsEvents.ToggleStickerReplies -> {
SignalStore.labs.stickerReplies = event.enabled
_state.value = _state.value.copy(stickerReplies = event.enabled)
}
is LabsSettingsEvents.ToggleMuteBreakthroughNotifications -> {
SignalStore.labs.muteBreakthroughNotifications = event.enabled
_state.value = _state.value.copy(muteBreakthroughNotifications = event.enabled)
}
}
}
@@ -51,8 +54,9 @@ class LabsSettingsViewModel : ViewModel() {
storyArchive = SignalStore.labs.storyArchive,
incognito = SignalStore.labs.incognito,
betterSearch = SignalStore.labs.betterSearch,
autoLowerHand = SignalStore.labs.autoLowerHand,
starredMessages = SignalStore.labs.starredMessages
starredMessages = SignalStore.labs.starredMessages,
stickerReplies = SignalStore.labs.stickerReplies,
muteBreakthroughNotifications = SignalStore.labs.muteBreakthroughNotifications
)
}
}
@@ -33,7 +33,7 @@ class NotificationsSettingsViewModel(private val sharedPreferences: SharedPrefer
// Calculating slow notification stuff isn't thread-safe, so we do it without to start off so we have most state populated, then fetch it in the background.
store.update { getState(calculateSlowNotifications = false) }
viewModelScope.launch(Dispatchers.IO) {
viewModelScope.launch(Dispatchers.Default) {
store.update { getState(calculateSlowNotifications = true) }
}
}
@@ -45,7 +45,6 @@ import org.signal.core.ui.compose.Texts
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.compose.rememberStatusBarColorNestedScrollModifier
import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.RemoteConfig
import org.thoughtcrime.securesms.util.viewModel
/**
@@ -301,31 +300,29 @@ private fun AdvancedPrivacySettingsScreen(
)
}
if (RemoteConfig.internalUser) {
item {
Dividers.Default()
}
item {
Dividers.Default()
}
item {
val label = buildAnnotatedString {
append(stringResource(R.string.preferences_automatic_key_verification_body))
append(" ")
withLink(
LinkAnnotation.Clickable("learn-more", linkInteractionListener = {
callbacks.onAutomaticVerificationLearnMoreClick()
})
) {
append(stringResource(R.string.LearnMoreTextView_learn_more))
}
item {
val label = buildAnnotatedString {
append(stringResource(R.string.preferences_automatic_key_verification_body))
append(" ")
withLink(
LinkAnnotation.Clickable("learn-more", linkInteractionListener = {
callbacks.onAutomaticVerificationLearnMoreClick()
})
) {
append(stringResource(R.string.LearnMoreTextView_learn_more))
}
Rows.ToggleRow(
checked = state.allowAutomaticKeyVerification,
text = AnnotatedString(stringResource(R.string.preferences_automatic_key_verification)),
label = label,
onCheckChanged = callbacks::onAllowAutomaticVerificationChanged
)
}
Rows.ToggleRow(
checked = state.allowAutomaticKeyVerification,
text = AnnotatedString(stringResource(R.string.preferences_automatic_key_verification)),
label = label,
onCheckChanged = callbacks::onAllowAutomaticVerificationChanged
)
}
}
}
@@ -72,7 +72,7 @@ class AdvancedPrivacySettingsViewModel(
fun setAllowAutomaticVerification(enabled: Boolean) {
SignalStore.settings.automaticVerificationEnabled = enabled
refresh()
viewModelScope.launch(SignalDispatchers.IO) {
viewModelScope.launch(SignalDispatchers.Default) {
if (!enabled) {
SignalDatabase.recipients.clearAllKeyTransparencyData()
SignalStore.account.distinguishedHead = null
@@ -32,7 +32,7 @@ class PhoneNumberPrivacySettingsViewModel : ViewModel() {
val state: State<PhoneNumberPrivacySettingsState> = _state
init {
viewModelScope.launch(Dispatchers.IO) {
viewModelScope.launch(Dispatchers.Default) {
while (isActive) {
refresh()
delay(5.seconds)
@@ -118,6 +118,8 @@ class ManageStorageSettingsFragment : ComposeFragment() {
navController.navigate("paid-tier-pending")
} else if (state.onDeviceStorageOptimizationState == ManageStorageSettingsViewModel.OnDeviceStorageOptimizationState.REQUIRES_PAID_TIER) {
UpgradeToEnableOptimizedStorageSheet().show(parentFragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
} else if (enabled && state.localBackupsEnabled) {
navController.navigate("confirm-optimize-with-local-backup")
} else {
viewModel.setOptimizeStorage(enabled)
}
@@ -238,6 +240,17 @@ class ManageStorageSettingsFragment : ComposeFragment() {
)
}
dialog("confirm-optimize-with-local-backup") {
Dialogs.SimpleAlertDialog(
title = stringResource(id = R.string.ManageStorageSettingsFragment__media_will_be_removed_from_your_on_device_backup),
body = stringResource(id = R.string.ManageStorageSettingsFragment__turning_on_optimize_signal_storage_will_offload),
confirm = stringResource(id = R.string.ManageStorageSettingsFragment__turn_on),
dismiss = stringResource(id = android.R.string.cancel),
onConfirm = { viewModel.setOptimizeStorage(true) },
onDismiss = { navController.popBackStack() }
)
}
dialog(
route = "paid-tier-pending"
) {
@@ -35,13 +35,14 @@ class ManageStorageSettingsViewModel : ViewModel() {
ManageStorageState(
keepMessagesDuration = SignalStore.settings.keepMessagesDuration,
lengthLimit = if (SignalStore.settings.isTrimByLengthEnabled) SignalStore.settings.threadTrimLength else ManageStorageState.NO_LIMIT,
syncTrimDeletes = SignalStore.settings.shouldSyncThreadTrimDeletes()
syncTrimDeletes = SignalStore.settings.shouldSyncThreadTrimDeletes(),
localBackupsEnabled = SignalStore.backup.newLocalBackupsEnabled
)
)
val state = store.asStateFlow()
init {
viewModelScope.launch(Dispatchers.IO) {
viewModelScope.launch(Dispatchers.Default) {
InAppPaymentsRepository.observeLatestBackupPayment()
.collectLatest { payment ->
store.update { it.copy(isPaidTierPending = payment.state == InAppPaymentTable.State.PENDING) }
@@ -179,7 +180,8 @@ class ManageStorageSettingsViewModel : ViewModel() {
val breakdown: MediaTable.StorageBreakdown? = null,
val onDeviceStorageOptimizationState: OnDeviceStorageOptimizationState = OnDeviceStorageOptimizationState.FEATURE_NOT_AVAILABLE,
val storageOptimizationStateChanged: Boolean = false,
val isPaidTierPending: Boolean = false
val isPaidTierPending: Boolean = false,
val localBackupsEnabled: Boolean = false
) {
companion object {
const val NO_LIMIT = 0
@@ -25,7 +25,7 @@ class DonationPendingBottomSheetViewModel(
init {
viewModelScope.launch {
val inAppPayment = withContext(SignalDispatchers.IO) {
val inAppPayment = withContext(SignalDispatchers.Default) {
SignalDatabase.inAppPayments.getById(inAppPaymentId)!!
}
@@ -111,15 +111,7 @@ object InAppPaymentsRepository {
.cancellation(
InAppPaymentData.Cancellation(
reason = if (chargeFailure != null) InAppPaymentData.Cancellation.Reason.PAST_DUE else InAppPaymentData.Cancellation.Reason.CANCELED,
chargeFailure = chargeFailure?.let {
InAppPaymentData.ChargeFailure(
code = it.code,
message = it.message,
outcomeType = it.outcomeType,
outcomeNetworkReason = it.outcomeNetworkReason ?: "",
outcomeNetworkStatus = it.outcomeNetworkStatus
)
}
chargeFailure = chargeFailure?.toInAppPaymentDataChargeFailure()
)
)
.build()
@@ -21,10 +21,12 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.DonationP
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPendingBottomSheetArgs
import org.thoughtcrime.securesms.components.settings.app.subscription.thanks.ThanksForYourSupportBottomSheetDialogFragment
import org.thoughtcrime.securesms.components.settings.app.subscription.thanks.ThanksForYourSupportBottomSheetDialogFragmentArgs
import org.thoughtcrime.securesms.database.DatabaseObserver.InAppPaymentObserver
import org.thoughtcrime.securesms.database.InAppPaymentTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.databaseprotos.DonationErrorValue
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore
/**
@@ -53,11 +55,27 @@ class InAppPaymentsBottomSheetDelegate(
private val badgeRepository = TerminalDonationRepository()
/**
* Fires while resumed whenever an in-app payment changes, so terminal payments (e.g. a redirect/iDEAL
* payment that finishes redeeming after we've already resumed) surface their sheet live rather than
* waiting for the next resume.
*/
private val inAppPaymentObserver = InAppPaymentObserver {
handleInAppPaymentDonationSheets()
handleInAppPaymentBackupsSheets()
}
override fun onResume(owner: LifecycleOwner) {
handleLegacyTerminalDonationSheets()
handleLegacyVerifiedMonthlyDonationSheets()
handleInAppPaymentDonationSheets()
handleInAppPaymentBackupsSheets()
AppDependencies.databaseObserver.registerInAppPaymentObserver(inAppPaymentObserver)
}
override fun onPause(owner: LifecycleOwner) {
AppDependencies.databaseObserver.unregisterObserver(inAppPaymentObserver)
}
/**
@@ -135,7 +135,7 @@ class InAppPaymentCheckoutDelegate(
callback.onSubscriptionCancelled(InAppPaymentType.RECURRING_DONATION)
} else {
fragment.lifecycleScope.launch {
val inAppPayment = withContext(SignalDispatchers.IO) {
val inAppPayment = withContext(SignalDispatchers.Default) {
SignalDatabase.inAppPayments.getById(result.inAppPaymentId!!)!!
}
@@ -38,7 +38,7 @@ class CreditCardViewModel(
val calendar = Calendar.getInstance()
viewModelScope.launch {
val inAppPayment = withContext(Dispatchers.IO) {
val inAppPayment = withContext(Dispatchers.Default) {
SignalDatabase.inAppPayments.getById(inAppPaymentId)!!
}
@@ -37,7 +37,7 @@ object GatewaySelectorRepository {
}
suspend fun setInAppPaymentMethodType(inAppPayment: InAppPaymentTable.InAppPayment, paymentMethodType: InAppPaymentData.PaymentMethodType): InAppPaymentTable.InAppPayment {
return withContext(Dispatchers.IO) {
return withContext(Dispatchers.Default) {
SignalDatabase.inAppPayments.update(
inAppPayment.copy(
data = inAppPayment.data.copy(
@@ -39,7 +39,7 @@ class PayPalCompleteOrderBottomSheet : DSLSettingsBottomSheetFragment() {
PayPalCompleteOrderPaymentItem.register(adapter)
lifecycleScope.launch {
val inAppPayment = withContext(SignalDispatchers.IO) {
val inAppPayment = withContext(SignalDispatchers.Default) {
SignalDatabase.inAppPayments.getById(args.inAppPaymentId)!!
}
@@ -119,7 +119,7 @@ class Stripe3DSDialogFragment : DialogFragment(R.layout.donation_webview_fragmen
val progress = ProgressCardDialogFragment.create()
progress.show(parentFragmentManager, null)
withContext(Dispatchers.IO) {
withContext(Dispatchers.Default) {
SignalDatabase.inAppPayments.update(args.waitingForAuthPayment)
}
@@ -23,7 +23,7 @@ class IdealTransferDetailsViewModel(inAppPaymentId: InAppPaymentTable.InAppPayme
init {
viewModelScope.launch {
val inAppPayment = withContext(Dispatchers.IO) {
val inAppPayment = withContext(Dispatchers.Default) {
SignalDatabase.inAppPayments.getById(inAppPaymentId)!!
}
@@ -56,7 +56,7 @@ class BankTransferMandateViewModel(
}
suspend fun getPaymentMethodType(): InAppPaymentData.PaymentMethodType {
return withContext(SignalDispatchers.IO) {
return withContext(SignalDispatchers.Default) {
SignalDatabase.inAppPayments.getById(inAppPaymentId)!!.data.paymentMethodType
}
}
@@ -7,14 +7,9 @@ import androidx.activity.result.ActivityResultLauncher
import androidx.appcompat.widget.Toolbar
import androidx.core.content.ContextCompat
import androidx.fragment.app.viewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import org.signal.core.util.dp
import org.signal.core.util.money.FiatMoney
import org.signal.donations.InAppPaymentType
@@ -29,7 +24,6 @@ import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationSerializationHelper.toFiatMoney
import org.thoughtcrime.securesms.components.settings.app.subscription.completed.InAppPaymentsBottomSheetDelegate
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.CheckoutFlowActivity
import org.thoughtcrime.securesms.components.settings.app.subscription.thanks.ThanksForYourSupportBottomSheetDialogFragment
import org.thoughtcrime.securesms.components.settings.app.subscription.ui.NetworkFailure
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.components.settings.models.IndeterminateLoadingCircle
@@ -90,14 +84,6 @@ class ManageDonationsFragment :
if (savedInstanceState == null && args.directToCheckoutType != InAppPaymentType.UNKNOWN) {
launcher.launch(args.directToCheckoutType)
}
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.RESUMED) {
viewModel.displayThanksBottomSheetPulse.collectLatest {
ThanksForYourSupportBottomSheetDialogFragment.create(it).show(parentFragmentManager, ThanksForYourSupportBottomSheetDialogFragment.SHEET_TAG)
}
}
}
}
override fun onResume() {
@@ -1,42 +0,0 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.subscription.manage
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.channels.trySendBlocking
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import org.thoughtcrime.securesms.database.DatabaseObserver.InAppPaymentObserver
import org.thoughtcrime.securesms.database.InAppPaymentTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
import org.thoughtcrime.securesms.dependencies.AppDependencies
object ManageDonationsRepository {
/**
* Emits any time we see a successfully completed IDEAL payment that we've not notified the user about.
*/
fun consumeSuccessfulIdealPayments(): Flow<InAppPaymentTable.InAppPayment> {
return callbackFlow {
val observer = InAppPaymentObserver {
if (it.state == InAppPaymentTable.State.END &&
it.data.error == null &&
it.data.paymentMethodType == InAppPaymentData.PaymentMethodType.IDEAL &&
!it.notified
) {
trySendBlocking(it)
SignalDatabase.inAppPayments.update(
it.copy(notified = true)
)
}
}
AppDependencies.databaseObserver.registerInAppPaymentObserver(observer)
awaitClose { AppDependencies.databaseObserver.unregisterObserver(observer) }
}
}
}
@@ -10,15 +10,10 @@ import io.reactivex.rxjava3.kotlin.plusAssign
import io.reactivex.rxjava3.kotlin.subscribeBy
import io.reactivex.rxjava3.schedulers.Schedulers
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import kotlinx.coroutines.rx3.asFlow
import org.signal.core.util.logging.Log
import org.signal.donations.InAppPaymentType
import org.thoughtcrime.securesms.badges.Badges
import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.RecurringInAppPaymentRepository
import org.thoughtcrime.securesms.database.InAppPaymentTable
@@ -40,9 +35,6 @@ class ManageDonationsViewModel : ViewModel() {
private val networkDisposable: Disposable
val state: LiveData<ManageDonationsState> = store.stateLiveData
private val internalDisplayThanksBottomSheetPulse = MutableSharedFlow<Badge>()
val displayThanksBottomSheetPulse: SharedFlow<Badge> = internalDisplayThanksBottomSheetPulse
init {
store.update(Recipient.self().live().liveDataResolved) { self, state ->
@@ -58,14 +50,7 @@ class ManageDonationsViewModel : ViewModel() {
}
}
viewModelScope.launch {
ManageDonationsRepository.consumeSuccessfulIdealPayments()
.collectLatest {
internalDisplayThanksBottomSheetPulse.emit(Badges.fromDatabaseBadge(it.data.badge!!))
}
}
viewModelScope.launch(Dispatchers.IO) {
viewModelScope.launch(Dispatchers.Default) {
InAppPaymentsRepository.observeInAppPaymentRedemption(InAppPaymentType.RECURRING_DONATION)
.asFlow()
.collect { redemptionStatus ->
@@ -86,7 +71,7 @@ class ManageDonationsViewModel : ViewModel() {
}
}
viewModelScope.launch(Dispatchers.IO) {
viewModelScope.launch(Dispatchers.Default) {
InAppPaymentsRepository.observeInAppPaymentRedemption(InAppPaymentType.ONE_TIME_DONATION)
.asFlow()
.collect { redemptionStatus ->
@@ -84,12 +84,14 @@ import org.thoughtcrime.securesms.groups.ui.GroupErrors
import org.thoughtcrime.securesms.groups.ui.GroupLimitDialog
import org.thoughtcrime.securesms.groups.ui.GroupMemberEntry
import org.thoughtcrime.securesms.groups.ui.LeaveGroupDialog
import org.thoughtcrime.securesms.groups.ui.MemberSearchFragment
import org.thoughtcrime.securesms.groups.ui.addmembers.AddMembersActivity
import org.thoughtcrime.securesms.groups.ui.addtogroup.AddToGroupsActivity
import org.thoughtcrime.securesms.groups.ui.invitesandrequests.ManagePendingAndRequestingMembersActivity
import org.thoughtcrime.securesms.groups.ui.managegroup.dialogs.GroupDescriptionDialog
import org.thoughtcrime.securesms.groups.ui.managegroup.dialogs.GroupInviteSentDialog
import org.thoughtcrime.securesms.groups.ui.managegroup.dialogs.GroupsLearnMoreBottomSheetDialogFragment
import org.thoughtcrime.securesms.jobs.AttachmentDownloadJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.main.MainNavigationChatDetailRouter
import org.thoughtcrime.securesms.main.MainNavigationDetailLocation
@@ -220,6 +222,10 @@ class ConversationSettingsFragment :
navController.safeNavigate(ConversationSettingsFragmentDirections.actionConversationSettingsFragmentToMemberLabelFragment(groupId))
}
parentFragmentManager.setFragmentResultListener(MemberSearchFragment.RESULT_ADD_MEMBERS, viewLifecycleOwner) { _, _ ->
viewModel.onAddToGroup()
}
recyclerView?.addOnScrollListener(ConversationSettingsOnUserScrolledAnimationHelper(toolbarAvatarContainer, toolbarTitle, toolbarBackground))
}
@@ -698,8 +704,21 @@ class ConversationSettingsFragment :
mediaRecords = state.sharedMedia,
mediaIds = state.sharedMediaIds,
onMediaRecordClick = { view, mediaRecord, isLtr ->
if (mediaRecord.attachment?.transferState != AttachmentTable.TRANSFER_PROGRESS_DONE &&
mediaRecord.attachment?.transferState != AttachmentTable.TRANSFER_RESTORE_OFFLOADED
val attachment = mediaRecord.attachment
if (attachment == null) {
Toast.makeText(context, R.string.ConversationSettingsFragment__this_media_is_not_sent_yet, Toast.LENGTH_LONG).show()
return@Model
}
if (attachment.displayUri == null) {
if (attachment.transferState == AttachmentTable.TRANSFER_RESTORE_OFFLOADED) {
AttachmentDownloadJob.downloadAttachmentIfNeeded(attachment)
} else {
Toast.makeText(context, R.string.ConversationSettingsFragment__this_media_is_not_sent_yet, Toast.LENGTH_LONG).show()
}
return@Model
}
if (attachment.transferState != AttachmentTable.TRANSFER_PROGRESS_DONE &&
attachment.transferState != AttachmentTable.TRANSFER_RESTORE_OFFLOADED
) {
Toast.makeText(context, R.string.ConversationSettingsFragment__this_media_is_not_sent_yet, Toast.LENGTH_LONG).show()
return@Model
@@ -821,6 +840,7 @@ class ConversationSettingsFragment :
state.withGroupSettingsState { groupState ->
val memberCount = groupState.allMembers.size
val canAdd = groupState.canAddToGroup && !groupState.isTerminated && !state.isDeprecatedOrUnregistered
if (groupState.canAddToGroup || memberCount > 0) {
dividerPref()
@@ -830,10 +850,18 @@ class ConversationSettingsFragment :
} else {
resources.getQuantityString(R.plurals.ContactSelectionListFragment_d_members, memberCount, memberCount)
}
sectionHeaderPref(DSLSettingsText.from(memberHeaderText))
sectionHeaderPref(
title = DSLSettingsText.from(memberHeaderText),
iconEnd = DSLSettingsIcon.from(CoreUiR.drawable.symbol_search_24),
onClick = {
val action = ConversationSettingsFragmentDirections.actionConversationSettingsFragmentToMemberSearchFragment(groupState.groupId, canAdd, groupState.groupLinkEnabled)
navController.safeNavigate(action)
}
)
}
if (groupState.canAddToGroup && !groupState.isTerminated && !state.isDeprecatedOrUnregistered) {
if (canAdd) {
customPref(
LargeIconClickPreference.Model(
title = DSLSettingsText.from(R.string.ConversationSettingsFragment__add_members),
@@ -1110,7 +1138,7 @@ class ConversationSettingsFragment :
}
state.withGroupSettingsState { groupState ->
if (groupState.canEndGroup && RemoteConfig.groupTerminateSend) {
if (groupState.canEndGroup) {
dividerPref()
clickPref(
@@ -23,7 +23,7 @@ class ConversationSettingsNavHostFragment : NavHostFragment(), FragmentBackPress
companion object {
suspend fun createArgs(recipientId: RecipientId): Bundle {
val recipient = withContext(Dispatchers.IO) { Recipient.resolved(recipientId) }
val recipient = withContext(Dispatchers.Default) { Recipient.resolved(recipientId) }
val args = if (recipient.isGroup) {
ConversationSettingsFragmentArgs.Builder(null, recipient.requireGroupId(), null)
@@ -157,7 +157,7 @@ sealed class ConversationSettingsViewModel(
if (state.threadId > 0) {
val newArchived = !state.isArchived
store.update { it.copy(isArchived = newArchived) }
viewModelScope.launch(SignalDispatchers.IO) {
viewModelScope.launch(SignalDispatchers.Default) {
repository.setArchived(state.threadId, newArchived)
}
}
@@ -548,7 +548,7 @@ sealed class ConversationSettingsViewModel(
repository.unblock(groupId)
}
private fun loadMemberLabels(v2GroupId: GroupId.V2, groupMembers: List<GroupMemberEntry.FullMember>) = viewModelScope.launch(SignalDispatchers.IO) {
private fun loadMemberLabels(v2GroupId: GroupId.V2, groupMembers: List<GroupMemberEntry.FullMember>) = viewModelScope.launch(SignalDispatchers.Default) {
val labelsByRecipientId = MemberLabelRepository.instance
.getLabels(v2GroupId, groupMembers.map { it.member })
@@ -561,7 +561,7 @@ sealed class ConversationSettingsViewModel(
}
}
private fun loadCanSetMemberLabel(groupId: GroupId.V2) = viewModelScope.launch(SignalDispatchers.IO) {
private fun loadCanSetMemberLabel(groupId: GroupId.V2) = viewModelScope.launch(SignalDispatchers.Default) {
val canSetLabel = MemberLabelRepository.instance.canSetLabel(groupId, Recipient.self())
store.update {
it.copy(
@@ -174,7 +174,7 @@ class InternalConversationSettingsFragment : ComposeFragment(), InternalConversa
}
override fun add1000Messages(recipientId: RecipientId) {
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) {
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.Default) {
val recipient = Recipient.live(recipientId).get()
val messageCount = 1000
val startTime = System.currentTimeMillis() - messageCount
@@ -71,25 +71,25 @@ class SoundsAndNotificationsSettingsViewModel2(
}
private fun applySetMuteUntil(muteUntil: Long) {
viewModelScope.launch(Dispatchers.IO) {
viewModelScope.launch(Dispatchers.Default) {
SignalDatabase.recipients.setMuted(recipientId, muteUntil)
}
}
private fun applySetMentionSetting(setting: NotificationSetting) {
viewModelScope.launch(Dispatchers.IO) {
viewModelScope.launch(Dispatchers.Default) {
SignalDatabase.recipients.setMentionSetting(recipientId, setting)
}
}
private fun applySetCallNotificationSetting(setting: NotificationSetting) {
viewModelScope.launch(Dispatchers.IO) {
viewModelScope.launch(Dispatchers.Default) {
SignalDatabase.recipients.setCallNotificationSetting(recipientId, setting)
}
}
private fun applySetReplyNotificationSetting(setting: NotificationSetting) {
viewModelScope.launch(Dispatchers.IO) {
viewModelScope.launch(Dispatchers.Default) {
SignalDatabase.recipients.setReplyNotificationSetting(recipientId, setting)
}
}
@@ -137,13 +137,21 @@ class DSLConfiguration {
children.add(preference)
}
fun sectionHeaderPref(title: DSLSettingsText) {
val preference = SectionHeaderPreference(title)
fun sectionHeaderPref(
title: DSLSettingsText,
iconEnd: DSLSettingsIcon? = null,
onClick: (() -> Unit)? = null
) {
val preference = SectionHeaderPreference(title, iconEnd, onClick)
children.add(preference)
}
fun sectionHeaderPref(title: Int) {
val preference = SectionHeaderPreference(DSLSettingsText.from(title))
fun sectionHeaderPref(
title: Int,
iconEnd: DSLSettingsIcon? = null,
onClick: (() -> Unit)? = null
) {
val preference = SectionHeaderPreference(DSLSettingsText.from(title), iconEnd, onClick)
children.add(preference)
}
@@ -365,4 +373,12 @@ class ExternalLinkPreference(
@StringRes val linkId: Int
) : PreferenceModel<ExternalLinkPreference>()
class SectionHeaderPreference(override val title: DSLSettingsText) : PreferenceModel<SectionHeaderPreference>()
class SectionHeaderPreference(
override val title: DSLSettingsText,
override val iconEnd: DSLSettingsIcon? = null,
val onClick: (() -> Unit)? = null
) : PreferenceModel<SectionHeaderPreference>() {
override fun areContentsTheSame(newItem: SectionHeaderPreference): Boolean {
return super.areContentsTheSame(newItem) && onClick == null && newItem.onClick == null
}
}
@@ -41,13 +41,13 @@ class CallPeerRepository(
private val recipient: Flow<Recipient> = liveRecipient.flatMapLatest { it.observable().asFlow() }
private val groupRecipient = recipient.filter { it.isActiveGroup }
private val groupRecord: Flow<GroupRecord> = groupRecipient.mapLatest {
withContext(Dispatchers.IO) {
withContext(Dispatchers.Default) {
SignalDatabase.groups.getGroup(it.requireGroupId()).get()
}
}
val groupMembers: Flow<List<GroupMemberEntry.FullMember>> = groupRecord.mapLatest { record ->
withContext(Dispatchers.IO) {
withContext(Dispatchers.Default) {
record.members.map { Recipient.resolved(it) }.map { GroupMemberEntry.FullMember(it, record.isAdmin(it)) }
}
}

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