Compare commits

...

152 Commits

Author SHA1 Message Date
Cody Henthorne 39598a8da0 Bump version to 8.20.2 2026-07-16 16:33:22 -04:00
Cody Henthorne 5949d96cfd Update translations and other static files. 2026-07-16 16:29:26 -04:00
Alex Hart c9ebc1c12e Replace lottie with static badge image and update sheet sizing. 2026-07-16 17:15:28 -03:00
Cody Henthorne 99ed40580c Add missing field for 1:1 sync messages. 2026-07-16 15:53:14 -04:00
Cody Henthorne c5b48be263 Bump version to 8.20.1 2026-07-16 14:10:13 -04:00
Cody Henthorne 1742b2ec50 Update baseline profile. 2026-07-16 13:55:55 -04:00
Cody Henthorne 49ef205821 Update translations and other static files. 2026-07-16 13:47:16 -04:00
Cody Henthorne 6f81a08669 Prevent jobs from running during a backup restore. 2026-07-16 13:38:35 -04:00
Cody Henthorne 97a8795100 Keep device alive during backup restore flows and show progress for link and sync import. 2026-07-16 13:38:35 -04:00
Greyson Parrelli b8508af636 Update title sizes in two-pane layouts in regV5. 2026-07-16 13:38:35 -04:00
Greyson Parrelli c0f861d0b8 Fix rate limit displays for verification codes in regV5. 2026-07-16 13:38:35 -04:00
Alex Hart 59d61184f5 Adjust span count and padding for tablet avatar picker. 2026-07-16 13:38:35 -04:00
Cody Henthorne 622208831f Enable link and sync for external users. 2026-07-16 13:38:35 -04:00
Greyson Parrelli 67b50b499e Improve logging in regV5. 2026-07-16 13:38:35 -04:00
Cody Henthorne 4cafe16e25 Block additional entry points from being accessed on a linked device. 2026-07-16 13:38:35 -04:00
Greyson Parrelli 2f70d780d0 Use better progress for local backup v2 in regV5. 2026-07-16 13:38:35 -04:00
Greyson Parrelli fd83ad9625 Increase max allowable image size. 2026-07-15 23:47:40 -04:00
Greyson Parrelli b3857e4b35 Handle missing sessions when submitting captcha in regV5. 2026-07-15 23:44:58 -04:00
Cody Henthorne eff0e973aa Bump version to 8.20.0 2026-07-15 17:40:28 -04:00
Cody Henthorne 34f336c2a2 Update baseline profile. 2026-07-15 17:13:28 -04:00
Cody Henthorne b0f71c8613 Update translations and other static files. 2026-07-15 17:03:02 -04:00
Cody Henthorne 95019407c2 Hide optimize media on linked devices. 2026-07-15 16:43:50 -04:00
Greyson Parrelli 0101d41cf9 Enable regV5 for external users. 2026-07-15 16:43:50 -04:00
Greyson Parrelli 75c18806d5 Ensure we always use the user-entered AEP in regV5. 2026-07-15 16:43:50 -04:00
Greyson Parrelli 7a07a96fba Fix local backup v2 restore pre-registration in regV5. 2026-07-15 16:43:50 -04:00
Cody Henthorne 4f6c1c4647 Show backups information on linked devices and allow on-device backups. 2026-07-15 16:43:50 -04:00
Greyson Parrelli df4afe4e92 Fix time display for local backup v2 in regV5. 2026-07-15 16:43:50 -04:00
Greyson Parrelli 6a18bcc915 Use better country code fallback in regV5. 2026-07-15 16:43:50 -04:00
Michelle Tang 06e7b79e3d Enable disappearing calls. 2026-07-15 16:43:50 -04:00
Greyson Parrelli 31f0945db4 Add tests for AppRegistrationStorageController. 2026-07-15 16:43:50 -04:00
Greyson Parrelli edc629e7fa Remove OneTimeEvent pattern from regV5. 2026-07-15 16:43:49 -04:00
Alex Hart bdfe06ed74 Upgrade compose bom to 2026.06.01 2026-07-15 16:43:49 -04:00
Cody Henthorne 1d11e29e94 Add linked device account screen. 2026-07-15 16:43:49 -04:00
andrew-signal f70ae10067 Bump libsignal to v0.97.3 2026-07-15 16:43:49 -04:00
Cody Henthorne 7ca57e5531 Fix flakey attachmentBackfill instrumentation test. 2026-07-15 16:43:49 -04:00
Greyson Parrelli adad1425d8 Navigate reglock mismatch to account locked in regV5. 2026-07-15 16:43:49 -04:00
Cody Henthorne 693b208fe2 Fix chat session refreshed on secondary after link+sync. 2026-07-15 16:43:49 -04:00
Michelle Tang e975084b8e Use new transcoding config. 2026-07-15 16:43:49 -04:00
Greyson Parrelli 3bc693961d Fix QR code color in dark theme in regV5. 2026-07-15 16:43:49 -04:00
Greyson Parrelli 2dde0b43b8 Take a persistent URI permission for local backups in regV5. 2026-07-15 16:43:49 -04:00
Alex Hart 88366cd416 Add donation checkout entry-point tests. 2026-07-15 16:43:49 -04:00
Greyson Parrelli 4ee2b71b13 Fix reglock interaction during backup restore in regV5. 2026-07-15 16:43:49 -04:00
Alex Hart 86aae39141 Fix nav for expiring timer. 2026-07-15 16:43:48 -04:00
Greyson Parrelli e71ec7c6d9 Refactor regV5 account storage persistence. 2026-07-15 16:43:48 -04:00
Michelle Tang 20d24d177d Refresh attributes on KT failure. 2026-07-15 16:43:48 -04:00
Alex Hart cfc1ed8d5c Check isResolving flag before displaying 'Deleted account'.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-07-15 16:43:48 -04:00
Alex Hart 25f7c31e75 Fix backup subscription check test. 2026-07-15 16:43:48 -04:00
Greyson Parrelli 929a391d50 Improve local restore progress in regV5. 2026-07-15 16:43:48 -04:00
dependabot[bot] 76f2543308 ci: bump the actions group with 2 updates.
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-15 16:43:48 -04:00
Cody Henthorne 5eebaa3528 Update support article links in linked device reg flow. 2026-07-15 16:43:48 -04:00
Greyson Parrelli 673324f46a Fix text theming issue in contact search. 2026-07-15 16:43:48 -04:00
Greyson Parrelli ea9cd4251e Fix pin restore from remote backup in regV5. 2026-07-15 16:43:48 -04:00
Cody Henthorne ac7d54331a Update assets for linked device registration flows. 2026-07-15 16:43:48 -04:00
Alex Hart 5e3a34e1bc Add donation checkout spec test suite.
Co-authored-by: Cody Henthorne <cody@signal.org>
2026-07-15 16:43:47 -04:00
Greyson Parrelli fe6a74eb56 Add end-to-end regV5 tests for wrong backup passphrases. 2026-07-15 16:43:47 -04:00
Greyson Parrelli 6a6b7a1924 Improve quick restore in regV5. 2026-07-15 16:43:47 -04:00
Greyson Parrelli f50e970b5d Add more end-to-end regV5 tests. 2026-07-15 16:43:47 -04:00
Greyson Parrelli 32961106e1 Add an example end-to-end registration junit test. 2026-07-15 16:43:47 -04:00
andrew-signal 8d3728b32a Bump libsignal to v0.97.1 2026-07-15 16:43:47 -04:00
emir-signal 2f3b25c787 Update to RingRTC v2.69.7 2026-07-15 16:43:47 -04:00
BarbossHack 5cc7266a9e Refactor the XML diff in reproducible script
Closes signalapp/Signal-Android#14879
2026-07-15 16:43:47 -04:00
Greyson Parrelli 90cba056f1 Remove emoji from plaintext export names. 2026-07-15 16:43:47 -04:00
Greyson Parrelli 07ca23ceae Evaluate original envelope source when rejecting sealed-sender PNI messages. 2026-07-15 16:43:47 -04:00
Greyson Parrelli 0d0b9d345c Strip user-ID prefix from content authority in isValidExternalUri. 2026-07-15 16:43:47 -04:00
Greyson Parrelli 7f63af659d Preserve storageCapable across restore progress emissions in local backup V1 restore. 2026-07-15 16:43:47 -04:00
Greyson Parrelli 66390b4fe4 Catch malformed percent escapes when parsing call link urls. 2026-07-15 16:43:47 -04:00
Greyson Parrelli b60f53f4df Reject dot-only chat names to prevent zip-slip in plaintext export. 2026-07-15 16:43:46 -04:00
Greyson Parrelli 8bc31386a5 Build eager prefetch sessions under the SignalSessionLock. 2026-07-15 16:43:46 -04:00
Greyson Parrelli 8f4b4aa160 Improve check for self. 2026-07-15 16:43:46 -04:00
Greyson Parrelli 12af41174a Cap mention processing in story reply and edit message paths. 2026-07-15 16:43:46 -04:00
Greyson Parrelli e7c5af7ea5 Handle device-transfer authentication failures per connection instead of terminating the server. 2026-07-15 16:43:46 -04:00
Greyson Parrelli 51b1dbd470 Bound backup frame read retries and cap frame length to prevent restore crash. 2026-07-15 16:43:46 -04:00
Greyson Parrelli dd38f72f60 Validate content URI authority in system contacts entrypoint. 2026-07-15 16:43:46 -04:00
Greyson Parrelli db8a705704 Ensure backups are re-enabled post-reg in regV5. 2026-07-15 16:43:46 -04:00
Greyson Parrelli ba43403009 Restore the AEP in v1 local backups in regV5. 2026-07-15 16:43:46 -04:00
Greyson Parrelli 417b9eb50a Report progress for V1 local restore in regV5. 2026-07-15 16:43:46 -04:00
Greyson Parrelli 76c9b4a7dc Do not reprompt for restore if you restored pre-reg in regV5. 2026-07-15 16:43:46 -04:00
Greyson Parrelli d1b916d61c Fix local backup v1 restores post-registration in regV5. 2026-07-15 16:43:46 -04:00
Greyson Parrelli 908bb8dbe6 Fix race condition with event stream in regV5. 2026-07-15 16:43:46 -04:00
Greyson Parrelli 31216592de Ensure that all regV5 viewmodels follow the right patterns. 2026-07-15 16:43:45 -04:00
Greyson Parrelli bcc9f17e41 Use regV5 for partial restore state. 2026-07-15 16:43:45 -04:00
Greyson Parrelli d9dd15eff3 Use idiomatic nav3 transition patterns. 2026-07-15 16:43:45 -04:00
Alex Hart 1a12eb992d Fix crash when trying to dismiss a detached dialog window. 2026-07-15 16:43:45 -04:00
Greyson Parrelli 7c1e010482 Improve phone number pasting in regV5. 2026-07-15 16:43:45 -04:00
Greyson Parrelli 8a5826555c Properly restore keys from local backups in regV5. 2026-07-10 16:56:33 -04:00
Greyson Parrelli 8ffbfb4000 Fix local backup restore v2 in regV5. 2026-07-10 16:56:32 -04:00
Greyson Parrelli 16fe25a38a Fix some remote restore bugs in regV5. 2026-07-10 16:56:32 -04:00
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
1045 changed files with 35464 additions and 9271 deletions
+9 -5
View File
@@ -23,7 +23,7 @@ jobs:
lfs: true
- name: set up JDK 21
uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5
uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5
# gh api repos/actions/setup-java/commits/v5 --jq '.sha'
with:
distribution: temurin
@@ -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
+1 -1
View File
@@ -23,7 +23,7 @@ jobs:
ref: ${{ github.event.pull_request.base.sha }}
- name: set up JDK 21
uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5
uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5
# gh api repos/actions/setup-java/commits/v5 --jq '.sha'
with:
distribution: temurin
+2 -2
View File
@@ -14,9 +14,9 @@ jobs:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
# gh api repos/actions/checkout/commits/v6 --jq '.sha'
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: Build image
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: reproducible-builds
tags: signal-android
+1 -1
View File
@@ -14,7 +14,7 @@ jobs:
actions: write
steps:
- uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10
- uses: actions/stale@1e223db275d687790206a7acac4d1a11bd6fe629 # v10
# gh api repos/actions/stale/commits/v10 --jq '.sha'
with:
days-before-stale: 60
+168 -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 = 1715
val canonicalVersionName = "8.18.1"
val canonicalVersionCode = 1722
val canonicalVersionName = "8.20.2"
val currentHotfixVersion = 0
val maxHotfixVersions = 100
@@ -128,6 +132,17 @@ ktlint {
version.set("1.5.0")
}
// ktlint only scans convention source dirs, so the shared dirs added to the compile tasks are
// otherwise skipped. Add them to the base test/androidTest ktlint tasks so ktlintCheck/format cover them.
tasks.withType(org.jlleitschuh.gradle.ktlint.tasks.BaseKtLintCheckTask::class.java).configureEach {
if (name.endsWith("OverTestSourceSet") || name.endsWith("OverAndroidTestSourceSet")) {
source("$projectDir/src/testShared")
}
if (name.endsWith("OverAndroidTestSourceSet")) {
source("$projectDir/src/benchmarkShared/java")
}
}
screenshotTests {
// Fraction of differing pixels tolerated before a screenshot test fails (0.0001 = 0.01%).
imageDifferenceThreshold = 0.0001f
@@ -598,6 +613,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 {
@@ -790,8 +842,18 @@ dependencies {
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
androidTestImplementation(libs.androidx.compose.ui.test.manifest)
androidTestImplementation(testLibs.androidx.test.ext.junit)
androidTestImplementation(testLibs.espresso.core)
androidTestImplementation(testLibs.espresso.contrib) {
// espresso-contrib transitively pulls the full checkerframework jar (only its annotations are needed),
// whose MANIFEST.MF collides with other test dependencies during androidTest resource merging.
exclude(group = "org.checkerframework", module = "checker")
// accessibility-test-framework drags in an ancient com.google.protobuf:protobuf-lite:3.0.1 whose
// GeneratedMessageLite wins the merged dex and lacks registerDefaultInstance(Class, GeneratedMessageLite),
// crashing tests at runtime. We only use RecyclerViewActions from contrib, not the accessibility checks.
exclude(group = "com.google.android.apps.common.testing.accessibility.framework")
}
androidTestImplementation(testLibs.androidx.test.core)
androidTestImplementation(testLibs.androidx.test.core.ktx)
androidTestImplementation(testLibs.androidx.test.ext.junit.ktx)
@@ -975,6 +1037,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,191 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.subscription.donate
import android.app.Activity
import android.app.Application
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import androidx.fragment.app.Fragment
import androidx.navigation.fragment.NavHostFragment
import androidx.recyclerview.widget.RecyclerView
import androidx.test.core.app.ActivityScenario
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.ViewActions.click
import androidx.test.espresso.action.ViewActions.scrollTo
import androidx.test.espresso.contrib.RecyclerViewActions
import androidx.test.espresso.matcher.ViewMatchers.hasDescendant
import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.espresso.matcher.ViewMatchers.withText
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import assertk.assertThat
import assertk.assertions.isEqualTo
import assertk.assertions.isNotNull
import assertk.assertions.isNull
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.signal.core.util.getParcelableExtraCompat
import org.signal.donations.InAppPaymentType
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.badges.self.none.BecomeASustainerFragment
import org.thoughtcrime.securesms.badges.self.overview.BadgesOverviewFragment
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
import org.thoughtcrime.securesms.components.settings.app.routes.AppSettingsRoute
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.profiles.manage.EditProfileActivity
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.testing.InAppPaymentsRule
import org.thoughtcrime.securesms.testing.SignalActivityRule
import java.util.concurrent.TimeUnit
/**
* Entry-path coverage for the "become a subscriber" upsell.
*
* The upsell surface is [BecomeASustainerFragment], reached from the profile screen's badges row. The
* handoff test asserts the sheet's button launches the recurring-donation checkout route; the gating
* tests assert the "user might be a sustainer" rule in [org.thoughtcrime.securesms.profiles.manage.EditProfileFragment]:
* a non-donor sees the upsell, an existing donor is routed to badge management instead.
*/
@RunWith(AndroidJUnit4::class)
class BecomeASustainerUpsellTest {
@get:Rule
val harness = SignalActivityRule()
@get:Rule
val iapRule = InAppPaymentsRule()
private val context: Context get() = InstrumentationRegistry.getInstrumentation().targetContext
@Before
fun setUp() {
setSelfBadges(emptyList())
}
@Test
fun nonDonor_badgeTap_showsUpsellSheet() {
ActivityScenario.launch(EditProfileActivity::class.java).use { scenario ->
onView(withId(R.id.manage_profile_badges_container)).perform(scrollTo(), click())
val sheet = awaitNavHostFragment(scenario) { it.filterIsInstance<BecomeASustainerFragment>().firstOrNull() }
assertThat(sheet).isNotNull()
}
}
@Test
fun becomeASustainer_launchesRecurringCheckoutRoute() {
LaunchedActivityRecorder(AppSettingsActivity::class.java).use { recorder ->
ActivityScenario.launch(EditProfileActivity::class.java).use { scenario ->
onView(withId(R.id.manage_profile_badges_container)).perform(scrollTo(), click())
awaitNavHostFragment(scenario) { it.filterIsInstance<BecomeASustainerFragment>().firstOrNull() }
val becomeASustainer = withText(R.string.BecomeASustainerMegaphone__become_a_sustainer)
onView(withId(R.id.recycler)).perform(RecyclerViewActions.scrollTo<RecyclerView.ViewHolder>(hasDescendant(becomeASustainer)))
onView(becomeASustainer).perform(click())
val intent = recorder.awaitLaunch()
val route = intent.extras!!.keySet().firstNotNullOfOrNull { intent.getParcelableExtraCompat(it, AppSettingsRoute::class.java) }
assertThat(route).isEqualTo(AppSettingsRoute.DonationsRoute.Donations(directToCheckoutType = InAppPaymentType.RECURRING_DONATION))
}
}
}
@Test
fun donor_badgeTap_opensBadgeManagement() {
setSelfBadges(listOf(donorBadge()))
ActivityScenario.launch(EditProfileActivity::class.java).use { scenario ->
onView(withId(R.id.manage_profile_badges_container)).perform(scrollTo(), click())
val badgeManagement = awaitNavHostFragment(scenario) { it.filterIsInstance<BadgesOverviewFragment>().firstOrNull() }
assertThat(badgeManagement).isNotNull()
val upsell = navHostFragments(scenario).filterIsInstance<BecomeASustainerFragment>().firstOrNull()
assertThat(upsell).isNull()
}
}
private fun awaitNavHostFragment(scenario: ActivityScenario<EditProfileActivity>, selector: (List<Fragment>) -> Fragment?): Fragment {
return await(description = "nav host fragment") { selector(navHostFragments(scenario)) }
}
private fun navHostFragments(scenario: ActivityScenario<EditProfileActivity>): List<Fragment> {
var fragments: List<Fragment> = emptyList()
scenario.onActivity { activity ->
val navHost = activity.supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as? NavHostFragment
fragments = navHost?.childFragmentManager?.fragments ?: emptyList()
}
return fragments
}
private fun setSelfBadges(badges: List<Badge>) {
SignalDatabase.recipients.setBadges(Recipient.self().id, badges)
Recipient.self().fresh()
}
private fun donorBadge(): Badge {
return Badge(
id = "test-donor-badge",
category = Badge.Category.Donor,
name = "Signal Sustainer",
description = "",
imageUrl = Uri.EMPTY,
imageDensity = "xxhdpi",
expirationTimestamp = System.currentTimeMillis() + TimeUnit.DAYS.toMillis(30),
visible = true,
duration = TimeUnit.DAYS.toMillis(30)
)
}
private fun <T> await(timeoutMs: Long = 10_000, pollMs: Long = 50, description: String, supplier: () -> T?): T {
val deadline = System.currentTimeMillis() + timeoutMs
while (System.currentTimeMillis() < deadline) {
supplier()?.let { return it }
Thread.sleep(pollMs)
}
error("Timed out after ${timeoutMs}ms waiting for $description")
}
/**
* Captures the first launched activity of [activityClass] and finishes it immediately so its own
* downstream launches (e.g. the auto-launched checkout) don't cascade during the test.
*/
private inner class LaunchedActivityRecorder(private val activityClass: Class<out Activity>) : AutoCloseable {
@Volatile
private var launched: Intent? = null
private val app = context.applicationContext as Application
private val callbacks = object : Application.ActivityLifecycleCallbacks {
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {
if (activityClass.isInstance(activity) && launched == null) {
launched = activity.intent
activity.finish()
}
}
override fun onActivityStarted(activity: Activity) = Unit
override fun onActivityResumed(activity: Activity) = Unit
override fun onActivityPaused(activity: Activity) = Unit
override fun onActivityStopped(activity: Activity) = Unit
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) = Unit
override fun onActivityDestroyed(activity: Activity) = Unit
}
init {
app.registerActivityLifecycleCallbacks(callbacks)
}
fun awaitLaunch(): Intent = await(description = "${activityClass.simpleName} launch") { launched }
override fun close() = app.unregisterActivityLifecycleCallbacks(callbacks)
}
}
@@ -17,17 +17,24 @@ import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.espresso.matcher.ViewMatchers.withText
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import io.mockk.unmockkObject
import org.hamcrest.Matchers.allOf
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.signal.core.util.deleteAll
import org.signal.donations.InAppPaymentType
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.app.subscription.permits.DonationPermits
import org.thoughtcrime.securesms.database.InAppPaymentTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.testing.GooglePayTestRule
import org.thoughtcrime.securesms.testing.InAppPaymentsRule
import org.thoughtcrime.securesms.testing.RxTestSchedulerRule
import org.thoughtcrime.securesms.testing.SignalActivityRule
import org.thoughtcrime.securesms.testing.actions.RecyclerViewScrollToBottomAction
import org.thoughtcrime.securesms.testing.actions.scrollToDescendant
@Suppress("ClassName")
@RunWith(AndroidJUnit4::class)
@@ -51,9 +58,15 @@ class CheckoutFlowActivityTest__OneTimeDonations {
@Before
fun setUp() {
SignalDatabase.inAppPayments.writableDatabase.deleteAll(InAppPaymentTable.TABLE_NAME)
startJobLoopForTests()
}
@After
fun tearDown() {
unmockkObject(DonationPermits)
}
@Test
fun givenPermitAcquisitionFails_whenIDonateOnce_thenIExpectPaymentSetupErrorDialog() {
failDonationPermitAcquisition()
@@ -61,16 +74,17 @@ class CheckoutFlowActivityTest__OneTimeDonations {
val scenario = ActivityScenario.launch<CheckoutFlowActivity>(intent)
rxRule.defaultTestScheduler.triggerActions()
scrollToDescendant(R.id.recycler, withId(R.id.boost_1), rxRule.defaultTestScheduler)
onView(allOf(withId(R.id.boost_1), isClickable())).perform(ViewActions.click())
rxRule.defaultTestScheduler.triggerActions()
onView(withId(R.id.recycler)).perform(RecyclerViewScrollToBottomAction)
scrollToDescendant(R.id.recycler, withText(R.string.DonateToSignalFragment__continue), rxRule.defaultTestScheduler)
onView(withText(R.string.DonateToSignalFragment__continue)).perform(ViewActions.click())
rxRule.defaultTestScheduler.triggerActions()
scenario.selectGooglePay(composeRule, rxRule.defaultTestScheduler, InAppPaymentType.ONE_TIME_DONATION)
awaitDonationErrorDialog(rxRule.defaultTestScheduler, R.string.DonationsErrors__error_processing_payment)
awaitDialog(rxRule.defaultTestScheduler, R.string.DonationsErrors__error_processing_payment)
onView(withText(R.string.DonationsErrors__your_payment)).inRoot(isDialog()).check(matches(isDisplayed()))
}
}
@@ -13,35 +13,33 @@ import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.espresso.matcher.ViewMatchers.withText
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import io.mockk.every
import io.mockk.mockkObject
import io.mockk.unmockkObject
import org.junit.After
import org.junit.Before
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.whispersystems.signalservice.api.subscriptions.ActiveSubscription
import org.thoughtcrime.securesms.testing.actions.scrollToDescendant
import org.thoughtcrime.securesms.testing.endpoints.DonationResponses
import org.thoughtcrime.securesms.testing.endpoints.MockEndpoints
import org.thoughtcrime.securesms.testing.endpoints.failure
import org.thoughtcrime.securesms.testing.endpoints.ok
import org.whispersystems.signalservice.api.subscriptions.SubscriberId
import java.math.BigDecimal
import java.util.Currency
import kotlin.time.Duration.Companion.days
import kotlin.time.Duration.Companion.milliseconds
@Suppress("ClassName")
@RunWith(AndroidJUnit4::class)
@@ -65,6 +63,7 @@ class CheckoutFlowActivityTest__RecurringDonations {
@Before
fun setUp() {
SignalDatabase.inAppPayments.writableDatabase.deleteAll(InAppPaymentTable.TABLE_NAME)
startJobLoopForTests()
}
@@ -76,14 +75,15 @@ class CheckoutFlowActivityTest__RecurringDonations {
@Test
fun givenRecurringDonations_whenILoadScreen_thenIExpectMonthlySelected() {
ActivityScenario.launch<CheckoutFlowActivity>(intent)
scrollToDescendant(R.id.recycler, withId(R.id.monthly), rxRule.defaultTestScheduler)
onView(withId(R.id.monthly)).check(matches(isSelected()))
}
@Test
fun givenNoCurrentDonation_whenILoadScreen_thenIExpectContinueButton() {
ActivityScenario.launch<CheckoutFlowActivity>(intent)
onView(withId(R.id.recycler)).perform(RecyclerViewScrollToBottomAction)
onView(withText("Continue")).check(matches(isDisplayed()))
scrollToDescendant(R.id.recycler, withText(R.string.DonateToSignalFragment__continue), rxRule.defaultTestScheduler)
onView(withText(R.string.DonateToSignalFragment__continue)).check(matches(isDisplayed()))
}
@Test
@@ -94,8 +94,9 @@ class CheckoutFlowActivityTest__RecurringDonations {
rxRule.defaultTestScheduler.triggerActions()
onView(withId(R.id.recycler)).perform(RecyclerViewScrollToBottomAction)
scrollToDescendant(R.id.recycler, withText(R.string.SubscribeFragment__update_subscription), rxRule.defaultTestScheduler)
onView(withText(R.string.SubscribeFragment__update_subscription)).check(matches(isDisplayed()))
scrollToDescendant(R.id.recycler, withText(R.string.SubscribeFragment__cancel_subscription), rxRule.defaultTestScheduler)
onView(withText(R.string.SubscribeFragment__cancel_subscription)).check(matches(isDisplayed()))
}
@@ -107,7 +108,7 @@ class CheckoutFlowActivityTest__RecurringDonations {
rxRule.defaultTestScheduler.triggerActions()
onView(withId(R.id.recycler)).perform(RecyclerViewScrollToBottomAction)
scrollToDescendant(R.id.recycler, withText(R.string.SubscribeFragment__cancel_subscription), rxRule.defaultTestScheduler)
onView(withText(R.string.SubscribeFragment__cancel_subscription)).check(matches(isDisplayed()))
onView(withText(R.string.SubscribeFragment__cancel_subscription)).perform(ViewActions.click())
onView(withText(R.string.SubscribeFragment__confirm_cancellation)).check(matches(isDisplayed()))
@@ -122,7 +123,7 @@ class CheckoutFlowActivityTest__RecurringDonations {
rxRule.defaultTestScheduler.triggerActions()
onView(withId(R.id.recycler)).perform(RecyclerViewScrollToBottomAction)
scrollToDescendant(R.id.recycler, withText(R.string.SubscribeFragment__update_subscription), rxRule.defaultTestScheduler)
onView(withText(R.string.SubscribeFragment__update_subscription)).check(matches(isDisplayed()))
onView(withText(R.string.SubscribeFragment__update_subscription)).check(matches(isNotEnabled()))
}
@@ -134,75 +135,53 @@ class CheckoutFlowActivityTest__RecurringDonations {
val scenario = ActivityScenario.launch<CheckoutFlowActivity>(intent)
rxRule.defaultTestScheduler.triggerActions()
onView(withId(R.id.recycler)).perform(RecyclerViewScrollToBottomAction)
scrollToDescendant(R.id.recycler, withText(R.string.DonateToSignalFragment__continue), rxRule.defaultTestScheduler)
onView(withText(R.string.DonateToSignalFragment__continue)).perform(ViewActions.click())
rxRule.defaultTestScheduler.triggerActions()
scenario.selectGooglePay(composeRule, rxRule.defaultTestScheduler, InAppPaymentType.RECURRING_DONATION)
awaitDonationErrorDialog(rxRule.defaultTestScheduler, R.string.DonationsErrors__error_processing_payment)
awaitDialog(rxRule.defaultTestScheduler, R.string.DonationsErrors__error_processing_payment)
onView(withText(R.string.DonationsErrors__your_payment)).inRoot(isDialog()).check(matches(isDisplayed()))
}
@Test
fun givenSubscriptionPaymentMethodPermitRejected_whenISubscribe_thenIExpectPaymentSetupErrorDialog() {
AppDependencies.donationPermitsRepository.clearPermits()
mockkObject(DonationPermits)
every { DonationPermits.getDonationPermit() } returns RequestResult.Success("permit")
every { AppDependencies.donationsApi.createStripeSubscriptionPaymentMethod(any(), any(), any()) } returns NetworkResult.StatusCodeError(NonSuccessfulResponseCodeException(402))
succeedDonationPermitAcquisition()
MockEndpoints.responder.register({ it.method == "POST" && it.path.contains("/create_payment_method") }) {
failure(402)
}
val scenario = ActivityScenario.launch<CheckoutFlowActivity>(intent)
rxRule.defaultTestScheduler.triggerActions()
onView(withId(R.id.recycler)).perform(RecyclerViewScrollToBottomAction)
scrollToDescendant(R.id.recycler, withText(R.string.DonateToSignalFragment__continue), rxRule.defaultTestScheduler)
onView(withText(R.string.DonateToSignalFragment__continue)).perform(ViewActions.click())
rxRule.defaultTestScheduler.triggerActions()
scenario.selectGooglePay(composeRule, rxRule.defaultTestScheduler, InAppPaymentType.RECURRING_DONATION)
awaitDonationErrorDialog(rxRule.defaultTestScheduler, R.string.DonationsErrors__error_processing_payment)
awaitDialog(rxRule.defaultTestScheduler, R.string.DonationsErrors__error_processing_payment)
onView(withText(R.string.DonationsErrors__your_payment)).inRoot(isDialog()).check(matches(isDisplayed()))
}
private fun initialiseActiveSubscription() {
val currency = Currency.getInstance("USD")
val subscriber = InAppPaymentSubscriberRecord(
subscriberId = SubscriberId.generate(),
currency = currency,
type = InAppPaymentSubscriberRecord.Type.DONATION,
requiresCancel = false,
paymentMethodType = InAppPaymentData.PaymentMethodType.CARD,
iapSubscriptionId = null
)
InAppPaymentsRepository.setSubscriber(subscriber)
SignalStore.inAppPayments.setRecurringDonationCurrency(currency)
AppDependencies.donationsApi.apply {
every { getSubscription(subscriber.subscriberId) } returns NetworkResult.Success(
ActiveSubscription(
ActiveSubscription.Subscription(
200,
currency.currencyCode,
BigDecimal.ONE,
System.currentTimeMillis().milliseconds.inWholeSeconds + 30.days.inWholeSeconds,
true,
System.currentTimeMillis().milliseconds.inWholeSeconds + 30.days.inWholeSeconds,
false,
"active",
"STRIPE",
"CARD",
false
),
null
)
)
every { deleteSubscription(subscriber.subscriberId) } returns NetworkResult.Success(Unit)
val subscriber = registerSubscriber()
val serialized = subscriber.subscriberId.serialize()
MockEndpoints.responder.register({ it.method == "GET" && it.path.contains(serialized) }) {
ok(DonationResponses.activeSubscription(status = "active", active = true))
}
}
private fun initialisePendingSubscription() {
val subscriber = registerSubscriber()
val serialized = subscriber.subscriberId.serialize()
MockEndpoints.responder.register({ it.method == "GET" && it.path.contains(serialized) }) {
ok(DonationResponses.activeSubscription(status = "incomplete", active = false))
}
}
private fun registerSubscriber(): InAppPaymentSubscriberRecord {
val currency = Currency.getInstance("USD")
val subscriber = InAppPaymentSubscriberRecord(
subscriberId = SubscriberId.generate(),
@@ -216,25 +195,6 @@ class CheckoutFlowActivityTest__RecurringDonations {
InAppPaymentsRepository.setSubscriber(subscriber)
SignalStore.inAppPayments.setRecurringDonationCurrency(currency)
AppDependencies.donationsApi.apply {
every { getSubscription(subscriber.subscriberId) } returns NetworkResult.Success(
ActiveSubscription(
ActiveSubscription.Subscription(
200,
currency.currencyCode,
BigDecimal.ONE,
System.currentTimeMillis().milliseconds.inWholeSeconds + 30.days.inWholeSeconds,
false,
System.currentTimeMillis().milliseconds.inWholeSeconds + 30.days.inWholeSeconds,
false,
"incomplete",
"STRIPE",
"CARD",
false
),
null
)
)
}
return subscriber
}
}
@@ -0,0 +1,99 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.donate
import androidx.compose.ui.test.junit4.v2.createEmptyComposeRule
import androidx.test.core.app.ActivityScenario
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.ViewActions
import androidx.test.espresso.matcher.ViewMatchers.isClickable
import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.espresso.matcher.ViewMatchers.withText
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import assertk.assertThat
import assertk.assertions.isNotEmpty
import io.mockk.unmockkObject
import org.hamcrest.Matchers.allOf
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.signal.core.util.deleteAll
import org.signal.donations.InAppPaymentType
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.app.subscription.permits.DonationPermits
import org.thoughtcrime.securesms.database.DonationReceiptTable
import org.thoughtcrime.securesms.database.InAppPaymentTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.InAppPaymentReceiptRecord
import org.thoughtcrime.securesms.testing.GooglePayTestRule
import org.thoughtcrime.securesms.testing.InAppPaymentsRule
import org.thoughtcrime.securesms.testing.RxTestSchedulerRule
import org.thoughtcrime.securesms.testing.SignalActivityRule
import org.thoughtcrime.securesms.testing.actions.scrollToDescendant
import org.thoughtcrime.securesms.testing.endpoints.MockEndpoints
import org.thoughtcrime.securesms.testing.endpoints.registerStripeHappyPath
import org.thoughtcrime.securesms.testing.flushUntil
/**
* Milestone integration test: drives a Google Pay one-time donation through the entire lifecycle to
* receipt redemption, exercising all three outer boundaries at once — the Signal-service websocket
* responder (boost intent + receipt credential + redeem), the Stripe HTTP interceptor (payment
* method + confirm + intent status), and the test zk server (real client-side credential validation).
*/
@Suppress("ClassName")
@RunWith(AndroidJUnit4::class)
class CheckoutFlowActivityTest__Redemption {
@get:Rule
val harness = SignalActivityRule(othersCount = 10)
@get:Rule
val iapRule = InAppPaymentsRule()
@get:Rule
val rxRule = RxTestSchedulerRule()
@get:Rule
val googlePayRule = GooglePayTestRule()
@get:Rule
val composeRule = createEmptyComposeRule()
private val intent = CheckoutFlowActivity.createIntent(InstrumentationRegistry.getInstrumentation().targetContext, InAppPaymentType.ONE_TIME_DONATION)
@Before
fun setUp() {
SignalDatabase.inAppPayments.writableDatabase.deleteAll(InAppPaymentTable.TABLE_NAME)
SignalDatabase.donationReceipts.writableDatabase.deleteAll(DonationReceiptTable.TABLE_NAME)
startJobLoopForTests()
succeedDonationPermitAcquisition()
MockEndpoints.responder.registerStripeHappyPath()
}
@After
fun tearDown() {
unmockkObject(DonationPermits)
}
@Test
fun givenAllEndpointsSucceed_whenIDonateOnceWithGooglePay_thenIExpectAReceipt() {
val scenario = ActivityScenario.launch<CheckoutFlowActivity>(intent)
rxRule.defaultTestScheduler.triggerActions()
scrollToDescendant(R.id.recycler, withId(R.id.boost_1), rxRule.defaultTestScheduler)
onView(allOf(withId(R.id.boost_1), isClickable())).perform(ViewActions.click())
rxRule.defaultTestScheduler.triggerActions()
scrollToDescendant(R.id.recycler, withText(R.string.DonateToSignalFragment__continue), rxRule.defaultTestScheduler)
onView(withText(R.string.DonateToSignalFragment__continue)).perform(ViewActions.click())
rxRule.defaultTestScheduler.triggerActions()
scenario.selectGooglePay(composeRule, rxRule.defaultTestScheduler, InAppPaymentType.ONE_TIME_DONATION)
rxRule.defaultTestScheduler.flushUntil {
SignalDatabase.donationReceipts.getReceipts(InAppPaymentReceiptRecord.Type.ONE_TIME_DONATION).isNotEmpty()
}
assertThat(SignalDatabase.donationReceipts.getReceipts(InAppPaymentReceiptRecord.Type.ONE_TIME_DONATION)).isNotEmpty()
}
}
@@ -0,0 +1,280 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.donate
import android.os.Bundle
import androidx.compose.ui.test.hasTestTag
import androidx.compose.ui.test.junit4.ComposeTestRule
import androidx.compose.ui.test.onAllNodesWithTag
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollToNode
import androidx.compose.ui.test.performTextInput
import androidx.core.os.bundleOf
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity
import androidx.fragment.app.FragmentManager
import androidx.navigation.fragment.NavHostFragment
import androidx.test.core.app.ActivityScenario
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.ViewActions
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.matcher.ViewMatchers.isClickable
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import androidx.test.espresso.matcher.ViewMatchers.isEnabled
import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.espresso.matcher.ViewMatchers.withText
import io.reactivex.rxjava3.schedulers.TestScheduler
import org.hamcrest.Matchers.allOf
import org.signal.donations.InAppPaymentType
import org.signal.donations.StripeIntentAccessor
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewaySelectorTestTags
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.paypal.PayPalConfirmationDialogFragment
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.paypal.PayPalConfirmationResult
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.stripe.Stripe3DSDialogFragment
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.transfer.DonationTransferTestTags
import org.thoughtcrime.securesms.testing.actions.scrollToDescendant
import org.thoughtcrime.securesms.testing.endpoints.StripeResponses
import org.thoughtcrime.securesms.testing.flushUntil
/**
* Drives [CheckoutFlowActivity] through the UI "as a user" for a [DonationsCheckoutTestSpec]: selects
* an amount/tier, continues to the gateway selector, and completes the chosen payment method. Each
* payment method is a strategy dispatched on [DonationsCheckoutTestSpec.paymentMethod].
*
* Webview approval steps (PayPal, Stripe 3DS) can't complete against a mocked network, so instead of
* loading the approval URL we inject the fragment result the webview would have posted — the same
* mechanism the production dialog uses on return.
*/
class CheckoutFlowDriver(
private val composeRule: ComposeTestRule,
private val scheduler: TestScheduler
) {
companion object {
private const val TEST_NAME = "Test McTesterson"
private const val TEST_EMAIL = "test@signal.org"
/** A structurally valid German IBAN (passes mod-97), for the SEPA form. */
private const val TEST_IBAN = "DE89370400440532013000"
}
fun drive(scenario: ActivityScenario<CheckoutFlowActivity>, spec: DonationsCheckoutTestSpec) {
scheduler.triggerActions()
selectAmountOrTier(spec.inAppPaymentType)
proceedToGateway()
when (spec.paymentMethod) {
TestPaymentMethod.GOOGLE_PAY -> scenario.selectGooglePay(composeRule, scheduler, spec.inAppPaymentType)
TestPaymentMethod.CREDIT_CARD -> completeCreditCard()
TestPaymentMethod.PAYPAL -> completePayPal(scenario, spec.inAppPaymentType)
TestPaymentMethod.SEPA_DEBIT -> completeSepa()
TestPaymentMethod.IDEAL -> completeIdeal(spec.inAppPaymentType)
}
if (spec.world.stripe == StripeOutcome.REQUIRES_3DS && spec.paymentMethod.usesStripe()) {
complete3ds(scenario, spec.inAppPaymentType)
}
}
private fun selectAmountOrTier(type: InAppPaymentType) {
if (type == InAppPaymentType.ONE_TIME_DONATION) {
scrollToDescendant(R.id.recycler, withId(R.id.boost_1), scheduler)
onView(allOf(withId(R.id.boost_1), isClickable())).perform(ViewActions.click())
scheduler.triggerActions()
}
// Recurring: the monthly tier is selected by default, so nothing to pick here.
}
private fun proceedToGateway() {
scrollToDescendant(R.id.recycler, withText(R.string.DonateToSignalFragment__continue), scheduler)
onView(withText(R.string.DonateToSignalFragment__continue)).perform(ViewActions.click())
scheduler.triggerActions()
}
/**
* Fills the [org.thoughtcrime.securesms.components.settings.app.subscription.donate.card.CreditCardFragment]
* with a valid test card and continues, driving the flow into the Stripe payment pipeline.
*/
private fun completeCreditCard() {
clickGatewayButton(GatewaySelectorTestTags.CREDIT_CARD_BUTTON)
scheduler.flushUntil {
onView(withId(R.id.card_number)).check(matches(isDisplayed()))
true
}
onView(withId(R.id.card_number)).perform(ViewActions.typeText("4242424242424242"), ViewActions.closeSoftKeyboard())
onView(withId(R.id.card_expiry)).perform(ViewActions.typeText("1228"), ViewActions.closeSoftKeyboard())
onView(withId(R.id.card_cvv)).perform(ViewActions.typeText("123"), ViewActions.closeSoftKeyboard())
scheduler.flushUntil {
onView(withId(R.id.continue_button)).check(matches(isEnabled()))
true
}
onView(withId(R.id.continue_button)).perform(ViewActions.click())
scheduler.triggerActions()
}
/**
* Selects PayPal, then injects the approval result the webview would have returned. One-time uses a
* [PayPalConfirmationResult]; recurring posts `true`.
*/
private fun completePayPal(scenario: ActivityScenario<CheckoutFlowActivity>, type: InAppPaymentType) {
clickGatewayButton(GatewaySelectorTestTags.PAYPAL_BUTTON)
val result: Any = if (type == InAppPaymentType.ONE_TIME_DONATION) {
PayPalConfirmationResult(payerId = "test-payer", paymentId = null, paymentToken = "test-token")
} else {
true
}
awaitDialogAndSetResult(
scenario = scenario,
dialogClass = PayPalConfirmationDialogFragment::class.java,
resultKey = PayPalConfirmationDialogFragment.REQUEST_KEY,
bundle = bundleOf(PayPalConfirmationDialogFragment.REQUEST_KEY to result)
)
}
/** Injects the 3DS return the Stripe webview would have posted, resuming the Stripe pipeline. */
private fun complete3ds(scenario: ActivityScenario<CheckoutFlowActivity>, type: InAppPaymentType) {
val accessor = if (type == InAppPaymentType.ONE_TIME_DONATION) {
StripeIntentAccessor(StripeIntentAccessor.ObjectType.PAYMENT_INTENT, StripeResponses.DEFAULT_PAYMENT_INTENT_ID, "${StripeResponses.DEFAULT_PAYMENT_INTENT_ID}_secret_test")
} else {
StripeIntentAccessor(StripeIntentAccessor.ObjectType.SETUP_INTENT, StripeResponses.DEFAULT_SETUP_INTENT_ID, "${StripeResponses.DEFAULT_SETUP_INTENT_ID}_secret_test")
}
awaitDialogAndSetResult(
scenario = scenario,
dialogClass = Stripe3DSDialogFragment::class.java,
resultKey = Stripe3DSDialogFragment.REQUEST_KEY,
bundle = bundleOf(Stripe3DSDialogFragment.REQUEST_KEY to accessor)
)
}
/**
* Selects SEPA, accepts the mandate (its button scrolls the text to the bottom before it agrees, so
* it is clicked until the details form appears), fills the bank details, and donates. SEPA requires
* a EUR donation currency (see [WorldState.currencyCode]).
*/
private fun completeSepa() {
clickGatewayButton(GatewaySelectorTestTags.SEPA_BUTTON)
scheduler.flushUntil {
val ibanPresent = runCatching {
composeRule.onAllNodesWithTag(DonationTransferTestTags.SEPA_IBAN_FIELD).fetchSemanticsNodes().isNotEmpty()
}.getOrDefault(false)
if (!ibanPresent) {
runCatching { composeRule.onNodeWithTag(DonationTransferTestTags.SEPA_MANDATE_CONTINUE_BUTTON).performClick() }
}
ibanPresent
}
typeIntoField(DonationTransferTestTags.SEPA_DETAILS_LIST, DonationTransferTestTags.SEPA_IBAN_FIELD, TEST_IBAN)
typeIntoField(DonationTransferTestTags.SEPA_DETAILS_LIST, DonationTransferTestTags.SEPA_NAME_FIELD, TEST_NAME)
typeIntoField(DonationTransferTestTags.SEPA_DETAILS_LIST, DonationTransferTestTags.SEPA_EMAIL_FIELD, TEST_EMAIL)
clickWhenReady(DonationTransferTestTags.SEPA_DONATE_BUTTON)
}
/**
* Selects iDEAL, fills the bank details (email is recurring-only), and donates. iDEAL requires a EUR
* donation currency and, on success, lands in an awaiting-authorization state.
*/
private fun completeIdeal(type: InAppPaymentType) {
clickGatewayButton(GatewaySelectorTestTags.IDEAL_BUTTON)
scheduler.flushUntil {
runCatching {
composeRule.onAllNodesWithTag(DonationTransferTestTags.IDEAL_NAME_FIELD).fetchSemanticsNodes().isNotEmpty()
}.getOrDefault(false)
}
typeIntoField(DonationTransferTestTags.IDEAL_DETAILS_LIST, DonationTransferTestTags.IDEAL_NAME_FIELD, TEST_NAME)
if (type.recurring) {
typeIntoField(DonationTransferTestTags.IDEAL_DETAILS_LIST, DonationTransferTestTags.IDEAL_EMAIL_FIELD, TEST_EMAIL)
}
clickWhenReady(DonationTransferTestTags.IDEAL_DONATE_BUTTON)
}
/**
* Scrolls the [listTag] lazy list until the field tagged [fieldTag] is composed and on-screen, then types
* [text] into it. The transfer forms lay their fields out in a [androidx.compose.foundation.lazy.LazyColumn],
* so a lower field is not composed on a short screen until scrolled to — without this, the input silently
* matches nothing on Firebase's smaller devices.
*/
private fun typeIntoField(listTag: String, fieldTag: String, text: String) {
composeRule.onNodeWithTag(listTag).performScrollToNode(hasTestTag(fieldTag))
composeRule.onNodeWithTag(fieldTag).performTextInput(text)
}
/** Retries clicking a Compose node until the click lands (e.g. once a submit button becomes enabled). */
private fun clickWhenReady(tag: String) {
scheduler.flushUntil {
runCatching {
composeRule.onNodeWithTag(tag).performClick()
true
}.getOrDefault(false)
}
scheduler.triggerActions()
}
/**
* Clicks a Compose gateway-selector button and waits for the bottom sheet to dismiss. For methods
* other than Google Pay the checkout then navigates to the method's fragment; Google Pay instead
* requires a result injection (see `selectGooglePay`).
*/
private fun clickGatewayButton(tag: String) {
scheduler.flushUntil {
composeRule.onAllNodesWithTag(tag).fetchSemanticsNodes().isNotEmpty()
}
// The selector is a vertically scrolling bottom sheet; on a short screen the lower gateway buttons are
// off-screen, so bring the target on-screen before clicking (mirrors GatewaySelectorBottomSheetContentTest).
composeRule.onNodeWithTag(GatewaySelectorTestTags.CONTAINER).performScrollToNode(hasTestTag(tag))
composeRule.onNodeWithTag(tag).performClick()
scheduler.flushUntil {
runCatching {
composeRule.onAllNodesWithTag(tag).fetchSemanticsNodes().isEmpty()
}.getOrDefault(true)
}
}
/**
* Waits for a navigated dialog of [dialogClass] to be present, then posts [bundle] as its fragment
* result on the nav host's fragment manager (where the in-progress fragment registered its listener).
*/
private fun <T : Fragment> awaitDialogAndSetResult(
scenario: ActivityScenario<CheckoutFlowActivity>,
dialogClass: Class<T>,
resultKey: String,
bundle: Bundle
) {
scheduler.flushUntil {
var delivered = false
scenario.onActivity { activity ->
val fragmentManager = navHostChildFragmentManager(activity)
if (fragmentManager != null && fragmentManager.fragments.any { dialogClass.isInstance(it) }) {
fragmentManager.setFragmentResult(resultKey, bundle)
delivered = true
}
}
delivered
}
scheduler.triggerActions()
}
private fun navHostChildFragmentManager(activity: FragmentActivity): FragmentManager? {
return activity.supportFragmentManager.fragments
.filterIsInstance<NavHostFragment>()
.firstOrNull()
?.childFragmentManager
}
private fun TestPaymentMethod.usesStripe(): Boolean {
return this != TestPaymentMethod.PAYPAL
}
}
@@ -7,29 +7,21 @@ package org.thoughtcrime.securesms.components.settings.app.subscription.donate
import android.app.Activity
import android.content.Intent
import android.os.SystemClock
import androidx.compose.ui.test.hasTestTag
import androidx.compose.ui.test.junit4.ComposeTestRule
import androidx.compose.ui.test.onAllNodesWithTag
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollToNode
import androidx.test.core.app.ActivityScenario
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.matcher.RootMatchers.isDialog
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import androidx.test.espresso.matcher.ViewMatchers.withText
import androidx.test.platform.app.InstrumentationRegistry
import com.google.android.gms.wallet.PaymentData
import io.mockk.Runs
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.mockkConstructor
import io.mockk.unmockkConstructor
import io.reactivex.rxjava3.core.Completable
import io.mockk.mockkObject
import io.reactivex.rxjava3.schedulers.TestScheduler
import org.junit.rules.ExternalResource
import org.signal.donations.GooglePayApi
import org.signal.donations.InAppPaymentType
import org.signal.libsignal.net.RequestResult
import org.signal.network.rest.RestStatusCodeError
@@ -37,82 +29,86 @@ import org.thoughtcrime.securesms.SignalInstrumentationApplicationContext
import org.thoughtcrime.securesms.components.settings.app.subscription.GooglePayComponent
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewaySelectorTestTags
import org.thoughtcrime.securesms.components.settings.app.subscription.permits.DonationPermits
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.testing.flushUntil
/**
* Makes Google Pay appear available and return a fake [com.google.android.gms.wallet.PaymentData] without
* launching the real Google Pay sheet, allowing checkout to be driven to the payment pipeline in instrumentation.
*/
class GooglePayTestRule : ExternalResource() {
override fun before() {
val paymentData = mockk<PaymentData> {
every { toJson() } returns GOOGLE_PAY_PAYMENT_DATA_JSON
}
mockkConstructor(GooglePayApi::class)
every { anyConstructed<GooglePayApi>().queryIsReadyToPay() } returns Completable.complete()
every { anyConstructed<GooglePayApi>().requestPayment(any(), any(), any()) } just Runs
every { anyConstructed<GooglePayApi>().onActivityResult(any(), any(), any(), any(), any()) } answers {
arg<GooglePayApi.PaymentRequestCallback>(4).onSuccess(paymentData)
}
}
override fun after() {
unmockkConstructor(GooglePayApi::class)
}
}
/**
* Minimal but well-formed Google Pay payload. [org.signal.donations.GooglePayPaymentSource] parses
* `paymentMethodData.tokenizationData.token` (itself a JSON object with an `id`) when the source is
* serialized into the setup job, so a relaxed mock that returns an empty body fails before the job runs.
*/
private const val GOOGLE_PAY_PAYMENT_DATA_JSON = """{"paymentMethodData":{"tokenizationData":{"token":"{\"id\":\"tok_test\"}"}},"email":"test@signal.org"}"""
/**
* Forces real donation-permit acquisition to fail at the issuer, exercising the permit code path through
* [org.thoughtcrime.securesms.components.settings.app.subscription.permits.DonationPermits].
* Forces donation-permit acquisition to fail, so the checkout surfaces a payment-setup error before
* reaching the payment method. Stubs the [DonationPermits] object directly (the underlying
* `createDonationPermits` call rides the websocket, which the responder does not mint valid permits
* for). Call `unmockkObject(DonationPermits)` in teardown.
*/
fun failDonationPermitAcquisition(statusCode: Int = 500) {
AppDependencies.donationPermitsRepository.clearPermits()
every { AppDependencies.donationsApi.createDonationPermits(any()) } returns RequestResult.NonSuccess(RestStatusCodeError(statusCode, emptyMap(), null))
mockkObject(DonationPermits)
every { DonationPermits.getDonationPermit() } returns RequestResult.NonSuccess(RestStatusCodeError(statusCode, emptyMap(), null))
}
/**
* Makes donation-permit acquisition succeed with a placeholder permit, letting the checkout proceed
* past the permit gate to the payment method. Call `unmockkObject(DonationPermits)` in teardown.
*/
fun succeedDonationPermitAcquisition() {
AppDependencies.donationPermitsRepository.clearPermits()
mockkObject(DonationPermits)
every { DonationPermits.getDonationPermit() } returns RequestResult.Success("permit")
}
/**
* The instrumentation app stubs [org.thoughtcrime.securesms.ApplicationContext.beginJobLoop] to a no-op, so enqueued
* jobs never run. The checkout pipeline drives a real setup job through the JobManager, so the loop must be started.
*
* Before starting it, cancel any leftover in-app-payment jobs. A recurring setup job retries indefinitely over a
* one-day lifespan, so a spec that fails setup leaves one persisted in the JobManager. On Firebase Test Lab the
* orchestrator's `clearPackageData` does not reliably wipe the JobManager between parameterized specs (it does on a
* local emulator, which is why this only surfaces on-device), so that job survives into the next spec and runs after
* its InAppPayment row was deleted in setup — `InAppPaymentsRepository.resolveLock` then throws "Not found" from the
* job thread and crashes the whole app. Clearing the queues here makes each test start from a clean job state.
*/
fun startJobLoopForTests() {
AppDependencies.jobManager.cancelAllInQueues(
setOf(
InAppPaymentsRepository.getRecurringJobQueueKey(InAppPaymentType.RECURRING_DONATION),
InAppPaymentsRepository.getRecurringJobQueueKey(InAppPaymentType.RECURRING_BACKUP)
)
)
AppDependencies.jobManager.flush()
(AppDependencies.application as SignalInstrumentationApplicationContext).beginJobLoopForTests()
}
/**
* Selects Google Pay in the Compose gateway selector and feeds the stubbed Google Pay result back into the
* checkout, navigating to the payment-in-progress screen where the pipeline runs.
*
* The gateway selector is populated by Rx work on [scheduler], so we [flushUntil] the button is present
* rather than sleeping. Selecting Google Pay dismisses the gateway sheet and hands a fragment result back to
* the checkout, which runs `launchGooglePay` -> `provideGatewayRequestForGooglePay` on [scheduler]; only then
* does the checkout's subscriber consume a [GooglePayComponent.googlePayResultPublisher] emission
* (`consumeGatewayRequestForGooglePay` returns null until then, and the publisher is a hot PublishSubject that
* drops earlier emissions). So we [flushUntil] the sheet has dismissed — a real signal that the click was
* fully processed — before dispatching the result, rather than pumping a fixed number of times and racing.
*/
fun ActivityScenario<CheckoutFlowActivity>.selectGooglePay(
composeRule: ComposeTestRule,
scheduler: TestScheduler,
inAppPaymentType: InAppPaymentType
) {
val deadline = SystemClock.uptimeMillis() + 10_000
var present = false
while (SystemClock.uptimeMillis() < deadline && !present) {
scheduler.triggerActions()
InstrumentationRegistry.getInstrumentation().waitForIdleSync()
present = try {
composeRule.onAllNodesWithTag(GatewaySelectorTestTags.GOOGLE_PAY_BUTTON).fetchSemanticsNodes().isNotEmpty()
} catch (e: IllegalStateException) {
false
}
if (!present) {
Thread.sleep(100)
}
scheduler.flushUntil {
composeRule.onAllNodesWithTag(GatewaySelectorTestTags.GOOGLE_PAY_BUTTON).fetchSemanticsNodes().isNotEmpty()
}
check(present) { "Google Pay button never appeared in the gateway selector." }
composeRule.onNodeWithTag(GatewaySelectorTestTags.CONTAINER).performScrollToNode(hasTestTag(GatewaySelectorTestTags.GOOGLE_PAY_BUTTON))
composeRule.onNodeWithTag(GatewaySelectorTestTags.GOOGLE_PAY_BUTTON).performClick()
pump(scheduler, iterations = 20)
scheduler.flushUntil {
// Once the gateway sheet dismisses there is no Compose hierarchy left, so fetchSemanticsNodes throws
// rather than returning empty; treat both the empty list and the missing hierarchy as "sheet gone".
runCatching {
composeRule.onAllNodesWithTag(GatewaySelectorTestTags.GOOGLE_PAY_BUTTON).fetchSemanticsNodes().isEmpty()
}.getOrDefault(true)
}
onActivity { activity ->
(activity as GooglePayComponent).googlePayResultPublisher.onNext(
@@ -126,34 +122,21 @@ fun ActivityScenario<CheckoutFlowActivity>.selectGooglePay(
}
/**
* Advances the Rx [scheduler] and pumps the main looper until the checkout error dialog with [titleResId] is
* displayed, bridging the real JobManager-backed setup job, the Rx observers, and dialog rendering.
* Waits for a dialog whose title is [titleResId] to be displayed. Matches any dialog by title (error,
* confirmation, thanks, etc.) — it is not specific to error dialogs.
*
* [flushUntil] advances the Rx pipeline (which may create the payment, enqueue the real setup job, and react
* to its committed state) while yielding real time for any job to run, until the dialog renders. A single
* condition-driven pump rather than a fixed-duration wall-clock poll. Letting the Espresso check throw (rather
* than collapsing it to a boolean) lets [flushUntil] chain the last matcher failure as the timeout cause.
*/
fun awaitDonationErrorDialog(scheduler: TestScheduler, titleResId: Int, timeoutMillis: Long = 15_000) {
val deadline = SystemClock.uptimeMillis() + timeoutMillis
var lastFailure: Throwable? = null
while (SystemClock.uptimeMillis() < deadline) {
scheduler.triggerActions()
InstrumentationRegistry.getInstrumentation().waitForIdleSync()
try {
onView(withText(titleResId)).inRoot(isDialog()).check(matches(isDisplayed()))
return
} catch (t: Throwable) {
lastFailure = t
}
Thread.sleep(100)
}
throw AssertionError("Donation error dialog ($titleResId) was not displayed within ${timeoutMillis}ms.", lastFailure)
}
private fun pump(scheduler: TestScheduler, iterations: Int, intervalMs: Long = 100) {
repeat(iterations) {
scheduler.triggerActions()
InstrumentationRegistry.getInstrumentation().waitForIdleSync()
Thread.sleep(intervalMs)
fun awaitDialog(
scheduler: TestScheduler,
titleResId: Int,
timeoutMillis: Long = 15_000
) {
scheduler.flushUntil(timeoutMillis) {
onView(withText(titleResId)).inRoot(isDialog()).check(matches(isDisplayed()))
true
}
}
@@ -0,0 +1,282 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.donate
import androidx.compose.ui.test.junit4.v2.createEmptyComposeRule
import androidx.test.core.app.ActivityScenario
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.matcher.RootMatchers.isDialog
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import androidx.test.espresso.matcher.ViewMatchers.withText
import androidx.test.platform.app.InstrumentationRegistry
import assertk.assertThat
import assertk.assertions.isNotEmpty
import assertk.assertions.isNotNull
import assertk.assertions.isNull
import io.mockk.unmockkObject
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.Parameterized
import org.signal.core.util.deleteAll
import org.signal.donations.InAppPaymentType
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.app.subscription.permits.DonationPermits
import org.thoughtcrime.securesms.database.DonationReceiptTable
import org.thoughtcrime.securesms.database.InAppPaymentTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.InAppPaymentReceiptRecord
import org.thoughtcrime.securesms.testing.GooglePayTestRule
import org.thoughtcrime.securesms.testing.InAppPaymentsRule
import org.thoughtcrime.securesms.testing.RawFlag
import org.thoughtcrime.securesms.testing.RemoteConfigForTest
import org.thoughtcrime.securesms.testing.RxTestSchedulerRule
import org.thoughtcrime.securesms.testing.SignalActivityRule
import org.thoughtcrime.securesms.testing.flushUntil
/**
* Spec-driven integration test for donation checkout. Each [DonationsCheckoutTestSpec] declares a
* "world" (what each endpoint returns) and an expected outcome; this runner applies the world, drives
* [CheckoutFlowActivity] through the real UI via [CheckoutFlowDriver], and asserts the outcome.
*
* Adding coverage is data: append a spec to [DonationsCheckoutSpecs.ALL].
*/
@RunWith(Parameterized::class)
@RemoteConfigForTest(
rawFlags = [
RawFlag("android.sepa.debit.donations.5", "true"),
RawFlag("android.ideal.donations.5", "true"),
// Region codes are matched against the digit-only self E164 ("15555550101"), so no leading "+".
RawFlag("global.donations.sepaEnabledRegions", "1"),
RawFlag("global.donations.idealEnabledRegions", "1")
]
)
class DonationsCheckoutSpecTest(private val spec: DonationsCheckoutTestSpec) {
@get:Rule
val harness = SignalActivityRule(othersCount = 10)
@get:Rule
val iapRule = InAppPaymentsRule()
@get:Rule
val rxRule = RxTestSchedulerRule()
@get:Rule
val googlePayRule = GooglePayTestRule()
@get:Rule
val composeRule = createEmptyComposeRule()
@Before
fun setUp() {
SignalDatabase.inAppPayments.writableDatabase.deleteAll(InAppPaymentTable.TABLE_NAME)
SignalDatabase.donationReceipts.writableDatabase.deleteAll(DonationReceiptTable.TABLE_NAME)
startJobLoopForTests()
spec.world.apply()
}
@After
fun tearDown() {
unmockkObject(DonationPermits)
}
@Test
fun runSpec() {
val intent = CheckoutFlowActivity.createIntent(InstrumentationRegistry.getInstrumentation().targetContext, spec.inAppPaymentType)
val scenario = ActivityScenario.launch<CheckoutFlowActivity>(intent)
CheckoutFlowDriver(composeRule, rxRule.defaultTestScheduler).drive(scenario, spec)
when (val expected = spec.expected) {
is ExpectedOutcome.Redeemed -> {
rxRule.defaultTestScheduler.flushUntil { receipts().isNotEmpty() }
assertThat(receipts()).isNotEmpty()
}
is ExpectedOutcome.ErrorDialog -> {
awaitDialog(rxRule.defaultTestScheduler, expected.titleRes)
if (expected.messageRes != null) {
onView(withText(expected.messageRes)).inRoot(isDialog()).check(matches(isDisplayed()))
}
}
is ExpectedOutcome.PaymentSubmitted -> {
rxRule.defaultTestScheduler.flushUntil { submittedPayment() != null }
val payment = submittedPayment()
assertThat(payment).isNotNull()
assertThat(payment!!.data.error).isNull()
}
}
}
/** The latest payment once it has moved past CREATED with no error, or null if not yet there. */
private fun submittedPayment(): InAppPaymentTable.InAppPayment? {
return SignalDatabase.inAppPayments.getLatestInAppPaymentByType(spec.inAppPaymentType)
?.takeIf { it.state != InAppPaymentTable.State.CREATED && it.data.error == null }
}
private fun receipts(): List<InAppPaymentReceiptRecord> {
val type = if (spec.inAppPaymentType == InAppPaymentType.ONE_TIME_DONATION) {
InAppPaymentReceiptRecord.Type.ONE_TIME_DONATION
} else {
InAppPaymentReceiptRecord.Type.RECURRING_DONATION
}
return SignalDatabase.donationReceipts.getReceipts(type)
}
companion object {
@JvmStatic
@Parameterized.Parameters(name = "{0}")
fun specs(): Collection<DonationsCheckoutTestSpec> = DonationsCheckoutSpecs.ALL
}
}
/**
* The enumerated donation checkout scenarios. Currently seeded with Google Pay coverage; the other
* payment-method drivers (card, SEPA, iDEAL, PayPal) and their scenarios are added alongside their
* [CheckoutFlowDriver] strategies.
*/
object DonationsCheckoutSpecs {
private val paymentErrorDialog = ExpectedOutcome.ErrorDialog(
titleRes = R.string.DonationsErrors__error_processing_payment,
messageRes = R.string.DonationsErrors__your_payment
)
val ALL: List<DonationsCheckoutTestSpec> = listOf(
DonationsCheckoutTestSpec(
name = "googlePay_oneTime_happyPath_isRedeemed",
inAppPaymentType = InAppPaymentType.ONE_TIME_DONATION,
paymentMethod = TestPaymentMethod.GOOGLE_PAY,
world = WorldState(),
expected = ExpectedOutcome.Redeemed
),
DonationsCheckoutTestSpec(
name = "googlePay_oneTime_permitFailure_showsError",
inAppPaymentType = InAppPaymentType.ONE_TIME_DONATION,
paymentMethod = TestPaymentMethod.GOOGLE_PAY,
world = WorldState(permit = PermitOutcome.FAILURE),
expected = paymentErrorDialog
),
DonationsCheckoutTestSpec(
name = "googlePay_oneTime_stripeDecline_showsError",
inAppPaymentType = InAppPaymentType.ONE_TIME_DONATION,
paymentMethod = TestPaymentMethod.GOOGLE_PAY,
world = WorldState(stripe = StripeOutcome.DECLINE),
expected = paymentErrorDialog
),
DonationsCheckoutTestSpec(
name = "googlePay_oneTime_signalCreateIntent402_showsError",
inAppPaymentType = InAppPaymentType.ONE_TIME_DONATION,
paymentMethod = TestPaymentMethod.GOOGLE_PAY,
world = WorldState(signalCreatePaymentStatus = 402),
expected = paymentErrorDialog
),
DonationsCheckoutTestSpec(
name = "googlePay_recurring_permitFailure_showsError",
inAppPaymentType = InAppPaymentType.RECURRING_DONATION,
paymentMethod = TestPaymentMethod.GOOGLE_PAY,
world = WorldState(permit = PermitOutcome.FAILURE),
expected = paymentErrorDialog
),
DonationsCheckoutTestSpec(
name = "creditCard_oneTime_happyPath_isRedeemed",
inAppPaymentType = InAppPaymentType.ONE_TIME_DONATION,
paymentMethod = TestPaymentMethod.CREDIT_CARD,
world = WorldState(),
expected = ExpectedOutcome.Redeemed
),
DonationsCheckoutTestSpec(
name = "creditCard_oneTime_stripeDecline_showsError",
inAppPaymentType = InAppPaymentType.ONE_TIME_DONATION,
paymentMethod = TestPaymentMethod.CREDIT_CARD,
world = WorldState(stripe = StripeOutcome.DECLINE),
expected = paymentErrorDialog
),
DonationsCheckoutTestSpec(
name = "creditCard_oneTime_3dsApproved_isRedeemed",
inAppPaymentType = InAppPaymentType.ONE_TIME_DONATION,
paymentMethod = TestPaymentMethod.CREDIT_CARD,
world = WorldState(stripe = StripeOutcome.REQUIRES_3DS),
expected = ExpectedOutcome.Redeemed
),
DonationsCheckoutTestSpec(
name = "payPal_oneTime_happyPath_isRedeemed",
inAppPaymentType = InAppPaymentType.ONE_TIME_DONATION,
paymentMethod = TestPaymentMethod.PAYPAL,
world = WorldState(),
expected = ExpectedOutcome.Redeemed
),
DonationsCheckoutTestSpec(
name = "sepa_oneTime_happyPath_isSubmitted",
inAppPaymentType = InAppPaymentType.ONE_TIME_DONATION,
paymentMethod = TestPaymentMethod.SEPA_DEBIT,
world = WorldState(currencyCode = "EUR"),
expected = ExpectedOutcome.PaymentSubmitted
),
DonationsCheckoutTestSpec(
name = "ideal_oneTime_happyPath_isSubmitted",
inAppPaymentType = InAppPaymentType.ONE_TIME_DONATION,
paymentMethod = TestPaymentMethod.IDEAL,
world = WorldState(currencyCode = "EUR"),
expected = ExpectedOutcome.PaymentSubmitted
),
// Recurring happy paths. Asserted as "submitted": the recurring-specific work is the setup
// pipeline (create subscriber, setup intent, set default, set level); receipt redemption is
// shared with the one-time path already covered above.
DonationsCheckoutTestSpec(
name = "googlePay_recurring_happyPath_isSubmitted",
inAppPaymentType = InAppPaymentType.RECURRING_DONATION,
paymentMethod = TestPaymentMethod.GOOGLE_PAY,
world = WorldState(recurringActivatesAfterSetup = true),
expected = ExpectedOutcome.PaymentSubmitted
),
DonationsCheckoutTestSpec(
name = "creditCard_recurring_happyPath_isSubmitted",
inAppPaymentType = InAppPaymentType.RECURRING_DONATION,
paymentMethod = TestPaymentMethod.CREDIT_CARD,
world = WorldState(recurringActivatesAfterSetup = true),
expected = ExpectedOutcome.PaymentSubmitted
),
DonationsCheckoutTestSpec(
name = "payPal_recurring_happyPath_isSubmitted",
inAppPaymentType = InAppPaymentType.RECURRING_DONATION,
paymentMethod = TestPaymentMethod.PAYPAL,
world = WorldState(recurringActivatesAfterSetup = true),
expected = ExpectedOutcome.PaymentSubmitted
),
DonationsCheckoutTestSpec(
name = "sepa_recurring_happyPath_isSubmitted",
inAppPaymentType = InAppPaymentType.RECURRING_DONATION,
paymentMethod = TestPaymentMethod.SEPA_DEBIT,
world = WorldState(currencyCode = "EUR"),
expected = ExpectedOutcome.PaymentSubmitted
),
// Additional error cases.
DonationsCheckoutTestSpec(
name = "creditCard_oneTime_permitFailure_showsError",
inAppPaymentType = InAppPaymentType.ONE_TIME_DONATION,
paymentMethod = TestPaymentMethod.CREDIT_CARD,
world = WorldState(permit = PermitOutcome.FAILURE),
expected = paymentErrorDialog
),
DonationsCheckoutTestSpec(
name = "creditCard_recurring_stripeDecline_showsError",
inAppPaymentType = InAppPaymentType.RECURRING_DONATION,
paymentMethod = TestPaymentMethod.CREDIT_CARD,
world = WorldState(stripe = StripeOutcome.DECLINE),
expected = paymentErrorDialog
),
DonationsCheckoutTestSpec(
name = "googlePay_recurring_signalCreatePaymentMethod402_showsError",
inAppPaymentType = InAppPaymentType.RECURRING_DONATION,
paymentMethod = TestPaymentMethod.GOOGLE_PAY,
world = WorldState(signalCreatePaymentStatus = 402),
expected = paymentErrorDialog
)
)
}
@@ -0,0 +1,159 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.donate
import androidx.annotation.StringRes
import org.signal.donations.InAppPaymentType
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.testing.endpoints.DonationResponses
import org.thoughtcrime.securesms.testing.endpoints.EndpointResponse
import org.thoughtcrime.securesms.testing.endpoints.MockEndpoints
import org.thoughtcrime.securesms.testing.endpoints.StripeResponses
import org.thoughtcrime.securesms.testing.endpoints.failure
import org.thoughtcrime.securesms.testing.endpoints.ok
import org.thoughtcrime.securesms.testing.endpoints.registerPayPalHappyPath
import org.thoughtcrime.securesms.testing.endpoints.registerStripeHappyPath
import java.util.Currency
import java.util.concurrent.atomic.AtomicBoolean
/**
* A declarative "state of the world" + expected outcome for one donation checkout scenario. Specs
* are enumerated by [DonationsCheckoutSpecTest] and each one drives [CheckoutFlowActivity] end to end
* for its [paymentMethod].
*/
data class DonationsCheckoutTestSpec(
val name: String,
val inAppPaymentType: InAppPaymentType,
val paymentMethod: TestPaymentMethod,
val world: WorldState = WorldState(),
val expected: ExpectedOutcome
) {
/** Used as the parameterized test label. */
override fun toString(): String = name
}
enum class TestPaymentMethod {
GOOGLE_PAY,
CREDIT_CARD,
SEPA_DEBIT,
IDEAL,
PAYPAL
}
/** Whether donation-permit acquisition succeeds or fails. */
enum class PermitOutcome { SUCCESS, FAILURE }
/** The Stripe HTTP edge's behavior for a scenario. */
enum class StripeOutcome {
SUCCESS,
DECLINE,
FAILURE,
REQUIRES_3DS
}
/** Pre-existing recurring subscription state (recurring scenarios only). */
enum class SubscriptionState { NONE, ACTIVE, PENDING }
/**
* The knobs that define what each endpoint returns. [apply] registers the corresponding handlers on
* the shared responder (overriding the [org.thoughtcrime.securesms.testing.InAppPaymentsRule]
* defaults, since later registrations win) and sets the permit outcome.
*/
data class WorldState(
val permit: PermitOutcome = PermitOutcome.SUCCESS,
val stripe: StripeOutcome = StripeOutcome.SUCCESS,
val declineCode: String = "card_declined",
val signalCreatePaymentStatus: Int = 200,
val receiptCredentialStatus: Int = 200,
val redeemStatus: Int = 200,
val existingSubscription: SubscriptionState = SubscriptionState.NONE,
/** Donation currency to use. SEPA/iDEAL require EUR. */
val currencyCode: String = "USD",
/**
* For a new recurring donation: report no active subscription until the subscription level is set
* during setup, then report an active one. This lets the checkout show "Continue" (new subscriber)
* while still letting the recurring receipt-credential context job see an active subscription to redeem.
*/
val recurringActivatesAfterSetup: Boolean = false
) {
fun apply() {
val currency = Currency.getInstance(currencyCode)
SignalStore.inAppPayments.setOneTimeCurrency(currency)
SignalStore.inAppPayments.setRecurringDonationCurrency(currency)
when (permit) {
PermitOutcome.SUCCESS -> succeedDonationPermitAcquisition()
PermitOutcome.FAILURE -> failDonationPermitAcquisition()
}
val responder = MockEndpoints.responder
// Signal-service PayPal endpoints (harmless for non-PayPal specs; distinct paths).
responder.registerPayPalHappyPath()
// Stripe HTTP edge.
responder.registerStripeHappyPath()
when (stripe) {
StripeOutcome.SUCCESS -> Unit
StripeOutcome.DECLINE -> responder.register({ it.method == "POST" && (it.path.endsWith("/confirm") || it.path.contains("/payment_methods")) }) {
failure(402, StripeResponses.declineError(declineCode))
}
StripeOutcome.FAILURE -> responder.register({ it.method == "POST" && it.path.endsWith("/confirm") }) {
failure(402, StripeResponses.failureError("card_not_supported"))
}
StripeOutcome.REQUIRES_3DS -> responder.register({ it.method == "POST" && it.path.endsWith("/confirm") }) {
ok(StripeResponses.confirm3dsRequired())
}
}
// Signal-service edge overrides.
if (signalCreatePaymentStatus != 200) {
responder.register({ it.method == "POST" && (it.path.contains("/create_payment_method") || it.path.endsWith("/boost/create")) }) {
failure(signalCreatePaymentStatus)
}
}
if (receiptCredentialStatus != 200) {
responder.register({ it.method == "POST" && it.path.contains("/receipt_credentials") }) { failure(receiptCredentialStatus) }
}
if (redeemStatus != 200) {
responder.register({ it.method == "POST" && it.path.contains("/redeem-receipt") }) { failure(redeemStatus) }
}
when (existingSubscription) {
SubscriptionState.NONE -> Unit
SubscriptionState.ACTIVE -> registerSubscription(status = "active", active = true)
SubscriptionState.PENDING -> registerSubscription(status = "incomplete", active = false)
}
if (recurringActivatesAfterSetup) {
val activated = AtomicBoolean(false)
responder.register({ it.method == "PUT" && it.path.contains("/level/") }) {
activated.set(true)
ok()
}
responder.register({ it.method == "GET" && it.path.contains("/v1/subscription/") && !it.path.contains("configuration") && !it.path.contains("bank_mandate") }) {
if (activated.get()) ok(DonationResponses.activeSubscription()) else ok(DonationResponses.emptySubscription())
}
}
}
private fun registerSubscription(status: String, active: Boolean) {
MockEndpoints.responder.register({ it.method == "GET" && it.path.contains("/v1/subscription/") && !it.path.contains("configuration") }) {
EndpointResponse(status = 200, body = DonationResponses.activeSubscription(status = status, active = active))
}
}
}
/** The expected terminal result of driving a spec. */
sealed interface ExpectedOutcome {
/** An error dialog with [titleRes] (and optionally [messageRes]) is shown. */
data class ErrorDialog(@StringRes val titleRes: Int, @StringRes val messageRes: Int? = null) : ExpectedOutcome
/** The full lifecycle completes and a donation receipt is recorded. */
data object Redeemed : ExpectedOutcome
/**
* Payment setup succeeded and the payment moved past [org.thoughtcrime.securesms.database.InAppPaymentTable.State.CREATED]
* with no error. Used for bank-transfer methods (SEPA/iDEAL), which don't redeem immediately —
* they land in a pending / awaiting-authorization state.
*/
data object PaymentSubmitted : ExpectedOutcome
}
@@ -0,0 +1,200 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.conversation.v2
import android.app.Activity
import android.app.Application
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.view.View
import androidx.fragment.app.FragmentActivity
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import assertk.assertThat
import assertk.assertions.isEqualTo
import assertk.assertions.isInstanceOf
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.signal.core.util.getSerializableCompat
import org.signal.donations.InAppPaymentType
import org.thoughtcrime.securesms.MainActivity
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonateToSignalFragment
import org.thoughtcrime.securesms.conversation.ConversationIntents
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.testing.InAppPaymentsRule
import org.thoughtcrime.securesms.testing.SignalActivityRule
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
/**
* Entry-path coverage for the inline chat donation prompt: the Release-Channel "donation request"
* conversation item that renders a "Donate" action button and opens the one-time checkout.
*
* The conversation is hosted by [MainActivity], so this drives it the same way
* [org.thoughtcrime.securesms.main.MainNavigationLaunchTest] does — a manual lifecycle-callback launch
* plus direct view interaction, since ActivityScenario/Espresso misbehave for the conversation
* custom-action intent.
*
* Unlike the megaphone and subscriber-upsell paths, this prompt is intentionally NOT gated by sustainer
* status; [sustainer_donatePromptStillShown] documents that.
*/
@RunWith(AndroidJUnit4::class)
class InlineDonationPromptTest {
@get:Rule
val harness = SignalActivityRule()
@get:Rule
val iapRule = InAppPaymentsRule()
private val context: Context get() = InstrumentationRegistry.getInstrumentation().targetContext
@Before
fun setUp() {
SignalStore.inAppPayments.setLastEndOfPeriod(0L)
SignalDatabase.recipients.setBadges(Recipient.self().id, emptyList())
Recipient.self().fresh()
}
@Test
fun nonSustainer_donatePromptOpensOneTimeCheckout() {
seedDonationRequest()
assertDonatePromptOpensCheckout()
}
@Test
fun sustainer_donatePromptStillShown() {
SignalStore.inAppPayments.setLastEndOfPeriod(TimeUnit.MILLISECONDS.toSeconds(System.currentTimeMillis()) + TimeUnit.DAYS.toSeconds(30))
SignalDatabase.recipients.setBadges(Recipient.self().id, listOf(donorBadge()))
Recipient.self().fresh()
seedDonationRequest()
assertDonatePromptOpensCheckout()
}
private fun assertDonatePromptOpensCheckout() {
val activity = launchConversation()
val donateButton = awaitView(activity, R.id.conversation_update_action)
runOnMainSync { donateButton.performClick() }
val dialog = await(description = "one-time checkout dialog") {
activity.supportFragmentManager.findFragmentByTag(ONE_TIME_NAV_TAG)
}
assertThat(dialog).isInstanceOf(DonateToSignalFragment.Dialog::class)
val type = dialog.requireArguments().getSerializableCompat(DonateToSignalFragment.Dialog.ARG, InAppPaymentType::class.java)
assertThat(type).isEqualTo(InAppPaymentType.ONE_TIME_DONATION)
}
private fun seedDonationRequest() {
val recipientId: RecipientId = SignalDatabase.recipients.insertReleaseChannelRecipient()
SignalStore.releaseChannel.setReleaseChannelRecipientId(recipientId)
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(Recipient.resolved(recipientId))
SignalDatabase.messages.insertBoostRequestMessage(recipientId, threadId)
}
private fun launchConversation(): MainActivity {
val recipientId = SignalStore.releaseChannel.releaseChannelRecipientId!!
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(Recipient.resolved(recipientId))
val conversationIntent = ConversationIntents.createBuilder(context, recipientId, threadId).blockingGet().build()
val launchIntent = Intent(context, MainActivity::class.java).apply {
action = ConversationIntents.ACTION
putExtras(conversationIntent)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
val app = context.applicationContext as Application
val resumed = CountDownLatch(1)
val holder = arrayOfNulls<MainActivity>(1)
val callbacks = object : Application.ActivityLifecycleCallbacks {
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) = Unit
// Capture on resume rather than create: a CLEAR_TOP|SINGLE_TOP conversation intent may be
// delivered to an existing MainActivity via onNewIntent, which never fires onActivityCreated.
override fun onActivityResumed(activity: Activity) {
if (activity is MainActivity) {
holder[0] = activity
resumed.countDown()
}
}
override fun onActivityStarted(activity: Activity) = Unit
override fun onActivityPaused(activity: Activity) = Unit
override fun onActivityStopped(activity: Activity) = Unit
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) = Unit
override fun onActivityDestroyed(activity: Activity) = Unit
}
app.registerActivityLifecycleCallbacks(callbacks)
app.startActivity(launchIntent)
if (!resumed.await(15, TimeUnit.SECONDS)) {
app.unregisterActivityLifecycleCallbacks(callbacks)
error("MainActivity did not reach RESUMED within 15s")
}
app.unregisterActivityLifecycleCallbacks(callbacks)
return holder[0] ?: error("MainActivity was not captured")
}
private fun awaitView(activity: FragmentActivity, viewId: Int): View {
return await(description = "view $viewId") {
activity.findViewById<View>(viewId)?.takeIf { it.isShown }
}
}
private fun <T> await(
timeoutMs: Long = 15_000,
pollMs: Long = 50,
description: String,
supplier: () -> T?
): T {
val deadline = System.currentTimeMillis() + timeoutMs
while (System.currentTimeMillis() < deadline) {
val result = runOnMainSync(supplier)
if (result != null) return result
Thread.sleep(pollMs)
}
error("Timed out after ${timeoutMs}ms waiting for $description")
}
private fun <T> runOnMainSync(block: () -> T): T {
var result: Result<T> = Result.failure(IllegalStateException("runOnMainSync produced no result"))
InstrumentationRegistry.getInstrumentation().runOnMainSync { result = runCatching(block) }
return result.getOrThrow()
}
private fun donorBadge(): Badge {
return Badge(
id = "test-donor-badge",
category = Badge.Category.Donor,
name = "Signal Sustainer",
description = "",
imageUrl = Uri.EMPTY,
imageDensity = "xxhdpi",
expirationTimestamp = System.currentTimeMillis() + TimeUnit.DAYS.toMillis(30),
visible = true,
duration = TimeUnit.DAYS.toMillis(30)
)
}
companion object {
private const val ONE_TIME_NAV_TAG = "one_time_nav"
}
}
@@ -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
)
}
@@ -3,19 +3,28 @@ package org.thoughtcrime.securesms.dependencies
import android.app.Application
import io.mockk.mockk
import io.mockk.spyk
import okhttp3.OkHttpClient
import org.signal.core.util.UptimeSleepTimer
import org.signal.core.util.billing.BillingApi
import org.signal.libsignal.net.Network
import org.signal.libsignal.zkgroup.receipts.ClientZkReceiptOperations
import org.signal.network.api.ArchiveApi
import org.thoughtcrime.securesms.push.SignalServiceNetworkAccess
import org.thoughtcrime.securesms.recipients.LiveRecipientCache
import org.thoughtcrime.securesms.testing.endpoints.DonationTestServer
import org.thoughtcrime.securesms.testing.endpoints.MockEndpoints
import org.thoughtcrime.securesms.testing.endpoints.ResponderInterceptor
import org.thoughtcrime.securesms.testing.endpoints.ResponderWebSocketConnection
import org.whispersystems.signalservice.api.SignalServiceDataStore
import org.whispersystems.signalservice.api.SignalServiceMessageSender
import org.whispersystems.signalservice.api.account.AccountApi
import org.whispersystems.signalservice.api.donations.DonationsApi
import org.whispersystems.signalservice.api.keys.KeysApi
import org.whispersystems.signalservice.api.message.MessageApi
import org.whispersystems.signalservice.api.websocket.SignalWebSocket
import org.whispersystems.signalservice.internal.configuration.SignalServiceConfiguration
import org.whispersystems.signalservice.internal.push.PushServiceSocket
import java.util.function.Supplier
import kotlin.time.Duration.Companion.seconds
/**
* Dependency provider used for instrumentation tests (aka androidTests).
@@ -45,8 +54,54 @@ class InstrumentationApplicationDependencyProvider(val application: Application,
return mockk()
}
override fun provideDonationsApi(authWebSocket: SignalWebSocket.AuthenticatedWebSocket, unauthWebSocket: SignalWebSocket.UnauthenticatedWebSocket): DonationsApi {
return mockk()
/**
* Adds the Stripe-matching [ResponderInterceptor] on top of the default client (which supplies the
* user agent + DNS), so `api.stripe.com` requests made by [org.signal.donations.StripeApi] are
* answered from the shared [MockEndpoints.responder] and never hit the real network.
*/
override fun provideOkHttpClient(): OkHttpClient {
return default.provideOkHttpClient()
.newBuilder()
.addInterceptor(ResponderInterceptor(MockEndpoints.responder))
.build()
}
/**
* Backs the real [org.whispersystems.signalservice.api.donations.DonationsApi] (and any other
* websocket API) with a [ResponderWebSocketConnection] driven by the shared [MockEndpoints.responder],
* so Signal-service requests are answered from the scenario's "world" and never hit the network.
*/
override fun provideAuthWebSocket(
signalServiceConfigurationSupplier: Supplier<SignalServiceConfiguration>,
libSignalNetworkSupplier: Supplier<Network>
): SignalWebSocket.AuthenticatedWebSocket {
return SignalWebSocket.AuthenticatedWebSocket(
connectionFactory = { ResponderWebSocketConnection(MockEndpoints.responder) },
canConnect = { true },
sleepTimer = UptimeSleepTimer(),
disconnectTimeoutMs = 15.seconds.inWholeMilliseconds
)
}
override fun provideUnauthWebSocket(
signalServiceConfigurationSupplier: Supplier<SignalServiceConfiguration>,
libSignalNetworkSupplier: Supplier<Network>
): SignalWebSocket.UnauthenticatedWebSocket {
return SignalWebSocket.UnauthenticatedWebSocket(
connectionFactory = { ResponderWebSocketConnection(MockEndpoints.responder) },
canConnect = { true },
sleepTimer = UptimeSleepTimer(),
disconnectTimeoutMs = 15.seconds.inWholeMilliseconds
)
}
/**
* Uses the test zk server's public params so credentials minted by [MockEndpoints] validate here.
* The real [ClientZkReceiptOperations.receiveReceiptCredential] validation still runs in the
* receipt-credential context job — only the params are swapped for test ones.
*/
override fun provideClientZkReceiptOperations(signalServiceConfiguration: SignalServiceConfiguration): ClientZkReceiptOperations {
return DonationTestServer.clientReceiptOperations
}
override fun provideSignalServiceMessageSender(
@@ -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()
}
}
@@ -121,8 +121,6 @@ class BackupSubscriptionCheckJobTest {
)
)
every { AppDependencies.donationsApi.putSubscription(any()) } returns NetworkResult.Success(Unit)
insertSubscriber()
}
@@ -49,7 +49,6 @@ import java.util.concurrent.TimeUnit
*/
@RunWith(AndroidJUnit4::class)
class MainNavigationLaunchTest {
@get:Rule
val harness = SignalActivityRule(othersCount = 2)
@@ -89,7 +88,8 @@ class MainNavigationLaunchTest {
appendLine("fragments observed: ${recorder.allCreated}")
appendLine("activity fragments: ${launched.activity.supportFragmentManager.fragments.map { it::class.simpleName }}")
appendLine("vm.currentListLocation: ${vm.mainNavigationState.value.currentListLocation}")
appendLine("vm.earlyNavigationDetailLocationRequested: ${vm.earlyNavigationDetailLocationRequested}")
appendLine("vm.detailLocation: ${vm.detailLocation.value}")
appendLine("vm.chatsBackStackEntries: ${vm.chatsBackStackEntries.toList()}")
}
}
throw IllegalStateException("${e.message}\n$state", e)
@@ -254,8 +254,9 @@ class MainNavigationLaunchTest {
"starts handling it on cold launch, update or delete this test. Got: ${recorder.allCreated}"
}
val vm = runOnMainSync { launched.activity.mainNavigationViewModel() }
check(vm.earlyNavigationDetailLocationRequested == null) {
"Expected no early detail to be staged, got ${vm.earlyNavigationDetailLocationRequested}"
val staged = runOnMainSync { vm.chatsBackStackEntries.filterNot { it is MainNavigationDetailLocation.Empty } }
check(staged.isEmpty()) {
"Expected no detail to be staged on the chats back stack, got $staged"
}
}
}
@@ -289,8 +290,9 @@ class MainNavigationLaunchTest {
"Expected default CHATS, got ${vm.mainNavigationState.value.currentListLocation}"
}
Thread.sleep(750)
check(vm.earlyNavigationDetailLocationRequested == null) {
"Expected no early detail, got ${vm.earlyNavigationDetailLocationRequested}"
val detailLocation = runOnMainSync { vm.detailLocation.value }
check(detailLocation == MainNavigationDetailLocation.Empty) {
"Expected Empty detail location, got $detailLocation"
}
check(recorder.createdArgs.isEmpty()) {
"Expected no ConversationFragment for bare launch, got ${recorder.createdArgs.size}"
@@ -0,0 +1,84 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.megaphone
import androidx.test.ext.junit.runners.AndroidJUnit4
import assertk.assertThat
import assertk.assertions.isEqualTo
import assertk.assertions.isFalse
import assertk.assertions.isNull
import assertk.assertions.isTrue
import io.mockk.every
import io.mockk.mockkObject
import io.mockk.mockkStatic
import io.mockk.unmockkObject
import io.mockk.unmockkStatic
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.signal.core.util.deleteAll
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppDonations
import org.thoughtcrime.securesms.database.InAppPaymentTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.testing.InAppPaymentsRule
import org.thoughtcrime.securesms.testing.SignalActivityRule
import org.thoughtcrime.securesms.util.VersionTracker
/**
* The "user might be a sustainer" rule for the donations remote megaphone: the `standard_donate`
* conditional (via [RemoteMegaphoneRepository.getRemoteMegaphoneToShow]) suppresses the megaphone for
* existing donors and shows it otherwise.
*/
@RunWith(AndroidJUnit4::class)
class DonationMegaphoneGatingTest {
@get:Rule
val harness = SignalActivityRule()
@get:Rule
val iapRule = InAppPaymentsRule()
@Before
fun setUp() {
SignalDatabase.inAppPayments.writableDatabase.deleteAll(InAppPaymentTable.TABLE_NAME)
SignalDatabase.remoteMegaphones.debugRemoveAll()
setSelfBadges(emptyList())
// Freshly-installed test APKs report 0 days installed and no configured payment methods, both of
// which independently fail shouldShowDonateMegaphone. Fix them so the donor badge is the only
// variable across the gating tests.
mockkStatic(VersionTracker::class)
mockkObject(InAppDonations)
every { VersionTracker.getDaysSinceFirstInstalled(any()) } returns 30L
every { InAppDonations.hasAtLeastOnePaymentMethodAvailable() } returns true
}
@After
fun tearDown() {
unmockkStatic(VersionTracker::class)
unmockkObject(InAppDonations)
}
@Test
fun nonDonor_showsStandardDonateMegaphone() {
val record = donateMegaphoneRecord(conditionalId = "standard_donate")
SignalDatabase.remoteMegaphones.insert(record)
assertThat(RemoteMegaphoneRepository.getRemoteMegaphoneToShow()?.uuid).isEqualTo(record.uuid)
assertThat(RemoteMegaphoneRepository.hasRemoteMegaphoneToShow(canShowLocalDonate = true)).isTrue()
}
@Test
fun sustainer_donorBadge_suppressesStandardDonateMegaphone() {
SignalDatabase.remoteMegaphones.insert(donateMegaphoneRecord(conditionalId = "standard_donate"))
setSelfBadges(listOf(donorBadge()))
assertThat(RemoteMegaphoneRepository.getRemoteMegaphoneToShow()).isNull()
assertThat(RemoteMegaphoneRepository.hasRemoteMegaphoneToShow(canShowLocalDonate = true)).isFalse()
}
}
@@ -0,0 +1,113 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.megaphone
import android.app.Activity
import android.content.Context
import android.content.Intent
import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.fragment.app.DialogFragment
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import assertk.assertThat
import assertk.assertions.isEqualTo
import assertk.assertions.isNotNull
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.signal.core.ui.compose.theme.SignalTheme
import org.signal.core.util.getSerializableCompat
import org.signal.donations.InAppPaymentType
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.CheckoutFlowActivity
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.RemoteMegaphoneRecord
import org.thoughtcrime.securesms.testing.InAppPaymentsRule
import org.thoughtcrime.securesms.testing.SignalActivityRule
/**
* Entry-path coverage for the donations remote megaphone: the megaphone shown on the conversation list
* that carries a "Donate" action into the one-time checkout flow.
*
* The megaphone renders through [MegaphoneComponent], so this renders that composable directly (rather
* than launching the full [org.thoughtcrime.securesms.MainActivity]) and asserts that tapping Donate
* runs the production [RemoteMegaphoneRepository] action, which navigates to [CheckoutFlowActivity]
* with [InAppPaymentType.ONE_TIME_DONATION]. The sustainer gating is covered by [DonationMegaphoneGatingTest].
*/
@RunWith(AndroidJUnit4::class)
class DonationMegaphoneTest {
@get:Rule
val harness = SignalActivityRule()
@get:Rule
val iapRule = InAppPaymentsRule()
@get:Rule
val composeRule = createComposeRule()
private val context: Context get() = InstrumentationRegistry.getInstrumentation().targetContext
@Before
fun setUp() {
SignalDatabase.remoteMegaphones.debugRemoveAll()
}
@Test
fun donateMegaphone_donateClick_opensOneTimeCheckout() {
val record = donateMegaphoneRecord(conditionalId = null)
SignalDatabase.remoteMegaphones.insert(record)
val controller = RecordingMegaphoneActionController()
composeRule.setContent {
SignalTheme {
MegaphoneComponent(buildDonateMegaphone(record), controller)
}
}
composeRule.onNodeWithText(record.primaryActionText!!).performClick()
val intent = controller.navigationIntent
assertThat(intent).isNotNull()
assertThat(intent!!.component?.className).isEqualTo(CheckoutFlowActivity::class.java.name)
val type = intent.extras!!.getSerializableCompat(CheckoutFlowActivity.ARG_IN_APP_PAYMENT_TYPE, InAppPaymentType::class.java)
assertThat(type).isEqualTo(InAppPaymentType.ONE_TIME_DONATION)
}
/** Mirrors [Megaphones.buildRemoteMegaphone]: wires the primary button to the real repository action. */
private fun buildDonateMegaphone(record: RemoteMegaphoneRecord): Megaphone {
return Megaphone.Builder(Megaphones.Event.REMOTE_MEGAPHONE, Megaphone.Style.BASIC)
.setTitle(record.title)
.setBody(record.body)
.setActionButton(record.primaryActionText!!) { _, controller ->
RemoteMegaphoneRepository.getAction(record.primaryActionId!!).run(context, controller, record)
}
.build()
}
private class RecordingMegaphoneActionController : MegaphoneActionController {
var navigationIntent: Intent? = null
private set
override fun onMegaphoneNavigationRequested(intent: Intent) {
navigationIntent = intent
}
override fun onMegaphoneNavigationRequested(intent: Intent, requestCode: Int) {
navigationIntent = intent
}
override fun onMegaphoneToastRequested(string: String) = Unit
override fun getMegaphoneActivity(): Activity = throw UnsupportedOperationException("not used")
override fun onMegaphoneSnooze(event: Megaphones.Event) = Unit
override fun onMegaphoneCompleted(event: Megaphones.Event) = Unit
override fun onMegaphoneDialogFragmentRequested(dialogFragment: DialogFragment) = Unit
}
}
@@ -0,0 +1,54 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.megaphone
import android.net.Uri
import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.RemoteMegaphoneRecord
import org.thoughtcrime.securesms.recipients.Recipient
import java.util.UUID
import java.util.concurrent.TimeUnit
fun donateMegaphoneRecord(conditionalId: String?): RemoteMegaphoneRecord {
val now = System.currentTimeMillis()
return RemoteMegaphoneRecord(
priority = 100,
uuid = UUID.randomUUID().toString(),
countries = null,
minimumVersion = 1,
doNotShowBefore = now - TimeUnit.DAYS.toMillis(2),
doNotShowAfter = now + TimeUnit.DAYS.toMillis(28),
showForNumberOfDays = 30,
conditionalId = conditionalId,
primaryActionId = RemoteMegaphoneRecord.ActionId.DONATE,
secondaryActionId = RemoteMegaphoneRecord.ActionId.SNOOZE,
imageUrl = null,
title = "Donate Test",
body = "Donate body test.",
primaryActionText = "Donate",
secondaryActionText = "Snooze"
)
}
fun donorBadge(): Badge {
return Badge(
id = "test-donor-badge",
category = Badge.Category.Donor,
name = "Signal Sustainer",
description = "",
imageUrl = Uri.EMPTY,
imageDensity = "xxhdpi",
expirationTimestamp = System.currentTimeMillis() + TimeUnit.DAYS.toMillis(30),
visible = true,
duration = TimeUnit.DAYS.toMillis(30)
)
}
fun setSelfBadges(badges: List<Badge>) {
SignalDatabase.recipients.setBadges(Recipient.self().id, badges)
Recipient.self().fresh()
}
@@ -26,6 +26,7 @@ import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.testing.MessageContentFuzzer
import org.thoughtcrime.securesms.testing.SignalActivityRule
import org.thoughtcrime.securesms.util.MediaUtil
import org.thoughtcrime.securesms.util.TextSecurePreferences
import org.whispersystems.signalservice.api.push.SignalServiceAddress
import org.whispersystems.signalservice.internal.push.AddressableMessage
import org.whispersystems.signalservice.internal.push.AttachmentPointer
@@ -51,6 +52,14 @@ class SyncMessageProcessorTest_attachmentBackfill {
originalDeviceId = SignalStore.account.deviceId
// Make this device a linked device so backfill response handling activates.
SignalStore.account.deviceId = 2
// Prevent AttachmentDownloadJob onAdded from async changing the attachment state.
TextSecurePreferences.getSharedPreferences(harness.application)
.edit()
.putStringSet(TextSecurePreferences.MEDIA_DOWNLOAD_WIFI_PREF, emptySet())
.putStringSet(TextSecurePreferences.MEDIA_DOWNLOAD_MOBILE_PREF, emptySet())
.putStringSet(TextSecurePreferences.MEDIA_DOWNLOAD_ROAMING_PREF, emptySet())
.commit()
}
@After
@@ -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)
}
}
@@ -7,38 +7,90 @@ package org.thoughtcrime.securesms.testing
import androidx.test.platform.app.InstrumentationRegistry
import io.mockk.every
import org.json.JSONObject
import org.junit.rules.ExternalResource
import org.signal.core.util.JsonUtils
import org.signal.network.NetworkResult
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.whispersystems.signalservice.internal.push.SubscriptionsConfiguration
import org.thoughtcrime.securesms.testing.endpoints.DonationResponses
import org.thoughtcrime.securesms.testing.endpoints.DonationTestServer
import org.thoughtcrime.securesms.testing.endpoints.EndpointResponse
import org.thoughtcrime.securesms.testing.endpoints.MockEndpoints
import org.thoughtcrime.securesms.testing.endpoints.delete
import org.thoughtcrime.securesms.testing.endpoints.get
import org.thoughtcrime.securesms.testing.endpoints.ok
import org.thoughtcrime.securesms.testing.endpoints.post
import org.thoughtcrime.securesms.testing.endpoints.put
import org.whispersystems.signalservice.internal.push.WhoAmIResponse
/**
* Sets up some common infrastructure for on-device InAppPayment testing
* Sets up common infrastructure for on-device InAppPayment testing.
*
* The Signal-service edge is driven through the shared [MockEndpoints.responder] (the real
* [org.whispersystems.signalservice.api.donations.DonationsApi] runs against these handlers), while
* account/archive lookups that are not websocket-backed here stay mocked via mockk. A scenario can
* override any default by registering a more specific handler after this rule runs (last-registered
* wins).
*/
class InAppPaymentsRule : ExternalResource() {
override fun before() {
initialiseConfigurationResponse()
initialisePutSubscription()
MockEndpoints.responder.clear()
registerDonationServiceDefaults()
initialiseSetArchiveBackupId()
initialiseSetAccountAttributes()
initialiseAccountLookups()
}
private fun initialiseConfigurationResponse() {
val assets = InstrumentationRegistry.getInstrumentation().context.resources.assets
val response = assets.open("inAppPaymentsTests/configuration.json").use { stream ->
NetworkResult.Success(JsonUtils.fromJson(stream, SubscriptionsConfiguration::class.java))
}
AppDependencies.donationsApi.apply {
every { getDonationsConfiguration(any()) } returns response
}
override fun after() {
MockEndpoints.responder.clear()
}
private fun initialisePutSubscription() {
AppDependencies.donationsApi.apply {
every { putSubscription(any()) } returns NetworkResult.Success(Unit)
every { createSubscriber(any(), any()) } returns NetworkResult.Success(Unit)
/**
* Default happy-path responses for the Signal donations endpoints. Registered generic-to-specific
* so more specific paths registered later win.
*/
private fun registerDonationServiceDefaults() {
val responder = MockEndpoints.responder
// Subscriber lifecycle: create/put/update-level (PUT), cancel (DELETE) all succeed by default.
responder.put("/v1/subscription/") { ok() }
responder.delete("/v1/subscription/") { ok() }
// getSubscription defaults to "no active subscription".
responder.get("/v1/subscription/") { ok(DonationResponses.emptySubscription()) }
// Donations configuration (registered after the generic GET so it wins for its path).
val configuration = InstrumentationRegistry.getInstrumentation().context.resources.assets
.open("inAppPaymentsTests/configuration.json")
.use { it.readBytes().decodeToString() }
responder.get("/v1/subscription/configuration") { ok(configuration) }
// SEPA bank-transfer mandate text (registered after the generic GET so it wins for its path).
responder.get("/v1/subscription/bank_mandate/") { ok(DonationResponses.bankMandate()) }
// One-time (boost) payment intent.
responder.post("/v1/subscription/boost/create") { ok(DonationResponses.stripeClientSecret()) }
// Recurring payment method setup + default payment method + level are keyed by subscriber id in
// the middle of the path, so match on a distinctive suffix.
responder.register({ it.method == "POST" && it.path.contains("/create_payment_method") && !it.path.contains("paypal") }) {
ok(DonationResponses.setupIntentClientSecret())
}
responder.register({ it.method == "POST" && it.path.contains("/default_payment_method") }) { ok() }
// Receipt credential submission (boost + recurring) — mint a valid credential from the client's request.
responder.register({ it.method == "POST" && it.path.contains("/receipt_credentials") }) { request ->
mintReceiptCredential(request.bodyString)
}
// Redemption.
responder.post("/v1/donation/redeem-receipt") { ok() }
}
private fun mintReceiptCredential(requestBody: String): EndpointResponse {
val requestBase64 = JSONObject(requestBody).getString("receiptCredentialRequest")
val minted = DonationTestServer.issueReceiptCredential(requestBase64)
return ok(DonationResponses.receiptCredential(minted))
}
private fun initialiseSetArchiveBackupId() {
@@ -46,4 +98,20 @@ 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)
}
}
/**
* whoAmI is served by the (still mocked) account API rather than the websocket responder, so
* background jobs that resolve the account during a test hit a handled path instead of throwing.
*/
private fun initialiseAccountLookups() {
AppDependencies.accountApi.apply {
every { whoAmI() } returns NetworkResult.Success(WhoAmIResponse(number = "+15555550123"))
}
}
}
@@ -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()
}
}
@@ -16,6 +16,7 @@ import org.signal.benchmark.setup.Harness
import org.signal.benchmark.setup.OtherClient
import org.signal.core.util.ThreadUtil
import org.signal.core.util.logging.Log
import org.signal.network.websocket.WebSocketRequestMessage
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.TestDbUtils
import org.thoughtcrime.securesms.dependencies.AppDependencies
@@ -23,7 +24,6 @@ import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.recipients.Recipient
import org.whispersystems.signalservice.internal.push.Envelope
import org.whispersystems.signalservice.internal.websocket.BenchmarkWebSocketConnection
import org.signal.network.websocket.WebSocketRequestMessage
import kotlin.random.Random
/**
@@ -78,7 +78,6 @@ class NoOpJob(parameters: Parameters) : Job(parameters) {
StoryOnboardingDownloadJob.KEY
)
fun replaceFactories(factories: Map<String, Job.Factory<*>>): Map<String, Job.Factory<*>> =
factories.mapValues { if (it.key in STARTUP_NETWORK_JOB_KEYS) Factory() else it.value }
fun replaceFactories(factories: Map<String, Job.Factory<*>>): Map<String, Job.Factory<*>> = factories.mapValues { if (it.key in STARTUP_NETWORK_JOB_KEYS) Factory() else it.value }
}
}
@@ -8,10 +8,10 @@ package org.whispersystems.signalservice.internal.websocket
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.subjects.BehaviorSubject
import org.signal.core.util.JsonUtils
import org.signal.network.websocket.WebSocketRequestMessage
import org.signal.network.websocket.WebSocketResponseMessage
import org.signal.network.websocket.WebsocketResponse
import org.signal.core.util.JsonUtils
import org.thoughtcrime.securesms.util.SignalTrace
import org.whispersystems.signalservice.api.websocket.WebSocketConnectionState
import org.whispersystems.signalservice.internal.push.SendMessageResponse
+8 -1
View File
@@ -522,6 +522,13 @@
</intent-filter>
</activity>
<activity
android:name=".preferences.EditProxyActivity"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
android:exported="false"
android:theme="@style/Signal.DayNight.NoActionBar"
android:windowSoftInputMode="adjustResize" />
<activity
android:name=".stories.my.MyStoriesActivity"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
@@ -1529,7 +1536,7 @@
</receiver>
<receiver
android:name="org.thoughtcrime.securesms.jobs.ForegroundServiceUtil$Receiver"
android:name="org.signal.core.util.ForegroundServiceUtil$Receiver"
android:exported="false" />
<receiver
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -52,7 +52,7 @@ import org.signal.ringrtc.CallManager;
import org.thoughtcrime.securesms.apkupdate.ApkUpdateRefreshListener;
import org.thoughtcrime.securesms.avatar.AvatarPickerStorage;
import org.thoughtcrime.securesms.backup.v2.BackupRepository;
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity;
import org.thoughtcrime.securesms.preferences.EditProxyActivity;
import org.thoughtcrime.securesms.conversation.drafts.DraftBlobs;
import org.thoughtcrime.securesms.crypto.AppAttachmentSecretStore;
import org.thoughtcrime.securesms.crypto.DatabaseSecretProvider;
@@ -186,9 +186,9 @@ public class ApplicationContext extends Application implements AppForegroundObse
initializeLogging();
Log.i(TAG, "onCreate()");
})
.addBlocking("security-provider", this::initializeSecurityProvider)
.addBlocking("app-dependencies", this::initializeAppDependencies)
.addBlocking("anr-detector", this::startAnrDetector)
.addBlocking("security-provider", this::initializeSecurityProvider)
.addBlocking("crash-handling", this::initializeCrashHandling)
.addBlocking("rx-init", this::initializeRx)
.addBlocking("event-bus", () -> EventBus.builder().logNoSubscriberMessages(false).installDefaultEventBus())
@@ -438,7 +438,7 @@ public class ApplicationContext extends Application implements AppForegroundObse
return Unit.INSTANCE;
},
context -> {
context.startActivity(AppSettingsActivity.proxy(context));
context.startActivity(EditProxyActivity.intent(context));
return Unit.INSTANCE;
},
(context, subject) -> {
@@ -280,7 +280,7 @@ public final class ContactSelectionListFragment extends LoggingFragment {
false,
new ContactSelectionListAdapter.ArbitraryRepository(),
new SearchRepository(requireContext().getString(R.string.note_to_self)),
new ContactSearchPagedDataSourceRepository(requireContext()),
new ContactSearchPagedDataSourceRepository(requireContext(), requireContext().getString(R.string.note_to_self)),
fixedContacts,
false
)
@@ -9,10 +9,10 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
public class DeviceProvisioningActivity extends PassphraseRequiredActivity {
@SuppressWarnings("unused")
private static final String TAG = Log.tag(DeviceProvisioningActivity.class);
@Override
@@ -22,6 +22,13 @@ public class DeviceProvisioningActivity extends PassphraseRequiredActivity {
@Override
protected void onCreate(Bundle bundle, boolean ready) {
if (SignalStore.account().isLinkedDevice()) {
Log.i(TAG, "Cannot link a device from a linked device. Ignoring provisioning intent.");
startActivity(MainActivity.clearTop(this));
finish();
return;
}
AlertDialog dialog = new MaterialAlertDialogBuilder(this)
.setTitle(getString(R.string.DeviceProvisioningActivity_link_a_signal_device))
.setMessage(getString(R.string.DeviceProvisioningActivity_to_link_a_desktop_or_ipad_to_this_signal_account))
@@ -76,6 +76,7 @@ import androidx.lifecycle.createSavedStateHandle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
import androidx.navigation3.ui.NavDisplay
@@ -145,7 +146,6 @@ import org.thoughtcrime.securesms.devicetransfer.olddevice.OldDeviceExitActivity
import org.thoughtcrime.securesms.groups.ui.creategroup.CreateGroupActivity
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.lock.v2.CreateSvrPinActivity
import org.thoughtcrime.securesms.main.DetailsScreenNavHost
import org.thoughtcrime.securesms.main.MainBottomChrome
import org.thoughtcrime.securesms.main.MainBottomChromeCallback
import org.thoughtcrime.securesms.main.MainBottomChromeState
@@ -165,10 +165,6 @@ import org.thoughtcrime.securesms.main.MainToolbarMode
import org.thoughtcrime.securesms.main.MainToolbarState
import org.thoughtcrime.securesms.main.MainToolbarViewModel
import org.thoughtcrime.securesms.main.Material3OnScrollHelperBinder
import org.thoughtcrime.securesms.main.navigateToDetailLocation
import org.thoughtcrime.securesms.main.rememberDetailNavHostController
import org.thoughtcrime.securesms.main.rememberFocusRequester
import org.thoughtcrime.securesms.main.storiesNavGraphBuilder
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionActivity
import org.thoughtcrime.securesms.mediasend.v3.mediaSendLauncher
import org.thoughtcrime.securesms.megaphone.Megaphone
@@ -183,9 +179,8 @@ import org.thoughtcrime.securesms.service.BackupMediaRestoreService
import org.thoughtcrime.securesms.service.KeyCachingService
import org.thoughtcrime.securesms.starred.StarredMessagesActivity
import org.thoughtcrime.securesms.stories.Stories
import org.thoughtcrime.securesms.stories.archive.StoryArchiveActivity
import org.thoughtcrime.securesms.stories.landing.StoriesLandingFragment
import org.thoughtcrime.securesms.stories.settings.StorySettingsActivity
import org.thoughtcrime.securesms.stories.storiesNavEntries
import org.thoughtcrime.securesms.util.AppStartup
import org.thoughtcrime.securesms.util.CachedInflater
import org.thoughtcrime.securesms.util.CommunicationActions
@@ -506,50 +501,6 @@ class MainActivity :
mainNavigationViewModel.onSplitPaneChanged(isSplitPane)
}
val storiesNavHostController = rememberDetailNavHostController(
onRequestFocus = rememberFocusRequester(
mainNavigationViewModel = mainNavigationViewModel,
currentListLocation = mainNavigationState.currentListLocation
) { it == MainNavigationListLocation.STORIES }
) {
storiesNavGraphBuilder()
}
LaunchedEffect(Unit) {
fun navigateToLocation(location: MainNavigationDetailLocation) {
when (location) {
is MainNavigationDetailLocation.Empty -> {
when (mainNavigationState.currentListLocation) {
MainNavigationListLocation.CHATS, MainNavigationListLocation.ARCHIVE -> {
throw IllegalStateException("Navigation to ${mainNavigationState.currentListLocation} should be handled by ChatsBackStack.")
}
MainNavigationListLocation.CALLS -> {
throw IllegalStateException("Navigation to ${MainNavigationListLocation.CALLS} should be handled by CallsBackStack.")
}
MainNavigationListLocation.STORIES -> storiesNavHostController.navigateToDetailLocation(location)
}
}
is MainNavigationDetailLocation.Conversation, is MainNavigationDetailLocation.Chats -> {
throw IllegalStateException("Navigation to $location should be handled by ChatsBackStack.")
}
is MainNavigationDetailLocation.CallLinkDetails, is MainNavigationDetailLocation.Calls -> {
throw IllegalStateException("Navigation to $location should be handled by CallsBackStack.")
}
is MainNavigationDetailLocation.Stories -> storiesNavHostController.navigateToDetailLocation(location)
}
}
mainNavigationViewModel.earlyNavigationDetailLocationRequested?.let { navigateToLocation(it) }
mainNavigationViewModel.clearEarlyDetailLocation()
mainNavigationViewModel.detailLocation.collect { navigateToLocation(it) }
}
val scope = rememberCoroutineScope()
BackHandler(paneExpansionState.currentAnchor == detailOnlyAnchor) {
@@ -723,23 +674,23 @@ class MainActivity :
primaryContent = {
when (mainNavigationState.currentListLocation) {
MainNavigationListLocation.CHATS, MainNavigationListLocation.ARCHIVE -> {
NavDisplay(
NavDisplay<NavKey>(
backStack = mainNavigationViewModel.chatsBackStackEntries,
onBack = { mainNavigationViewModel.popChatsDetailLocation() },
transitionSpec = TransitionSpecs.HorizontalSlide.transitionSpec,
popTransitionSpec = TransitionSpecs.HorizontalSlide.popTransitionSpec,
predictivePopTransitionSpec = TransitionSpecs.HorizontalSlide.predictivePopTransitionSpec,
transitionSpec = { TransitionSpecs.HorizontalSlide.transitionSpec },
popTransitionSpec = { TransitionSpecs.HorizontalSlide.popTransitionSpec },
predictivePopTransitionSpec = { TransitionSpecs.HorizontalSlide.predictivePopTransitionSpec },
entryProvider = entryProvider { chatsNavEntries(convoTransitionState) }
)
}
MainNavigationListLocation.CALLS -> {
NavDisplay(
NavDisplay<NavKey>(
backStack = mainNavigationViewModel.callsBackStackEntries,
onBack = { mainNavigationViewModel.popCallsDetailLocation() },
transitionSpec = TransitionSpecs.HorizontalSlide.transitionSpec,
popTransitionSpec = TransitionSpecs.HorizontalSlide.popTransitionSpec,
predictivePopTransitionSpec = TransitionSpecs.HorizontalSlide.predictivePopTransitionSpec,
transitionSpec = { TransitionSpecs.HorizontalSlide.transitionSpec },
popTransitionSpec = { TransitionSpecs.HorizontalSlide.popTransitionSpec },
predictivePopTransitionSpec = { TransitionSpecs.HorizontalSlide.predictivePopTransitionSpec },
entryDecorators = listOf(
rememberSaveableStateHolderNavEntryDecorator(),
rememberViewModelStoreNavEntryDecorator()
@@ -749,9 +700,17 @@ class MainActivity :
}
MainNavigationListLocation.STORIES -> {
DetailsScreenNavHost(
navHostController = storiesNavHostController,
contentLayoutData = contentLayoutData
NavDisplay<NavKey>(
backStack = mainNavigationViewModel.storiesBackStackEntries,
onBack = { mainNavigationViewModel.popStoriesDetailLocation() },
transitionSpec = { TransitionSpecs.HorizontalSlide.transitionSpec },
popTransitionSpec = { TransitionSpecs.HorizontalSlide.popTransitionSpec },
predictivePopTransitionSpec = { TransitionSpecs.HorizontalSlide.predictivePopTransitionSpec },
entryDecorators = listOf(
rememberSaveableStateHolderNavEntryDecorator(),
rememberViewModelStoreNavEntryDecorator()
),
entryProvider = entryProvider { storiesNavEntries() }
)
}
}
@@ -1269,11 +1228,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() &&
@@ -242,10 +255,17 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
}
private Intent getTransferOrRestoreIntent() {
if (Environment.USE_NEW_REGISTRATION) {
return org.signal.registration.RegistrationActivity.createIntent(this, MainActivity.clearTop(this));
}
Intent intent = RestoreActivity.getRestoreIntent(this);
return getRoutedIntent(intent, MainActivity.clearTop(this));
}
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());
@@ -79,7 +79,13 @@ class SystemContactsEntrypointViewModel : ViewModel() {
}
private fun getDestinationForSyncAdapter(context: Context, intent: Intent): DestinationAndBody {
context.contentResolver.query(intent.data!!, null, null, null, null).use { cursor ->
val uri = intent.data
if (uri == null || uri.authority != ContactsContract.AUTHORITY) {
Log.w(TAG, "Ignoring content URI with an unexpected authority.")
return DestinationAndBody("", "")
}
context.contentResolver.query(uri, null, null, null, null).use { cursor ->
if (cursor != null && cursor.moveToNext()) {
return DestinationAndBody(cursor.getString(cursor.getColumnIndexOrThrow(ContactsContract.RawContacts.Data.DATA1)), "")
}
@@ -13,10 +13,14 @@ import androidx.fragment.app.setFragmentResult
import androidx.fragment.app.setFragmentResultListener
import androidx.fragment.app.viewModels
import androidx.navigation.Navigation
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
import org.signal.core.models.media.Media
import org.signal.core.ui.WindowBreakpoint
import org.signal.core.ui.getWindowBreakpoint
import org.signal.core.ui.permissions.Permissions
import org.signal.core.util.ThreadUtil
import org.signal.core.util.dp
import org.signal.core.util.getParcelableExtraCompat
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.avatar.Avatar
@@ -31,6 +35,7 @@ import org.thoughtcrime.securesms.mediasend.AvatarSelectionActivity
import org.thoughtcrime.securesms.util.ViewUtil
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import org.thoughtcrime.securesms.util.navigation.safeNavigate
import org.thoughtcrime.securesms.util.padding
import org.thoughtcrime.securesms.util.visible
/**
@@ -65,8 +70,25 @@ class AvatarPickerFragment : Fragment(R.layout.avatar_picker_fragment) {
val saveButton: View = view.findViewById(R.id.avatar_picker_save)
val clearButton: View = view.findViewById(R.id.avatar_picker_clear)
val spanCount = when (resources.getWindowBreakpoint()) {
is WindowBreakpoint.Small -> 4
else -> 6
}
val recyclerPadding = when (resources.getWindowBreakpoint()) {
is WindowBreakpoint.Small -> 0
else -> 112.dp
}
recycler = view.findViewById(R.id.avatar_picker_recycler)
recycler.addItemDecoration(GridDividerDecoration(4, ViewUtil.dpToPx(16)))
recycler.addItemDecoration(GridDividerDecoration(spanCount, ViewUtil.dpToPx(16)))
recycler.padding(
left = recyclerPadding,
right = recyclerPadding
)
val gridLayoutManager: GridLayoutManager = recycler.layoutManager as GridLayoutManager
gridLayoutManager.spanCount = spanCount
val adapter = MappingAdapter()
AvatarPickerItem.register(adapter, this::onAvatarClick, this::onAvatarLongClick)
@@ -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 {
@@ -53,7 +53,7 @@ public enum BackupFileIOError {
PendingIntent pendingIntent = PendingIntent.getActivity(context, -1, AppSettingsActivity.backups(context), PendingIntentFlags.mutable());
Notification backupFailedNotification = new NotificationCompat.Builder(context, NotificationChannels.getInstance().FAILURES)
.setSmallIcon(R.drawable.ic_signal_backup)
.setSmallIcon(org.signal.core.ui.R.drawable.ic_signal_backup)
.setContentTitle(context.getString(titleId))
.setContentText(context.getString(messageId))
.setStyle(new NotificationCompat.BigTextStyle().bigText(context.getString(messageId)))
@@ -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() }
@@ -102,6 +102,7 @@ import org.thoughtcrime.securesms.database.KeyValueDatabase
import org.thoughtcrime.securesms.database.KyberPreKeyTable
import org.thoughtcrime.securesms.database.OneTimePreKeyTable
import org.thoughtcrime.securesms.database.SearchTable
import org.thoughtcrime.securesms.database.SessionTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.SignedPreKeyTable
import org.thoughtcrime.securesms.database.StickerTable
@@ -184,6 +185,7 @@ import kotlin.time.Duration
import kotlin.time.Duration.Companion.days
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
import org.signal.registration.R as RegistrationR
object BackupRepository {
@@ -646,7 +648,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()
}
@@ -1126,7 +1128,7 @@ object BackupRepository {
}
return frameReader.use { reader ->
import(reader, selfData, cancellationSignal = { false })
import(reader, selfData, backupMode = BackupMode.LOCAL, cancellationSignal = { false })
}
}
@@ -1157,7 +1159,7 @@ object BackupRepository {
}
return frameReader.use { reader ->
import(reader, selfData, cancellationSignal)
import(reader, selfData, backupMode = BackupMode.REMOTE, cancellationSignal = cancellationSignal)
}
} catch (e: IOException) {
Log.w(TAG, "Unable to restore signal backup", e)
@@ -1185,7 +1187,7 @@ object BackupRepository {
)
return frameReader.use { reader ->
import(reader, selfData, cancellationSignal)
import(reader, selfData, backupMode = BackupMode.LINK_SYNC, cancellationSignal = cancellationSignal)
}
}
@@ -1211,7 +1213,7 @@ object BackupRepository {
}
return frameReader.use { reader ->
import(reader, selfData, cancellationSignal)
import(reader, selfData, backupMode = BackupMode.REMOTE, cancellationSignal = cancellationSignal)
}
}
@@ -1227,13 +1229,14 @@ object BackupRepository {
val frameReader = PlainTextBackupReader(inputStreamFactory(), length)
return frameReader.use { reader ->
import(reader, selfData, cancellationSignal)
import(reader, selfData, backupMode = BackupMode.PLAINTEXT_EXPORT, cancellationSignal = cancellationSignal)
}
}
private fun import(
frameReader: BackupImportReader,
selfData: SelfData,
backupMode: BackupMode,
cancellationSignal: () -> Boolean
): ImportResult {
val stopwatch = Stopwatch("import")
@@ -1291,7 +1294,16 @@ object BackupRepository {
}
Log.d(TAG, "[import] --- Recreating all tables ---")
val skipTables = setOf(KyberPreKeyTable.TABLE_NAME, OneTimePreKeyTable.TABLE_NAME, SignedPreKeyTable.TABLE_NAME)
val skipTables = buildSet {
add(KyberPreKeyTable.TABLE_NAME)
add(OneTimePreKeyTable.TABLE_NAME)
add(SignedPreKeyTable.TABLE_NAME)
// Preserve the session established with the primary during linking
if (backupMode.isLinkAndSync) {
add(SessionTable.TABLE_NAME)
}
}
val tableMetadata = SignalDatabase.rawDatabase.getAllTableDefinitions().filter { !it.name.startsWith(SearchTable.FTS_TABLE_NAME + "_") }
for (table in tableMetadata) {
if (skipTables.contains(table.name)) {
@@ -1664,6 +1676,19 @@ object BackupRepository {
}
}
/**
* Stores the remote backup's last-modified time in [BackupValues.lastBackupTime], (404/401 clear it to 0).
*/
fun refreshBackupFileTimestamp(): NetworkResult<ZonedDateTime> {
return getBackupFileLastModified().also { result ->
when (result) {
is NetworkResult.Success -> SignalStore.backup.lastBackupTime = result.result.toMillis()
is NetworkResult.StatusCodeError if (result.code == 404 || result.code == 401) -> SignalStore.backup.lastBackupTime = 0L
else -> Log.w(TAG, "Failed to refresh last backup time from remote: ${result::class.simpleName}")
}
}
}
/**
* Returns an object with details about the remote backup state.
*/
@@ -2334,7 +2359,7 @@ object BackupRepository {
try {
DataRestoreConstraint.isRestoringData = true
return withContext(Dispatchers.IO) {
return@withContext BackupProgressService.start(context, context.getString(R.string.BackupProgressService_title)).use {
return@withContext BackupProgressService.start(context, context.getString(RegistrationR.string.MessageSyncScreen__syncing_messages)).use {
restoreLinkAndSyncBackup(response, ephemeralBackupKey, controller = it, cancellationSignal = { !isActive })
}
}
@@ -2349,7 +2374,7 @@ object BackupRepository {
val progressListener = object : ProgressListener {
override fun onAttachmentProgress(progress: AttachmentTransferProgress) {
controller.update(
title = AppDependencies.application.getString(R.string.BackupProgressService_title_downloading),
title = AppDependencies.application.getString(RegistrationR.string.MessageSyncScreen__syncing_messages),
progress = progress.value,
indeterminate = false
)
@@ -2381,7 +2406,7 @@ object BackupRepository {
}
controller.update(
title = AppDependencies.application.getString(R.string.BackupProgressService_title),
title = AppDependencies.application.getString(RegistrationR.string.MessageSyncScreen__syncing_messages),
progress = 0f,
indeterminate = true
)
@@ -6,6 +6,7 @@
package org.thoughtcrime.securesms.backup.v2
import org.signal.core.util.LongSerializer
import org.signal.libsignal.zkgroup.backups.BackupLevel
/**
* Serializable enum value for what we think a user's current backup tier is.
@@ -18,6 +19,13 @@ enum class MessageBackupTier(val value: Int) {
FREE(0),
PAID(1);
fun toBackupLevel(): Long {
return when (this) {
FREE -> BackupLevel.FREE.value.toLong()
PAID -> BackupLevel.PAID.value.toLong()
}
}
companion object Serializer : LongSerializer<MessageBackupTier?> {
override fun serialize(data: MessageBackupTier?): Long {
return data?.value?.toLong() ?: -1
@@ -26,5 +34,13 @@ enum class MessageBackupTier(val value: Int) {
override fun deserialize(data: Long): MessageBackupTier? {
return entries.firstOrNull { it.value == data.toInt() }
}
fun fromBackupLevel(backupLevel: Long?): MessageBackupTier? {
return when (backupLevel) {
BackupLevel.FREE.value.toLong() -> FREE
BackupLevel.PAID.value.toLong() -> PAID
else -> null
}
}
}
}
@@ -26,6 +26,7 @@ import org.signal.core.util.toJson
import org.signal.libsignal.crypto.Aes256Ctr32
import org.thoughtcrime.securesms.backup.LocalExportProgress
import org.thoughtcrime.securesms.backup.v2.BackupRepository
import org.thoughtcrime.securesms.backup.v2.ImportResult
import org.thoughtcrime.securesms.database.AttachmentTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.keyvalue.SignalStore
@@ -269,13 +270,18 @@ object LocalArchiver {
val mainStreamLength = snapshotFileSystem.mainLength() ?: return ArchiveResult.failure(RestoreFailure.MainStream)
BackupRepository.importLocal(
val importResult = BackupRepository.importLocal(
mainStreamFactory = { snapshotFileSystem.mainInputStream()!! },
mainStreamLength = mainStreamLength,
selfData = selfData,
backupId = backupId,
messageBackupKey = messageBackupKey
)
if (importResult is ImportResult.Failure) {
Log.w(TAG, "Local backup import failed")
return RestoreResult.failure(RestoreFailure.ImportFailed)
}
} finally {
metadataStream?.close()
}
@@ -350,6 +356,7 @@ object LocalArchiver {
data object MainStream : RestoreFailure
data object Cancelled : RestoreFailure
data object BackupIdMissing : RestoreFailure
data object ImportFailed : RestoreFailure
data class VersionMismatch(val backupVersion: Int, val supportedVersion: Int) : RestoreFailure
}
@@ -16,7 +16,6 @@ import org.signal.core.models.database.AttachmentId
import org.signal.core.util.UuidUtil
import org.signal.core.util.logging.Log
import org.signal.core.util.toByteArray
import org.signal.libsignal.zkgroup.backups.BackupLevel
import org.signal.mediasend.SentMediaQuality
import org.thoughtcrime.securesms.backup.v2.ExportState
import org.thoughtcrime.securesms.backup.v2.ImportState
@@ -127,7 +126,7 @@ object AccountDataArchiveProcessor {
hasCompletedUsernameOnboarding = signalStore.uiHintValues.hasCompletedUsernameOnboarding(),
customChatColors = db.chatColorsTable.getSavedChatColors().toRemoteChatColors().also { colors -> exportState.customChatColorIds.addAll(colors.map { it.id }) },
optimizeOnDeviceStorage = signalStore.backupValues.optimizeStorage && signalStore.backupValues.backupTier == MessageBackupTier.PAID,
backupTier = signalStore.backupValues.backupTier.toRemoteBackupTier(),
backupTier = signalStore.backupValues.backupTier?.toBackupLevel(),
defaultSentMediaQuality = signalStore.settingsValues.sentMediaQuality.toRemoteSentMediaQuality(),
autoDownloadSettings = AccountData.AutoDownloadSettings(
images = getRemoteAutoDownloadOption("image", mobileAutoDownload, wifiAutoDownload),
@@ -277,7 +276,7 @@ object AccountDataArchiveProcessor {
SignalStore.story.userHasSeenGroupStoryEducationSheet = settings.hasSeenGroupStoryEducationSheet
SignalStore.story.viewedReceiptsEnabled = settings.storyViewReceiptsEnabled ?: settings.readReceipts
SignalStore.backup.optimizeStorage = settings.optimizeOnDeviceStorage
SignalStore.backup.backupTier = settings.backupTier?.toLocalBackupTier()
SignalStore.backup.backupTier = MessageBackupTier.fromBackupLevel(settings.backupTier)
SignalStore.settings.sentMediaQuality = settings.defaultSentMediaQuality.toLocalSentMediaQuality()
SignalStore.settings.setTheme(settings.appTheme.toLocalTheme())
SignalStore.settings.setCallDataMode(settings.callsUseLessDataSetting.toLocalCallDataMode())
@@ -456,22 +455,6 @@ object AccountDataArchiveProcessor {
}
}
private fun MessageBackupTier?.toRemoteBackupTier(): Long? {
return when (this) {
MessageBackupTier.FREE -> BackupLevel.FREE.value.toLong()
MessageBackupTier.PAID -> BackupLevel.PAID.value.toLong()
null -> null
}
}
private fun Long?.toLocalBackupTier(): MessageBackupTier? {
return when (this) {
BackupLevel.FREE.value.toLong() -> MessageBackupTier.FREE
BackupLevel.PAID.value.toLong() -> MessageBackupTier.PAID
else -> null
}
}
private fun SentMediaQuality.toRemoteSentMediaQuality(): AccountData.SentMediaQuality {
return when (this) {
SentMediaQuality.STANDARD -> AccountData.SentMediaQuality.STANDARD
@@ -49,7 +49,7 @@ object BackupAlertDelegate {
}
private suspend fun displayBackupDownloadNotifier(fragmentManager: FragmentManager) {
val downloadYourBackupToday = withContext(SignalDispatchers.IO) { BackupRepository.getDownloadYourBackupData() }
val downloadYourBackupToday = withContext(SignalDispatchers.Default) { BackupRepository.getDownloadYourBackupData() }
when (downloadYourBackupToday?.type) {
BackupDownloadNotifierState.Type.SHEET -> {
Log.d(TAG, "Displaying 'Download your backup today' sheet.")
@@ -145,7 +145,7 @@ class MessageBackupsFlowViewModel(
}
} catch (e: Exception) {
Log.d(TAG, "Failed to handle purchase.", e)
withContext(SignalDispatchers.IO) {
withContext(SignalDispatchers.Default) {
InAppPaymentsRepository.handlePipelineError(
inAppPaymentId = id,
error = e
@@ -297,7 +297,7 @@ class MessageBackupsFlowViewModel(
private fun validateTypeAndUpdateState(state: MessageBackupsFlowState): MessageBackupsFlowState {
return when (state.selectedMessageBackupTier!!) {
MessageBackupTier.FREE -> {
viewModelScope.launch(SignalDispatchers.IO) {
viewModelScope.launch(SignalDispatchers.Default) {
SignalDatabase.recipients.markNeedsSync(Recipient.self().id)
StorageSyncHelper.scheduleSyncForDataChange()
}
@@ -40,7 +40,7 @@ class MonthlyDonationCanceledViewModel(
private fun initializeFromInAppPaymentId(inAppPaymentId: InAppPaymentTable.InAppPaymentId) {
viewModelScope.launch {
val inAppPayment = withContext(Dispatchers.IO) {
val inAppPayment = withContext(Dispatchers.Default) {
SignalDatabase.inAppPayments.getById(inAppPaymentId)
}
@@ -8,6 +8,7 @@ package org.thoughtcrime.securesms.banner
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.runtime.Composable
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow
/**
* This class represents a banner across the top of the screen.
@@ -28,6 +29,12 @@ abstract class Banner<Model> {
*/
abstract val dataFlow: Flow<Model>
/**
* Emits whenever this banner's [enabled] state may have changed.
*/
open val stateUpdates: Flow<Unit>
get() = emptyFlow()
/**
* Composable function to display the content emitted from [dataFlow].
* You likely want to use [org.thoughtcrime.securesms.banner.ui.compose.DefaultBanner].
@@ -15,6 +15,11 @@ import androidx.compose.ui.platform.ViewCompositionStrategy
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.onStart
import org.signal.core.ui.compose.theme.SignalTheme
import org.signal.core.util.logging.Log
@@ -32,6 +37,22 @@ class BannerManager @JvmOverloads constructor(
val TAG = Log.tag(BannerManager::class)
}
private fun selectEnabledBanner(): Banner<Any>? = banners.firstOrNull { it.enabled } as Banner<Any>?
/**
* Reactively selects which [Banner] should be shown. Re-evaluates whenever any banner signals via
* [Banner.stateUpdates] that its eligibility may have changed.
*/
private val selectedBanner: Flow<Banner<Any>?> =
if (banners.isEmpty()) {
flowOf(null)
} else {
merge(*banners.map { it.stateUpdates }.toTypedArray())
.onStart { emit(Unit) }
.map { selectEnabledBanner() }
.distinctUntilChanged()
}
/**
* Re-evaluates the [Banner]s, choosing one to render (if any) and updating the view.
*/
@@ -39,19 +60,20 @@ class BannerManager @JvmOverloads constructor(
composeView.apply {
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
setContent {
val banner: Banner<Any>? = banners.firstOrNull { it.enabled } as Banner<Any>?
if (banner == null) {
val banner: Banner<Any>? by selectedBanner.collectAsStateWithLifecycle(initialValue = selectEnabledBanner())
val selected = banner
if (selected == null) {
onNoBannerShownListener()
return@setContent
}
key(banner) {
val bannerState by banner.dataFlow.collectAsStateWithLifecycle(initialValue = null)
key(selected) {
val bannerState by selected.dataFlow.collectAsStateWithLifecycle(initialValue = null)
bannerState?.let { model ->
SignalTheme {
Box {
banner.DisplayBanner(model, PaddingValues(horizontal = 12.dp, vertical = 8.dp))
selected.DisplayBanner(model, PaddingValues(horizontal = 12.dp, vertical = 8.dp))
}
}
onNewBannerShownListener()
@@ -66,16 +88,14 @@ class BannerManager @JvmOverloads constructor(
*/
@Composable
fun Banner() {
val banner: Banner<Any>? = banners.firstOrNull { it.enabled } as Banner<Any>?
if (banner == null) {
return
}
val banner: Banner<Any>? by selectedBanner.collectAsStateWithLifecycle(initialValue = selectEnabledBanner())
val selected = banner ?: return
key(banner) {
val bannerState by banner.dataFlow.collectAsStateWithLifecycle(initialValue = null)
key(selected) {
val bannerState by selected.dataFlow.collectAsStateWithLifecycle(initialValue = null)
bannerState?.let { model ->
banner.DisplayBanner(model, PaddingValues(horizontal = 12.dp, vertical = 8.dp))
selected.DisplayBanner(model, PaddingValues(horizontal = 12.dp, vertical = 8.dp))
}
}
}
@@ -9,7 +9,9 @@ import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.runtime.Composable
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import org.thoughtcrime.securesms.backup.v2.ArchiveRestoreProgress
import org.thoughtcrime.securesms.backup.v2.ArchiveRestoreProgressState
@@ -32,6 +34,12 @@ class ArchiveRestoreStatusBanner(private val listener: RestoreProgressBannerList
}
}
override val stateUpdates: Flow<Unit>
get() = ArchiveRestoreProgress.stateFlow
.map { enabled }
.distinctUntilChanged()
.map { }
@Composable
override fun DisplayBanner(model: ArchiveRestoreProgressState, contentPadding: PaddingValues) {
ArchiveRestoreStatusBanner(
@@ -40,7 +48,6 @@ class ArchiveRestoreStatusBanner(private val listener: RestoreProgressBannerList
onActionClick = listener::onActionClick,
onDismissClick = {
ArchiveRestoreProgress.clearFinishedStatus()
listener.onDismissComplete()
}
)
}
@@ -48,6 +55,5 @@ class ArchiveRestoreStatusBanner(private val listener: RestoreProgressBannerList
interface RestoreProgressBannerListener {
fun onBannerClick()
fun onActionClick(data: ArchiveRestoreProgressState)
fun onDismissComplete()
}
}
@@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.runtime.Composable
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import org.signal.core.util.bytes
import org.thoughtcrime.securesms.backup.ArchiveUploadProgress
@@ -78,6 +79,12 @@ class ArchiveUploadStatusBanner(private val listener: UploadProgressBannerListen
}
}
override val stateUpdates: Flow<Unit>
get() = ArchiveUploadProgress.progress
.map { enabled }
.distinctUntilChanged()
.map { }
@Composable
override fun DisplayBanner(model: ArchiveUploadStatusBannerViewState, contentPadding: PaddingValues) {
ArchiveUploadStatusBannerView(
@@ -92,7 +99,7 @@ class ArchiveUploadStatusBanner(private val listener: UploadProgressBannerListen
}
ArchiveUploadStatusBannerViewEvents.HideClicked -> {
SignalStore.backup.uploadBannerVisible = false
listener.onHidden()
ArchiveUploadProgress.triggerUpdate()
}
}
}
@@ -105,6 +112,5 @@ class ArchiveUploadStatusBanner(private val listener: UploadProgressBannerListen
interface UploadProgressBannerListener {
fun onBannerClick()
fun onCancelClicked()
fun onHidden()
}
}
@@ -12,7 +12,10 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.rx3.asFlow
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Previews
import org.thoughtcrime.securesms.R
@@ -22,6 +25,7 @@ import org.thoughtcrime.securesms.banner.ui.compose.DefaultBanner
import org.thoughtcrime.securesms.banner.ui.compose.Importance
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.net.DeviceTransferBlockingInterceptor
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.registration.ui.RegistrationActivity
import org.thoughtcrime.securesms.util.TextSecurePreferences
@@ -36,28 +40,47 @@ class UnauthorizedBanner(val context: Context) : Banner<Unit>() {
override val dataFlow: Flow<Unit>
get() = flowOf(Unit)
override val stateUpdates: Flow<Unit>
get() = Recipient.self()
.live()
.observable()
.asFlow()
.map { enabled }
.distinctUntilChanged()
.map { }
@Composable
override fun DisplayBanner(model: Unit, contentPadding: PaddingValues) {
Banner(contentPadding)
Banner(contentPadding, SignalStore.account.isLinkedDevice)
}
}
@Composable
private fun Banner(contentPadding: PaddingValues) {
private fun Banner(contentPadding: PaddingValues, isLinkedDevice: Boolean) {
val context = LocalContext.current
DefaultBanner(
title = null,
body = stringResource(id = R.string.UnauthorizedReminder_this_is_likely_because_you_registered_your_phone_number_with_Signal_on_a_different_device),
body = stringResource(
id = if (isLinkedDevice) {
R.string.UnauthorizedReminder_this_device_is_no_longer_linked_relink_to_continue_messaging
} else {
R.string.UnauthorizedReminder_this_is_likely_because_you_registered_your_phone_number_with_Signal_on_a_different_device
}
),
importance = Importance.ERROR,
actions = listOf(
Action(R.string.UnauthorizedReminder_reregister_action) {
Action(if (isLinkedDevice) R.string.UnauthorizedReminder_relink_action else R.string.UnauthorizedReminder_reregister_action) {
if (SignalStore.misc.isOldDeviceTransferLocked) {
SignalStore.misc.isOldDeviceTransferLocked = false
DeviceTransferBlockingInterceptor.getInstance().unblockNetwork()
}
val registrationIntent = RegistrationActivity.newIntentForReRegistration(context)
val registrationIntent = if (isLinkedDevice) {
RegistrationActivity.newIntentForReLinkDevice(context)
} else {
RegistrationActivity.newIntentForReRegistration(context)
}
context.startActivity(registrationIntent)
}
),
@@ -69,6 +92,6 @@ private fun Banner(contentPadding: PaddingValues) {
@Composable
private fun BannerPreview() {
Previews.Preview {
Banner(PaddingValues(0.dp))
Banner(PaddingValues(0.dp), isLinkedDevice = false)
}
}
@@ -26,6 +26,10 @@ class UsernameOutOfSyncBanner(private val onActionClick: (UsernameSyncState) ->
override val enabled: Boolean
get() {
if (SignalStore.account.isLinkedDevice) {
return false
}
return when (SignalStore.account.usernameSyncState) {
AccountValues.UsernameSyncState.USERNAME_AND_LINK_CORRUPTED -> true
AccountValues.UsernameSyncState.LINK_CORRUPTED -> true
@@ -110,6 +110,9 @@ object CallLinks {
} catch (_: UnsupportedEncodingException) {
Log.w(TAG, "Invalid url: $url")
return null
} catch (_: IllegalArgumentException) {
Log.w(TAG, "Invalid url: $url")
return null
}
val key = fragmentQuery[ROOT_KEY]
@@ -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)
@@ -48,11 +48,15 @@ class SignalProgressDialog private constructor(
}
fun hide() {
dialog.hide()
if (dialog.window?.decorView?.isAttachedToWindow == true) {
dialog.hide()
}
}
fun dismiss() {
dialog.dismiss()
if (dialog.window?.decorView?.isAttachedToWindow == true) {
dialog.dismiss()
}
}
companion object {
@@ -90,7 +90,11 @@ class AppSettingsActivity : DSLSettingsActivity(), GooglePayComponent {
is AppSettingsRoute.BackupsRoute.Backups -> AppSettingsFragmentDirections.actionDirectToBackupsSettingsFragment().setLaunchCheckoutFlow(appSettingsRoute.launchCheckoutFlow)
AppSettingsRoute.Invite -> AppSettingsFragmentDirections.actionDirectToInviteFragment()
AppSettingsRoute.DataAndStorageRoute.DataAndStorage -> AppSettingsFragmentDirections.actionDirectToStoragePreferenceFragment()
AppSettingsRoute.AccountRoute.Account -> AppSettingsFragmentDirections.actionDirectToAccountSettingsFragment()
AppSettingsRoute.AccountRoute.Account -> if (SignalStore.account.isPrimaryDevice) {
AppSettingsFragmentDirections.actionDirectToAccountSettingsFragment()
} else {
AppSettingsFragmentDirections.actionDirectToLinkedDeviceAccountSettingsFragment()
}
else -> error("Unsupported start location: ${appSettingsRoute?.javaClass?.name}")
}
}
@@ -76,6 +76,7 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaym
import org.thoughtcrime.securesms.components.settings.app.subscription.completed.InAppPaymentsBottomSheetDelegate
import org.thoughtcrime.securesms.compose.rememberStatusBarColorNestedScrollModifier
import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.profiles.ProfileName
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.CommunicationActions
@@ -96,7 +97,11 @@ class AppSettingsFragment : ComposeFragment(), Callbacks {
appSettingsRouter.currentRoute.collect { route ->
when (route) {
is AppSettingsRoute.BackupsRoute.Remote -> findNavController().safeNavigate(R.id.action_appSettingsFragment_to_remoteBackupsSettingsFragment)
is AppSettingsRoute.AccountRoute.Account -> findNavController().safeNavigate(R.id.action_appSettingsFragment_to_accountSettingsFragment)
is AppSettingsRoute.AccountRoute.Account -> if (SignalStore.account.isPrimaryDevice) {
findNavController().safeNavigate(R.id.action_appSettingsFragment_to_accountSettingsFragment)
} else {
findNavController().safeNavigate(R.id.action_appSettingsFragment_to_linkedDeviceAccountSettingsFragment)
}
is AppSettingsRoute.LinkDeviceRoute.LinkDevice -> findNavController().safeNavigate(R.id.action_appSettingsFragment_to_linkDeviceFragment)
is AppSettingsRoute.DonationsRoute.Donations -> findNavController().safeNavigate(R.id.action_appSettingsFragment_to_manageDonationsFragment)
is AppSettingsRoute.AppearanceRoute.Appearance -> findNavController().safeNavigate(R.id.action_appSettingsFragment_to_appearanceSettingsFragment)
@@ -180,7 +185,7 @@ class AppSettingsFragment : ComposeFragment(), Callbacks {
@StringRes toastSuccessStringRes: Int
) {
lifecycleScope.launch {
val subscriber = withContext(Dispatchers.IO) {
val subscriber = withContext(Dispatchers.Default) {
InAppPaymentsRepository.getSubscriber(subscriberType)
}
@@ -292,17 +297,17 @@ private fun AppSettingsContent(
BackupFailureState.NONE -> Unit
}
if (state.isPrimaryDevice) {
item {
Rows.TextRow(
text = stringResource(R.string.AccountSettingsFragment__account),
icon = painterResource(CoreUiR.drawable.symbol_person_circle_24),
onClick = {
callbacks.navigate(AppSettingsRoute.AccountRoute.Account)
}
)
}
item {
Rows.TextRow(
text = stringResource(R.string.AccountSettingsFragment__account),
icon = painterResource(CoreUiR.drawable.symbol_person_circle_24),
onClick = {
callbacks.navigate(AppSettingsRoute.AccountRoute.Account)
}
)
}
if (state.isPrimaryDevice) {
item {
Rows.TextRow(
text = stringResource(R.string.preferences__linked_devices),
@@ -412,20 +417,20 @@ private fun AppSettingsContent(
)
}
if (state.isPrimaryDevice) {
item {
Rows.TextRow(
icon = SignalIcons.Backup.imageVector,
text = stringResource(R.string.preferences_chats__backups),
onClick = {
callbacks.navigate(AppSettingsRoute.BackupsRoute.Backups())
},
onLongClick = {
callbacks.copyRemoteBackupsSubscriberIdToClipboard()
},
enabled = isRegisteredAndUpToDate
)
}
item {
Rows.TextRow(
icon = SignalIcons.Backup.imageVector,
text = stringResource(R.string.preferences_chats__backups),
onClick = {
callbacks.navigate(AppSettingsRoute.BackupsRoute.Backups())
},
onLongClick = if (state.isPrimaryDevice) {
{ callbacks.copyRemoteBackupsSubscriberIdToClipboard() }
} else {
null
},
enabled = isRegisteredAndUpToDate
)
}
item {
@@ -0,0 +1,142 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.account
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.fragment.app.FragmentManager
import org.signal.core.ui.BottomSheetUtil
import org.signal.core.ui.compose.BottomSheets
import org.signal.core.ui.compose.ComposeBottomSheetDialogFragment
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.SignalIcons
import org.signal.core.ui.compose.horizontalGutters
import org.thoughtcrime.securesms.R
/**
* Bottom sheet shown from the linked-device Account settings screen explaining what linked devices
* are and that account management lives on the primary device.
*/
class LinkedDeviceAccountLearnMoreBottomSheet : ComposeBottomSheetDialogFragment() {
override val peekHeightPercentage: Float = 0.75f
companion object {
@JvmStatic
fun show(fragmentManager: FragmentManager) {
LinkedDeviceAccountLearnMoreBottomSheet()
.show(fragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
}
}
@Composable
override fun SheetContent() {
LinkedDeviceAccountLearnMoreSheet()
}
}
@Composable
private fun LinkedDeviceAccountLearnMoreSheet() {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxWidth()
.horizontalGutters()
.padding(bottom = 48.dp)
) {
BottomSheets.Handle()
Icon(
imageVector = SignalIcons.Devices.imageVector,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier
.padding(top = 16.dp, bottom = 24.dp)
.clip(RoundedCornerShape(50))
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.12f))
.padding(horizontal = 20.dp, vertical = 6.dp)
.size(40.dp)
)
val linkedDevices = stringResource(R.string.LinkedDeviceAccountLearnMoreBottomSheet__linked_devices)
val text = stringResource(R.string.LinkedDeviceAccountLearnMoreBottomSheet__linked_devices_let_you_access)
val annotatedText = remember(text, linkedDevices) {
buildAnnotatedString {
val start = text.indexOf(linkedDevices)
if (start >= 0) {
append(text.substring(0, start))
withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) {
append(linkedDevices)
}
append(text.substring(start + linkedDevices.length))
} else {
append(text)
}
}
}
Text(
text = annotatedText,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 20.dp)
)
BulletRow(stringResource(R.string.LinkedDeviceAccountLearnMoreBottomSheet__you_can_link_a_desktop))
BulletRow(stringResource(R.string.LinkedDeviceAccountLearnMoreBottomSheet__your_primary_device_manages))
BulletRow(stringResource(R.string.LinkedDeviceAccountLearnMoreBottomSheet__some_account_settings))
}
}
@Composable
private fun BulletRow(text: String) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 12.dp),
verticalAlignment = Alignment.Top
) {
Text(
text = "\u2022",
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(end = 8.dp)
)
Text(
text = text,
style = MaterialTheme.typography.bodyLarge
)
}
}
@DayNightPreviews
@Composable
private fun LinkedDeviceAccountLearnMoreSheetPreview() {
Previews.BottomSheetContentPreview {
LinkedDeviceAccountLearnMoreSheet()
}
}
@@ -0,0 +1,29 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.account
sealed interface LinkedDeviceAccountSettingsEvent {
/** The user tapped "Learn more" on the linked-device callout. */
data object LearnMoreClicked : LinkedDeviceAccountSettingsEvent
/** The user tapped the navigation (back) icon. */
data object NavigateBackClicked : LinkedDeviceAccountSettingsEvent
/** The user tapped the "Delete app data" row. */
data object DeleteAppDataClicked : LinkedDeviceAccountSettingsEvent
/** The user confirmed the delete in the confirmation dialog. */
data object DeleteConfirmed : LinkedDeviceAccountSettingsEvent
/** The user dismissed the delete confirmation dialog. */
data object DeleteDismissed : LinkedDeviceAccountSettingsEvent
/** The fragment reported that clearing application data failed. */
data object DataWipeFailed : LinkedDeviceAccountSettingsEvent
/** The fragment has handled the current [LinkedDeviceAccountSettingsState.oneTimeEvent]. */
data object ConsumeOneTimeEvent : LinkedDeviceAccountSettingsEvent
}
@@ -0,0 +1,220 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.account
import android.widget.Toast
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.fragment.app.viewModels
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.ComposeFragment
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Dialogs
import org.signal.core.ui.compose.Dividers
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.Rows
import org.signal.core.ui.compose.Scaffolds
import org.signal.core.ui.compose.SignalIcons
import org.signal.core.ui.compose.theme.SignalTheme
import org.signal.core.util.ServiceUtil
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.app.account.LinkedDeviceAccountSettingsState.OneTimeEvent
import org.thoughtcrime.securesms.compose.rememberStatusBarColorNestedScrollModifier
import org.thoughtcrime.securesms.dependencies.AppDependencies
/**
* Account settings shown when the current device is a linked (non-primary) device. Account
* management lives on the primary device, so this screen only surfaces an informational callout
* and the ability to delete the Signal data stored on this device.
*/
class LinkedDeviceAccountSettingsFragment : ComposeFragment() {
private val viewModel: LinkedDeviceAccountSettingsViewModel by viewModels()
@Composable
override fun FragmentContent() {
val state by viewModel.state.collectAsStateWithLifecycle()
LaunchedEffect(state.oneTimeEvent) {
viewModel.onEvent(LinkedDeviceAccountSettingsEvent.ConsumeOneTimeEvent)
when (state.oneTimeEvent) {
OneTimeEvent.OpenLearnMore -> LinkedDeviceAccountLearnMoreBottomSheet.show(childFragmentManager)
OneTimeEvent.NavigateBack -> requireActivity().onBackPressedDispatcher.onBackPressed()
OneTimeEvent.WipeData -> {
if (!ServiceUtil.getActivityManager(AppDependencies.application).clearApplicationUserData()) {
viewModel.onEvent(LinkedDeviceAccountSettingsEvent.DataWipeFailed)
}
}
OneTimeEvent.DeleteFailed -> Toast.makeText(requireContext(), R.string.preferences_account_delete_all_data_failed, Toast.LENGTH_LONG).show()
null -> Unit
}
}
LinkedDeviceAccountSettingsScreen(
state = state,
onEvent = viewModel::onEvent
)
}
}
@Composable
private fun LinkedDeviceAccountSettingsScreen(
state: LinkedDeviceAccountSettingsState,
onEvent: (LinkedDeviceAccountSettingsEvent) -> Unit
) {
Scaffolds.Settings(
title = stringResource(R.string.AccountSettingsFragment__account),
onNavigationClick = { onEvent(LinkedDeviceAccountSettingsEvent.NavigateBackClicked) },
navigationIcon = SignalIcons.ArrowStart.imageVector
) { paddingValues ->
LazyColumn(
modifier = Modifier
.padding(paddingValues)
.then(rememberStatusBarColorNestedScrollModifier())
) {
item {
LinkedDeviceCallout(onLearnMoreClick = { onEvent(LinkedDeviceAccountSettingsEvent.LearnMoreClicked) })
}
item {
Dividers.Default()
}
item {
Rows.TextRow(
text = stringResource(R.string.LinkedDeviceAccountSettingsFragment__delete_app_data),
label = stringResource(R.string.LinkedDeviceAccountSettingsFragment__this_will_remove_all_data),
onClick = { onEvent(LinkedDeviceAccountSettingsEvent.DeleteAppDataClicked) }
)
}
}
}
if (state.showDeleteConfirmationDialog) {
DeleteAppDataConfirmationDialog(
onConfirm = { onEvent(LinkedDeviceAccountSettingsEvent.DeleteConfirmed) },
onDismiss = { onEvent(LinkedDeviceAccountSettingsEvent.DeleteDismissed) }
)
}
if (state.deleting) {
Dialogs.IndeterminateProgressDialog()
}
}
@Composable
private fun LinkedDeviceCallout(
onLearnMoreClick: () -> Unit
) {
Row(
modifier = Modifier
.padding(horizontal = 24.dp, vertical = 12.dp)
.fillMaxWidth()
.background(color = SignalTheme.colors.colorSurface2, shape = RoundedCornerShape(12.dp))
.padding(20.dp)
) {
Icon(
imageVector = SignalIcons.Devices.imageVector,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurface
)
Spacer(modifier = Modifier.width(24.dp))
Column {
Text(
text = stringResource(R.string.LinkedDeviceAccountSettingsFragment__this_is_a_linked_device),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringResource(R.string.LinkedDeviceAccountSettingsFragment__to_manage_your_account_settings),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(12.dp))
Buttons.Small(
onClick = onLearnMoreClick,
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.secondaryContainer, contentColor = MaterialTheme.colorScheme.onSecondaryContainer)
) {
Text(text = stringResource(R.string.LearnMoreTextView_learn_more))
}
}
}
}
@Composable
private fun DeleteAppDataConfirmationDialog(
onConfirm: () -> Unit,
onDismiss: () -> Unit
) {
Dialogs.SimpleAlertDialog(
title = stringResource(R.string.LinkedDeviceAccountSettingsFragment__delete_app_data_question),
body = stringResource(R.string.LinkedDeviceAccountSettingsFragment__this_will_delete_all_data_and_messages),
confirm = stringResource(R.string.delete),
confirmColor = MaterialTheme.colorScheme.error,
onConfirm = onConfirm,
dismiss = stringResource(android.R.string.cancel),
onDismiss = onDismiss
)
}
@DayNightPreviews
@Composable
private fun LinkedDeviceAccountSettingsScreenPreview() {
Previews.Preview {
LinkedDeviceAccountSettingsScreen(
state = LinkedDeviceAccountSettingsState(),
onEvent = {}
)
}
}
@DayNightPreviews
@Composable
private fun LinkedDeviceAccountSettingsScreenDeleteConfirmationPreview() {
Previews.Preview {
LinkedDeviceAccountSettingsScreen(
state = LinkedDeviceAccountSettingsState(showDeleteConfirmationDialog = true),
onEvent = {}
)
}
}
@DayNightPreviews
@Composable
private fun LinkedDeviceAccountSettingsScreenDeletingPreview() {
Previews.Preview {
LinkedDeviceAccountSettingsScreen(
state = LinkedDeviceAccountSettingsState(deleting = true),
onEvent = {}
)
}
}
@@ -0,0 +1,20 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.account
data class LinkedDeviceAccountSettingsState(
val showDeleteConfirmationDialog: Boolean = false,
val deleting: Boolean = false,
val oneTimeEvent: OneTimeEvent? = null
) {
/** One-time side effects the fragment must carry out (they need Android/fragment context). Consumed via [LinkedDeviceAccountSettingsEvent.ConsumeOneTimeEvent]. */
sealed interface OneTimeEvent {
data object OpenLearnMore : OneTimeEvent
data object NavigateBack : OneTimeEvent
data object WipeData : OneTimeEvent
data object DeleteFailed : OneTimeEvent
}
}
@@ -0,0 +1,58 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.account
import androidx.annotation.VisibleForTesting
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.withContext
import org.signal.core.ui.compose.EventDrivenViewModel
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.components.settings.app.account.LinkedDeviceAccountSettingsState.OneTimeEvent
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.linkdevice.LinkDeviceRepository
/**
* Drives the account settings screen shown on a linked (non-primary) device. Owns the screen state
* and, on delete, makes a best-effort attempt to unlink this device from the server before the
* fragment wipes local data.
*/
class LinkedDeviceAccountSettingsViewModel(
private val selfDeviceId: () -> Int = { SignalStore.account.deviceId },
private val removeDevice: suspend (Int) -> Boolean = LinkDeviceRepository::removeDevice
) : EventDrivenViewModel<LinkedDeviceAccountSettingsEvent>(TAG) {
companion object {
private val TAG = Log.tag(LinkedDeviceAccountSettingsViewModel::class)
}
private val _state = MutableStateFlow(LinkedDeviceAccountSettingsState())
val state: StateFlow<LinkedDeviceAccountSettingsState> = _state.asStateFlow()
override suspend fun processEvent(event: LinkedDeviceAccountSettingsEvent) {
applyEvent(_state.value, event) { _state.value = it }
}
@VisibleForTesting
suspend fun applyEvent(state: LinkedDeviceAccountSettingsState, event: LinkedDeviceAccountSettingsEvent, stateEmitter: (LinkedDeviceAccountSettingsState) -> Unit) {
when (event) {
LinkedDeviceAccountSettingsEvent.LearnMoreClicked -> stateEmitter(state.copy(oneTimeEvent = OneTimeEvent.OpenLearnMore))
LinkedDeviceAccountSettingsEvent.NavigateBackClicked -> stateEmitter(state.copy(oneTimeEvent = OneTimeEvent.NavigateBack))
LinkedDeviceAccountSettingsEvent.DeleteAppDataClicked -> stateEmitter(state.copy(showDeleteConfirmationDialog = true))
LinkedDeviceAccountSettingsEvent.DeleteDismissed -> stateEmitter(state.copy(showDeleteConfirmationDialog = false))
LinkedDeviceAccountSettingsEvent.DeleteConfirmed -> {
val deletingState = state.copy(showDeleteConfirmationDialog = false, deleting = true)
stateEmitter(deletingState)
withContext(Dispatchers.IO) { removeDevice(selfDeviceId()) }
stateEmitter(deletingState.copy(oneTimeEvent = OneTimeEvent.WipeData))
}
LinkedDeviceAccountSettingsEvent.DataWipeFailed -> stateEmitter(state.copy(deleting = false, oneTimeEvent = OneTimeEvent.DeleteFailed))
LinkedDeviceAccountSettingsEvent.ConsumeOneTimeEvent -> stateEmitter(state.copy(oneTimeEvent = null))
}
}
}
@@ -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()
}
@@ -35,6 +35,11 @@ import androidx.compose.ui.platform.LocalLocale
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.withLink
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.fragment.app.viewModels
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -58,6 +63,7 @@ import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsType
import org.thoughtcrime.securesms.components.settings.app.subscription.MessageBackupsCheckoutLauncher.createBackupsCheckoutLauncher
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.DateUtils
import org.thoughtcrime.securesms.util.Environment
import org.thoughtcrime.securesms.util.navigation.safeNavigate
@@ -86,7 +92,7 @@ class BackupsSettingsFragment : ComposeFragment() {
findNavController().safeNavigate(R.id.action_backupsSettingsFragment_to_remoteBackupsSettingsFragment)
}
if (savedInstanceState == null && args.launchCheckoutFlow) {
if (savedInstanceState == null && args.launchCheckoutFlow && SignalStore.account.isPrimaryDevice) {
checkoutLauncher.launch(null)
}
}
@@ -103,7 +109,9 @@ class BackupsSettingsFragment : ComposeFragment() {
is BackupState.Error -> Unit
BackupState.None -> {
checkoutLauncher.launch(null)
if (!state.isLinkedDevice) {
checkoutLauncher.launch(null)
}
}
else -> {
@@ -112,13 +120,14 @@ class BackupsSettingsFragment : ComposeFragment() {
}
},
onOnDeviceBackupsRowClick = {
if (SignalStore.backup.newLocalBackupsEnabled || (Environment.Backups.isNewFormatSupportedForLocalBackup() && !SignalStore.settings.isBackupEnabled)) {
if (state.isLinkedDevice || SignalStore.backup.newLocalBackupsEnabled || (Environment.Backups.isNewFormatSupportedForLocalBackup() && !SignalStore.settings.isBackupEnabled)) {
findNavController().safeNavigate(R.id.action_backupsSettingsFragment_to_localBackupsFragment)
} else {
findNavController().safeNavigate(R.id.action_backupsSettingsFragment_to_backupsPreferenceFragment)
}
},
onBackupTierInternalOverrideChanged = { viewModel.onBackupTierInternalOverrideChanged(it) }
onBackupTierInternalOverrideChanged = { viewModel.onBackupTierInternalOverrideChanged(it) },
onLearnMoreClick = { CommunicationActions.openBrowserLink(requireContext(), "https://support.signal.org/hc/articles/360007059752") }
)
}
}
@@ -129,7 +138,8 @@ private fun BackupsSettingsContent(
onNavigationClick: () -> Unit = {},
onBackupsRowClick: () -> Unit = {},
onOnDeviceBackupsRowClick: () -> Unit = {},
onBackupTierInternalOverrideChanged: (MessageBackupTier?) -> Unit = {}
onBackupTierInternalOverrideChanged: (MessageBackupTier?) -> Unit = {},
onLearnMoreClick: () -> Unit = {}
) {
Scaffolds.Settings(
title = stringResource(R.string.preferences_chats__backups),
@@ -166,21 +176,29 @@ private fun BackupsSettingsContent(
}
item {
val displayActionButton = !backupsSettingsState.isLinkedDevice
when (backupsSettingsState.backupState) {
is BackupState.LocalStore -> {
LocalStoreBackupRow(
backupState = backupsSettingsState.backupState,
lastBackupAt = backupsSettingsState.lastBackupAt,
onBackupsRowClick = onBackupsRowClick
onBackupsRowClick = onBackupsRowClick,
displayActionButton = displayActionButton
)
OtherWaysToBackUpHeading()
}
is BackupState.Inactive -> {
InactiveBackupsRow(
onBackupsRowClick = onBackupsRowClick
)
if (backupsSettingsState.isLinkedDevice) {
BackupsOffLinkedDeviceRow(onLearnMoreClick = onLearnMoreClick)
} else {
InactiveBackupsRow(
onBackupsRowClick = onBackupsRowClick,
displayActionButton = true
)
}
OtherWaysToBackUpHeading()
}
@@ -189,23 +207,30 @@ private fun BackupsSettingsContent(
ActiveBackupsRow(
backupState = backupsSettingsState.backupState,
onBackupsRowClick = onBackupsRowClick,
lastBackupAt = backupsSettingsState.lastBackupAt
lastBackupAt = backupsSettingsState.lastBackupAt,
displayActionButton = true
)
OtherWaysToBackUpHeading()
}
BackupState.None -> {
NeverEnabledBackupsRow(
onBackupsRowClick = onBackupsRowClick
)
if (backupsSettingsState.isLinkedDevice) {
BackupsOffLinkedDeviceRow(onLearnMoreClick = onLearnMoreClick)
} else {
NeverEnabledBackupsRow(
onBackupsRowClick = onBackupsRowClick,
displayActionButton = true
)
}
OtherWaysToBackUpHeading()
}
is BackupState.Error -> {
WaitingForNetworkRow(
onBackupsRowClick = onBackupsRowClick
onBackupsRowClick = onBackupsRowClick,
displayActionButton = displayActionButton
)
OtherWaysToBackUpHeading()
@@ -213,7 +238,8 @@ private fun BackupsSettingsContent(
BackupState.NotFound -> {
NotFoundBackupRow(
onBackupsRowClick = onBackupsRowClick
onBackupsRowClick = onBackupsRowClick,
displayActionButton = displayActionButton
)
OtherWaysToBackUpHeading()
@@ -221,7 +247,8 @@ private fun BackupsSettingsContent(
is BackupState.Pending -> {
PendingBackupRow(
onBackupsRowClick = onBackupsRowClick
onBackupsRowClick = onBackupsRowClick,
displayActionButton = displayActionButton
)
OtherWaysToBackUpHeading()
@@ -231,7 +258,8 @@ private fun BackupsSettingsContent(
ActiveBackupsRow(
backupState = backupsSettingsState.backupState,
lastBackupAt = backupsSettingsState.lastBackupAt,
onBackupsRowClick = onBackupsRowClick
onBackupsRowClick = onBackupsRowClick,
displayActionButton = displayActionButton
)
OtherWaysToBackUpHeading()
@@ -262,7 +290,8 @@ private fun OtherWaysToBackUpHeading() {
@Composable
private fun NeverEnabledBackupsRow(
onBackupsRowClick: () -> Unit = {}
onBackupsRowClick: () -> Unit = {},
displayActionButton: Boolean = true
) {
Rows.TextRow(
modifier = Modifier.wrapContentHeight(),
@@ -291,13 +320,15 @@ private fun NeverEnabledBackupsRow(
style = MaterialTheme.typography.bodyMedium
)
Buttons.MediumTonal(
onClick = onBackupsRowClick,
modifier = Modifier.padding(top = 12.dp)
) {
Text(
text = stringResource(R.string.BackupsSettingsFragment_set_up)
)
if (displayActionButton) {
Buttons.MediumTonal(
onClick = onBackupsRowClick,
modifier = Modifier.padding(top = 12.dp)
) {
Text(
text = stringResource(R.string.BackupsSettingsFragment_set_up)
)
}
}
}
}
@@ -305,12 +336,57 @@ private fun NeverEnabledBackupsRow(
}
@Composable
private fun WaitingForNetworkRow(onBackupsRowClick: () -> Unit = {}) {
private fun BackupsOffLinkedDeviceRow(
onLearnMoreClick: () -> Unit = {}
) {
val description = buildAnnotatedString {
append(stringResource(R.string.BackupsSettingsFragment__automatic_backups_get_started_on_your_phone))
append(" ")
withLink(LinkAnnotation.Clickable(tag = "learn-more") { onLearnMoreClick() }) {
withStyle(SpanStyle(color = MaterialTheme.colorScheme.primary)) {
append(stringResource(R.string.RemoteBackupsSettingsFragment__learn_more))
}
}
}
Rows.TextRow(
modifier = Modifier.wrapContentHeight(),
icon = {
Box(
modifier = Modifier
.padding(top = 12.dp)
.align(Alignment.Top)
) {
Icon(
painter = SignalIcons.Backup.painter,
contentDescription = null
)
}
},
text = {
Column {
Text(
text = stringResource(R.string.RemoteBackupsSettingsFragment__signal_backups),
style = MaterialTheme.typography.bodyLarge
)
Text(
text = description,
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodyMedium
)
}
}
)
}
@Composable
private fun WaitingForNetworkRow(onBackupsRowClick: () -> Unit = {}, displayActionButton: Boolean = true) {
Rows.TextRow(
text = {
Column {
Text(text = stringResource(R.string.RemoteBackupsSettingsFragment__waiting_for_network))
ViewSettingsButton(onBackupsRowClick)
ViewSettingsButton(onBackupsRowClick, displayActionButton)
}
},
icon = {
@@ -321,7 +397,8 @@ private fun WaitingForNetworkRow(onBackupsRowClick: () -> Unit = {}) {
@Composable
private fun InactiveBackupsRow(
onBackupsRowClick: () -> Unit = {}
onBackupsRowClick: () -> Unit = {},
displayActionButton: Boolean = true
) {
Rows.TextRow(
text = {
@@ -336,7 +413,7 @@ private fun InactiveBackupsRow(
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
ViewSettingsButton(onBackupsRowClick)
ViewSettingsButton(onBackupsRowClick, displayActionButton)
}
},
icon = {
@@ -354,7 +431,8 @@ private fun InactiveBackupsRow(
@Composable
private fun NotFoundBackupRow(
onBackupsRowClick: () -> Unit = {}
onBackupsRowClick: () -> Unit = {},
displayActionButton: Boolean = true
) {
Rows.TextRow(
modifier = Modifier.wrapContentHeight(),
@@ -382,7 +460,7 @@ private fun NotFoundBackupRow(
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
ViewSettingsButton(onBackupsRowClick)
ViewSettingsButton(onBackupsRowClick, displayActionButton)
}
}
)
@@ -390,7 +468,8 @@ private fun NotFoundBackupRow(
@Composable
private fun PendingBackupRow(
onBackupsRowClick: () -> Unit = {}
onBackupsRowClick: () -> Unit = {},
displayActionButton: Boolean = true
) {
Rows.TextRow(
modifier = Modifier.wrapContentHeight(),
@@ -418,14 +497,18 @@ private fun PendingBackupRow(
style = MaterialTheme.typography.bodyMedium
)
ViewSettingsButton(onBackupsRowClick)
ViewSettingsButton(onBackupsRowClick, displayActionButton)
}
}
)
}
@Composable
private fun ViewSettingsButton(onClick: () -> Unit) {
private fun ViewSettingsButton(onClick: () -> Unit, visible: Boolean = true) {
if (!visible) {
return
}
Buttons.MediumTonal(
onClick = onClick,
modifier = Modifier.padding(top = 12.dp)
@@ -440,7 +523,8 @@ private fun ViewSettingsButton(onClick: () -> Unit) {
private fun LocalStoreBackupRow(
backupState: BackupState.LocalStore,
lastBackupAt: Duration,
onBackupsRowClick: () -> Unit
onBackupsRowClick: () -> Unit,
displayActionButton: Boolean = true
) {
Rows.TextRow(
modifier = Modifier.wrapContentHeight(),
@@ -475,7 +559,7 @@ private fun LocalStoreBackupRow(
)
LastBackedUpText(lastBackupAt)
ViewSettingsButton(onBackupsRowClick)
ViewSettingsButton(onBackupsRowClick, displayActionButton)
}
}
)
@@ -485,7 +569,8 @@ private fun LocalStoreBackupRow(
private fun ActiveBackupsRow(
backupState: BackupState.WithTypeAndRenewalTime,
lastBackupAt: Duration,
onBackupsRowClick: () -> Unit = {}
onBackupsRowClick: () -> Unit = {},
displayActionButton: Boolean = true
) {
Rows.TextRow(
modifier = Modifier.wrapContentHeight(),
@@ -552,7 +637,7 @@ private fun ActiveBackupsRow(
LastBackedUpText(lastBackupAt)
ViewSettingsButton(onBackupsRowClick)
ViewSettingsButton(onBackupsRowClick, displayActionButton)
}
}
)
@@ -644,7 +729,8 @@ private fun BackupsSettingsContentPreview() {
renewalTime = 0.seconds,
price = FiatMoney(BigDecimal.valueOf(4), Currency.getInstance("CAD"))
),
lastBackupAt = 0.seconds
lastBackupAt = 0.seconds,
isLinkedDevice = false
)
)
}
@@ -652,14 +738,21 @@ private fun BackupsSettingsContentPreview() {
@DayNightPreviews
@Composable
private fun BackupsSettingsContentBackupTierInternalOverridePreview() {
private fun BackupsSettingsContentLinkedDevicePreview() {
Previews.Preview {
BackupsSettingsContent(
backupsSettingsState = BackupsSettingsState(
backupState = BackupState.None,
showBackupTierInternalOverride = true,
backupTierInternalOverride = null,
lastBackupAt = 0.seconds
backupState = BackupState.ActivePaid(
messageBackupsType = MessageBackupsType.Paid(
pricePerMonth = FiatMoney(BigDecimal.valueOf(2.99), Currency.getInstance("USD")),
storageAllowanceBytes = 1_000_000,
mediaTtl = 30.days
),
renewalTime = 0.seconds,
price = FiatMoney(BigDecimal.valueOf(2.99), Currency.getInstance("USD"))
),
lastBackupAt = 0.seconds,
isLinkedDevice = true
)
)
}
@@ -758,3 +851,11 @@ private fun NeverEnabledBackupsRowPreview() {
NeverEnabledBackupsRow()
}
}
@DayNightPreviews
@Composable
private fun BackupsOffLinkedDeviceRowPreview() {
Previews.Preview {
BackupsOffLinkedDeviceRow()
}
}
@@ -17,5 +17,6 @@ data class BackupsSettingsState(
val backupState: BackupState,
val lastBackupAt: Duration = SignalStore.backup.lastBackupTime.milliseconds,
val showBackupTierInternalOverride: Boolean = false,
val backupTierInternalOverride: MessageBackupTier? = null
val backupTierInternalOverride: MessageBackupTier? = null,
val isLinkedDevice: Boolean = SignalStore.account.isLinkedDevice
)
@@ -15,6 +15,7 @@ import kotlinx.coroutines.launch
import org.signal.core.util.concurrent.SignalDispatchers
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.backup.DeletionState
import org.thoughtcrime.securesms.backup.v2.BackupRepository
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.keyvalue.SignalStore
@@ -44,14 +45,14 @@ class BackupsSettingsViewModel : ViewModel() {
it.copy(
backupState = enabledState,
lastBackupAt = SignalStore.backup.lastBackupTime.milliseconds,
showBackupTierInternalOverride = Environment.IS_STAGING,
showBackupTierInternalOverride = Environment.IS_STAGING && SignalStore.account.isPrimaryDevice,
backupTierInternalOverride = SignalStore.backup.backupTierInternalOverride
)
}
}
}
viewModelScope.launch(Dispatchers.IO) {
viewModelScope.launch(Dispatchers.Default) {
SignalStore.backup.lastBackupTimeFlow
.collect { lastBackupTime ->
internalStateFlow.update {
@@ -59,6 +60,12 @@ class BackupsSettingsViewModel : ViewModel() {
}
}
}
if (SignalStore.account.isLinkedDevice) {
viewModelScope.launch(Dispatchers.IO) {
BackupRepository.refreshBackupFileTimestamp()
}
}
}
fun onBackupTierInternalOverrideChanged(tier: MessageBackupTier?) {
@@ -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()
}
@@ -452,7 +452,8 @@ private fun RemoteBackupsSettingsContent(
state = state.backupState,
onLearnMoreClick = contentCallbacks::onLearnMoreAboutLostSubscription,
onRenewClick = contentCallbacks::onRenewLostSubscription,
isRenewEnabled = backupDeleteState.isIdle()
isRenewEnabled = backupDeleteState.isIdle(),
isLinkedDevice = state.isLinkedDevice
)
}
@@ -464,7 +465,8 @@ private fun RemoteBackupsSettingsContent(
onBackupTypeActionButtonClicked = contentCallbacks::onBackupTypeActionClick,
isPaidTierPricingAvailable = state.isPaidTierPricingAvailable,
isGooglePlayServicesAvailable = state.isGooglePlayServicesAvailable,
buttonsEnabled = backupDeleteState.isIdle()
buttonsEnabled = backupDeleteState.isIdle(),
isLinkedDevice = state.isLinkedDevice
)
}
@@ -473,13 +475,16 @@ private fun RemoteBackupsSettingsContent(
title = stringResource(R.string.RemoteBackupsSettingsFragment__your_subscription_was_not_found),
onRenewClick = contentCallbacks::onRenewLostSubscription,
onLearnMoreClick = contentCallbacks::onLearnMoreAboutLostSubscription,
isRenewEnabled = backupDeleteState.isIdle()
isRenewEnabled = backupDeleteState.isIdle(),
isLinkedDevice = state.isLinkedDevice
)
}
}
}
if (backupDeleteState != DeletionState.NONE && backupDeleteState != DeletionState.CLEAR_LOCAL_STATE) {
if (state.isLinkedDevice) {
appendReducedBackupDetailsItems(state)
} else if (backupDeleteState != DeletionState.NONE && backupDeleteState != DeletionState.CLEAR_LOCAL_STATE) {
appendBackupDeletionItems(
backupDeleteState = backupDeleteState,
backupRestoreState = backupRestoreState,
@@ -973,12 +978,58 @@ private fun LazyListScope.appendBackupDetailsItems(
}
}
private fun LazyListScope.appendReducedBackupDetailsItems(
state: RemoteBackupsSettingsState
) {
item {
Dividers.Default()
}
item {
Texts.SectionHeader(text = stringResource(id = R.string.RemoteBackupsSettingsFragment__backup_details))
}
item {
ReadOnlyLastBackupRow(lastBackupTimestamp = state.lastBackupTimestamp)
}
}
@Composable
private fun ReadOnlyLastBackupRow(
lastBackupTimestamp: Long
) {
val label = if (lastBackupTimestamp > 0) {
val context = LocalContext.current
val day = remember(lastBackupTimestamp) {
DateUtils.getDayPrecisionTimeString(context, Locale.getDefault(), lastBackupTimestamp)
}
val time = remember(lastBackupTimestamp) {
DateUtils.getOnlyTimeString(context, lastBackupTimestamp)
}
stringResource(
id = R.string.RemoteBackupsSettingsFragment__your_phone_s,
stringResource(id = R.string.RemoteBackupsSettingsFragment__s_at_s, day, time)
)
} else {
stringResource(id = R.string.RemoteBackupsSettingsFragment__never)
}
Rows.TextRow(
text = stringResource(id = R.string.RemoteBackupsSettingsFragment__last_backup),
label = label
)
}
@Composable
private fun BackupCard(
backupState: BackupState.WithTypeAndRenewalTime,
isPaidTierPricingAvailable: Boolean,
isGooglePlayServicesAvailable: Boolean,
buttonsEnabled: Boolean,
isLinkedDevice: Boolean = false,
onBackupTypeActionButtonClicked: (MessageBackupTier) -> Unit = {}
) {
val messageBackupsType = backupState.messageBackupsType
@@ -1070,7 +1121,21 @@ private fun BackupCard(
)
}
if (backupState.isActive() && isPaidTierPricingAvailable && isGooglePlayServicesAvailable) {
if (isLinkedDevice) {
val primaryDeviceText = when (backupState) {
is BackupState.ActivePaid -> stringResource(R.string.RemoteBackupsSettingsFragment__you_can_manage_or_cancel_your_subscription_on_your_primary_device)
is BackupState.Canceled -> stringResource(R.string.RemoteBackupsSettingsFragment__you_can_manage_or_renew_your_subscription_on_your_primary_device)
is BackupState.ActiveFree -> stringResource(R.string.RemoteBackupsSettingsFragment__you_can_manage_or_upgrade_your_subscription_on_your_primary_device)
else -> stringResource(R.string.RemoteBackupsSettingsFragment__you_can_manage_or_upgrade_backups_on_your_primary_device)
}
Text(
text = primaryDeviceText,
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(top = 12.dp)
)
} else if (backupState.isActive() && isPaidTierPricingAvailable && isGooglePlayServicesAvailable) {
val buttonText = when (messageBackupsType) {
is MessageBackupsType.Paid -> stringResource(R.string.RemoteBackupsSettingsFragment__manage_or_cancel)
is MessageBackupsType.Free -> stringResource(R.string.RemoteBackupsSettingsFragment__upgrade)
@@ -1270,6 +1335,7 @@ private fun PendingCard(
private fun SubscriptionNotFoundCard(
title: String,
isRenewEnabled: Boolean,
isLinkedDevice: Boolean = false,
onRenewClick: () -> Unit = {},
onLearnMoreClick: () -> Unit = {}
) {
@@ -1308,38 +1374,47 @@ private fun SubscriptionNotFoundCard(
}
}
Row(
horizontalArrangement = spacedBy(16.dp)
) {
Buttons.MediumTonal(
onClick = onRenewClick,
colors = ButtonDefaults.filledTonalButtonColors().copy(
containerColor = SignalTheme.colors.colorTransparent5,
contentColor = colorResource(CoreUiR.color.signal_light_colorOnSurface)
),
modifier = Modifier
.padding(top = 24.dp)
.weight(1f)
if (isLinkedDevice) {
Text(
text = stringResource(R.string.RemoteBackupsSettingsFragment__you_can_manage_or_upgrade_backups_on_your_primary_device),
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(top = 12.dp)
)
} else {
Row(
horizontalArrangement = spacedBy(16.dp)
) {
Text(
text = stringResource(R.string.RemoteBackupsSettingsFragment__renew)
)
}
Buttons.MediumTonal(
onClick = onRenewClick,
colors = ButtonDefaults.filledTonalButtonColors().copy(
containerColor = SignalTheme.colors.colorTransparent5,
contentColor = colorResource(CoreUiR.color.signal_light_colorOnSurface)
),
modifier = Modifier
.padding(top = 24.dp)
.weight(1f)
) {
Text(
text = stringResource(R.string.RemoteBackupsSettingsFragment__renew)
)
}
Buttons.MediumTonal(
onClick = onLearnMoreClick,
enabled = isRenewEnabled,
colors = ButtonDefaults.filledTonalButtonColors().copy(
containerColor = SignalTheme.colors.colorTransparent5,
contentColor = colorResource(CoreUiR.color.signal_light_colorOnSurface)
),
modifier = Modifier
.padding(top = 24.dp)
.weight(1f)
) {
Text(
text = stringResource(R.string.RemoteBackupsSettingsFragment__learn_more)
)
Buttons.MediumTonal(
onClick = onLearnMoreClick,
enabled = isRenewEnabled,
colors = ButtonDefaults.filledTonalButtonColors().copy(
containerColor = SignalTheme.colors.colorTransparent5,
contentColor = colorResource(CoreUiR.color.signal_light_colorOnSurface)
),
modifier = Modifier
.padding(top = 24.dp)
.weight(1f)
) {
Text(
text = stringResource(R.string.RemoteBackupsSettingsFragment__learn_more)
)
}
}
}
}
@@ -1349,6 +1424,7 @@ private fun SubscriptionNotFoundCard(
private fun SubscriptionMismatchMissingGooglePlayCard(
state: BackupState.SubscriptionMismatchMissingGooglePlay,
isRenewEnabled: Boolean,
isLinkedDevice: Boolean = false,
onRenewClick: () -> Unit = {},
onLearnMoreClick: () -> Unit = {}
) {
@@ -1357,6 +1433,7 @@ private fun SubscriptionMismatchMissingGooglePlayCard(
SubscriptionNotFoundCard(
title = pluralStringResource(R.plurals.RemoteBackupsSettingsFragment__your_subscription_on_this_device_is_valid, days.toInt(), days),
isRenewEnabled = isRenewEnabled,
isLinkedDevice = isLinkedDevice,
onRenewClick = onRenewClick,
onLearnMoreClick = onLearnMoreClick
)
@@ -1847,6 +1924,64 @@ private fun RemoteBackupsSettingsInternalUserContentPreview() {
}
}
@DayNightPreviews
@Composable
private fun RemoteBackupsSettingsLinkedDevicePaidContentPreview() {
Previews.Preview {
RemoteBackupsSettingsContent(
state = RemoteBackupsSettingsState(
backupsEnabled = true,
isLinkedDevice = true,
lastBackupTimestamp = -1,
canBackUpUsingCellular = false,
canRestoreUsingCellular = false,
dialog = RemoteBackupsSettingsState.Dialog.NONE,
snackbar = RemoteBackupsSettingsState.Snackbar.NONE,
backupMediaSize = 2300000,
backupState = BackupState.ActivePaid(
messageBackupsType = MessageBackupsType.Paid(
pricePerMonth = FiatMoney(BigDecimal.valueOf(2.99), Currency.getInstance("USD")),
storageAllowanceBytes = 1_000_000,
mediaTtl = 30.days
),
price = FiatMoney(BigDecimal.valueOf(2.99), Currency.getInstance("USD")),
renewalTime = 1_752_710_400.seconds
)
),
statusBarColorNestedScrollConnection = null,
backupDeleteState = DeletionState.NONE,
backupRestoreState = BackupRestoreState.None,
contentCallbacks = ContentCallbacks.Empty,
backupProgress = null
)
}
}
@DayNightPreviews
@Composable
private fun RemoteBackupsSettingsLinkedDeviceNotFoundContentPreview() {
Previews.Preview {
RemoteBackupsSettingsContent(
state = RemoteBackupsSettingsState(
backupsEnabled = true,
isLinkedDevice = true,
lastBackupTimestamp = -1,
canBackUpUsingCellular = false,
canRestoreUsingCellular = false,
dialog = RemoteBackupsSettingsState.Dialog.NONE,
snackbar = RemoteBackupsSettingsState.Snackbar.NONE,
backupMediaSize = 2300000,
backupState = BackupState.NotFound
),
statusBarColorNestedScrollConnection = null,
backupDeleteState = DeletionState.NONE,
backupRestoreState = BackupRestoreState.None,
contentCallbacks = ContentCallbacks.Empty,
backupProgress = null
)
}
}
@DayNightPreviews
@Composable
private fun RedemptionErrorAlertPreview() {
@@ -1875,10 +2010,8 @@ private fun ErrorCardPreview() {
@Composable
private fun PendingCardPreview() {
Previews.Preview {
val locale = LocalLocale.current.platformLocale
PendingCard(
price = FiatMoney(BigDecimal.TEN, Currency.getInstance(locale))
price = FiatMoney(BigDecimal.TEN, Currency.getInstance(Locale.US))
)
}
}
@@ -35,7 +35,8 @@ data class RemoteBackupsSettingsState(
val backupCreationError: BackupValues.BackupCreationError? = null,
val lastMessageCutoffTime: Long = 0,
val freeTierMediaRetentionDays: Int = -1,
val isGooglePlayServicesAvailable: Boolean = false
val isGooglePlayServicesAvailable: Boolean = false,
val isLinkedDevice: Boolean = false
) {
data class BackupMediaDetails(
@@ -79,7 +79,8 @@ class RemoteBackupsSettingsViewModel : ViewModel() {
internalUser = RemoteConfig.internalUser,
includeDebuglog = SignalStore.internal.includeDebuglogInBackup.takeIf { RemoteConfig.internalUser },
backupCreationError = SignalStore.backup.backupCreationError,
lastMessageCutoffTime = SignalStore.backup.lastUsedMessageCutoffTime
lastMessageCutoffTime = SignalStore.backup.lastUsedMessageCutoffTime,
isLinkedDevice = SignalStore.account.isLinkedDevice
)
)
@@ -92,6 +93,14 @@ class RemoteBackupsSettingsViewModel : ViewModel() {
private var forQuickRestore = false
init {
if (state.value.isLinkedDevice) {
initLinkedDevice()
} else {
initPrimaryDevice()
}
}
private fun initPrimaryDevice() {
ArchiveUploadProgress.triggerUpdate()
viewModelScope.launch(Dispatchers.IO) {
@@ -143,7 +152,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,11 +203,34 @@ class RemoteBackupsSettingsViewModel : ViewModel() {
}
}
viewModelScope.launch(Dispatchers.IO) {
viewModelScope.launch(Dispatchers.Default) {
BackupRepository.maybeFixAnyDanglingUploadProgress()
}
}
/**
* Render remote backups as read-only and refresh the last-backup time from the CDN.
*/
private fun initLinkedDevice() {
viewModelScope.launch(Dispatchers.IO) {
BackupStateObserver(viewModelScope, useDatabaseFallbackOnNetworkError = true).backupState.collect { backupState ->
_state.update {
it.copy(backupState = backupState)
}
}
}
viewModelScope.launch(Dispatchers.Default) {
SignalStore.backup.lastBackupTimeFlow.collect { lastBackupTime ->
_state.update { it.copy(lastBackupTimestamp = lastBackupTime) }
}
}
viewModelScope.launch(Dispatchers.IO) {
BackupRepository.refreshBackupFileTimestamp()
}
}
fun setCanBackUpUsingCellular(canBackUpUsingCellular: Boolean) {
SignalStore.backup.backupWithCellular = canBackUpUsingCellular
_state.update {
@@ -259,6 +291,10 @@ class RemoteBackupsSettingsViewModel : ViewModel() {
}
fun refresh() {
if (state.value.isLinkedDevice) {
return
}
viewModelScope.launch(Dispatchers.IO) {
val id = SignalDatabase.inAppPayments.getLatestInAppPaymentByType(InAppPaymentType.RECURRING_BACKUP)?.id
@@ -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)
}
@@ -588,19 +588,6 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
dividerPref()
sectionHeaderPref(DSLSettingsText.from("Media"))
switchPref(
title = DSLSettingsText.from("Enable HEVC Encoding for HD Videos"),
summary = DSLSettingsText.from("Videos sent in \"HD\" quality will be encoded in HEVC on compatible devices."),
isChecked = state.hevcEncoding,
onClick = {
viewModel.setHevcEncoding(!state.hevcEncoding)
}
)
dividerPref()
sectionHeaderPref(DSLSettingsText.from("Conversations and Shortcuts"))
clickPref(
@@ -28,7 +28,6 @@ data class InternalSettingsState(
val pnpInitialized: Boolean,
val useConversationItemV2ForMedia: Boolean,
val hasPendingOneTimeDonation: Boolean,
val hevcEncoding: Boolean,
val forceSplitPane: Boolean,
val forceSinglePane: Boolean,
val useNewMediaActivity: Boolean,
@@ -152,11 +152,6 @@ class InternalSettingsViewModel(private val repository: InternalSettingsReposito
refresh()
}
fun setHevcEncoding(enabled: Boolean) {
SignalStore.internal.hevcEncoding = enabled
refresh()
}
fun addSampleReleaseNote(callToAction: String = "action") {
repository.addSampleReleaseNote(callToAction)
}
@@ -242,7 +237,6 @@ class InternalSettingsViewModel(private val repository: InternalSettingsReposito
pnpInitialized = SignalStore.misc.hasPniInitializedDevices,
useConversationItemV2ForMedia = SignalStore.internal.useConversationItemV2Media,
hasPendingOneTimeDonation = SignalStore.inAppPayments.getPendingOneTimeDonation() != null,
hevcEncoding = SignalStore.internal.hevcEncoding,
forceSplitPane = SignalStore.internal.forceSplitPane,
forceSinglePane = SignalStore.internal.forceSinglePane,
useNewMediaActivity = SignalStore.internal.useNewMediaActivity,
@@ -352,7 +352,7 @@ class InternalBackupPlaygroundViewModel : ViewModel() {
}
fun loadStats() {
viewModelScope.launch(Dispatchers.IO) {
viewModelScope.launch(Dispatchers.Default) {
launch {
var stats = SignalDatabase.attachments.debugGetAttachmentStats()
@@ -398,7 +398,7 @@ class InternalBackupPlaygroundViewModel : ViewModel() {
return@withContext false
}
suspend fun clearLocalMediaBackupState() = withContext(Dispatchers.IO) {
suspend fun clearLocalMediaBackupState() = withContext(Dispatchers.Default) {
SignalDatabase.attachments.clearAllArchiveData()
}
@@ -41,7 +41,7 @@ class DataSeedingPlaygroundViewModel(application: Application) : AndroidViewMode
val state: StateFlow<DataSeedingPlaygroundState> = _state.asStateFlow()
fun loadThreads() {
viewModelScope.launch(Dispatchers.IO) {
viewModelScope.launch(Dispatchers.Default) {
try {
val threads = mutableListOf<ThreadWithRecipient>()
val cursor: Cursor = SignalDatabase.threads.getRecentConversationList(
@@ -42,7 +42,7 @@ class InternalIssuesViewModel(application: Application) : AndroidViewModel(appli
private fun load() {
viewModelScope.launch {
allIssues = withContext(Dispatchers.IO) { issues.getRecent() }
allIssues = withContext(Dispatchers.Default) { issues.getRecent() }
_state.update { it.copy(loading = false, notificationPriority = SignalStore.internal.issueNotificationPriority).withVisibleIssues() }
}
}
@@ -54,7 +54,7 @@ class InternalIssuesViewModel(application: Application) : AndroidViewModel(appli
private fun clearAll() {
viewModelScope.launch {
withContext(Dispatchers.IO) { issues.clear() }
withContext(Dispatchers.Default) { issues.clear() }
allIssues = emptyList()
_state.update { it.copy(nameFilter = null, expandedIds = emptySet()).withVisibleIssues() }
}
@@ -27,7 +27,7 @@ class InternalSqlitePlaygroundViewModel : ViewModel() {
get() = _queryResults
fun onQuerySubmitted(query: String) {
viewModelScope.launch(Dispatchers.IO) {
viewModelScope.launch(Dispatchers.Default) {
_queryResults.value = null
val startTime = System.nanoTime()
@@ -33,7 +33,7 @@ class NotificationsSettingsViewModel(private val sharedPreferences: SharedPrefer
// Calculating slow notification stuff isn't thread-safe, so we do it without to start off so we have most state populated, then fetch it in the background.
store.update { getState(calculateSlowNotifications = false) }
viewModelScope.launch(Dispatchers.IO) {
viewModelScope.launch(Dispatchers.Default) {
store.update { getState(calculateSlowNotifications = true) }
}
}
@@ -71,8 +71,10 @@ class AdvancedPrivacySettingsViewModel(
fun setAllowAutomaticVerification(enabled: Boolean) {
SignalStore.settings.automaticVerificationEnabled = enabled
SignalStore.misc.hasKeyTransparencyFailure = false
SignalStore.misc.hasSeenKeyTransparencyFailure = false
refresh()
viewModelScope.launch(SignalDispatchers.IO) {
viewModelScope.launch(SignalDispatchers.Default) {
if (!enabled) {
SignalDatabase.recipients.clearAllKeyTransparencyData()
SignalStore.account.distinguishedHead = null
@@ -67,7 +67,9 @@ class ExpireTimerSettingsFragment : ComposeFragment() {
if (state.isGroupCreate) {
requireActivity().setResult(Activity.RESULT_OK, Intent().putExtra(FOR_RESULT_VALUE, saveState.result))
}
requireActivity().onNavigateUp()
if (!NavHostFragment.findNavController(this).popBackStack()) {
requireActivity().onNavigateUp()
}
}
is ProcessState.Failure -> {
@@ -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) }
@@ -132,6 +133,7 @@ class ManageStorageSettingsViewModel : ViewModel() {
private suspend fun getOnDeviceStorageOptimizationState(): OnDeviceStorageOptimizationState {
return when {
!SignalStore.account.isPrimaryDevice -> OnDeviceStorageOptimizationState.FEATURE_NOT_AVAILABLE
!SignalStore.backup.areBackupsEnabled || !BackupUpgradeAvailabilityChecker.isUpgradeAvailable(AppDependencies.application) -> OnDeviceStorageOptimizationState.FEATURE_NOT_AVAILABLE
SignalStore.backup.backupTier != MessageBackupTier.PAID -> OnDeviceStorageOptimizationState.REQUIRES_PAID_TIER
SignalStore.backup.optimizeStorage -> OnDeviceStorageOptimizationState.ENABLED
@@ -179,7 +181,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()
@@ -9,6 +9,7 @@ import android.content.Context
import android.content.Intent
import android.os.Parcelable
import androidx.activity.result.contract.ActivityResultContract
import androidx.annotation.VisibleForTesting
import androidx.fragment.app.Fragment
import io.reactivex.rxjava3.subjects.PublishSubject
import io.reactivex.rxjava3.subjects.Subject
@@ -26,7 +27,8 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.GooglePay
class CheckoutFlowActivity : FragmentWrapperActivity(), GooglePayComponent {
companion object {
private const val ARG_IN_APP_PAYMENT_TYPE = "in_app_payment_type"
@VisibleForTesting
const val ARG_IN_APP_PAYMENT_TYPE = "in_app_payment_type"
const val RESULT_DATA = "result_data"
fun createIntent(context: Context, inAppPaymentType: InAppPaymentType): Intent {
@@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.components.settings.app.subscription.donate
import android.text.SpannableStringBuilder
import android.view.View
import android.view.ViewGroup
import androidx.annotation.VisibleForTesting
import androidx.appcompat.widget.Toolbar
import androidx.core.content.ContextCompat
import androidx.core.os.bundleOf
@@ -78,7 +79,8 @@ class DonateToSignalFragment :
companion object {
private const val ARG = "in_app_payment_type"
@VisibleForTesting
const val ARG = "in_app_payment_type"
@JvmStatic
fun create(inAppPaymentType: InAppPaymentType): DialogFragment {
@@ -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)!!
}
@@ -93,12 +93,14 @@ private fun Loading() {
@Composable
private fun Ready(state: GatewaySelectorState.Ready, onEvent: (GatewaySelectorBottomSheetEvent) -> Unit) {
Spacer(modifier = Modifier.size(38.dp))
BadgeImage112(
badge = state.inAppPayment.data.badge!!.let { Badges.fromDatabaseBadge(it) },
modifier = Modifier.size(112.dp)
modifier = Modifier.size(80.dp)
)
Spacer(modifier = Modifier.size(12.dp))
Spacer(modifier = Modifier.size(16.dp))
TitleAndSubtitle(state.inAppPayment)

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