mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-07-28 08:55:01 +01:00
Compare commits
201 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 205549360a | |||
| 09f8f456f6 | |||
| 4d94ad8929 | |||
| e3e86d1b65 | |||
| b535d3f473 | |||
| 0b7eda3e94 | |||
| 2162fccd7d | |||
| 9ca47f5fc5 | |||
| e458b3ff66 | |||
| 7a34150319 | |||
| 71bd8f9ec3 | |||
| ad3d537d92 | |||
| b019aba3fc | |||
| bd5c60d442 | |||
| a57572cf64 | |||
| 59bd04d9b7 | |||
| 7f4829c80c | |||
| 70be67b267 | |||
| 6dc0229e72 | |||
| 338283eff5 | |||
| 9804c6cf5e | |||
| 072185ec36 | |||
| d239f503a8 | |||
| 12785f35c9 | |||
| c8dfc74d41 | |||
| 5b42b115db | |||
| 22f8996d3f | |||
| a5fe925958 | |||
| ba4d92de45 | |||
| 3e8190637c | |||
| 7ea3679a24 | |||
| 5a6c41e166 | |||
| 263d7b7df1 | |||
| be9ae5c8f2 | |||
| 635b5795ea | |||
| cfce0ad8f1 | |||
| d55d1a2acd | |||
| 0451ea188d | |||
| 01a2599c3c | |||
| 682e5e4a01 | |||
| 11ea9cb7b4 | |||
| 687818fbb5 | |||
| 7a2c084b49 | |||
| dfe6ae812d | |||
| 8b1c2bf2af | |||
| ab61b79028 | |||
| 2d04bb32fd | |||
| 836645ed2b | |||
| dd32a92104 | |||
| 4817b5a7ac | |||
| e0603b14bb | |||
| 5f22566e10 | |||
| 6f5d7c4b7b | |||
| 393f58c833 | |||
| d25ac71907 | |||
| 0df6c66d08 | |||
| 9abe34bd69 | |||
| 8e54254ffa | |||
| 0b6cd03115 | |||
| 07500f62ab | |||
| 3cbc61cd11 | |||
| 1be2aeaf2b | |||
| b9012e2820 | |||
| e9249f33a5 | |||
| 7af82fbc87 | |||
| 8b71bde890 | |||
| 1ae5e884ec | |||
| a30cacdb0c | |||
| 0a18d01801 | |||
| 4aa5447967 | |||
| a92079e31b | |||
| 628576817e | |||
| 297b7fedd3 | |||
| c0283fb75f | |||
| 70e768ba05 | |||
| 551a25cfc6 | |||
| 39da95f29b | |||
| 35ca16f294 | |||
| 7e79dfa57f | |||
| abf05293eb | |||
| e81098378f | |||
| 28d2dee617 | |||
| 7ad05dd53c | |||
| 75036acce5 | |||
| 1bebab13db | |||
| ec2a8d37d6 | |||
| ce05c60eb4 | |||
| d818a93928 | |||
| 4af791c903 | |||
| dcef0c9667 | |||
| 974e2efad5 | |||
| 85261afe42 | |||
| 1f7c42c24b | |||
| 4888805873 | |||
| c27dc2a42d | |||
| 17b806ae0e | |||
| 3fbce0c774 | |||
| 7c464a7759 | |||
| 0e1d60bf1d | |||
| 1f5d211b06 | |||
| 81926d1bc4 | |||
| b327d92b82 | |||
| 9a8e0dd64f | |||
| c50757880e | |||
| 99b1fa0ebb | |||
| 74f08927f2 | |||
| 39598a8da0 | |||
| 5949d96cfd | |||
| c9ebc1c12e | |||
| 99ed40580c | |||
| c5b48be263 | |||
| 1742b2ec50 | |||
| 49ef205821 | |||
| 6f81a08669 | |||
| 97a8795100 | |||
| b8508af636 | |||
| c0f861d0b8 | |||
| 59d61184f5 | |||
| 622208831f | |||
| 67b50b499e | |||
| 4cafe16e25 | |||
| 2f70d780d0 | |||
| fd83ad9625 | |||
| b3857e4b35 | |||
| eff0e973aa | |||
| 34f336c2a2 | |||
| b0f71c8613 | |||
| 95019407c2 | |||
| 0101d41cf9 | |||
| 75c18806d5 | |||
| 7a07a96fba | |||
| 4f6c1c4647 | |||
| df4afe4e92 | |||
| 6a18bcc915 | |||
| 06e7b79e3d | |||
| 31f0945db4 | |||
| edc629e7fa | |||
| bdfe06ed74 | |||
| 1d11e29e94 | |||
| f70ae10067 | |||
| 7ca57e5531 | |||
| adad1425d8 | |||
| 693b208fe2 | |||
| e975084b8e | |||
| 3bc693961d | |||
| 2dde0b43b8 | |||
| 88366cd416 | |||
| 4ee2b71b13 | |||
| 86aae39141 | |||
| e71ec7c6d9 | |||
| 20d24d177d | |||
| cfc1ed8d5c | |||
| 25f7c31e75 | |||
| 929a391d50 | |||
| 76f2543308 | |||
| 5eebaa3528 | |||
| 673324f46a | |||
| ea9cd4251e | |||
| ac7d54331a | |||
| 5e3a34e1bc | |||
| fe6a74eb56 | |||
| 6a6b7a1924 | |||
| f50e970b5d | |||
| 32961106e1 | |||
| 8d3728b32a | |||
| 2f3b25c787 | |||
| 5cc7266a9e | |||
| 90cba056f1 | |||
| 07ca23ceae | |||
| 0d0b9d345c | |||
| 7f63af659d | |||
| 66390b4fe4 | |||
| b60f53f4df | |||
| 8bc31386a5 | |||
| 8f4b4aa160 | |||
| 12af41174a | |||
| e7c5af7ea5 | |||
| 51b1dbd470 | |||
| dd38f72f60 | |||
| db8a705704 | |||
| ba43403009 | |||
| 417b9eb50a | |||
| 76c9b4a7dc | |||
| d1b916d61c | |||
| 908bb8dbe6 | |||
| 31216592de | |||
| bcc9f17e41 | |||
| d9dd15eff3 | |||
| 1a12eb992d | |||
| 7c1e010482 | |||
| 8a5826555c | |||
| 8ffbfb4000 | |||
| 16fe25a38a | |||
| c47a74ba09 | |||
| 24287a15e2 | |||
| 77bef0b101 | |||
| e2ef8c2805 | |||
| a52799e069 | |||
| 2cabdd0978 | |||
| 02e134c16f | |||
| fe87ae203a |
+30
-27
@@ -1,27 +1,30 @@
|
||||
version: 2
|
||||
updates:
|
||||
# Automatically keep GitHub Actions SHA-pinned to the latest commit SHAs.
|
||||
# Dependabot will update both the SHA and the inline version comment (e.g. # v6)
|
||||
# while leaving any extra documentation comments intact.
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "monday"
|
||||
labels:
|
||||
- "dependencies"
|
||||
commit-message:
|
||||
prefix: "ci"
|
||||
groups:
|
||||
actions:
|
||||
patterns:
|
||||
- "actions/*"
|
||||
gradle-actions:
|
||||
patterns:
|
||||
- "gradle/*"
|
||||
peter-evans:
|
||||
patterns:
|
||||
- "peter-evans/*"
|
||||
usefulness:
|
||||
patterns:
|
||||
- "usefulness/*"
|
||||
# Dependabot automated schedule is currently disabled.
|
||||
# To re-enable, uncomment the block below.
|
||||
#
|
||||
# version: 2
|
||||
# updates:
|
||||
# # Automatically keep GitHub Actions SHA-pinned to the latest commit SHAs.
|
||||
# # Dependabot will update both the SHA and the inline version comment (e.g. # v6)
|
||||
# # while leaving any extra documentation comments intact.
|
||||
# - package-ecosystem: "github-actions"
|
||||
# directory: "/"
|
||||
# schedule:
|
||||
# interval: "weekly"
|
||||
# day: "monday"
|
||||
# labels:
|
||||
# - "dependencies"
|
||||
# commit-message:
|
||||
# prefix: "ci"
|
||||
# groups:
|
||||
# actions:
|
||||
# patterns:
|
||||
# - "actions/*"
|
||||
# gradle-actions:
|
||||
# patterns:
|
||||
# - "gradle/*"
|
||||
# peter-evans:
|
||||
# patterns:
|
||||
# - "peter-evans/*"
|
||||
# usefulness:
|
||||
# patterns:
|
||||
# - "usefulness/*"
|
||||
|
||||
@@ -16,14 +16,14 @@ jobs:
|
||||
runs-on: ubuntu-latest-8-cores
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
# gh api repos/actions/checkout/commits/v6 --jq '.sha'
|
||||
with:
|
||||
submodules: true
|
||||
lfs: true
|
||||
|
||||
- name: set up JDK 21
|
||||
uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5
|
||||
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # 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() }}
|
||||
|
||||
@@ -16,14 +16,14 @@ jobs:
|
||||
runs-on: ubuntu-latest-8-cores
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
# gh api repos/actions/checkout/commits/v6 --jq '.sha'
|
||||
with:
|
||||
submodules: true
|
||||
ref: ${{ github.event.pull_request.base.sha }}
|
||||
|
||||
- name: set up JDK 21
|
||||
uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5
|
||||
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5
|
||||
# gh api repos/actions/setup-java/commits/v5 --jq '.sha'
|
||||
with:
|
||||
distribution: temurin
|
||||
@@ -61,7 +61,7 @@ jobs:
|
||||
if: steps.cache-base.outputs.cache-hit != 'true'
|
||||
run: mv app/build/outputs/apk/playProd/release/*arm64*.apk diffuse-base.apk
|
||||
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
# gh api repos/actions/checkout/commits/v6 --jq '.sha'
|
||||
with:
|
||||
submodules: true
|
||||
|
||||
@@ -11,7 +11,29 @@ jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- name: Free up disk space
|
||||
run: |
|
||||
# The build runs entirely inside the Docker container, which ships its
|
||||
# own JDK, Android SDK, and NDK. None of the host toolchains are needed.
|
||||
sudo rm -rf /usr/lib/jvm
|
||||
sudo rm -rf /usr/share/dotnet
|
||||
sudo rm -rf /usr/share/swift
|
||||
sudo rm -rf /usr/local/.ghcup
|
||||
sudo rm -rf /usr/local/julia*
|
||||
sudo rm -rf /usr/local/lib/android
|
||||
sudo rm -rf /usr/local/share/chromium
|
||||
sudo rm -rf /opt/microsoft /opt/google
|
||||
sudo rm -rf /opt/az
|
||||
sudo rm -rf /usr/local/share/powershell
|
||||
sudo rm -rf /opt/hostedtoolcache
|
||||
docker system prune -af || true
|
||||
docker builder prune -af || true
|
||||
- name: Disk usage after cleanup
|
||||
if: always()
|
||||
run: |
|
||||
df -h
|
||||
docker system df || true
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
# gh api repos/actions/checkout/commits/v6 --jq '.sha'
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
@@ -23,5 +45,15 @@ jobs:
|
||||
load: true
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
- name: Disk usage after image build
|
||||
if: always()
|
||||
run: |
|
||||
df -h
|
||||
docker system df || true
|
||||
- name: Test build
|
||||
run: docker run --memory=12g --rm -v "$(pwd)":/project -w /project signal-android ./gradlew --no-daemon --max-workers=1 -Dorg.gradle.jvmargs="-Xmx4g -XX:MaxMetaspaceSize=512m" -Dkotlin.compiler.execution.strategy=in-process bundlePlayProdRelease
|
||||
- name: Disk usage after test build
|
||||
if: always()
|
||||
run: |
|
||||
df -h
|
||||
docker system df || true
|
||||
|
||||
@@ -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
|
||||
|
||||
+27
-9
@@ -32,8 +32,8 @@ plugins {
|
||||
val staticIps = Properties().apply { file("static-ips.properties").reader().use { load(it) } }
|
||||
staticIps.stringPropertyNames().forEach { rootProject.extra[it] = staticIps.getProperty(it) }
|
||||
|
||||
val canonicalVersionCode = 1717
|
||||
val canonicalVersionName = "8.19.0"
|
||||
val canonicalVersionCode = 1725
|
||||
val canonicalVersionName = "8.21.0"
|
||||
val currentHotfixVersion = 0
|
||||
val maxHotfixVersions = 100
|
||||
|
||||
@@ -132,6 +132,17 @@ ktlint {
|
||||
version.set("1.5.0")
|
||||
}
|
||||
|
||||
// ktlint only scans convention source dirs, so the shared dirs added to the compile tasks are
|
||||
// otherwise skipped. Add them to the base test/androidTest ktlint tasks so ktlintCheck/format cover them.
|
||||
tasks.withType(org.jlleitschuh.gradle.ktlint.tasks.BaseKtLintCheckTask::class.java).configureEach {
|
||||
if (name.endsWith("OverTestSourceSet") || name.endsWith("OverAndroidTestSourceSet")) {
|
||||
source("$projectDir/src/testShared")
|
||||
}
|
||||
if (name.endsWith("OverAndroidTestSourceSet")) {
|
||||
source("$projectDir/src/benchmarkShared/java")
|
||||
}
|
||||
}
|
||||
|
||||
screenshotTests {
|
||||
// Fraction of differing pixels tolerated before a screenshot test fails (0.0001 = 0.01%).
|
||||
imageDifferenceThreshold = 0.0001f
|
||||
@@ -143,7 +154,11 @@ android {
|
||||
experimentalProperties["android.experimental.enableScreenshotTest"] = true
|
||||
|
||||
buildToolsVersion = libs.versions.buildTools.get()
|
||||
compileSdkVersion(libs.versions.compileSdk.get())
|
||||
|
||||
compileSdk {
|
||||
version = release(libs.versions.compileSdk.get().toInt())
|
||||
}
|
||||
|
||||
ndkVersion = libs.versions.ndk.get()
|
||||
|
||||
flavorDimensions += listOf("distribution", "environment")
|
||||
@@ -180,12 +195,12 @@ android {
|
||||
|
||||
sourceSets {
|
||||
getByName("test") {
|
||||
java.srcDir("$projectDir/src/testShared")
|
||||
java.directories += "$projectDir/src/testShared"
|
||||
}
|
||||
|
||||
getByName("androidTest") {
|
||||
java.srcDir("$projectDir/src/testShared")
|
||||
java.srcDir("$projectDir/src/benchmarkShared/java")
|
||||
java.directories += "$projectDir/src/testShared"
|
||||
java.directories += "$projectDir/src/benchmarkShared/java"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -400,6 +415,7 @@ android {
|
||||
isDefault = false
|
||||
isDebuggable = false
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
matchingFallbacks += "debug"
|
||||
buildConfigField("String", "BUILD_VARIANT_TYPE", "\"Benchmark\"")
|
||||
buildConfigField("boolean", "TRACING_ENABLED", "true")
|
||||
@@ -510,18 +526,18 @@ android {
|
||||
android.buildTypes.configureEach {
|
||||
val path = if (name == "release") releaseDir else debugDir
|
||||
sourceSets.named(name) {
|
||||
java.srcDir(path)
|
||||
java.directories += path
|
||||
}
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
getByName("mocked") {
|
||||
java.srcDir("$projectDir/src/benchmarkShared/java")
|
||||
java.directories += "$projectDir/src/benchmarkShared/java"
|
||||
manifest.srcFile("$projectDir/src/benchmarkShared/AndroidManifest.xml")
|
||||
}
|
||||
|
||||
getByName("benchmark") {
|
||||
java.srcDir("$projectDir/src/benchmarkShared/java")
|
||||
java.directories += "$projectDir/src/benchmarkShared/java"
|
||||
manifest.srcFile("$projectDir/src/benchmarkShared/AndroidManifest.xml")
|
||||
}
|
||||
}
|
||||
@@ -687,6 +703,7 @@ dependencies {
|
||||
implementation(project(":core:ui"))
|
||||
implementation(project(":core:models"))
|
||||
implementation(project(":core:models-jvm"))
|
||||
implementation(project(":core:serialization"))
|
||||
implementation(project(":feature:camera"))
|
||||
implementation(project(":feature:registration"))
|
||||
implementation(project(":lib:apng"))
|
||||
@@ -831,6 +848,7 @@ dependencies {
|
||||
|
||||
androidTestImplementation(platform(libs.androidx.compose.bom))
|
||||
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
|
||||
androidTestImplementation(libs.androidx.compose.ui.test.manifest)
|
||||
androidTestImplementation(testLibs.androidx.test.ext.junit)
|
||||
androidTestImplementation(testLibs.espresso.core)
|
||||
androidTestImplementation(testLibs.espresso.contrib) {
|
||||
|
||||
+191
@@ -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)
|
||||
}
|
||||
}
|
||||
+12
@@ -17,7 +17,9 @@ import androidx.test.espresso.matcher.ViewMatchers.withId
|
||||
import androidx.test.espresso.matcher.ViewMatchers.withText
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import io.mockk.unmockkObject
|
||||
import org.hamcrest.Matchers.allOf
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
@@ -25,8 +27,10 @@ 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.DisableAnimationsRule
|
||||
import org.thoughtcrime.securesms.testing.GooglePayTestRule
|
||||
import org.thoughtcrime.securesms.testing.InAppPaymentsRule
|
||||
import org.thoughtcrime.securesms.testing.RxTestSchedulerRule
|
||||
@@ -51,6 +55,9 @@ class CheckoutFlowActivityTest__OneTimeDonations {
|
||||
@get:Rule
|
||||
val composeRule = createEmptyComposeRule()
|
||||
|
||||
@get:Rule
|
||||
val animationsRule = DisableAnimationsRule()
|
||||
|
||||
private val intent = CheckoutFlowActivity.createIntent(InstrumentationRegistry.getInstrumentation().targetContext, InAppPaymentType.ONE_TIME_DONATION)
|
||||
|
||||
@Before
|
||||
@@ -59,6 +66,11 @@ class CheckoutFlowActivityTest__OneTimeDonations {
|
||||
startJobLoopForTests()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
unmockkObject(DonationPermits)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenPermitAcquisitionFails_whenIDonateOnce_thenIExpectPaymentSetupErrorDialog() {
|
||||
failDonationPermitAcquisition()
|
||||
|
||||
+25
-68
@@ -13,8 +13,6 @@ import androidx.test.espresso.matcher.ViewMatchers.withId
|
||||
import androidx.test.espresso.matcher.ViewMatchers.withText
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import io.mockk.every
|
||||
import io.mockk.mockkObject
|
||||
import io.mockk.unmockkObject
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
@@ -23,9 +21,6 @@ import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.signal.core.util.deleteAll
|
||||
import org.signal.donations.InAppPaymentType
|
||||
import org.signal.libsignal.net.RequestResult
|
||||
import org.signal.network.NetworkResult
|
||||
import org.signal.network.exceptions.NonSuccessfulResponseCodeException
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.permits.DonationPermits
|
||||
@@ -33,19 +28,19 @@ 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.DisableAnimationsRule
|
||||
import org.thoughtcrime.securesms.testing.GooglePayTestRule
|
||||
import org.thoughtcrime.securesms.testing.InAppPaymentsRule
|
||||
import org.thoughtcrime.securesms.testing.RxTestSchedulerRule
|
||||
import org.thoughtcrime.securesms.testing.SignalActivityRule
|
||||
import org.thoughtcrime.securesms.testing.actions.scrollToDescendant
|
||||
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription
|
||||
import org.thoughtcrime.securesms.testing.endpoints.DonationResponses
|
||||
import org.thoughtcrime.securesms.testing.endpoints.MockEndpoints
|
||||
import org.thoughtcrime.securesms.testing.endpoints.failure
|
||||
import org.thoughtcrime.securesms.testing.endpoints.ok
|
||||
import org.whispersystems.signalservice.api.subscriptions.SubscriberId
|
||||
import java.math.BigDecimal
|
||||
import java.util.Currency
|
||||
import kotlin.time.Duration.Companion.days
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
@Suppress("ClassName")
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
@@ -65,6 +60,9 @@ class CheckoutFlowActivityTest__RecurringDonations {
|
||||
@get:Rule
|
||||
val composeRule = createEmptyComposeRule()
|
||||
|
||||
@get:Rule
|
||||
val animationsRule = DisableAnimationsRule()
|
||||
|
||||
private val intent = CheckoutFlowActivity.createIntent(InstrumentationRegistry.getInstrumentation().targetContext, InAppPaymentType.RECURRING_DONATION)
|
||||
|
||||
@Before
|
||||
@@ -153,10 +151,10 @@ class CheckoutFlowActivityTest__RecurringDonations {
|
||||
|
||||
@Test
|
||||
fun givenSubscriptionPaymentMethodPermitRejected_whenISubscribe_thenIExpectPaymentSetupErrorDialog() {
|
||||
AppDependencies.donationPermitsRepository.clearPermits()
|
||||
mockkObject(DonationPermits)
|
||||
every { DonationPermits.getDonationPermit() } returns RequestResult.Success("permit")
|
||||
every { AppDependencies.donationsApi.createStripeSubscriptionPaymentMethod(any(), any(), any()) } returns NetworkResult.StatusCodeError(NonSuccessfulResponseCodeException(402))
|
||||
succeedDonationPermitAcquisition()
|
||||
MockEndpoints.responder.register({ it.method == "POST" && it.path.contains("/create_payment_method") }) {
|
||||
failure(402)
|
||||
}
|
||||
|
||||
val scenario = ActivityScenario.launch<CheckoutFlowActivity>(intent)
|
||||
rxRule.defaultTestScheduler.triggerActions()
|
||||
@@ -172,44 +170,22 @@ class CheckoutFlowActivityTest__RecurringDonations {
|
||||
}
|
||||
|
||||
private fun initialiseActiveSubscription() {
|
||||
val currency = Currency.getInstance("USD")
|
||||
val subscriber = InAppPaymentSubscriberRecord(
|
||||
subscriberId = SubscriberId.generate(),
|
||||
currency = currency,
|
||||
type = InAppPaymentSubscriberRecord.Type.DONATION,
|
||||
requiresCancel = false,
|
||||
paymentMethodType = InAppPaymentData.PaymentMethodType.CARD,
|
||||
iapSubscriptionId = null
|
||||
)
|
||||
|
||||
InAppPaymentsRepository.setSubscriber(subscriber)
|
||||
SignalStore.inAppPayments.setRecurringDonationCurrency(currency)
|
||||
|
||||
AppDependencies.donationsApi.apply {
|
||||
every { getSubscription(subscriber.subscriberId) } returns NetworkResult.Success(
|
||||
ActiveSubscription(
|
||||
ActiveSubscription.Subscription(
|
||||
200,
|
||||
currency.currencyCode,
|
||||
BigDecimal.ONE,
|
||||
System.currentTimeMillis().milliseconds.inWholeSeconds + 30.days.inWholeSeconds,
|
||||
true,
|
||||
System.currentTimeMillis().milliseconds.inWholeSeconds + 30.days.inWholeSeconds,
|
||||
false,
|
||||
"active",
|
||||
"STRIPE",
|
||||
"CARD",
|
||||
false
|
||||
),
|
||||
null
|
||||
)
|
||||
)
|
||||
|
||||
every { deleteSubscription(subscriber.subscriberId) } returns NetworkResult.Success(Unit)
|
||||
val subscriber = registerSubscriber()
|
||||
val serialized = subscriber.subscriberId.serialize()
|
||||
MockEndpoints.responder.register({ it.method == "GET" && it.path.contains(serialized) }) {
|
||||
ok(DonationResponses.activeSubscription(status = "active", active = true))
|
||||
}
|
||||
}
|
||||
|
||||
private fun initialisePendingSubscription() {
|
||||
val subscriber = registerSubscriber()
|
||||
val serialized = subscriber.subscriberId.serialize()
|
||||
MockEndpoints.responder.register({ it.method == "GET" && it.path.contains(serialized) }) {
|
||||
ok(DonationResponses.activeSubscription(status = "incomplete", active = false))
|
||||
}
|
||||
}
|
||||
|
||||
private fun registerSubscriber(): InAppPaymentSubscriberRecord {
|
||||
val currency = Currency.getInstance("USD")
|
||||
val subscriber = InAppPaymentSubscriberRecord(
|
||||
subscriberId = SubscriberId.generate(),
|
||||
@@ -223,25 +199,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
|
||||
}
|
||||
}
|
||||
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
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.DisableAnimationsRule
|
||||
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()
|
||||
|
||||
@get:Rule
|
||||
val animationsRule = DisableAnimationsRule()
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
+280
@@ -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
|
||||
}
|
||||
}
|
||||
+36
-3
@@ -7,10 +7,12 @@ package org.thoughtcrime.securesms.components.settings.app.subscription.donate
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import androidx.compose.ui.test.hasTestTag
|
||||
import androidx.compose.ui.test.junit4.ComposeTestRule
|
||||
import androidx.compose.ui.test.onAllNodesWithTag
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performScrollToNode
|
||||
import androidx.test.core.app.ActivityScenario
|
||||
import androidx.test.espresso.Espresso.onView
|
||||
import androidx.test.espresso.assertion.ViewAssertions.matches
|
||||
@@ -18,6 +20,7 @@ import androidx.test.espresso.matcher.RootMatchers.isDialog
|
||||
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
|
||||
import androidx.test.espresso.matcher.ViewMatchers.withText
|
||||
import io.mockk.every
|
||||
import io.mockk.mockkObject
|
||||
import io.reactivex.rxjava3.schedulers.TestScheduler
|
||||
import org.signal.donations.InAppPaymentType
|
||||
import org.signal.libsignal.net.RequestResult
|
||||
@@ -26,23 +29,52 @@ import org.thoughtcrime.securesms.SignalInstrumentationApplicationContext
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.GooglePayComponent
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewaySelectorTestTags
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.permits.DonationPermits
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.testing.flushUntil
|
||||
|
||||
/**
|
||||
* Forces real donation-permit acquisition to fail at the issuer, exercising the permit code path through
|
||||
* [org.thoughtcrime.securesms.components.settings.app.subscription.permits.DonationPermits].
|
||||
* Forces donation-permit acquisition to fail, so the checkout surfaces a payment-setup error before
|
||||
* reaching the payment method. Stubs the [DonationPermits] object directly (the underlying
|
||||
* `createDonationPermits` call rides the websocket, which the responder does not mint valid permits
|
||||
* for). Call `unmockkObject(DonationPermits)` in teardown.
|
||||
*/
|
||||
fun failDonationPermitAcquisition(statusCode: Int = 500) {
|
||||
AppDependencies.donationPermitsRepository.clearPermits()
|
||||
every { AppDependencies.donationsApi.createDonationPermits(any()) } returns RequestResult.NonSuccess(RestStatusCodeError(statusCode, emptyMap(), null))
|
||||
mockkObject(DonationPermits)
|
||||
every { DonationPermits.getDonationPermit() } returns RequestResult.NonSuccess(RestStatusCodeError(statusCode, emptyMap(), null))
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes donation-permit acquisition succeed with a placeholder permit, letting the checkout proceed
|
||||
* past the permit gate to the payment method. Call `unmockkObject(DonationPermits)` in teardown.
|
||||
*/
|
||||
fun succeedDonationPermitAcquisition() {
|
||||
AppDependencies.donationPermitsRepository.clearPermits()
|
||||
mockkObject(DonationPermits)
|
||||
every { DonationPermits.getDonationPermit() } returns RequestResult.Success("permit")
|
||||
}
|
||||
|
||||
/**
|
||||
* The instrumentation app stubs [org.thoughtcrime.securesms.ApplicationContext.beginJobLoop] to a no-op, so enqueued
|
||||
* jobs never run. The checkout pipeline drives a real setup job through the JobManager, so the loop must be started.
|
||||
*
|
||||
* Before starting it, cancel any leftover in-app-payment jobs. A recurring setup job retries indefinitely over a
|
||||
* one-day lifespan, so a spec that fails setup leaves one persisted in the JobManager. On Firebase Test Lab the
|
||||
* orchestrator's `clearPackageData` does not reliably wipe the JobManager between parameterized specs (it does on a
|
||||
* local emulator, which is why this only surfaces on-device), so that job survives into the next spec and runs after
|
||||
* its InAppPayment row was deleted in setup — `InAppPaymentsRepository.resolveLock` then throws "Not found" from the
|
||||
* job thread and crashes the whole app. Clearing the queues here makes each test start from a clean job state.
|
||||
*/
|
||||
fun startJobLoopForTests() {
|
||||
AppDependencies.jobManager.cancelAllInQueues(
|
||||
setOf(
|
||||
InAppPaymentsRepository.getRecurringJobQueueKey(InAppPaymentType.RECURRING_DONATION),
|
||||
InAppPaymentsRepository.getRecurringJobQueueKey(InAppPaymentType.RECURRING_BACKUP)
|
||||
)
|
||||
)
|
||||
AppDependencies.jobManager.flush()
|
||||
|
||||
(AppDependencies.application as SignalInstrumentationApplicationContext).beginJobLoopForTests()
|
||||
}
|
||||
|
||||
@@ -67,6 +99,7 @@ fun ActivityScenario<CheckoutFlowActivity>.selectGooglePay(
|
||||
composeRule.onAllNodesWithTag(GatewaySelectorTestTags.GOOGLE_PAY_BUTTON).fetchSemanticsNodes().isNotEmpty()
|
||||
}
|
||||
|
||||
composeRule.onNodeWithTag(GatewaySelectorTestTags.CONTAINER).performScrollToNode(hasTestTag(GatewaySelectorTestTags.GOOGLE_PAY_BUTTON))
|
||||
composeRule.onNodeWithTag(GatewaySelectorTestTags.GOOGLE_PAY_BUTTON).performClick()
|
||||
|
||||
scheduler.flushUntil {
|
||||
|
||||
+286
@@ -0,0 +1,286 @@
|
||||
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.DisableAnimationsRule
|
||||
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()
|
||||
|
||||
@get:Rule
|
||||
val animationsRule = DisableAnimationsRule()
|
||||
|
||||
@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
|
||||
)
|
||||
)
|
||||
}
|
||||
+159
@@ -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
|
||||
}
|
||||
+200
@@ -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"
|
||||
}
|
||||
}
|
||||
+59
-4
@@ -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.signal.network.config.SignalServiceConfiguration
|
||||
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(
|
||||
|
||||
-2
@@ -121,8 +121,6 @@ class BackupSubscriptionCheckJobTest {
|
||||
)
|
||||
)
|
||||
|
||||
every { AppDependencies.donationsApi.putSubscription(any()) } returns NetworkResult.Success(Unit)
|
||||
|
||||
insertSubscriber()
|
||||
}
|
||||
|
||||
|
||||
+84
@@ -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()
|
||||
}
|
||||
}
|
||||
+113
@@ -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
|
||||
}
|
||||
}
|
||||
+54
@@ -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()
|
||||
}
|
||||
+2
-1
@@ -101,7 +101,7 @@ class DataMessageProcessorTest_polls {
|
||||
message = DataMessage(pollTerminate = DataMessage.PollTerminate(targetSentTimestamp = 100)),
|
||||
senderRecipient = alice,
|
||||
metadata = EnvelopeMetadata(alice.requireServiceId(), null, 1, false, null, harness.self.requireServiceId(), CiphertextMessage.WHISPER_TYPE),
|
||||
threadRecipient = bob,
|
||||
threadRecipient = Recipient.resolved(groupRecipientId),
|
||||
groupId = groupId,
|
||||
receivedTime = 200
|
||||
)
|
||||
@@ -310,6 +310,7 @@ class DataMessageProcessorTest_polls {
|
||||
envelope = MessageContentFuzzer.envelope(100),
|
||||
message = DataMessage(pollVote = pollVote),
|
||||
senderRecipient = senderRecipient,
|
||||
threadRecipient = Recipient.resolved(groupRecipientId),
|
||||
earlyMessageCacheEntry = null
|
||||
)
|
||||
}
|
||||
|
||||
+9
@@ -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 android.provider.Settings
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import org.junit.rules.ExternalResource
|
||||
import java.io.FileInputStream
|
||||
|
||||
/**
|
||||
* Disables system animation scales for the duration of a test and restores them afterward.
|
||||
*
|
||||
* Espresso and [android.app.Instrumentation.waitForIdleSync] only make progress once the main looper is idle. An
|
||||
* on-screen indeterminate animation (e.g. the checkout's `CircularProgressIndicator`) posts frame callbacks forever, so
|
||||
* the looper never idles and any idle-gated wait hangs indefinitely rather than timing out. Forcing the scales to 0
|
||||
* stops those animations so the looper can idle.
|
||||
*
|
||||
* Writes go through the shell (`UiAutomation`), which holds `WRITE_SECURE_SETTINGS`; the app process does not.
|
||||
*/
|
||||
class DisableAnimationsRule : ExternalResource() {
|
||||
|
||||
private val scales = listOf(
|
||||
Settings.Global.WINDOW_ANIMATION_SCALE,
|
||||
Settings.Global.TRANSITION_ANIMATION_SCALE,
|
||||
Settings.Global.ANIMATOR_DURATION_SCALE
|
||||
)
|
||||
|
||||
private lateinit var previous: Map<String, Float>
|
||||
|
||||
override fun before() {
|
||||
val resolver = InstrumentationRegistry.getInstrumentation().targetContext.contentResolver
|
||||
previous = scales.associateWith { Settings.Global.getFloat(resolver, it, 1f) }
|
||||
scales.forEach { putScale(it, 0f) }
|
||||
}
|
||||
|
||||
override fun after() {
|
||||
if (::previous.isInitialized) {
|
||||
previous.forEach { (key, value) -> putScale(key, value) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun putScale(key: String, value: Float) {
|
||||
val stream = InstrumentationRegistry.getInstrumentation().uiAutomation.executeShellCommand("settings put global $key $value")
|
||||
FileInputStream(stream.fileDescriptor).use { it.readBytes() }
|
||||
}
|
||||
}
|
||||
@@ -7,43 +7,90 @@ package org.thoughtcrime.securesms.testing
|
||||
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import io.mockk.every
|
||||
import org.json.JSONObject
|
||||
import org.junit.rules.ExternalResource
|
||||
import org.signal.core.util.JsonUtils
|
||||
import org.signal.network.NetworkResult
|
||||
import org.signal.network.exceptions.NonSuccessfulResponseCodeException
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription
|
||||
import org.whispersystems.signalservice.internal.push.SubscriptionsConfiguration
|
||||
import org.thoughtcrime.securesms.testing.endpoints.DonationResponses
|
||||
import org.thoughtcrime.securesms.testing.endpoints.DonationTestServer
|
||||
import org.thoughtcrime.securesms.testing.endpoints.EndpointResponse
|
||||
import org.thoughtcrime.securesms.testing.endpoints.MockEndpoints
|
||||
import org.thoughtcrime.securesms.testing.endpoints.delete
|
||||
import org.thoughtcrime.securesms.testing.endpoints.get
|
||||
import org.thoughtcrime.securesms.testing.endpoints.ok
|
||||
import org.thoughtcrime.securesms.testing.endpoints.post
|
||||
import org.thoughtcrime.securesms.testing.endpoints.put
|
||||
import org.whispersystems.signalservice.internal.push.WhoAmIResponse
|
||||
|
||||
/**
|
||||
* Sets up some common infrastructure for on-device InAppPayment testing
|
||||
* Sets up common infrastructure for on-device InAppPayment testing.
|
||||
*
|
||||
* The Signal-service edge is driven through the shared [MockEndpoints.responder] (the real
|
||||
* [org.whispersystems.signalservice.api.donations.DonationsApi] runs against these handlers), while
|
||||
* account/archive lookups that are not websocket-backed here stay mocked via mockk. A scenario can
|
||||
* override any default by registering a more specific handler after this rule runs (last-registered
|
||||
* wins).
|
||||
*/
|
||||
class InAppPaymentsRule : ExternalResource() {
|
||||
|
||||
override fun before() {
|
||||
initialiseConfigurationResponse()
|
||||
initialisePutSubscription()
|
||||
MockEndpoints.responder.clear()
|
||||
registerDonationServiceDefaults()
|
||||
initialiseSetArchiveBackupId()
|
||||
initialiseSetAccountAttributes()
|
||||
initialiseAccountAndSubscriptionLookups()
|
||||
initialiseAccountLookups()
|
||||
}
|
||||
|
||||
private fun initialiseConfigurationResponse() {
|
||||
val assets = InstrumentationRegistry.getInstrumentation().context.resources.assets
|
||||
val response = assets.open("inAppPaymentsTests/configuration.json").use { stream ->
|
||||
NetworkResult.Success(JsonUtils.fromJson(stream, SubscriptionsConfiguration::class.java))
|
||||
}
|
||||
|
||||
AppDependencies.donationsApi.apply {
|
||||
every { getDonationsConfiguration(any()) } returns response
|
||||
}
|
||||
override fun after() {
|
||||
MockEndpoints.responder.clear()
|
||||
}
|
||||
|
||||
private fun initialisePutSubscription() {
|
||||
AppDependencies.donationsApi.apply {
|
||||
every { putSubscription(any()) } returns NetworkResult.Success(Unit)
|
||||
every { createSubscriber(any(), any()) } returns NetworkResult.Success(Unit)
|
||||
/**
|
||||
* Default happy-path responses for the Signal donations endpoints. Registered generic-to-specific
|
||||
* so more specific paths registered later win.
|
||||
*/
|
||||
private fun registerDonationServiceDefaults() {
|
||||
val responder = MockEndpoints.responder
|
||||
|
||||
// Subscriber lifecycle: create/put/update-level (PUT), cancel (DELETE) all succeed by default.
|
||||
responder.put("/v1/subscription/") { ok() }
|
||||
responder.delete("/v1/subscription/") { ok() }
|
||||
|
||||
// getSubscription defaults to "no active subscription".
|
||||
responder.get("/v1/subscription/") { ok(DonationResponses.emptySubscription()) }
|
||||
|
||||
// Donations configuration (registered after the generic GET so it wins for its path).
|
||||
val configuration = InstrumentationRegistry.getInstrumentation().context.resources.assets
|
||||
.open("inAppPaymentsTests/configuration.json")
|
||||
.use { it.readBytes().decodeToString() }
|
||||
responder.get("/v1/subscription/configuration") { ok(configuration) }
|
||||
|
||||
// SEPA bank-transfer mandate text (registered after the generic GET so it wins for its path).
|
||||
responder.get("/v1/subscription/bank_mandate/") { ok(DonationResponses.bankMandate()) }
|
||||
|
||||
// One-time (boost) payment intent.
|
||||
responder.post("/v1/subscription/boost/create") { ok(DonationResponses.stripeClientSecret()) }
|
||||
|
||||
// Recurring payment method setup + default payment method + level are keyed by subscriber id in
|
||||
// the middle of the path, so match on a distinctive suffix.
|
||||
responder.register({ it.method == "POST" && it.path.contains("/create_payment_method") && !it.path.contains("paypal") }) {
|
||||
ok(DonationResponses.setupIntentClientSecret())
|
||||
}
|
||||
responder.register({ it.method == "POST" && it.path.contains("/default_payment_method") }) { ok() }
|
||||
|
||||
// Receipt credential submission (boost + recurring) — mint a valid credential from the client's request.
|
||||
responder.register({ it.method == "POST" && it.path.contains("/receipt_credentials") }) { request ->
|
||||
mintReceiptCredential(request.bodyString)
|
||||
}
|
||||
|
||||
// Redemption.
|
||||
responder.post("/v1/donation/redeem-receipt") { ok() }
|
||||
}
|
||||
|
||||
private fun mintReceiptCredential(requestBody: String): EndpointResponse {
|
||||
val requestBase64 = JSONObject(requestBody).getString("receiptCredentialRequest")
|
||||
val minted = DonationTestServer.issueReceiptCredential(requestBase64)
|
||||
return ok(DonationResponses.receiptCredential(minted))
|
||||
}
|
||||
|
||||
private fun initialiseSetArchiveBackupId() {
|
||||
@@ -59,23 +106,12 @@ class InAppPaymentsRule : ExternalResource() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Starting the real job loop lets background jobs unrelated to the assertion under test run against the strict
|
||||
* API mocks (e.g. [org.thoughtcrime.securesms.jobs.InAppPaymentRecurringContextJob] querying whoAmI, the
|
||||
* active subscription, and then submitting receipt credentials). Stub these calls so those jobs hit a handled
|
||||
* path and terminate quietly instead of throwing [io.mockk.MockKException] on a job thread, which crashes the
|
||||
* whole app process (the test asserts nothing, so it surfaces as "Application crashed" with no failed test).
|
||||
* Tests that supply an active subscription push the job past the [getSubscription] guard to
|
||||
* [submitReceiptCredentials], so that must be stubbed too. End-to-end coverage of that pipeline is tracked
|
||||
* separately; here we only keep the process alive and the logs clean.
|
||||
* whoAmI is served by the (still mocked) account API rather than the websocket responder, so
|
||||
* background jobs that resolve the account during a test hit a handled path instead of throwing.
|
||||
*/
|
||||
private fun initialiseAccountAndSubscriptionLookups() {
|
||||
private fun initialiseAccountLookups() {
|
||||
AppDependencies.accountApi.apply {
|
||||
every { whoAmI() } returns NetworkResult.Success(WhoAmIResponse(number = "+15555550123"))
|
||||
}
|
||||
|
||||
AppDependencies.donationsApi.apply {
|
||||
every { getSubscription(any()) } returns NetworkResult.Success(ActiveSubscription.EMPTY)
|
||||
every { submitReceiptCredentials(any(), any()) } returns NetworkResult.StatusCodeError(NonSuccessfulResponseCodeException(402))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -4,13 +4,13 @@ import android.app.Application
|
||||
import org.signal.benchmark.setup.NoOpJob
|
||||
import org.signal.core.util.UptimeSleepTimer
|
||||
import org.signal.libsignal.net.Network
|
||||
import org.signal.network.config.SignalServiceConfiguration
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencyProvider
|
||||
import org.thoughtcrime.securesms.dependencies.InstrumentationApplicationDependencyProvider
|
||||
import org.thoughtcrime.securesms.jobmanager.JobManager
|
||||
import org.thoughtcrime.securesms.jobs.JobManagerFactories
|
||||
import org.whispersystems.signalservice.api.websocket.SignalWebSocket
|
||||
import org.whispersystems.signalservice.internal.configuration.SignalServiceConfiguration
|
||||
import org.whispersystems.signalservice.internal.websocket.BenchmarkWebSocketConnection
|
||||
import java.util.function.Supplier
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
@@ -9,13 +9,13 @@ import android.app.Application
|
||||
import org.signal.benchmark.setup.NoOpJob
|
||||
import org.signal.core.util.UptimeSleepTimer
|
||||
import org.signal.libsignal.net.Network
|
||||
import org.signal.network.config.SignalServiceConfiguration
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencyProvider
|
||||
import org.thoughtcrime.securesms.jobmanager.JobManager
|
||||
import org.thoughtcrime.securesms.jobs.JobManagerFactories
|
||||
import org.thoughtcrime.securesms.net.DeviceTransferBlockingInterceptor
|
||||
import org.whispersystems.signalservice.api.websocket.SignalWebSocket
|
||||
import org.whispersystems.signalservice.internal.configuration.SignalServiceConfiguration
|
||||
import org.whispersystems.signalservice.internal.websocket.BenchmarkWebSocketConnection
|
||||
import java.util.function.Supplier
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -469,18 +469,18 @@
|
||||
<activity
|
||||
android:name=".mediasend.v2.MediaSelectionActivity"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize|uiMode"
|
||||
android:screenOrientation="portrait"
|
||||
android:exported="false"
|
||||
android:launchMode="singleTop"
|
||||
android:screenOrientation="portrait"
|
||||
android:theme="@style/TextSecure.DarkNoActionBar"
|
||||
android:windowSoftInputMode="stateAlwaysHidden|adjustNothing" />
|
||||
|
||||
<activity
|
||||
android:name=".mediasend.v3.MediaSendV3Activity"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize|uiMode"
|
||||
android:screenOrientation="portrait"
|
||||
android:exported="false"
|
||||
android:launchMode="singleTop"
|
||||
android:screenOrientation="portrait"
|
||||
android:theme="@style/Signal.DayNight.NoActionBar"
|
||||
android:windowSoftInputMode="stateAlwaysHidden|adjustNothing" />
|
||||
|
||||
@@ -892,6 +892,14 @@
|
||||
android:launchMode="singleTask"
|
||||
android:theme="@style/Theme.Signal.DayNight.NoActionBar" />
|
||||
|
||||
<activity
|
||||
android:name=".clockskew.ClockSkewActivity"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
|
||||
android:excludeFromRecents="true"
|
||||
android:exported="false"
|
||||
android:launchMode="singleTask"
|
||||
android:theme="@style/Theme.Signal.DayNight.NoActionBar" />
|
||||
|
||||
<activity
|
||||
android:name=".ratelimit.RecaptchaProofActivity"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
|
||||
@@ -1536,7 +1544,7 @@
|
||||
</receiver>
|
||||
|
||||
<receiver
|
||||
android:name="org.thoughtcrime.securesms.jobs.ForegroundServiceUtil$Receiver"
|
||||
android:name="org.signal.core.util.ForegroundServiceUtil$Receiver"
|
||||
android:exported="false" />
|
||||
|
||||
<receiver
|
||||
|
||||
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -52,6 +52,7 @@ import org.signal.ringrtc.CallManager;
|
||||
import org.thoughtcrime.securesms.apkupdate.ApkUpdateRefreshListener;
|
||||
import org.thoughtcrime.securesms.avatar.AvatarPickerStorage;
|
||||
import org.thoughtcrime.securesms.backup.v2.BackupRepository;
|
||||
import org.thoughtcrime.securesms.clockskew.ClockSkewDetector;
|
||||
import org.thoughtcrime.securesms.preferences.EditProxyActivity;
|
||||
import org.thoughtcrime.securesms.conversation.drafts.DraftBlobs;
|
||||
import org.thoughtcrime.securesms.crypto.AppAttachmentSecretStore;
|
||||
@@ -420,6 +421,7 @@ public class ApplicationContext extends Application implements AppForegroundObse
|
||||
AppDependencies.init(this, new ApplicationDependencyProvider(this));
|
||||
}
|
||||
AppForegroundObserver.begin();
|
||||
ClockSkewDetector.beginObserving(this);
|
||||
|
||||
if (Environment.USE_NEW_REGISTRATION) {
|
||||
initializeRegistrationDependencies();
|
||||
@@ -429,7 +431,7 @@ public class ApplicationContext extends Application implements AppForegroundObse
|
||||
private void initializeRegistrationDependencies() {
|
||||
RegistrationDependencies.provide(
|
||||
new RegistrationDependencies(
|
||||
new AppRegistrationNetworkController(this, AppDependencies.getPushServiceSocket()),
|
||||
new AppRegistrationNetworkController(this, AppDependencies.getRegistrationApiV2()),
|
||||
new AppRegistrationStorageController(this),
|
||||
Environment.IS_LINK_AND_SYNC_AVAILABLE,
|
||||
null,
|
||||
|
||||
@@ -36,6 +36,7 @@ import org.thoughtcrime.securesms.contacts.avatars.ProfileContactPhoto;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.util.FullscreenHelper;
|
||||
import org.thoughtcrime.securesms.util.WindowUtil;
|
||||
|
||||
/**
|
||||
* Activity for displaying avatars full screen.
|
||||
@@ -77,6 +78,9 @@ public final class AvatarPreviewActivity extends PassphraseRequiredActivity {
|
||||
setTheme(R.style.TextSecure_MediaPreview);
|
||||
setContentView(R.layout.contact_photo_preview_activity);
|
||||
|
||||
WindowUtil.clearLightStatusBar(getWindow());
|
||||
WindowUtil.clearLightNavigationBar(getWindow());
|
||||
|
||||
postponeEnterTransition();
|
||||
TransitionInflater inflater = TransitionInflater.from(this);
|
||||
getWindow().setSharedElementEnterTransition(inflater.inflateTransition(R.transition.full_screen_avatar_image_enter_transition_set));
|
||||
|
||||
@@ -6,6 +6,7 @@ import android.content.res.Configuration;
|
||||
import android.os.Bundle;
|
||||
import android.view.View;
|
||||
|
||||
import androidx.activity.EdgeToEdge;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.appcompat.app.ActionBar;
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
@@ -34,6 +35,7 @@ public abstract class BaseActivity extends AppCompatActivity {
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
AppStartup.getInstance().onCriticalRenderEventStart();
|
||||
logEvent("onCreate()");
|
||||
EdgeToEdge.enable(this);
|
||||
super.onCreate(savedInstanceState);
|
||||
AppStartup.getInstance().onCriticalRenderEventEnd();
|
||||
}
|
||||
|
||||
@@ -19,9 +19,11 @@ package org.thoughtcrime.securesms;
|
||||
import android.content.Context;
|
||||
import android.os.AsyncTask;
|
||||
import android.os.Bundle;
|
||||
import android.view.ViewGroup;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.appcompat.widget.Toolbar;
|
||||
import androidx.core.view.WindowInsetsCompat;
|
||||
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout;
|
||||
|
||||
import org.signal.core.util.DimensionUnit;
|
||||
@@ -34,6 +36,7 @@ import org.thoughtcrime.securesms.contacts.sync.ContactDiscovery;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme;
|
||||
import org.thoughtcrime.securesms.util.DynamicTheme;
|
||||
import org.thoughtcrime.securesms.util.SystemWindowInsetsSetter;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.lang.ref.WeakReference;
|
||||
@@ -110,11 +113,16 @@ public abstract class ContactSelectionActivity extends PassphraseRequiredActivit
|
||||
getSupportActionBar().setDisplayHomeAsUpEnabled(false);
|
||||
getSupportActionBar().setIcon(null);
|
||||
getSupportActionBar().setLogo(null);
|
||||
|
||||
toolbar.getLayoutParams().height = ViewGroup.LayoutParams.WRAP_CONTENT;
|
||||
SystemWindowInsetsSetter.attach(toolbar, this, WindowInsetsCompat.Type.statusBars());
|
||||
}
|
||||
|
||||
private void initializeResources() {
|
||||
contactsFragment = (ContactSelectionListFragment) getSupportFragmentManager().findFragmentById(R.id.contact_selection_list_fragment);
|
||||
contactsFragment.setOnRefreshListener(this);
|
||||
|
||||
SystemWindowInsetsSetter.attach(contactsFragment.requireView(), this, WindowInsetsCompat.Type.navigationBars());
|
||||
}
|
||||
|
||||
private void initializeSearch() {
|
||||
|
||||
@@ -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
|
||||
@@ -671,46 +672,54 @@ class MainActivity :
|
||||
}
|
||||
},
|
||||
primaryContent = {
|
||||
when (mainNavigationState.currentListLocation) {
|
||||
MainNavigationListLocation.CHATS, MainNavigationListLocation.ARCHIVE -> {
|
||||
NavDisplay(
|
||||
backStack = mainNavigationViewModel.chatsBackStackEntries,
|
||||
onBack = { mainNavigationViewModel.popChatsDetailLocation() },
|
||||
transitionSpec = TransitionSpecs.HorizontalSlide.transitionSpec,
|
||||
popTransitionSpec = TransitionSpecs.HorizontalSlide.popTransitionSpec,
|
||||
predictivePopTransitionSpec = TransitionSpecs.HorizontalSlide.predictivePopTransitionSpec,
|
||||
entryProvider = entryProvider { chatsNavEntries(convoTransitionState) }
|
||||
)
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(end = contentLayoutData.detailPaddingEnd)
|
||||
.clip(contentLayoutData.shape)
|
||||
.background(color = MaterialTheme.colorScheme.surface)
|
||||
.fillMaxSize()
|
||||
) {
|
||||
when (mainNavigationState.currentListLocation) {
|
||||
MainNavigationListLocation.CHATS, MainNavigationListLocation.ARCHIVE -> {
|
||||
NavDisplay<NavKey>(
|
||||
backStack = mainNavigationViewModel.chatsBackStackEntries,
|
||||
onBack = { mainNavigationViewModel.popChatsDetailLocation() },
|
||||
transitionSpec = { TransitionSpecs.HorizontalSlide.transitionSpec },
|
||||
popTransitionSpec = { TransitionSpecs.HorizontalSlide.popTransitionSpec },
|
||||
predictivePopTransitionSpec = { TransitionSpecs.HorizontalSlide.predictivePopTransitionSpec },
|
||||
entryProvider = entryProvider { chatsNavEntries(convoTransitionState) }
|
||||
)
|
||||
}
|
||||
|
||||
MainNavigationListLocation.CALLS -> {
|
||||
NavDisplay(
|
||||
backStack = mainNavigationViewModel.callsBackStackEntries,
|
||||
onBack = { mainNavigationViewModel.popCallsDetailLocation() },
|
||||
transitionSpec = TransitionSpecs.HorizontalSlide.transitionSpec,
|
||||
popTransitionSpec = TransitionSpecs.HorizontalSlide.popTransitionSpec,
|
||||
predictivePopTransitionSpec = TransitionSpecs.HorizontalSlide.predictivePopTransitionSpec,
|
||||
entryDecorators = listOf(
|
||||
rememberSaveableStateHolderNavEntryDecorator(),
|
||||
rememberViewModelStoreNavEntryDecorator()
|
||||
),
|
||||
entryProvider = entryProvider { callsNavEntries(isSplitPane) }
|
||||
)
|
||||
}
|
||||
MainNavigationListLocation.CALLS -> {
|
||||
NavDisplay<NavKey>(
|
||||
backStack = mainNavigationViewModel.callsBackStackEntries,
|
||||
onBack = { mainNavigationViewModel.popCallsDetailLocation() },
|
||||
transitionSpec = { TransitionSpecs.HorizontalSlide.transitionSpec },
|
||||
popTransitionSpec = { TransitionSpecs.HorizontalSlide.popTransitionSpec },
|
||||
predictivePopTransitionSpec = { TransitionSpecs.HorizontalSlide.predictivePopTransitionSpec },
|
||||
entryDecorators = listOf(
|
||||
rememberSaveableStateHolderNavEntryDecorator(),
|
||||
rememberViewModelStoreNavEntryDecorator()
|
||||
),
|
||||
entryProvider = entryProvider { callsNavEntries(isSplitPane) }
|
||||
)
|
||||
}
|
||||
|
||||
MainNavigationListLocation.STORIES -> {
|
||||
NavDisplay(
|
||||
backStack = mainNavigationViewModel.storiesBackStackEntries,
|
||||
onBack = { mainNavigationViewModel.popStoriesDetailLocation() },
|
||||
transitionSpec = TransitionSpecs.HorizontalSlide.transitionSpec,
|
||||
popTransitionSpec = TransitionSpecs.HorizontalSlide.popTransitionSpec,
|
||||
predictivePopTransitionSpec = TransitionSpecs.HorizontalSlide.predictivePopTransitionSpec,
|
||||
entryDecorators = listOf(
|
||||
rememberSaveableStateHolderNavEntryDecorator(),
|
||||
rememberViewModelStoreNavEntryDecorator()
|
||||
),
|
||||
entryProvider = entryProvider { storiesNavEntries() }
|
||||
)
|
||||
MainNavigationListLocation.STORIES -> {
|
||||
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() }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -965,7 +974,6 @@ class MainActivity :
|
||||
onSetToolbarColor = {
|
||||
toolbarViewModel.setToolbarColor(it)
|
||||
},
|
||||
setStatusBarColor = {},
|
||||
lifecycleOwner = lifecycleOwner
|
||||
).attach(recyclerView)
|
||||
}
|
||||
@@ -975,7 +983,6 @@ class MainActivity :
|
||||
activity = this,
|
||||
views = listOf(chatFolders),
|
||||
viewStubs = listOf(),
|
||||
setStatusBarColor = {},
|
||||
onSetToolbarColor = {
|
||||
toolbarViewModel.setToolbarColor(it)
|
||||
},
|
||||
|
||||
@@ -18,6 +18,8 @@ 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.clockskew.ClockSkewActivity;
|
||||
import org.thoughtcrime.securesms.clockskew.ClockSkewDetector;
|
||||
import org.thoughtcrime.securesms.components.settings.app.changenumber.ChangeNumberLockActivity;
|
||||
import org.thoughtcrime.securesms.crypto.MasterSecretUtil;
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies;
|
||||
@@ -59,6 +61,7 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
|
||||
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 static final int STATE_CLOCK_SKEW = 13;
|
||||
|
||||
private SignalServiceNetworkAccess networkAccess;
|
||||
private BroadcastReceiver clearKeyReceiver;
|
||||
@@ -158,6 +161,7 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
|
||||
case STATE_CHANGE_NUMBER_LOCK: return getChangeNumberLockIntent();
|
||||
case STATE_TRANSFER_OR_RESTORE: return getTransferOrRestoreIntent();
|
||||
case STATE_RESUME_LINKING_REG: return getResumeLinkedRegistrationIntent();
|
||||
case STATE_CLOCK_SKEW: return getClockSkewIntent();
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
@@ -187,6 +191,8 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
|
||||
return STATE_TRANSFER_LOCKED;
|
||||
} else if (SignalStore.misc().isChangeNumberLocked() && getClass() != ChangeNumberLockActivity.class) {
|
||||
return STATE_CHANGE_NUMBER_LOCK;
|
||||
} else if (ClockSkewDetector.INSTANCE.isDetected() && getClass() != ClockSkewActivity.class) {
|
||||
return STATE_CLOCK_SKEW;
|
||||
} else {
|
||||
return STATE_NORMAL;
|
||||
}
|
||||
@@ -255,6 +261,9 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
|
||||
}
|
||||
|
||||
private Intent getTransferOrRestoreIntent() {
|
||||
if (Environment.USE_NEW_REGISTRATION) {
|
||||
return org.signal.registration.RegistrationActivity.createIntent(this, MainActivity.clearTop(this));
|
||||
}
|
||||
Intent intent = RestoreActivity.getRestoreIntent(this);
|
||||
return getRoutedIntent(intent, MainActivity.clearTop(this));
|
||||
}
|
||||
@@ -285,6 +294,10 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
|
||||
return ChangeNumberLockActivity.createIntent(this);
|
||||
}
|
||||
|
||||
private Intent getClockSkewIntent() {
|
||||
return ClockSkewActivity.createIntent(this);
|
||||
}
|
||||
|
||||
private Intent getRoutedIntent(Intent destination, @Nullable Intent nextIntent) {
|
||||
if (nextIntent != null) destination.putExtra(NEXT_INTENT_EXTRA, nextIntent);
|
||||
return destination;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -63,6 +63,7 @@ class TextAvatarCreationFragment : Fragment(R.layout.text_avatar_creation_fragme
|
||||
val tabLayout: ControllableTabLayout = view.findViewById(R.id.text_avatar_creation_tabs)
|
||||
val doneButton: View = view.findViewById(R.id.text_avatar_creation_done)
|
||||
val keyboardAwareLayout: KeyboardAwareLinearLayout = view.findViewById(R.id.keyboard_aware_layout)
|
||||
keyboardAwareLayout.setInsetPaddingMode(KeyboardAwareLinearLayout.InsetPaddingMode.SAFE_AREA)
|
||||
|
||||
withRecyclerSet.load(requireContext(), R.layout.text_avatar_creation_fragment_content)
|
||||
withoutRecyclerSet.load(requireContext(), R.layout.text_avatar_creation_fragment_content_hidden_recycler)
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -46,7 +46,7 @@ import org.thoughtcrime.securesms.database.SenderKeyTable;
|
||||
import org.thoughtcrime.securesms.database.SenderKeySharedTable;
|
||||
import org.thoughtcrime.securesms.database.SessionTable;
|
||||
import org.thoughtcrime.securesms.database.SignedPreKeyTable;
|
||||
import org.thoughtcrime.securesms.database.StickerTable;
|
||||
import org.thoughtcrime.securesms.database.StickerTables;
|
||||
import org.thoughtcrime.securesms.database.model.AvatarPickerDatabase;
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies;
|
||||
import org.thoughtcrime.securesms.keyvalue.KeyValueDataSet;
|
||||
@@ -186,7 +186,7 @@ public class FullBackupExporter extends FullBackupBase {
|
||||
count = exportTable(table, input, outputStream, cursor -> isForNonExpiringPollMessage(input, cursor.getLong(cursor.getColumnIndexOrThrow(PollTables.PollOptionTable.POLL_ID))), null, count, estimatedCount, cancellationSignal);
|
||||
} else if (table.equals(PollTables.PollVoteTable.TABLE_NAME)) {
|
||||
count = exportTable(table, input, outputStream, cursor -> isForNonExpiringPollMessage(input, cursor.getLong(cursor.getColumnIndexOrThrow(PollTables.PollVoteTable.POLL_ID))), null, count, estimatedCount, cancellationSignal);
|
||||
} else if (table.equals(StickerTable.TABLE_NAME)) {
|
||||
} else if (table.equals(StickerTables.Sticker.TABLE_NAME)) {
|
||||
count = exportTable(table, input, outputStream, cursor -> true, (cursor, innerCount) -> exportSticker(attachmentSecret, cursor, outputStream, innerCount, estimatedCount), count, estimatedCount, cancellationSignal);
|
||||
} else if (!TABLE_CONTENT_BLOCKLIST.contains(table)) {
|
||||
count = exportTable(table, input, outputStream, null, null, count, estimatedCount, cancellationSignal);
|
||||
@@ -238,7 +238,7 @@ public class FullBackupExporter extends FullBackupBase {
|
||||
count += getCount(input, BackupCountQueries.getGroupReceiptCount());
|
||||
} else if (table.equals(AttachmentTable.TABLE_NAME)) {
|
||||
count += getCount(input, BackupCountQueries.getAttachmentCount());
|
||||
} else if (table.equals(StickerTable.TABLE_NAME)) {
|
||||
} else if (table.equals(StickerTables.Sticker.TABLE_NAME)) {
|
||||
count += getCount(input, "SELECT COUNT(*) FROM " + table);
|
||||
} else if (!TABLE_CONTENT_BLOCKLIST.contains(table)) {
|
||||
count += getCount(input, "SELECT COUNT(*) FROM " + table);
|
||||
@@ -497,11 +497,11 @@ public class FullBackupExporter extends FullBackupBase {
|
||||
long estimatedCount)
|
||||
throws IOException
|
||||
{
|
||||
long rowId = cursor.getLong(cursor.getColumnIndexOrThrow(StickerTable.ID));
|
||||
long size = cursor.getLong(cursor.getColumnIndexOrThrow(StickerTable.FILE_LENGTH));
|
||||
long rowId = cursor.getLong(cursor.getColumnIndexOrThrow(StickerTables.Sticker.ID));
|
||||
long size = cursor.getLong(cursor.getColumnIndexOrThrow(StickerTables.Sticker.FILE_LENGTH));
|
||||
|
||||
String data = cursor.getString(cursor.getColumnIndexOrThrow(StickerTable.FILE_PATH));
|
||||
byte[] random = cursor.getBlob(cursor.getColumnIndexOrThrow(StickerTable.FILE_RANDOM));
|
||||
String data = cursor.getString(cursor.getColumnIndexOrThrow(StickerTables.Sticker.FILE_PATH));
|
||||
byte[] random = cursor.getBlob(cursor.getColumnIndexOrThrow(StickerTables.Sticker.FILE_RANDOM));
|
||||
|
||||
if (!TextUtils.isEmpty(data) && size > 0) {
|
||||
EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, ++count, estimatedCount));
|
||||
|
||||
@@ -34,7 +34,7 @@ import org.thoughtcrime.securesms.database.LastResortKeyTupleTable;
|
||||
import org.thoughtcrime.securesms.database.OneTimePreKeyTable;
|
||||
import org.thoughtcrime.securesms.database.SearchTable;
|
||||
import org.thoughtcrime.securesms.database.SignedPreKeyTable;
|
||||
import org.thoughtcrime.securesms.database.StickerTable;
|
||||
import org.thoughtcrime.securesms.database.StickerTables;
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies;
|
||||
import org.thoughtcrime.securesms.keyvalue.KeyValueDataSet;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
@@ -244,7 +244,7 @@ public class FullBackupImporter extends FullBackupBase {
|
||||
private static void processSticker(@NonNull Context context, @NonNull AttachmentSecret attachmentSecret, @NonNull SQLiteDatabase db, @NonNull Sticker sticker, BackupRecordInputStream inputStream)
|
||||
throws IOException
|
||||
{
|
||||
File stickerDirectory = context.getDir(StickerTable.DIRECTORY, Context.MODE_PRIVATE);
|
||||
File stickerDirectory = context.getDir(StickerTables.DIRECTORY, Context.MODE_PRIVATE);
|
||||
File dataFile = File.createTempFile("sticker", ".mms", stickerDirectory);
|
||||
|
||||
Pair<byte[], OutputStream> output = ModernEncryptingPartOutputStream.createFor(attachmentSecret, dataFile, false);
|
||||
@@ -252,12 +252,12 @@ public class FullBackupImporter extends FullBackupBase {
|
||||
inputStream.readAttachmentTo(output.getSecond(), sticker.length);
|
||||
|
||||
ContentValues contentValues = new ContentValues();
|
||||
contentValues.put(StickerTable.FILE_PATH, dataFile.getAbsolutePath());
|
||||
contentValues.put(StickerTable.FILE_LENGTH, sticker.length);
|
||||
contentValues.put(StickerTable.FILE_RANDOM, output.getFirst());
|
||||
contentValues.put(StickerTables.Sticker.FILE_PATH, dataFile.getAbsolutePath());
|
||||
contentValues.put(StickerTables.Sticker.FILE_LENGTH, sticker.length);
|
||||
contentValues.put(StickerTables.Sticker.FILE_RANDOM, output.getFirst());
|
||||
|
||||
db.update(StickerTable.TABLE_NAME, contentValues,
|
||||
StickerTable.ID + " = ?",
|
||||
db.update(StickerTables.Sticker.TABLE_NAME, contentValues,
|
||||
StickerTables.Sticker.ID + " = ?",
|
||||
new String[] {String.valueOf(sticker.rowId)});
|
||||
}
|
||||
|
||||
|
||||
@@ -102,9 +102,10 @@ 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
|
||||
import org.thoughtcrime.securesms.database.StickerTables
|
||||
import org.thoughtcrime.securesms.database.ThreadTable
|
||||
import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)) {
|
||||
@@ -1326,7 +1338,7 @@ object BackupRepository {
|
||||
SignalDatabase.recipients.setProfileKey(selfId, selfData.profileKey)
|
||||
SignalDatabase.recipients.setProfileSharing(selfId, true)
|
||||
|
||||
val importState = ImportState(mediaRootBackupKey)
|
||||
val importState = ImportState(mediaRootBackupKey, backupMode)
|
||||
val chatItemInserter: ChatItemArchiveImporter = ChatItemArchiveProcessor.beginImport(importState)
|
||||
|
||||
Log.d(TAG, "[import] Beginning to read frames.")
|
||||
@@ -1475,7 +1487,7 @@ object BackupRepository {
|
||||
}
|
||||
|
||||
val stickerJobs = SignalDatabase.stickers.getAllStickerPacks().use { cursor ->
|
||||
val reader = StickerTable.StickerPackRecordReader(cursor)
|
||||
val reader = StickerTables.StickerPackRecordReader(cursor)
|
||||
reader
|
||||
.filter { it.isInstalled }
|
||||
.map {
|
||||
@@ -1510,7 +1522,7 @@ object BackupRepository {
|
||||
val jobs = mutableListOf<Job>()
|
||||
groups
|
||||
.asSequence()
|
||||
.filter { it.id.isV2 }
|
||||
.filter { it.id.isV2 && it.hasV2GroupProperties }
|
||||
.forEach { group ->
|
||||
jobs.add(RequestGroupV2InfoJob(group.id as GroupId.V2))
|
||||
val avatarKey = group.requireV2GroupProperties().avatarKey
|
||||
@@ -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
|
||||
)
|
||||
@@ -2505,7 +2530,7 @@ class ExportState(
|
||||
var releaseNoteRecipientId: Long? = null
|
||||
}
|
||||
|
||||
class ImportState(val mediaRootBackupKey: MediaRootBackupKey) {
|
||||
class ImportState(val mediaRootBackupKey: MediaRootBackupKey, val backupMode: BackupMode) {
|
||||
val remoteToLocalRecipientId: MutableMap<Long, RecipientId> = hashMapOf()
|
||||
val chatIdToLocalThreadId: MutableMap<Long, Long> = hashMapOf()
|
||||
val chatIdToLocalRecipientId: MutableMap<Long, RecipientId> = hashMapOf()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,11 +104,23 @@ class ArchiveFileSystem private constructor(private val context: Context, root:
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if [dir] appears to be a SignalBackups directory based on its name and
|
||||
* expected internal structure (presence of the "files" subdirectory).
|
||||
* Returns true if [dir] appears to be a SignalBackups directory based on its expected internal
|
||||
* structure. We can't rely on the directory being named [MAIN_DIRECTORY_NAME], since users may
|
||||
* rename it or restore it into a differently-named folder (e.g. when transferring a backup
|
||||
* between devices). A directory is therefore considered a backups directory if it either matches
|
||||
* the expected name or directly contains the archive contents (a "files" subdirectory alongside
|
||||
* at least one snapshot directory).
|
||||
*/
|
||||
private fun looksLikeSignalBackupsDirectory(dir: DocumentFile): Boolean {
|
||||
return dir.name == MAIN_DIRECTORY_NAME && dir.findFile("files") != null
|
||||
if (dir.findFile("files") == null) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (dir.name == MAIN_DIRECTORY_NAME) {
|
||||
return true
|
||||
}
|
||||
|
||||
return dir.listFiles().any { it.isDirectory && it.name?.startsWith(BACKUP_DIRECTORY_PREFIX) == true }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+3
-20
@@ -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),
|
||||
@@ -276,8 +275,8 @@ object AccountDataArchiveProcessor {
|
||||
SignalStore.story.isFeatureDisabled = settings.storiesDisabled
|
||||
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.optimizeStorage = !importState.backupMode.isLinkAndSync && settings.optimizeOnDeviceStorage
|
||||
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
|
||||
|
||||
+6
-19
@@ -10,13 +10,10 @@ import org.signal.archive.proto.Frame
|
||||
import org.signal.archive.proto.StickerPack
|
||||
import org.signal.archive.stream.BackupFrameEmitter
|
||||
import org.signal.core.util.Hex
|
||||
import org.signal.core.util.insertInto
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.backup.v2.ExportSkips
|
||||
import org.thoughtcrime.securesms.database.SQLiteDatabase
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.StickerTable
|
||||
import org.thoughtcrime.securesms.database.StickerTable.StickerPackRecordReader
|
||||
import org.thoughtcrime.securesms.database.StickerTables.StickerPackRecordReader
|
||||
import org.thoughtcrime.securesms.database.model.StickerPackRecord
|
||||
import java.io.IOException
|
||||
|
||||
@@ -27,7 +24,7 @@ private val TAG = Log.tag(StickerArchiveProcessor::class)
|
||||
*/
|
||||
object StickerArchiveProcessor {
|
||||
fun export(db: SignalDatabase, emitter: BackupFrameEmitter) {
|
||||
StickerPackRecordReader(db.stickerTable.getAllStickerPacks()).use { reader ->
|
||||
StickerPackRecordReader(db.stickerTables.getAllStickerPacks()).use { reader ->
|
||||
var record: StickerPackRecord? = null
|
||||
while (reader.getNext()?.let { record = it } != null) {
|
||||
if (record!!.isInstalled) {
|
||||
@@ -39,20 +36,10 @@ object StickerArchiveProcessor {
|
||||
}
|
||||
|
||||
fun import(stickerPack: StickerPack) {
|
||||
SignalDatabase.rawDatabase
|
||||
.insertInto(StickerTable.TABLE_NAME)
|
||||
.values(
|
||||
StickerTable.PACK_ID to Hex.toStringCondensed(stickerPack.packId.toByteArray()),
|
||||
StickerTable.PACK_KEY to Hex.toStringCondensed(stickerPack.packKey.toByteArray()),
|
||||
StickerTable.PACK_TITLE to "",
|
||||
StickerTable.PACK_AUTHOR to "",
|
||||
StickerTable.INSTALLED to 1,
|
||||
StickerTable.COVER to 1,
|
||||
StickerTable.EMOJI to "",
|
||||
StickerTable.CONTENT_TYPE to "",
|
||||
StickerTable.FILE_PATH to ""
|
||||
)
|
||||
.run(SQLiteDatabase.CONFLICT_IGNORE)
|
||||
SignalDatabase.stickers.insertPackReference(
|
||||
packId = Hex.toStringCondensed(stickerPack.packId.toByteArray()),
|
||||
packKey = Hex.toStringCondensed(stickerPack.packKey.toByteArray())
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
-8
@@ -7,8 +7,6 @@ package org.thoughtcrime.securesms.backup.v2.ui.subscription
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.result.contract.ActivityResultContract
|
||||
import androidx.core.content.IntentCompat
|
||||
import androidx.core.os.bundleOf
|
||||
@@ -42,12 +40,6 @@ class MessageBackupsCheckoutActivity : FragmentWrapperActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) {
|
||||
super.onCreate(savedInstanceState, ready)
|
||||
|
||||
enableEdgeToEdge()
|
||||
}
|
||||
|
||||
override fun getFragment(): Fragment = MessageBackupsFlowFragment.create(
|
||||
IntentCompat.getSerializableExtra(intent, TIER, MessageBackupTier::class.java)
|
||||
)
|
||||
|
||||
+2
-2
@@ -52,14 +52,14 @@ import org.signal.core.ui.compose.Previews
|
||||
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.ui.fonts.SignalSymbols
|
||||
import org.signal.core.ui.fonts.SignalSymbols.signalSymbolText
|
||||
import org.signal.core.util.ByteUnit
|
||||
import org.signal.core.util.billing.BillingResponseCode
|
||||
import org.signal.core.util.bytes
|
||||
import org.signal.core.util.money.FiatMoney
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
|
||||
import org.thoughtcrime.securesms.fonts.SignalSymbols
|
||||
import org.thoughtcrime.securesms.fonts.SignalSymbols.signalSymbolText
|
||||
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
|
||||
import java.math.BigDecimal
|
||||
import java.util.Currency
|
||||
|
||||
-3
@@ -5,7 +5,6 @@ import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
@@ -49,8 +48,6 @@ class VerifyBackupKeyActivity : PassphraseRequiredActivity() {
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) {
|
||||
enableEdgeToEdge()
|
||||
|
||||
setContent {
|
||||
SignalTheme {
|
||||
val context = LocalContext.current
|
||||
|
||||
+2
@@ -21,6 +21,7 @@ import org.signal.donations.InAppPaymentType
|
||||
import org.thoughtcrime.securesms.MainActivity
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.InputAwareLayout
|
||||
import org.thoughtcrime.securesms.components.KeyboardAwareLinearLayout
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiEventListener
|
||||
import org.thoughtcrime.securesms.components.emoji.MediaKeyboard
|
||||
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
|
||||
@@ -94,6 +95,7 @@ class GiftFlowConfirmationFragment :
|
||||
.create()
|
||||
|
||||
inputAwareLayout = requireView().findViewById(R.id.input_aware_layout)
|
||||
inputAwareLayout.setInsetPaddingMode(KeyboardAwareLinearLayout.InsetPaddingMode.KEYBOARD_AND_NAVIGATION_BAR)
|
||||
emojiKeyboard = requireView().findViewById(R.id.emoji_drawer)
|
||||
|
||||
emojiKeyboard.setFragmentManager(childFragmentManager)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -4,11 +4,13 @@ import android.content.Intent;
|
||||
import android.graphics.Color;
|
||||
import android.os.Bundle;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.StringRes;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
import androidx.appcompat.widget.Toolbar;
|
||||
import androidx.core.view.WindowInsetsCompat;
|
||||
import androidx.fragment.app.Fragment;
|
||||
import androidx.lifecycle.ViewModelProvider;
|
||||
|
||||
@@ -28,6 +30,7 @@ import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme;
|
||||
import org.thoughtcrime.securesms.util.DynamicTheme;
|
||||
import org.signal.core.util.concurrent.LifecycleDisposable;
|
||||
import org.thoughtcrime.securesms.util.SystemWindowInsetsSetter;
|
||||
import org.thoughtcrime.securesms.util.ViewUtil;
|
||||
|
||||
import java.util.Optional;
|
||||
@@ -63,6 +66,11 @@ public class BlockedUsersActivity extends PassphraseRequiredActivity implements
|
||||
container = findViewById(R.id.fragment_container);
|
||||
|
||||
toolbar.setNavigationOnClickListener(unused -> onBackPressed());
|
||||
|
||||
toolbar.getLayoutParams().height = ViewGroup.LayoutParams.WRAP_CONTENT;
|
||||
SystemWindowInsetsSetter.attach(toolbar, this, WindowInsetsCompat.Type.statusBars());
|
||||
SystemWindowInsetsSetter.attach(container, this, WindowInsetsCompat.Type.navigationBars());
|
||||
|
||||
contactFilterView.setOnFilterChangedListener(query -> {
|
||||
Fragment fragment = getSupportFragmentManager().findFragmentByTag(CONTACT_SELECTION_FRAGMENT);
|
||||
if (fragment != null) {
|
||||
|
||||
@@ -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]
|
||||
|
||||
+10
-1
@@ -11,7 +11,9 @@ import android.view.WindowManager
|
||||
import androidx.activity.compose.LocalOnBackPressedDispatcherOwner
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
@@ -43,6 +45,7 @@ import org.signal.core.ui.compose.Buttons
|
||||
import org.signal.core.ui.compose.ComposeDialogFragment
|
||||
import org.signal.core.ui.compose.Scaffolds
|
||||
import org.signal.core.ui.compose.SignalIcons
|
||||
import org.signal.core.ui.enableEdgeToEdge
|
||||
import org.signal.core.ui.rememberIsSplitPane
|
||||
import org.signal.core.util.BreakIteratorCompat
|
||||
import org.thoughtcrime.securesms.R
|
||||
@@ -68,6 +71,7 @@ class EditCallLinkNameDialogFragment : ComposeDialogFragment() {
|
||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||
val dialog = super.onCreateDialog(savedInstanceState)
|
||||
dialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE)
|
||||
dialog.window?.enableEdgeToEdge()
|
||||
return dialog
|
||||
}
|
||||
|
||||
@@ -143,7 +147,12 @@ private fun EditCallLinkNameScreen(
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val breakIterator = remember { BreakIteratorCompat.getInstance() }
|
||||
|
||||
Surface(modifier = Modifier.padding(paddingValues)) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.padding(paddingValues)
|
||||
.consumeWindowInsets(paddingValues)
|
||||
.imePadding()
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(
|
||||
|
||||
@@ -91,28 +91,24 @@ class CallEventCache(
|
||||
): CallLogRow.Call? {
|
||||
val parent = next()
|
||||
|
||||
if (parent.type == Type.AD_HOC_CALL.code && !callLinksSeen.add(parent.peer)) {
|
||||
return null
|
||||
}
|
||||
val isActiveCallLinkRow = parent.type != Type.AD_HOC_CALL.code || callLinksSeen.add(parent.peer)
|
||||
|
||||
val children = mutableSetOf<Long>()
|
||||
while (hasNext()) {
|
||||
val child = next()
|
||||
|
||||
if (child.type == Type.AD_HOC_CALL.code) {
|
||||
previous()
|
||||
break
|
||||
}
|
||||
if (parent.type != Type.AD_HOC_CALL.code) {
|
||||
while (hasNext()) {
|
||||
val child = next()
|
||||
|
||||
if (parent.peer == child.peer && parent.direction == child.direction && isEventMatch(parent, child) && isWithinTimeout(parent, child)) {
|
||||
children.add(child.rowId)
|
||||
} else {
|
||||
previous()
|
||||
break
|
||||
if (parent.peer == child.peer && parent.direction == child.direction && isEventMatch(parent, child) && isWithinTimeout(parent, child)) {
|
||||
children.add(child.rowId)
|
||||
} else {
|
||||
previous()
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return createParentCallLogRow(parent, children, filterState, groupCallStateMap, canUserBeginCallMap)
|
||||
return createParentCallLogRow(parent, children, filterState, groupCallStateMap, canUserBeginCallMap, isActiveCallLinkRow)
|
||||
}
|
||||
|
||||
private fun readDataFromDatabase(): List<CacheRecord> {
|
||||
@@ -170,7 +166,8 @@ class CallEventCache(
|
||||
children: Set<Long>,
|
||||
filterState: FilterState,
|
||||
groupCallStateCache: MutableMap<Long, CallLogRow.GroupCallState>,
|
||||
canUserBeginCallMap: MutableMap<Long, CallLogRow.CanStartCall>
|
||||
canUserBeginCallMap: MutableMap<Long, CallLogRow.CanStartCall>,
|
||||
isActiveCallLinkRow: Boolean
|
||||
): CallLogRow.Call {
|
||||
val peer = Recipient.resolved(RecipientId.from(parent.peer))
|
||||
return CallLogRow.Call(
|
||||
@@ -196,7 +193,7 @@ class CallEventCache(
|
||||
},
|
||||
children = setOf(parent.rowId) + children,
|
||||
searchQuery = filterState.query,
|
||||
callLinkPeekInfo = AppDependencies.signalCallManager.peekInfoSnapshot[peer.id],
|
||||
callLinkPeekInfo = if (isActiveCallLinkRow) AppDependencies.signalCallManager.peekInfoSnapshot[peer.id] else null,
|
||||
canUserBeginCall = if (peer.isGroup) {
|
||||
canUserBeginCallMap.getOrPut(parent.peer) { canUserBeginCall(peer, parent.decryptedGroupBytes) }
|
||||
} else {
|
||||
|
||||
@@ -10,7 +10,6 @@ import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.activity.compose.LocalActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
@@ -60,7 +59,6 @@ class NewCallActivity : PassphraseRequiredActivity() {
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) {
|
||||
enableEdgeToEdge()
|
||||
super.onCreate(savedInstanceState, ready)
|
||||
|
||||
val navigateBack = onBackPressedDispatcher::onBackPressed
|
||||
|
||||
@@ -8,8 +8,6 @@ package org.thoughtcrime.securesms.chats
|
||||
import android.os.Bundle
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
@@ -124,8 +122,6 @@ private fun MessageDetailsEntry(route: MainNavigationDetailLocation.Chats.Messag
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
.statusBarsPadding()
|
||||
.navigationBarsPadding()
|
||||
)
|
||||
}
|
||||
|
||||
@@ -152,8 +148,6 @@ private fun ConversationSettingsEntry(route: MainNavigationDetailLocation.Chats.
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
.statusBarsPadding()
|
||||
.navigationBarsPadding()
|
||||
) { fragment ->
|
||||
backPressedState.attach(fragment)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.clockskew
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.provider.Settings
|
||||
import androidx.activity.addCallback
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.launch
|
||||
import org.signal.core.ui.compose.theme.SignalTheme
|
||||
import org.thoughtcrime.securesms.PassphraseRequiredActivity
|
||||
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme
|
||||
import org.thoughtcrime.securesms.util.viewModel
|
||||
|
||||
/**
|
||||
* Hosts [ClockSkewScreen], the full-screen blocking screen shown when the local device clock is too far out of sync
|
||||
* with the server's clock (see [ClockSkewDetector]). The user cannot leave until they fix their clock; the screen
|
||||
* dismisses itself once the skew is resolved (which is re-checked when the app is backgrounded/foregrounded).
|
||||
*/
|
||||
class ClockSkewActivity : PassphraseRequiredActivity() {
|
||||
|
||||
private val theme = DynamicNoActionBarTheme()
|
||||
private val viewModel: ClockSkewViewModel by viewModel { ClockSkewViewModel() }
|
||||
|
||||
override fun onPreCreate() {
|
||||
theme.onCreate(this)
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) {
|
||||
onBackPressedDispatcher.addCallback(this) {
|
||||
// Disabled: the user must fix their clock.
|
||||
}
|
||||
|
||||
lifecycleScope.launch {
|
||||
viewModel.actions.collect { action ->
|
||||
when (action) {
|
||||
ClockSkewScreenAction.OpenDateSettings -> startActivity(Intent(Settings.ACTION_DATE_SETTINGS))
|
||||
ClockSkewScreenAction.Finish -> finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setContent {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
SignalTheme {
|
||||
ClockSkewScreen(
|
||||
state = state,
|
||||
onEvent = viewModel::onEvent
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
theme.onResume(this)
|
||||
viewModel.onEvent(ClockSkewScreenEvent.ScreenResumed)
|
||||
}
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
fun createIntent(context: Context): Intent {
|
||||
return Intent(context, ClockSkewActivity::class.java)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.clockskew
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Intent
|
||||
import android.os.SystemClock
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.launch
|
||||
import org.signal.core.util.AppForegroundObserver
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.util.RemoteConfig
|
||||
import kotlin.math.abs
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
/**
|
||||
* Detects when the local device clock is too far out of sync with the server's clock and holds that state in memory.
|
||||
*
|
||||
* We learn the server's true time from two sources: the websocket ([org.signal.libsignal.net.ChatConnectionListener.onServerTimestamp],
|
||||
* routed through [org.thoughtcrime.securesms.net.SignalWebSocketHealthMonitor]) and the remote config fetch. Because we
|
||||
* hold the websocket open aggressively, a user can change their clock while already connected — in which case we won't
|
||||
* receive a fresh server time. To catch that, we cache the last-known server time alongside a monotonic
|
||||
* [SystemClock.elapsedRealtime] reading and re-check on foreground, estimating the current server time without needing
|
||||
* another network round-trip.
|
||||
*
|
||||
* When skew is detected we stop trying to keep the websocket open (so we don't reconnect in a loop) regardless of
|
||||
* whether the app is foregrounded, and — only while foregrounded — show [ClockSkewActivity]. The detection state is
|
||||
* intentionally not persisted and is re-evaluated via [recheck] whenever the app is backgrounded or foregrounded, so a
|
||||
* skew that has since been corrected clears itself and the user always gets a fresh attempt.
|
||||
*
|
||||
* Note that the monotonic reading is deliberately kept in memory only: it is meaningless across reboots, but a reboot
|
||||
* kills our process anyway, so a live cache is always from the current boot.
|
||||
*/
|
||||
object ClockSkewDetector {
|
||||
|
||||
private val TAG = Log.tag(ClockSkewDetector::class)
|
||||
|
||||
private val _detected = MutableStateFlow(false)
|
||||
val detected: StateFlow<Boolean> = _detected.asStateFlow()
|
||||
|
||||
val isDetected: Boolean
|
||||
get() = _detected.value
|
||||
|
||||
/** The amount our local clock was off from the server's when skew was detected. [Duration.ZERO] when not detected. */
|
||||
@Volatile
|
||||
var skew: Duration = Duration.ZERO
|
||||
private set
|
||||
|
||||
@Volatile
|
||||
private var lastServerTime: Long = 0
|
||||
|
||||
@Volatile
|
||||
private var lastServerTimeElapsedRealtime: Long = 0
|
||||
|
||||
private val allowedSkew: Duration
|
||||
get() = RemoteConfig.maxAllowedClockSkewSeconds.seconds
|
||||
|
||||
/**
|
||||
* Records a freshly-observed server time (both persisting it and caching it for [recheck]) and immediately checks it
|
||||
* against our local clock.
|
||||
*/
|
||||
fun onServerTimeReceived(serverTime: Long) {
|
||||
lastServerTime = serverTime
|
||||
lastServerTimeElapsedRealtime = SystemClock.elapsedRealtime()
|
||||
SignalStore.misc.setLastKnownServerTime(serverTime, System.currentTimeMillis())
|
||||
|
||||
val skew = skewFrom(serverTime)
|
||||
if (skew > allowedSkew) {
|
||||
Log.w(TAG, "Local clock is off from the server by $skew, which exceeds the allowed limit. Blocking.", true)
|
||||
markDetected(skew)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-evaluates clock skew using our cached server time and a monotonic estimate of elapsed time, without needing a
|
||||
* network round-trip. Intended to be called when the app is foregrounded, to catch the case where the user changed
|
||||
* their clock while we were already connected. Clears the detection state if the clock now looks fine.
|
||||
*/
|
||||
fun recheck() {
|
||||
if (lastServerTimeElapsedRealtime == 0L) {
|
||||
reset()
|
||||
return
|
||||
}
|
||||
|
||||
val estimatedServerTime = lastServerTime + (SystemClock.elapsedRealtime() - lastServerTimeElapsedRealtime)
|
||||
val skew = skewFrom(estimatedServerTime)
|
||||
if (skew > allowedSkew) {
|
||||
Log.w(TAG, "Local clock is off from the estimated server time by $skew, which exceeds the allowed limit. Blocking.", true)
|
||||
markDetected(skew)
|
||||
} else {
|
||||
reset()
|
||||
}
|
||||
}
|
||||
|
||||
/** Clears any detected skew, allowing the websocket to reconnect and re-check. */
|
||||
private fun reset() {
|
||||
skew = Duration.ZERO
|
||||
_detected.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Begins observing the detection state and launches [ClockSkewActivity] whenever clock skew is detected while the app
|
||||
* is foregrounded. Skew can also be detected while backgrounded (which still blocks the websocket), but we only bring
|
||||
* up the blocking screen when foregrounded, to avoid a background activity launch. Should be called once during app
|
||||
* startup.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun beginObserving(application: Application) {
|
||||
CoroutineScope(Dispatchers.Main).launch {
|
||||
detected.collect { detected ->
|
||||
if (detected && AppForegroundObserver.isForegrounded()) {
|
||||
application.startActivity(ClockSkewActivity.createIntent(application).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun markDetected(skew: Duration) {
|
||||
this.skew = skew
|
||||
_detected.value = true
|
||||
}
|
||||
|
||||
private fun skewFrom(serverTime: Long): Duration {
|
||||
return abs(System.currentTimeMillis() - serverTime).milliseconds
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.clockskew
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.displayCutoutPadding
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.systemBarsPadding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.signal.core.ui.compose.AllDevicePreviews
|
||||
import org.signal.core.ui.compose.Buttons
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.thoughtcrime.securesms.R
|
||||
|
||||
/**
|
||||
* Full-screen blocking screen shown when we've detected that the local device clock is too far out of sync with the
|
||||
* server's clock (see [ClockSkewDetector]).
|
||||
*/
|
||||
@Composable
|
||||
fun ClockSkewScreen(
|
||||
state: ClockSkewState,
|
||||
onEvent: (ClockSkewScreenEvent) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Surface(modifier = modifier) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.systemBarsPadding()
|
||||
.displayCutoutPadding()
|
||||
.padding(horizontal = 32.dp, vertical = 24.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.ClockSkewActivity__title),
|
||||
style = MaterialTheme.typography.headlineLarge,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
BoxWithConstraints(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = maxHeight)
|
||||
.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(64.dp))
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier
|
||||
.size(150.dp)
|
||||
.background(color = MaterialTheme.colorScheme.surfaceVariant, shape = CircleShape)
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.symbol_recent_24),
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(96.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(64.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.ClockSkewActivity__date_inaccurate_description),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.ClockSkewActivity__time_prefix, state.deviceDateTime),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Buttons.LargePrimary(
|
||||
onClick = { onEvent(ClockSkewScreenEvent.AdjustDateSelected) },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(text = stringResource(R.string.ClockSkewActivity__adjust_date_button))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@AllDevicePreviews
|
||||
@Composable
|
||||
private fun ClockSkewScreenPreview() {
|
||||
Previews.Preview {
|
||||
ClockSkewScreen(
|
||||
state = ClockSkewState(deviceDateTime = "12/17/25, 11:26 PM\n(Greenwich Mean Time)"),
|
||||
onEvent = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.clockskew
|
||||
|
||||
/**
|
||||
* One-shot side effects emitted by [ClockSkewViewModel] for the host [ClockSkewActivity] to perform, since they require
|
||||
* an Activity context.
|
||||
*/
|
||||
sealed interface ClockSkewScreenAction {
|
||||
/** Open the system date and time settings. */
|
||||
data object OpenDateSettings : ClockSkewScreenAction
|
||||
|
||||
/** Skew has been resolved; the blocking screen should close. */
|
||||
data object Finish : ClockSkewScreenAction
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.clockskew
|
||||
|
||||
/**
|
||||
* Events emitted by [ClockSkewScreen] and handled by [ClockSkewViewModel].
|
||||
*/
|
||||
sealed interface ClockSkewScreenEvent {
|
||||
/** The screen became visible; the displayed device time should be recomputed. */
|
||||
data object ScreenResumed : ClockSkewScreenEvent
|
||||
|
||||
/** The user tapped the button to adjust their device date. */
|
||||
data object AdjustDateSelected : ClockSkewScreenEvent
|
||||
|
||||
/** Internal: the [ClockSkewDetector]'s detection state changed. */
|
||||
data class SkewStateChanged(val detected: Boolean) : ClockSkewScreenEvent
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.clockskew
|
||||
|
||||
/**
|
||||
* View state for [ClockSkewScreen].
|
||||
*/
|
||||
data class ClockSkewState(
|
||||
/** The current device date and time, formatted for display (including the time zone). */
|
||||
val deviceDateTime: String = ""
|
||||
)
|
||||
@@ -0,0 +1,68 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.clockskew
|
||||
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import org.signal.core.ui.compose.EventDrivenViewModel
|
||||
import org.signal.core.util.logging.Log
|
||||
import java.text.DateFormat
|
||||
import java.util.Date
|
||||
import java.util.TimeZone
|
||||
|
||||
class ClockSkewViewModel(
|
||||
private val clock: () -> Long = { System.currentTimeMillis() },
|
||||
detected: StateFlow<Boolean> = ClockSkewDetector.detected
|
||||
) : EventDrivenViewModel<ClockSkewScreenEvent>(TAG) {
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(ClockSkewViewModel::class)
|
||||
}
|
||||
|
||||
private val _state = MutableStateFlow(ClockSkewState(deviceDateTime = formatDeviceDateTime()))
|
||||
val state: StateFlow<ClockSkewState> = _state.asStateFlow()
|
||||
|
||||
private val _actions = Channel<ClockSkewScreenAction>(Channel.UNLIMITED)
|
||||
val actions: Flow<ClockSkewScreenAction> = _actions.receiveAsFlow()
|
||||
|
||||
init {
|
||||
detected
|
||||
.onEach { onEvent(ClockSkewScreenEvent.SkewStateChanged(it)) }
|
||||
.launchIn(viewModelScope)
|
||||
}
|
||||
|
||||
override suspend fun processEvent(event: ClockSkewScreenEvent) {
|
||||
applyEvent(_state.value, event, { _actions.trySend(it) }) { _state.value = it }
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
fun applyEvent(
|
||||
state: ClockSkewState,
|
||||
event: ClockSkewScreenEvent,
|
||||
actionEmitter: (ClockSkewScreenAction) -> Unit,
|
||||
stateEmitter: (ClockSkewState) -> Unit
|
||||
) {
|
||||
when (event) {
|
||||
ClockSkewScreenEvent.ScreenResumed -> stateEmitter(state.copy(deviceDateTime = formatDeviceDateTime()))
|
||||
ClockSkewScreenEvent.AdjustDateSelected -> actionEmitter(ClockSkewScreenAction.OpenDateSettings)
|
||||
is ClockSkewScreenEvent.SkewStateChanged -> if (!event.detected) actionEmitter(ClockSkewScreenAction.Finish)
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatDeviceDateTime(): String {
|
||||
val dateTime = DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT).format(Date(clock()))
|
||||
val timeZone = TimeZone.getDefault().getDisplayName(false, TimeZone.LONG)
|
||||
return "$dateTime\n($timeZone)"
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.thoughtcrime.securesms.components;
|
||||
|
||||
import android.app.Dialog;
|
||||
import android.os.Bundle;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
@@ -12,7 +13,9 @@ import androidx.annotation.StringRes;
|
||||
import androidx.appcompat.widget.Toolbar;
|
||||
import androidx.fragment.app.DialogFragment;
|
||||
|
||||
import org.signal.core.ui.WindowExtensionsKt;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.util.SystemWindowInsetsSetter;
|
||||
import org.thoughtcrime.securesms.util.WindowUtil;
|
||||
|
||||
/**
|
||||
@@ -30,6 +33,15 @@ public abstract class FullScreenDialogFragment extends DialogFragment {
|
||||
setStyle(STYLE_NO_FRAME, R.style.Signal_DayNight_Dialog_FullScreen);
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull Dialog onCreateDialog(@Nullable Bundle savedInstanceState) {
|
||||
Dialog dialog = super.onCreateDialog(savedInstanceState);
|
||||
if (dialog.getWindow() != null) {
|
||||
WindowExtensionsKt.enableEdgeToEdge(dialog.getWindow());
|
||||
}
|
||||
return dialog;
|
||||
}
|
||||
|
||||
@Override
|
||||
public final @NonNull View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
|
||||
View view = inflater.inflate(R.layout.full_screen_dialog_fragment, container, false);
|
||||
@@ -41,6 +53,8 @@ public abstract class FullScreenDialogFragment extends DialogFragment {
|
||||
}
|
||||
|
||||
toolbar.setNavigationOnClickListener(v -> onNavigateUp());
|
||||
|
||||
SystemWindowInsetsSetter.attach(view, getViewLifecycleOwner());
|
||||
return view;
|
||||
}
|
||||
|
||||
|
||||
+65
-105
@@ -17,40 +17,37 @@
|
||||
package org.thoughtcrime.securesms.components;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.Rect;
|
||||
import android.os.Build;
|
||||
import android.content.res.Configuration;
|
||||
import android.preference.PreferenceManager;
|
||||
import android.util.AttributeSet;
|
||||
import android.util.DisplayMetrics;
|
||||
import android.view.Surface;
|
||||
import android.view.View;
|
||||
import android.view.WindowInsets;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.appcompat.widget.LinearLayoutCompat;
|
||||
import androidx.core.graphics.Insets;
|
||||
import androidx.core.view.ViewCompat;
|
||||
import androidx.core.view.WindowInsetsCompat;
|
||||
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.signal.core.util.ServiceUtil;
|
||||
import org.signal.core.util.Util;
|
||||
import org.thoughtcrime.securesms.util.ViewUtil;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* LinearLayout that, when a view container, will report back when it thinks a soft keyboard
|
||||
* has been opened and what its height would be.
|
||||
* has been opened and what its height would be. Driven by {@link WindowInsetsCompat.Type#ime()}
|
||||
* insets, so it requires an edge-to-edge host window (all activity and dialog windows in the app
|
||||
* are edge-to-edge). The reported height excludes the navigation bar, matching what a custom
|
||||
* keyboard sitting above a navigation-bar-padded container should be sized to.
|
||||
*/
|
||||
public class KeyboardAwareLinearLayout extends LinearLayoutCompat {
|
||||
private static final String TAG = Log.tag(KeyboardAwareLinearLayout.class);
|
||||
|
||||
private static final long KEYBOARD_DEBOUNCE = 150;
|
||||
|
||||
private final Rect rect = new Rect();
|
||||
private final Set<OnKeyboardHiddenListener> hiddenListeners = new HashSet<>();
|
||||
private final Set<OnKeyboardShownListener> shownListeners = new HashSet<>();
|
||||
private final DisplayMetrics displayMetrics = new DisplayMetrics();
|
||||
|
||||
private final int minKeyboardSize;
|
||||
private final int minCustomKeyboardSize;
|
||||
@@ -58,14 +55,14 @@ public class KeyboardAwareLinearLayout extends LinearLayoutCompat {
|
||||
private final int minCustomKeyboardTopMarginPortrait;
|
||||
private final int minCustomKeyboardTopMarginLandscape;
|
||||
private final int minCustomKeyboardTopMarginLandscapeBubble;
|
||||
private final int statusBarHeight;
|
||||
|
||||
private int viewInset;
|
||||
|
||||
private boolean keyboardOpen = false;
|
||||
private int rotation = 0;
|
||||
private boolean isBubble = false;
|
||||
private long openedAt = 0;
|
||||
private int lastKeyboardHeight;
|
||||
private boolean lastKeyboardVisible;
|
||||
|
||||
private InsetPaddingMode insetPaddingMode = InsetPaddingMode.NONE;
|
||||
|
||||
public KeyboardAwareLinearLayout(Context context) {
|
||||
this(context, null);
|
||||
@@ -83,48 +80,71 @@ public class KeyboardAwareLinearLayout extends LinearLayoutCompat {
|
||||
minCustomKeyboardTopMarginPortrait = getResources().getDimensionPixelSize(R.dimen.min_custom_keyboard_top_margin_portrait);
|
||||
minCustomKeyboardTopMarginLandscape = getResources().getDimensionPixelSize(R.dimen.min_custom_keyboard_top_margin_portrait);
|
||||
minCustomKeyboardTopMarginLandscapeBubble = getResources().getDimensionPixelSize(R.dimen.min_custom_keyboard_top_margin_landscape_bubble);
|
||||
statusBarHeight = ViewUtil.getStatusBarHeight(this);
|
||||
viewInset = getViewInset();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
|
||||
updateRotation();
|
||||
updateKeyboardState();
|
||||
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
|
||||
ViewCompat.setOnApplyWindowInsetsListener(this, (view, insets) -> {
|
||||
Insets ime = insets.getInsets(WindowInsetsCompat.Type.ime());
|
||||
Insets nav = insets.getInsets(WindowInsetsCompat.Type.navigationBars());
|
||||
Insets bars = insets.getInsets(WindowInsetsCompat.Type.systemBars());
|
||||
|
||||
lastKeyboardHeight = Math.max(0, ime.bottom - nav.bottom);
|
||||
lastKeyboardVisible = insets.isVisible(WindowInsetsCompat.Type.ime());
|
||||
|
||||
switch (insetPaddingMode) {
|
||||
case NONE:
|
||||
break;
|
||||
case KEYBOARD:
|
||||
setPadding(getPaddingLeft(), getPaddingTop(), getPaddingRight(), lastKeyboardVisible ? lastKeyboardHeight : 0);
|
||||
break;
|
||||
case KEYBOARD_AND_NAVIGATION_BAR:
|
||||
setPadding(getPaddingLeft(), getPaddingTop(), getPaddingRight(), Math.max(nav.bottom, ime.bottom));
|
||||
break;
|
||||
case SAFE_AREA:
|
||||
setPadding(bars.left, bars.top, bars.right, Math.max(bars.bottom, ime.bottom));
|
||||
break;
|
||||
}
|
||||
|
||||
updateKeyboardState();
|
||||
|
||||
return insets;
|
||||
});
|
||||
}
|
||||
|
||||
public void setIsBubble(boolean isBubble) {
|
||||
this.isBubble = isBubble;
|
||||
}
|
||||
|
||||
private void updateRotation() {
|
||||
int oldRotation = rotation;
|
||||
rotation = getDeviceRotation();
|
||||
if (oldRotation != rotation) {
|
||||
Log.i(TAG, "rotation changed");
|
||||
onKeyboardClose();
|
||||
}
|
||||
/**
|
||||
* Opt-in self-padding, replacing the window resize behavior these layouts relied on before the
|
||||
* host windows went edge-to-edge. [InsetPaddingMode.NONE] keeps the layout as a pure keyboard
|
||||
* event source (e.g. when a parent container already manages insets).
|
||||
*/
|
||||
public void setInsetPaddingMode(@NonNull InsetPaddingMode mode) {
|
||||
this.insetPaddingMode = mode;
|
||||
ViewCompat.requestApplyInsets(this);
|
||||
}
|
||||
|
||||
public enum InsetPaddingMode {
|
||||
/** Apply no padding; the layout only reports keyboard events. */
|
||||
NONE,
|
||||
/** Pad the bottom by the keyboard height above the navigation bar (use inside a container that already clears the navigation bar). */
|
||||
KEYBOARD,
|
||||
/** Pad the bottom by the keyboard or navigation bar, whichever is larger. */
|
||||
KEYBOARD_AND_NAVIGATION_BAR,
|
||||
/** Pad all sides by the system bars, and the bottom by the keyboard when it is taller. */
|
||||
SAFE_AREA
|
||||
}
|
||||
|
||||
private void updateKeyboardState() {
|
||||
if (viewInset == 0) viewInset = getViewInset();
|
||||
|
||||
getWindowVisibleDisplayFrame(rect);
|
||||
|
||||
final int availableHeight = getAvailableHeight();
|
||||
final int keyboardHeight = availableHeight - rect.bottom;
|
||||
|
||||
if (keyboardHeight > minKeyboardSize) {
|
||||
if (getKeyboardHeight() != keyboardHeight) {
|
||||
if (lastKeyboardVisible && lastKeyboardHeight > minKeyboardSize) {
|
||||
if (getKeyboardHeight() != lastKeyboardHeight) {
|
||||
if (isLandscape()) {
|
||||
setKeyboardLandscapeHeight(keyboardHeight);
|
||||
setKeyboardLandscapeHeight(lastKeyboardHeight);
|
||||
} else {
|
||||
setKeyboardPortraitHeight(keyboardHeight);
|
||||
setKeyboardPortraitHeight(lastKeyboardHeight);
|
||||
}
|
||||
}
|
||||
if (!keyboardOpen) {
|
||||
onKeyboardOpen(keyboardHeight);
|
||||
onKeyboardOpen(lastKeyboardHeight);
|
||||
}
|
||||
} else if (keyboardOpen) {
|
||||
onKeyboardClose();
|
||||
@@ -134,53 +154,7 @@ public class KeyboardAwareLinearLayout extends LinearLayoutCompat {
|
||||
@Override
|
||||
protected void onAttachedToWindow() {
|
||||
super.onAttachedToWindow();
|
||||
rotation = getDeviceRotation();
|
||||
if (getRootWindowInsets() != null) {
|
||||
int bottomInset;
|
||||
WindowInsets windowInsets = getRootWindowInsets();
|
||||
|
||||
if (Build.VERSION.SDK_INT >= 30) {
|
||||
bottomInset = windowInsets.getInsets(WindowInsets.Type.navigationBars()).bottom;
|
||||
} else {
|
||||
bottomInset = windowInsets.getStableInsetBottom();
|
||||
}
|
||||
|
||||
if (bottomInset != 0 && (viewInset == 0 || viewInset == statusBarHeight)) {
|
||||
Log.i(TAG, "Updating view inset based on WindowInsets. viewInset: " + viewInset + " windowInset: " + bottomInset);
|
||||
viewInset = bottomInset;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private int getViewInset() {
|
||||
try {
|
||||
Field attachInfoField = View.class.getDeclaredField("mAttachInfo");
|
||||
attachInfoField.setAccessible(true);
|
||||
Object attachInfo = attachInfoField.get(this);
|
||||
if (attachInfo != null) {
|
||||
Field stableInsetsField = attachInfo.getClass().getDeclaredField("mStableInsets");
|
||||
stableInsetsField.setAccessible(true);
|
||||
Rect insets = (Rect) stableInsetsField.get(attachInfo);
|
||||
if (insets != null) {
|
||||
return insets.bottom;
|
||||
}
|
||||
}
|
||||
} catch (NoSuchFieldException | IllegalAccessException e) {
|
||||
// Do nothing
|
||||
}
|
||||
return statusBarHeight;
|
||||
}
|
||||
|
||||
private int getAvailableHeight() {
|
||||
final int availableHeight = this.getRootView().getHeight() - viewInset;
|
||||
final int availableWidth = this.getRootView().getWidth();
|
||||
|
||||
if (isLandscape() && availableHeight > availableWidth) {
|
||||
//noinspection SuspiciousNameCombination
|
||||
return availableWidth;
|
||||
}
|
||||
|
||||
return availableHeight;
|
||||
ViewCompat.requestApplyInsets(this);
|
||||
}
|
||||
|
||||
protected void onKeyboardOpen(int keyboardHeight) {
|
||||
@@ -213,21 +187,7 @@ public class KeyboardAwareLinearLayout extends LinearLayoutCompat {
|
||||
}
|
||||
|
||||
public boolean isLandscape() {
|
||||
int rotation = getDeviceRotation();
|
||||
return rotation == Surface.ROTATION_90 || rotation == Surface.ROTATION_270;
|
||||
}
|
||||
|
||||
private int getDeviceRotation() {
|
||||
if (isInEditMode()) {
|
||||
return Surface.ROTATION_0;
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT >= 30) {
|
||||
getContext().getDisplay().getRealMetrics(displayMetrics);
|
||||
} else {
|
||||
ServiceUtil.getWindowManager(getContext()).getDefaultDisplay().getRealMetrics(displayMetrics);
|
||||
}
|
||||
return displayMetrics.widthPixels > displayMetrics.heightPixels ? Surface.ROTATION_90 : Surface.ROTATION_0;
|
||||
return getResources().getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE;
|
||||
}
|
||||
|
||||
private int getKeyboardLandscapeHeight() {
|
||||
|
||||
@@ -8,6 +8,7 @@ import android.view.ViewGroup
|
||||
import android.view.WindowManager
|
||||
import androidx.annotation.LayoutRes
|
||||
import androidx.fragment.app.DialogFragment
|
||||
import org.signal.core.ui.enableEdgeToEdge
|
||||
import org.thoughtcrime.securesms.R
|
||||
|
||||
/**
|
||||
@@ -38,6 +39,7 @@ abstract class KeyboardEntryDialogFragment(@LayoutRes contentLayoutId: Int) :
|
||||
}
|
||||
|
||||
dialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE or WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE)
|
||||
dialog.window?.enableEdgeToEdge()
|
||||
|
||||
return dialog
|
||||
}
|
||||
@@ -47,6 +49,7 @@ abstract class KeyboardEntryDialogFragment(@LayoutRes contentLayoutId: Int) :
|
||||
|
||||
val view = super.onCreateView(inflater, container, savedInstanceState)
|
||||
return if (view is KeyboardAwareLinearLayout) {
|
||||
view.setInsetPaddingMode(KeyboardAwareLinearLayout.InsetPaddingMode.SAFE_AREA)
|
||||
view.addOnKeyboardShownListener(this)
|
||||
view.addOnKeyboardHiddenListener(this)
|
||||
view
|
||||
|
||||
@@ -13,6 +13,7 @@ import android.widget.TextView;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.StringRes;
|
||||
import androidx.constraintlayout.widget.ConstraintLayout;
|
||||
|
||||
import com.bumptech.glide.RequestManager;
|
||||
|
||||
@@ -207,6 +208,27 @@ public class LinkPreviewView extends FrameLayout {
|
||||
} else {
|
||||
thumbnail.setVisibility(GONE);
|
||||
}
|
||||
|
||||
boolean thumbnailVisible = (showThumbnail && linkPreview.getThumbnail().isPresent()) || callLinkRootKey != null;
|
||||
alignTitleWithThumbnail(thumbnailVisible);
|
||||
}
|
||||
|
||||
private void alignTitleWithThumbnail(boolean thumbnailVisible) {
|
||||
ConstraintLayout.LayoutParams params = (ConstraintLayout.LayoutParams) title.getLayoutParams();
|
||||
if (thumbnailVisible) {
|
||||
params.topToTop = R.id.linkpreview_thumbnail;
|
||||
params.bottomToBottom = R.id.linkpreview_thumbnail;
|
||||
params.startToEnd = R.id.linkpreview_thumbnail;
|
||||
params.startToStart = ConstraintLayout.LayoutParams.UNSET;
|
||||
params.setMarginStart(ViewUtil.dpToPx(8));
|
||||
} else {
|
||||
params.topToTop = ConstraintLayout.LayoutParams.PARENT_ID;
|
||||
params.bottomToBottom = ConstraintLayout.LayoutParams.UNSET;
|
||||
params.startToEnd = ConstraintLayout.LayoutParams.UNSET;
|
||||
params.startToStart = ConstraintLayout.LayoutParams.PARENT_ID;
|
||||
params.setMarginStart(0);
|
||||
}
|
||||
title.setLayoutParams(params);
|
||||
}
|
||||
|
||||
public void setCorners(int topStart, int topEnd) {
|
||||
|
||||
@@ -22,6 +22,7 @@ import com.google.android.material.imageview.ShapeableImageView;
|
||||
import com.google.android.material.shape.CornerFamily;
|
||||
import com.google.android.material.shape.ShapeAppearanceModel;
|
||||
|
||||
import org.signal.core.ui.fonts.SignalSymbols;
|
||||
import org.signal.core.ui.view.Stub;
|
||||
import org.signal.core.util.DimensionUnit;
|
||||
import org.signal.core.util.Util;
|
||||
@@ -37,7 +38,6 @@ import org.thoughtcrime.securesms.conversation.MessageStyler;
|
||||
import org.thoughtcrime.securesms.conversation.v2.items.SenderNameWithLabelView;
|
||||
import org.thoughtcrime.securesms.database.model.Mention;
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList;
|
||||
import org.thoughtcrime.securesms.fonts.SignalSymbols;
|
||||
import org.thoughtcrime.securesms.groups.memberlabel.MemberLabel;
|
||||
import org.thoughtcrime.securesms.mms.QuoteModel;
|
||||
import org.thoughtcrime.securesms.mms.Slide;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
package org.thoughtcrime.securesms.components
|
||||
|
||||
import android.app.Dialog
|
||||
import android.content.DialogInterface
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.fragment.app.DialogFragment
|
||||
import androidx.fragment.app.Fragment
|
||||
import org.signal.core.ui.enableEdgeToEdge
|
||||
import org.signal.core.ui.initializeScreenshotSecurity
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.util.fragments.findListener
|
||||
@@ -41,6 +43,12 @@ abstract class WrapperDialogFragment : DialogFragment(R.layout.fragment_containe
|
||||
setStyle(STYLE_NO_FRAME, R.style.Signal_DayNight_Dialog_FullScreen)
|
||||
}
|
||||
|
||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||
return super.onCreateDialog(savedInstanceState).apply {
|
||||
window?.enableEdgeToEdge()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
requireActivity().onBackPressedDispatcher.addCallback(
|
||||
viewLifecycleOwner,
|
||||
|
||||
@@ -17,7 +17,7 @@ import java.util.Set;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
public final class EmojiUtil {
|
||||
private static final Pattern EMOJI_PATTERN = Pattern.compile("^(?:(?:[\u00a9\u00ae\u203c\u2049\u2122\u2139\u2194-\u2199\u21a9-\u21aa\u231a-\u231b\u2328\u23cf\u23e9-\u23f3\u23f8-\u23fa\u24c2\u25aa-\u25ab\u25b6\u25c0\u25fb-\u25fe\u2600-\u2604\u260e\u2611\u2614-\u2615\u2618\u261d\u2620\u2622-\u2623\u2626\u262a\u262e-\u262f\u2638-\u263a\u2648-\u2653\u2660\u2663\u2665-\u2666\u2668\u267b\u267f\u2692-\u2694\u2696-\u2697\u2699\u269b-\u269c\u26a0-\u26a1\u26aa-\u26ab\u26b0-\u26b1\u26bd-\u26be\u26c4-\u26c5\u26c8\u26ce-\u26cf\u26d1\u26d3-\u26d4\u26e9-\u26ea\u26f0-\u26f5\u26f7-\u26fa\u26fd\u2702\u2705\u2708-\u270d\u270f\u2712\u2714\u2716\u271d\u2721\u2728\u2733-\u2734\u2744\u2747\u274c\u274e\u2753-\u2755\u2757\u2763-\u2764\u2795-\u2797\u27a1\u27b0\u27bf\u2934-\u2935\u2b05-\u2b07\u2b1b-\u2b1c\u2b50\u2b55\u3030\u303d\u3297\u3299\ud83c\udc04\ud83c\udccf\ud83c\udd70-\ud83c\udd71\ud83c\udd7e-\ud83c\udd7f\ud83c\udd8e\ud83c\udd91-\ud83c\udd9a\ud83c\ude01-\ud83c\ude02\ud83c\ude1a\ud83c\ude2f\ud83c\ude32-\ud83c\ude3a\ud83c\ude50-\ud83c\ude51\u200d\ud83c\udf00-\ud83d\uddff\ud83d\ude00-\ud83d\ude4f\ud83d\ude80-\ud83d\udeff\ud83e\udd00-\ud83e\uddff\udb40\udc20-\udb40\udc7f]|\u200d[\u2640\u2642]|[\ud83c\udde6-\ud83c\uddff]{2}|.[\u20e0\u20e3\ufe0f]+)+)+$");
|
||||
private static final Pattern EMOJI_PATTERN = Pattern.compile("^(?:[\u00a9\u00ae\u203c\u2049\u2122\u2139\u2194-\u2199\u21a9-\u21aa\u231a-\u231b\u2328\u23cf\u23e9-\u23f3\u23f8-\u23fa\u24c2\u25aa-\u25ab\u25b6\u25c0\u25fb-\u25fe\u2600-\u2604\u260e\u2611\u2614-\u2615\u2618\u261d\u2620\u2622-\u2623\u2626\u262a\u262e-\u262f\u2638-\u263a\u2648-\u2653\u2660\u2663\u2665-\u2666\u2668\u267b\u267f\u2692-\u2694\u2696-\u2697\u2699\u269b-\u269c\u26a0-\u26a1\u26aa-\u26ab\u26b0-\u26b1\u26bd-\u26be\u26c4-\u26c5\u26c8\u26ce-\u26cf\u26d1\u26d3-\u26d4\u26e9-\u26ea\u26f0-\u26f5\u26f7-\u26fa\u26fd\u2702\u2705\u2708-\u270d\u270f\u2712\u2714\u2716\u271d\u2721\u2728\u2733-\u2734\u2744\u2747\u274c\u274e\u2753-\u2755\u2757\u2763-\u2764\u2795-\u2797\u27a1\u27b0\u27bf\u2934-\u2935\u2b05-\u2b07\u2b1b-\u2b1c\u2b50\u2b55\u3030\u303d\u3297\u3299\ud83c\udc04\ud83c\udccf\ud83c\udd70-\ud83c\udd71\ud83c\udd7e-\ud83c\udd7f\ud83c\udd8e\ud83c\udd91-\ud83c\udd9a\ud83c\ude01-\ud83c\ude02\ud83c\ude1a\ud83c\ude2f\ud83c\ude32-\ud83c\ude3a\ud83c\ude50-\ud83c\ude51\u200d\ud83c\udf00-\ud83d\uddff\ud83d\ude00-\ud83d\ude4f\ud83d\ude80-\ud83d\udeff\ud83e\udd00-\ud83e\uddff\udb40\udc20-\udb40\udc7f]|\u200d[\u2640\u2642]|[\ud83c\udde6-\ud83c\uddff]{2}|.[\u20e0\u20e3\ufe0f]++)+$");
|
||||
private static final String EMOJI_REGEX = "[^\\p{L}\\p{M}\\p{N}\\p{P}\\p{Z}\\p{Cf}\\p{Cs}\\s]";
|
||||
|
||||
private EmojiUtil() {}
|
||||
|
||||
+25
@@ -1,7 +1,12 @@
|
||||
package org.thoughtcrime.securesms.components.settings
|
||||
|
||||
import android.content.res.Configuration
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.activity.SystemBarStyle
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.Navigation
|
||||
import androidx.navigation.fragment.NavHostFragment
|
||||
@@ -9,6 +14,7 @@ import org.thoughtcrime.securesms.PassphraseRequiredActivity
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme
|
||||
import org.thoughtcrime.securesms.util.DynamicTheme
|
||||
import org.signal.core.ui.R as CoreUiR
|
||||
|
||||
/**
|
||||
* The DSL API can be completely replaced by compose.
|
||||
@@ -22,6 +28,8 @@ open class DSLSettingsActivity : PassphraseRequiredActivity() {
|
||||
private set
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) {
|
||||
enableSettingsEdgeToEdge()
|
||||
|
||||
setContentView(R.layout.dsl_settings_activity)
|
||||
|
||||
if (savedInstanceState == null) {
|
||||
@@ -62,6 +70,23 @@ open class DSLSettingsActivity : PassphraseRequiredActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun enableSettingsEdgeToEdge() {
|
||||
val navBarColor = ContextCompat.getColor(this, CoreUiR.color.signal_colorSurface2)
|
||||
val isDark = resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES
|
||||
|
||||
if (Build.VERSION.SDK_INT >= 26) {
|
||||
enableEdgeToEdge(
|
||||
navigationBarStyle = if (isDark) {
|
||||
SystemBarStyle.dark(navBarColor)
|
||||
} else {
|
||||
SystemBarStyle.light(navBarColor, navBarColor)
|
||||
}
|
||||
)
|
||||
} else {
|
||||
enableEdgeToEdge()
|
||||
}
|
||||
}
|
||||
|
||||
protected open fun onWillFinish() {}
|
||||
|
||||
protected open fun resolveNavGraphId(): Int = intent.getIntExtra(ARG_NAV_GRAPH, -1)
|
||||
|
||||
+41
@@ -4,6 +4,7 @@ import android.content.Context
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.EdgeEffect
|
||||
import androidx.annotation.CallSuper
|
||||
import androidx.annotation.LayoutRes
|
||||
@@ -11,12 +12,17 @@ import androidx.annotation.MenuRes
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.appcompat.widget.Toolbar
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.updateLayoutParams
|
||||
import androidx.recyclerview.widget.ConcatAdapter
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import org.signal.core.ui.logging.LoggingFragment
|
||||
import org.thoughtcrime.securesms.MainActivity
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.conversation.ConversationSettingsNavHostFragment
|
||||
import org.thoughtcrime.securesms.util.Material3OnScrollHelper
|
||||
import org.thoughtcrime.securesms.util.SystemWindowInsetsSetter
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
|
||||
|
||||
/**
|
||||
@@ -70,6 +76,8 @@ abstract class DSLSettingsFragment(
|
||||
}
|
||||
}
|
||||
|
||||
applyEdgeToEdgeInsets()
|
||||
|
||||
when (settingsAdapter) {
|
||||
is ConcatAdapter -> bindAdapters(settingsAdapter)
|
||||
is MappingAdapter -> bindAdapter(settingsAdapter)
|
||||
@@ -77,6 +85,39 @@ abstract class DSLSettingsFragment(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* All host windows are edge-to-edge — activities centrally via [org.thoughtcrime.securesms.BaseActivity],
|
||||
* and the full-screen dialog hosts ([org.thoughtcrime.securesms.components.WrapperDialogFragment],
|
||||
* [org.thoughtcrime.securesms.stories.settings.create.CreateStoryFlowDialogFragment]) via their own
|
||||
* `enableEdgeToEdge` calls — so the toolbar tint must extend behind the (transparent) status bar and the
|
||||
* list must scroll clear of the navigation bar. Skipped only for fragments embedded directly in
|
||||
* [MainActivity]'s Compose scaffolding, which insets those containers itself — except the
|
||||
* conversation-settings detail entry ([ConversationSettingsNavHostFragment]), which leaves insets to these
|
||||
* fragments so the toolbar scroll tint can reach behind the status bar.
|
||||
*/
|
||||
private fun applyEdgeToEdgeInsets() {
|
||||
if (isInsetByMainActivityHost()) {
|
||||
return
|
||||
}
|
||||
|
||||
toolbar?.let { toolbar ->
|
||||
toolbar.updateLayoutParams { height = ViewGroup.LayoutParams.WRAP_CONTENT }
|
||||
SystemWindowInsetsSetter.attach(toolbar, viewLifecycleOwner, WindowInsetsCompat.Type.statusBars())
|
||||
}
|
||||
|
||||
recyclerView?.let { recycler ->
|
||||
recycler.clipToPadding = false
|
||||
SystemWindowInsetsSetter.attach(recycler, viewLifecycleOwner, WindowInsetsCompat.Type.navigationBars())
|
||||
}
|
||||
}
|
||||
|
||||
private fun isInsetByMainActivityHost(): Boolean {
|
||||
if (activity !is MainActivity) {
|
||||
return false
|
||||
}
|
||||
return generateSequence(parentFragment) { it.parentFragment }.none { it is ConversationSettingsNavHostFragment }
|
||||
}
|
||||
|
||||
open fun getMaterial3OnScrollHelper(toolbar: Toolbar?): Material3OnScrollHelper? {
|
||||
if (toolbar == null) {
|
||||
return null
|
||||
|
||||
+5
-1
@@ -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}")
|
||||
}
|
||||
}
|
||||
|
||||
+31
-29
@@ -74,8 +74,8 @@ import org.thoughtcrime.securesms.components.settings.app.routes.AppSettingsRout
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.BadgeImageMedium
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository
|
||||
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 +96,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)
|
||||
@@ -214,9 +218,7 @@ private fun AppSettingsContent(
|
||||
) {
|
||||
bannerManager.Banner()
|
||||
|
||||
LazyColumn(
|
||||
modifier = rememberStatusBarColorNestedScrollModifier()
|
||||
) {
|
||||
LazyColumn {
|
||||
item {
|
||||
BioRow(
|
||||
self = self,
|
||||
@@ -292,17 +294,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 +414,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 {
|
||||
|
||||
+1
-4
@@ -47,7 +47,6 @@ import org.signal.core.ui.compose.Scaffolds
|
||||
import org.signal.core.ui.compose.Texts
|
||||
import org.signal.core.util.ServiceUtil
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.compose.rememberStatusBarColorNestedScrollModifier
|
||||
import org.thoughtcrime.securesms.contactshare.SimpleTextWatcher
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
@@ -265,9 +264,7 @@ fun AccountSettingsScreen(
|
||||
) { contentPadding ->
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.padding(contentPadding)
|
||||
.then(rememberStatusBarColorNestedScrollModifier())
|
||||
.testTag(AccountSettingsTestTags.SCROLLER)
|
||||
.padding(contentPadding).testTag(AccountSettingsTestTags.SCROLLER)
|
||||
) {
|
||||
item {
|
||||
Texts.SectionHeader(
|
||||
|
||||
+142
@@ -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()
|
||||
}
|
||||
}
|
||||
+29
@@ -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
|
||||
}
|
||||
+218
@@ -0,0 +1,218 @@
|
||||
/*
|
||||
* 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.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)
|
||||
) {
|
||||
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 = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
+20
@@ -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
|
||||
}
|
||||
}
|
||||
+58
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
-2
@@ -24,7 +24,6 @@ import org.signal.core.ui.compose.Scaffolds
|
||||
import org.signal.core.ui.compose.SignalIcons
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.app.appearance.navbar.ChooseNavigationBarStyleFragment
|
||||
import org.thoughtcrime.securesms.compose.rememberStatusBarColorNestedScrollModifier
|
||||
import org.thoughtcrime.securesms.keyvalue.SettingsValues
|
||||
import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
||||
|
||||
@@ -114,7 +113,6 @@ private fun AppearanceSettingsScreen(
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.padding(paddingValues)
|
||||
.then(rememberStatusBarColorNestedScrollModifier())
|
||||
) {
|
||||
item {
|
||||
Rows.RadioListRow(
|
||||
|
||||
+145
-44
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -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
|
||||
)
|
||||
|
||||
+8
-1
@@ -15,6 +15,7 @@ import kotlinx.coroutines.launch
|
||||
import org.signal.core.util.concurrent.SignalDispatchers
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.backup.DeletionState
|
||||
import org.thoughtcrime.securesms.backup.v2.BackupRepository
|
||||
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
@@ -44,7 +45,7 @@ class BackupsSettingsViewModel : ViewModel() {
|
||||
it.copy(
|
||||
backupState = enabledState,
|
||||
lastBackupAt = SignalStore.backup.lastBackupTime.milliseconds,
|
||||
showBackupTierInternalOverride = Environment.IS_STAGING,
|
||||
showBackupTierInternalOverride = Environment.IS_STAGING && SignalStore.account.isPrimaryDevice,
|
||||
backupTierInternalOverride = SignalStore.backup.backupTierInternalOverride
|
||||
)
|
||||
}
|
||||
@@ -59,6 +60,12 @@ class BackupsSettingsViewModel : ViewModel() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (SignalStore.account.isLinkedDevice) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
BackupRepository.refreshBackupFileTimestamp()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onBackupTierInternalOverrideChanged(tier: MessageBackupTier?) {
|
||||
|
||||
+171
-52
@@ -106,7 +106,6 @@ import org.thoughtcrime.securesms.components.compose.rememberBiometricsAuthentic
|
||||
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
|
||||
import org.thoughtcrime.securesms.components.settings.app.backups.BackupState
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.MessageBackupsCheckoutLauncher.createBackupsCheckoutLauncher
|
||||
import org.thoughtcrime.securesms.compose.StatusBarColorNestedScrollConnection
|
||||
import org.thoughtcrime.securesms.help.HelpFragment
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.keyvalue.protos.ArchiveUploadProgressState
|
||||
@@ -151,8 +150,7 @@ class RemoteBackupsSettingsFragment : ComposeFragment() {
|
||||
backupRestoreState = restoreState,
|
||||
backupDeleteState = deleteState,
|
||||
contentCallbacks = callbacks,
|
||||
backupProgress = backupProgress,
|
||||
statusBarColorNestedScrollConnection = remember { StatusBarColorNestedScrollConnection(requireActivity()) }
|
||||
backupProgress = backupProgress
|
||||
)
|
||||
}
|
||||
|
||||
@@ -360,8 +358,7 @@ private fun RemoteBackupsSettingsContent(
|
||||
backupRestoreState: BackupRestoreState,
|
||||
backupDeleteState: DeletionState,
|
||||
contentCallbacks: ContentCallbacks,
|
||||
backupProgress: ArchiveUploadProgressState?,
|
||||
statusBarColorNestedScrollConnection: StatusBarColorNestedScrollConnection?
|
||||
backupProgress: ArchiveUploadProgressState?
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val biometrics = rememberBiometricsAuthentication(
|
||||
@@ -402,13 +399,6 @@ private fun RemoteBackupsSettingsContent(
|
||||
)
|
||||
},
|
||||
modifier = Modifier
|
||||
.then(
|
||||
if (statusBarColorNestedScrollConnection != null) {
|
||||
Modifier.nestedScroll(statusBarColorNestedScrollConnection)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
)
|
||||
.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
snackbarHost = {
|
||||
Snackbars.Host(snackbarHostState = snackbarHostState)
|
||||
@@ -452,7 +442,8 @@ private fun RemoteBackupsSettingsContent(
|
||||
state = state.backupState,
|
||||
onLearnMoreClick = contentCallbacks::onLearnMoreAboutLostSubscription,
|
||||
onRenewClick = contentCallbacks::onRenewLostSubscription,
|
||||
isRenewEnabled = backupDeleteState.isIdle()
|
||||
isRenewEnabled = backupDeleteState.isIdle(),
|
||||
isLinkedDevice = state.isLinkedDevice
|
||||
)
|
||||
}
|
||||
|
||||
@@ -464,7 +455,8 @@ private fun RemoteBackupsSettingsContent(
|
||||
onBackupTypeActionButtonClicked = contentCallbacks::onBackupTypeActionClick,
|
||||
isPaidTierPricingAvailable = state.isPaidTierPricingAvailable,
|
||||
isGooglePlayServicesAvailable = state.isGooglePlayServicesAvailable,
|
||||
buttonsEnabled = backupDeleteState.isIdle()
|
||||
buttonsEnabled = backupDeleteState.isIdle(),
|
||||
isLinkedDevice = state.isLinkedDevice
|
||||
)
|
||||
}
|
||||
|
||||
@@ -473,13 +465,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 +968,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 +1111,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 +1325,7 @@ private fun PendingCard(
|
||||
private fun SubscriptionNotFoundCard(
|
||||
title: String,
|
||||
isRenewEnabled: Boolean,
|
||||
isLinkedDevice: Boolean = false,
|
||||
onRenewClick: () -> Unit = {},
|
||||
onLearnMoreClick: () -> Unit = {}
|
||||
) {
|
||||
@@ -1308,38 +1364,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 +1414,7 @@ private fun SubscriptionNotFoundCard(
|
||||
private fun SubscriptionMismatchMissingGooglePlayCard(
|
||||
state: BackupState.SubscriptionMismatchMissingGooglePlay,
|
||||
isRenewEnabled: Boolean,
|
||||
isLinkedDevice: Boolean = false,
|
||||
onRenewClick: () -> Unit = {},
|
||||
onLearnMoreClick: () -> Unit = {}
|
||||
) {
|
||||
@@ -1357,6 +1423,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
|
||||
)
|
||||
@@ -1808,7 +1875,6 @@ private fun RemoteBackupsSettingsContentPreview() {
|
||||
hasRedemptionError = true,
|
||||
isOutOfStorageSpace = true
|
||||
),
|
||||
statusBarColorNestedScrollConnection = null,
|
||||
backupDeleteState = DeletionState.NONE,
|
||||
backupRestoreState = BackupRestoreState.None,
|
||||
contentCallbacks = ContentCallbacks.Empty,
|
||||
@@ -1838,7 +1904,62 @@ private fun RemoteBackupsSettingsInternalUserContentPreview() {
|
||||
internalUser = true,
|
||||
includeDebuglog = true
|
||||
),
|
||||
statusBarColorNestedScrollConnection = null,
|
||||
backupDeleteState = DeletionState.NONE,
|
||||
backupRestoreState = BackupRestoreState.None,
|
||||
contentCallbacks = ContentCallbacks.Empty,
|
||||
backupProgress = null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@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
|
||||
)
|
||||
),
|
||||
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
|
||||
),
|
||||
backupDeleteState = DeletionState.NONE,
|
||||
backupRestoreState = BackupRestoreState.None,
|
||||
contentCallbacks = ContentCallbacks.Empty,
|
||||
@@ -1875,10 +1996,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))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -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(
|
||||
|
||||
+37
-1
@@ -79,7 +79,8 @@ class RemoteBackupsSettingsViewModel : ViewModel() {
|
||||
internalUser = RemoteConfig.internalUser,
|
||||
includeDebuglog = SignalStore.internal.includeDebuglogInBackup.takeIf { RemoteConfig.internalUser },
|
||||
backupCreationError = SignalStore.backup.backupCreationError,
|
||||
lastMessageCutoffTime = SignalStore.backup.lastUsedMessageCutoffTime
|
||||
lastMessageCutoffTime = SignalStore.backup.lastUsedMessageCutoffTime,
|
||||
isLinkedDevice = SignalStore.account.isLinkedDevice
|
||||
)
|
||||
)
|
||||
|
||||
@@ -92,6 +93,14 @@ class RemoteBackupsSettingsViewModel : ViewModel() {
|
||||
private var forQuickRestore = false
|
||||
|
||||
init {
|
||||
if (state.value.isLinkedDevice) {
|
||||
initLinkedDevice()
|
||||
} else {
|
||||
initPrimaryDevice()
|
||||
}
|
||||
}
|
||||
|
||||
private fun initPrimaryDevice() {
|
||||
ArchiveUploadProgress.triggerUpdate()
|
||||
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
@@ -199,6 +208,29 @@ class RemoteBackupsSettingsViewModel : ViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render remote backups as read-only and refresh the last-backup time from the CDN.
|
||||
*/
|
||||
private fun initLinkedDevice() {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
BackupStateObserver(viewModelScope, useDatabaseFallbackOnNetworkError = true).backupState.collect { backupState ->
|
||||
_state.update {
|
||||
it.copy(backupState = backupState)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
viewModelScope.launch(Dispatchers.Default) {
|
||||
SignalStore.backup.lastBackupTimeFlow.collect { lastBackupTime ->
|
||||
_state.update { it.copy(lastBackupTimestamp = lastBackupTime) }
|
||||
}
|
||||
}
|
||||
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
BackupRepository.refreshBackupFileTimestamp()
|
||||
}
|
||||
}
|
||||
|
||||
fun setCanBackUpUsingCellular(canBackUpUsingCellular: Boolean) {
|
||||
SignalStore.backup.backupWithCellular = canBackUpUsingCellular
|
||||
_state.update {
|
||||
@@ -259,6 +291,10 @@ class RemoteBackupsSettingsViewModel : ViewModel() {
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
if (state.value.isLinkedDevice) {
|
||||
return
|
||||
}
|
||||
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val id = SignalDatabase.inAppPayments.getLatestInAppPaymentByType(InAppPaymentType.RECURRING_BACKUP)?.id
|
||||
|
||||
|
||||
+4
@@ -10,6 +10,7 @@ import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.activity.viewModels
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.MainActivity
|
||||
@@ -23,6 +24,7 @@ import org.thoughtcrime.securesms.logsubmit.SubmitDebugLogActivity
|
||||
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme
|
||||
import org.thoughtcrime.securesms.util.DynamicTheme
|
||||
import org.thoughtcrime.securesms.util.SignalE164Util
|
||||
import org.thoughtcrime.securesms.util.SystemWindowInsetsSetter
|
||||
|
||||
/**
|
||||
* A captive activity that can determine if an interrupted/erred change number request
|
||||
@@ -58,6 +60,8 @@ class ChangeNumberLockActivity : PassphraseRequiredActivity() {
|
||||
|
||||
setContentView(R.layout.activity_change_number_lock)
|
||||
|
||||
SystemWindowInsetsSetter.attach(findViewById(R.id.change_number_lock_scroll), this, WindowInsetsCompat.Type.systemBars())
|
||||
|
||||
reattemptChange()
|
||||
}
|
||||
|
||||
|
||||
-2
@@ -28,7 +28,6 @@ import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.backup.isIdle
|
||||
import org.thoughtcrime.securesms.backup.v2.ui.status.BackupCreationProgressRow
|
||||
import org.thoughtcrime.securesms.components.compose.rememberBiometricsAuthentication
|
||||
import org.thoughtcrime.securesms.compose.rememberStatusBarColorNestedScrollModifier
|
||||
import org.thoughtcrime.securesms.keyvalue.protos.LocalBackupCreationProgress
|
||||
import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
||||
|
||||
@@ -162,7 +161,6 @@ private fun ChatsSettingsScreen(
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.padding(paddingValues)
|
||||
.then(rememberStatusBarColorNestedScrollModifier())
|
||||
) {
|
||||
item {
|
||||
Rows.ToggleRow(
|
||||
|
||||
-2
@@ -27,7 +27,6 @@ import org.signal.core.ui.compose.Texts
|
||||
import org.signal.core.util.bytes
|
||||
import org.signal.mediasend.SentMediaQuality
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.compose.rememberStatusBarColorNestedScrollModifier
|
||||
import org.thoughtcrime.securesms.util.AttachmentUtil
|
||||
import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
||||
import org.thoughtcrime.securesms.webrtc.CallDataMode
|
||||
@@ -135,7 +134,6 @@ private fun DataAndStorageSettingsScreen(
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.padding(paddingValues)
|
||||
.then(rememberStatusBarColorNestedScrollModifier())
|
||||
) {
|
||||
item {
|
||||
Rows.TextRow(
|
||||
|
||||
+54
-26
@@ -25,7 +25,6 @@ import org.signal.core.ui.BottomSheetUtil
|
||||
import org.signal.core.ui.permissions.PermissionDeniedBottomSheet
|
||||
import org.signal.core.ui.permissions.RationaleDialog
|
||||
import org.signal.core.util.AppUtil
|
||||
import org.signal.core.util.ThreadUtil
|
||||
import org.signal.core.util.Util
|
||||
import org.signal.core.util.concurrent.SignalExecutors
|
||||
import org.signal.core.util.concurrent.SimpleTask
|
||||
@@ -588,19 +587,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(
|
||||
@@ -790,6 +776,50 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
|
||||
}
|
||||
)
|
||||
|
||||
switchPref(
|
||||
title = DSLSettingsText.from("Set Video Config:"),
|
||||
isChecked = state.callingSetVideoConfig,
|
||||
onClick = {
|
||||
viewModel.setInternalCallingSetVideoConfig(!state.callingSetVideoConfig)
|
||||
}
|
||||
)
|
||||
|
||||
switchPref(
|
||||
title = DSLSettingsText.from(" Use Hardware Vp9 Encode"),
|
||||
isChecked = state.callingUseHardwareVp9Encode,
|
||||
isEnabled = state.callingSetVideoConfig,
|
||||
onClick = {
|
||||
viewModel.setInternalCallingUseHardwareVp9Encode(!state.callingUseHardwareVp9Encode)
|
||||
}
|
||||
)
|
||||
|
||||
switchPref(
|
||||
title = DSLSettingsText.from(" Use Hardware Vp9 Decode"),
|
||||
isChecked = state.callingUseHardwareVp9Decode,
|
||||
isEnabled = state.callingSetVideoConfig,
|
||||
onClick = {
|
||||
viewModel.setInternalCallingUseHardwareVp9Decode(!state.callingUseHardwareVp9Decode)
|
||||
}
|
||||
)
|
||||
|
||||
switchPref(
|
||||
title = DSLSettingsText.from(" Use Software Vp9 Encode"),
|
||||
isChecked = state.callingUseSoftwareVp9Encode,
|
||||
isEnabled = state.callingSetVideoConfig,
|
||||
onClick = {
|
||||
viewModel.setInternalCallingUseSoftwareVp9Encode(!state.callingUseSoftwareVp9Encode)
|
||||
}
|
||||
)
|
||||
|
||||
switchPref(
|
||||
title = DSLSettingsText.from(" Use Software Vp9 Decode"),
|
||||
isChecked = state.callingUseSoftwareVp9Decode,
|
||||
isEnabled = state.callingSetVideoConfig,
|
||||
onClick = {
|
||||
viewModel.setInternalCallingUseSoftwareVp9Decode(!state.callingUseSoftwareVp9Decode)
|
||||
}
|
||||
)
|
||||
|
||||
dividerPref()
|
||||
|
||||
// TODO [alex] -- db access on main thread!
|
||||
@@ -1068,19 +1098,17 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
|
||||
.setTitle("Unregister?")
|
||||
.setMessage("Are you sure? You'll have to re-register to use Signal again -- no promises that the process will go smoothly.")
|
||||
.setPositiveButton(android.R.string.ok) { _, _ ->
|
||||
AdvancedPrivacySettingsRepository(requireContext()).disablePushMessages {
|
||||
ThreadUtil.runOnMain {
|
||||
when (it) {
|
||||
AdvancedPrivacySettingsRepository.DisablePushMessagesResult.SUCCESS -> {
|
||||
SignalStore.account.setRegistered(false)
|
||||
SignalStore.registration.clearRegistrationComplete()
|
||||
SignalStore.registration.hasUploadedProfile = false
|
||||
Toast.makeText(context, "Unregistered!", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
lifecycleScope.launch {
|
||||
when (AdvancedPrivacySettingsRepository(requireContext()).disablePushMessages()) {
|
||||
AdvancedPrivacySettingsRepository.DisablePushMessagesResult.SUCCESS -> {
|
||||
SignalStore.account.setRegistered(false)
|
||||
SignalStore.registration.clearRegistrationComplete()
|
||||
SignalStore.registration.hasUploadedProfile = false
|
||||
Toast.makeText(context, "Unregistered!", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
AdvancedPrivacySettingsRepository.DisablePushMessagesResult.NETWORK_ERROR -> {
|
||||
Toast.makeText(context, "Network error!", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
AdvancedPrivacySettingsRepository.DisablePushMessagesResult.NETWORK_ERROR -> {
|
||||
Toast.makeText(context, "Network error!", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user