mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-06-12 02:06:06 +01:00
Compare commits
290 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c5efb2ce6c | |||
| c7b5738787 | |||
| df88b7fe35 | |||
| 46213b38e7 | |||
| f8b53378c6 | |||
| bbb09eb7a0 | |||
| 52ceb4062d | |||
| f7eaa1cb51 | |||
| c62c15c810 | |||
| 2389279cfd | |||
| 3e92fca26d | |||
| 526928ce0a | |||
| 2ea2f561ae | |||
| c88f048049 | |||
| 204a233235 | |||
| 2e4abd8ed3 | |||
| a2a0b11c98 | |||
| 96f893652b | |||
| d4924d2a13 | |||
| fa6b512cfc | |||
| d447af36ba | |||
| 41260f37c9 | |||
| 8950f7f7f9 | |||
| 14916d068f | |||
| 33022baaa2 | |||
| 4cdd1f70ac | |||
| 9e3ee16e65 | |||
| bf912e14d9 | |||
| 56f1a9e0ec | |||
| e468156c4c | |||
| 029b91066f | |||
| 5909a1b92a | |||
| 9478cdf049 | |||
| 6d260ab63d | |||
| 4bc11fcf0d | |||
| 33619fe463 | |||
| 2b17868797 | |||
| 582a464a52 | |||
| 812a858761 | |||
| 6ea96795cb | |||
| 70ab0baa3c | |||
| 5041057bab | |||
| 6210d1b397 | |||
| f1accae295 | |||
| 5f6d20453c | |||
| d33385d1b2 | |||
| 3d05bc3471 | |||
| 0514a5c6c8 | |||
| 798ba11e62 | |||
| 848a61787b | |||
| ccfbb27695 | |||
| 26b1d3a0f8 | |||
| c5785c086e | |||
| 6aeb145024 | |||
| 7e8c6228d8 | |||
| 4c08b94b88 | |||
| 135bc6e560 | |||
| 0ebeb5aa92 | |||
| aaa7a18190 | |||
| bfc1c4ebfa | |||
| b4cf59f9c2 | |||
| 15e7b30fa1 | |||
| a5359e05a3 | |||
| 73557ae72a | |||
| 2505049e39 | |||
| a4ae6581ef | |||
| 6399a2d899 | |||
| 56af57db9e | |||
| ddf0de52b1 | |||
| 925e2c1705 | |||
| cb719dff1a | |||
| f2fd3e63c8 | |||
| 8560ab0515 | |||
| d586eff80b | |||
| 27ddd62d7a | |||
| 38f31528ff | |||
| 335fcd72f3 | |||
| 118231a328 | |||
| 754dd15c94 | |||
| 566c2d5838 | |||
| eae894152c | |||
| 53c4069c64 | |||
| 65082893db | |||
| 595364b522 | |||
| 7cce504f16 | |||
| 337afb11db | |||
| 6027d58fb5 | |||
| 132eaa5c70 | |||
| ba3e15ea6d | |||
| ceee5f714d | |||
| 4c942f39b0 | |||
| f3a5bba3f2 | |||
| 8af7606e3f | |||
| 2adf84a895 | |||
| 30ed0aa11a | |||
| ec9ae9e3b1 | |||
| 6a23896077 | |||
| f5a1d79eb5 | |||
| 4f0f0938d8 | |||
| 0136971963 | |||
| f810d731dd | |||
| 7c7c364fef | |||
| aa9591211b | |||
| bbd48547e5 | |||
| 757b521744 | |||
| a6311c87c1 | |||
| 045bd9287b | |||
| f1a72dd01a | |||
| af4d0a0ef0 | |||
| 7dcaa933f2 | |||
| 2c88945e6b | |||
| f9b9ce6c14 | |||
| 1d8fbad17e | |||
| 6872a14378 | |||
| 3a1eb4bd88 | |||
| d9f93294e4 | |||
| f063c43b52 | |||
| a7ed672634 | |||
| 1371663163 | |||
| 1f0c24a5d5 | |||
| b732cbe00b | |||
| 85d60dd0da | |||
| c020bfeb7a | |||
| 3ad446c6c9 | |||
| bc9c560f96 | |||
| 2b54dc4715 | |||
| 1443457eca | |||
| ffbc4465bb | |||
| 4e5ddad78f | |||
| 47a69d667c | |||
| 9d3a51def2 | |||
| b8c964846c | |||
| b02210c166 | |||
| c2f8261419 | |||
| 089d47936b | |||
| 48f55bba0a | |||
| 348387f2d0 | |||
| 30c0ef255a | |||
| 64d3ba9e5b | |||
| 930a2f052a | |||
| efec070728 | |||
| 99aa8a602b | |||
| 4a68e0c469 | |||
| be80619a3b | |||
| a0d605d1b1 | |||
| 64f30bff47 | |||
| 843b656fb6 | |||
| f5f5bf0a67 | |||
| bf73954f42 | |||
| 1fd651ee50 | |||
| f76292769a | |||
| 51c4afe5f5 | |||
| fe435433fd | |||
| fa8098a9aa | |||
| 81e09e65cb | |||
| 1059fcafba | |||
| 91d3fa8ad5 | |||
| 89bffe39ae | |||
| de2a5ea440 | |||
| a54d62c09d | |||
| e8785218a5 | |||
| e6e6075c9b | |||
| 95b69faa58 | |||
| b7f09ef923 | |||
| 50884e144e | |||
| 274feb168e | |||
| 5b1f5a2a20 | |||
| 2cb9685024 | |||
| 0f4fc74829 | |||
| f5e8e15785 | |||
| 3ff1501090 | |||
| 0907898105 | |||
| 5fd8101180 | |||
| 7e6ad92ca8 | |||
| ae2477356b | |||
| 8d992fa7db | |||
| 71c33fc579 | |||
| bdefc274b9 | |||
| f926d9c893 | |||
| ec02d802f2 | |||
| 7925041982 | |||
| de42d748dc | |||
| 3e1df5b8ac | |||
| f1d722c5cd | |||
| abcd65603c | |||
| 7b23110cac | |||
| baa4dd3c86 | |||
| 0beda1e615 | |||
| b3acefdb08 | |||
| 9600366422 | |||
| d85a57adce | |||
| cb1b878198 | |||
| 4a2e2e0137 | |||
| b2f8445e1d | |||
| f5f686fece | |||
| 1864534174 | |||
| 54c4bda4f2 | |||
| 3cb61e3e8a | |||
| 83a83f65ef | |||
| 122e1770b7 | |||
| 68eb4d3c82 | |||
| ce4d68a20f | |||
| 5d45914a08 | |||
| b2f450d849 | |||
| ed73dc2c24 | |||
| 71967b278e | |||
| faadc9854f | |||
| f206487ede | |||
| 0284da2d0f | |||
| 15a3a8efde | |||
| b429d08c5e | |||
| 3892113a0e | |||
| 41f52ed886 | |||
| ec07b7805e | |||
| 333e514a2f | |||
| 15a17adf1a | |||
| 13888bab0a | |||
| 16fc81f715 | |||
| b4f2d8682f | |||
| a05ce88bc0 | |||
| 59c38147c7 | |||
| da85d0b4eb | |||
| 5aa7e3a7c1 | |||
| c92b0505df | |||
| ca8c494fc4 | |||
| fbbcadf09b | |||
| 3fc6ac3871 | |||
| 6a2ec01c52 | |||
| c1b3fb6d1b | |||
| 792d86f4d8 | |||
| 849856cde8 | |||
| 0646418d4d | |||
| ed540a2f9e | |||
| 00d86101f5 | |||
| 86e49cd564 | |||
| 21b7d64fcd | |||
| 42c0044096 | |||
| f2e8b83604 | |||
| 46b8ac6561 | |||
| 9089cc393e | |||
| 2ea59bef68 | |||
| 698fc38aed | |||
| 1d74b00b91 | |||
| ea861fff49 | |||
| 3b93edcdaf | |||
| 6722a28f98 | |||
| 16de2efa9e | |||
| 4d0919c9a8 | |||
| 01e1cb4d67 | |||
| 9d1d5142da | |||
| 49f0c2502b | |||
| 71ffc36e7f | |||
| 5941ff814d | |||
| 1661f3b5f7 | |||
| d682de08d2 | |||
| 5321f8124a | |||
| 5052f22d44 | |||
| 9a8cb1785b | |||
| a2065becdd | |||
| e85637a58d | |||
| 73c3d141e3 | |||
| eafba156ba | |||
| e6beafd612 | |||
| a9649fd017 | |||
| 4decae274b | |||
| dbb83d86e3 | |||
| 2aa27df95b | |||
| ec47b83f76 | |||
| 6eea4ba937 | |||
| 9f608337f1 | |||
| 28edcdf62d | |||
| 10d969ea35 | |||
| 38bac16640 | |||
| 93077ac457 | |||
| c069eb1b88 | |||
| e5cd18bf1e | |||
| 9e8ae7e26a | |||
| 00042b9579 | |||
| e750b81a31 | |||
| daec317f52 | |||
| 112514c221 | |||
| f43db8ace0 | |||
| 54df95727b | |||
| 022b4d9508 | |||
| 7411e725ec | |||
| 83a279f422 | |||
| 523066d093 | |||
| de27343c24 | |||
| c36179293e | |||
| a79a91bafb |
@@ -5,7 +5,7 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- 'main'
|
||||
- '7.**'
|
||||
- '8.**'
|
||||
|
||||
permissions:
|
||||
contents: read # to fetch code (actions/checkout)
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
runs-on: ubuntu-latest-8-cores
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
# gh api repos/actions/checkout/commits/v6 --jq '.sha'
|
||||
with:
|
||||
submodules: true
|
||||
@@ -27,14 +27,29 @@ jobs:
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 17
|
||||
cache: gradle
|
||||
|
||||
- name: Validate Gradle Wrapper
|
||||
uses: gradle/actions/wrapper-validation@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6
|
||||
# gh api repos/gradle/actions/commits/v6 --jq '.sha'
|
||||
|
||||
- name: Set up Gradle
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6
|
||||
# gh api repos/gradle/actions/commits/v6 --jq '.sha'
|
||||
with:
|
||||
# Only 8.** branch builds write to the cache; everything else (PRs, etc.) reads only.
|
||||
cache-read-only: ${{ !startsWith(github.ref, 'refs/heads/8.') }}
|
||||
# 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).
|
||||
- name: Build with Gradle
|
||||
run: ./gradlew qa
|
||||
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' }}
|
||||
|
||||
- name: Archive reports for failed build
|
||||
if: ${{ failure() }}
|
||||
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
runs-on: ubuntu-latest-8-cores
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
# gh api repos/actions/checkout/commits/v6 --jq '.sha'
|
||||
with:
|
||||
submodules: true
|
||||
@@ -28,7 +28,15 @@ jobs:
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 17
|
||||
cache: gradle
|
||||
|
||||
- name: Set up Gradle
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6
|
||||
# gh api repos/gradle/actions/commits/v6 --jq '.sha'
|
||||
with:
|
||||
# PR-only workflow: always read from the cache, never write.
|
||||
cache-read-only: true
|
||||
# Required to read the Gradle configuration cache persisted by 8.** builds.
|
||||
cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
|
||||
|
||||
- name: Install NDK
|
||||
run: echo "y" | ${ANDROID_SDK_ROOT}/cmdline-tools/latest/bin/sdkmanager --install "ndk;${{ env.NDK_VERSION }}"
|
||||
@@ -53,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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
# gh api repos/actions/checkout/commits/v6 --jq '.sha'
|
||||
with:
|
||||
submodules: true
|
||||
|
||||
@@ -11,7 +11,7 @@ jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
# gh api repos/actions/checkout/commits/v6 --jq '.sha'
|
||||
- name: Build image
|
||||
run: |
|
||||
|
||||
@@ -14,7 +14,7 @@ jobs:
|
||||
actions: write
|
||||
|
||||
steps:
|
||||
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10
|
||||
- uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10
|
||||
# gh api repos/actions/stale/commits/v10 --jq '.sha'
|
||||
with:
|
||||
days-before-stale: 60
|
||||
|
||||
+30
-23
@@ -27,8 +27,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 = 1690
|
||||
val canonicalVersionName = "8.11.2"
|
||||
val canonicalVersionCode = 1705
|
||||
val canonicalVersionName = "8.15.1"
|
||||
val currentHotfixVersion = 0
|
||||
val maxHotfixVersions = 100
|
||||
|
||||
@@ -56,6 +56,11 @@ val localProperties: Properties? = if (localPropertiesFile.exists()) {
|
||||
val quickstartCredentialsDir: String? = localProperties?.getProperty("quickstart.credentials.dir")
|
||||
val benchmarkBackupFile: String? = localProperties?.getProperty("benchmark.backup.file")
|
||||
|
||||
val isInstrumentationTestRun = gradle.startParameter.taskNames.any { taskName ->
|
||||
val lower = taskName.lowercase()
|
||||
lower.contains("androidtest") || lower.contains("connectedcheck")
|
||||
}
|
||||
|
||||
val selectableVariants = listOf(
|
||||
"nightlyProdSpinner",
|
||||
"nightlyProdPerf",
|
||||
@@ -68,13 +73,11 @@ val selectableVariants = listOf(
|
||||
"playProdMocked",
|
||||
"playProdNonMinifiedMocked",
|
||||
"playProdBenchmark",
|
||||
"playProdInstrumentation",
|
||||
"playProdRelease",
|
||||
"playStagingDebug",
|
||||
"playStagingCanary",
|
||||
"playStagingSpinner",
|
||||
"playStagingPerf",
|
||||
"playStagingInstrumentation",
|
||||
"playStagingRelease",
|
||||
"playProdQuickstart",
|
||||
"playStagingQuickstart",
|
||||
@@ -100,6 +103,9 @@ tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile::class.java).conf
|
||||
if (!isTestTask && (name.contains("Mocked") || name.contains("Benchmark"))) {
|
||||
source("$projectDir/src/benchmarkShared/java")
|
||||
}
|
||||
if (isTestTask && name.contains("AndroidTest")) {
|
||||
source("$projectDir/src/benchmarkShared/java")
|
||||
}
|
||||
}
|
||||
|
||||
wire {
|
||||
@@ -129,7 +135,6 @@ android {
|
||||
ndkVersion = libs.versions.ndk.get()
|
||||
|
||||
flavorDimensions += listOf("distribution", "environment")
|
||||
testBuildType = "instrumentation"
|
||||
|
||||
android.bundle.language.enableSplit = false
|
||||
|
||||
@@ -168,6 +173,7 @@ android {
|
||||
|
||||
getByName("androidTest") {
|
||||
java.srcDir("$projectDir/src/testShared")
|
||||
java.srcDir("$projectDir/src/benchmarkShared/java")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -215,6 +221,10 @@ android {
|
||||
versionCode = (canonicalVersionCode * maxHotfixVersions) + possibleHotfixVersions[currentHotfixVersion]
|
||||
versionName = canonicalVersionName
|
||||
|
||||
if (isInstrumentationTestRun) {
|
||||
applicationIdSuffix = ".test_run"
|
||||
}
|
||||
|
||||
minSdk = libs.versions.minSdk.get().toInt()
|
||||
targetSdk = libs.versions.targetSdk.get().toInt()
|
||||
|
||||
@@ -249,8 +259,8 @@ android {
|
||||
buildConfigField("String[]", "SIGNAL_CDSI_IPS", rootProject.extra["cdsi_ips"] as String)
|
||||
buildConfigField("String[]", "SIGNAL_SVR2_IPS", rootProject.extra["svr2_ips"] as String)
|
||||
buildConfigField("String", "SIGNAL_AGENT", "\"OWA\"")
|
||||
buildConfigField("String", "SVR2_MRENCLAVE_LEGACY", "\"29cd63c87bea751e3bfd0fbd401279192e2e5c99948b4ee9437eafc4968355fb\"")
|
||||
buildConfigField("String", "SVR2_MRENCLAVE", "\"1240acbd4aa26974184844c8a46b1022d3957ac8a76c1fd8f5b1a15141ee0708\"")
|
||||
buildConfigField("String", "SVR2_MRENCLAVE_LEGACY", "\"1240acbd4aa26974184844c8a46b1022d3957ac8a76c1fd8f5b1a15141ee0708\"")
|
||||
buildConfigField("String", "SVR2_MRENCLAVE", "\"ced8217b26228e4b210c985786999d095c4958a94faf37b14acaf25c4cbb02a4\"")
|
||||
buildConfigField("String[]", "UNIDENTIFIED_SENDER_TRUST_ROOTS", "new String[]{ \"BXu6QIKVz5MA8gstzfOgRQGqyLqOwNKHL6INkv3IHWMF\", \"BUkY0I+9+oPgDCn4+Ac6Iu813yvqkDr/ga8DzLxFxuk6\"}")
|
||||
buildConfigField("String", "ZKGROUP_SERVER_PUBLIC_PARAMS", "\"AMhf5ywVwITZMsff/eCyudZx9JDmkkkbV6PInzG4p8x3VqVJSFiMvnvlEKWuRob/1eaIetR31IYeAbm0NdOuHH8Qi+Rexi1wLlpzIo1gstHWBfZzy1+qHRV5A4TqPp15YzBPm0WSggW6PbSn+F4lf57VCnHF7p8SvzAA2ZZJPYJURt8X7bbg+H3i+PEjH9DXItNEqs2sNcug37xZQDLm7X36nOoGPs54XsEGzPdEV+itQNGUFEjY6X9Uv+Acuks7NpyGvCoKxGwgKgE5XyJ+nNKlyHHOLb6N1NuHyBrZrgtY/JYJHRooo5CEqYKBqdFnmbTVGEkCvJKxLnjwKWf+fEPoWeQFj5ObDjcKMZf2Jm2Ae69x+ikU5gBXsRmoF94GXTLfN0/vLt98KDPnxwAQL9j5V1jGOY8jQl6MLxEs56cwXN0dqCnImzVH3TZT1cJ8SW1BRX6qIVxEzjsSGx3yxF3suAilPMqGRp4ffyopjMD1JXiKR2RwLKzizUe5e8XyGOy9fplzhw3jVzTRyUZTRSZKkMLWcQ/gv0E4aONNqs4P+NameAZYOD12qRkxosQQP5uux6B2nRyZ7sAV54DgFyLiRcq1FvwKw2EPQdk4HDoePrO/RNUbyNddnM/mMgj4FW65xCoT1LmjrIjsv/Ggdlx46ueczhMgtBunx1/w8k8V+l8LVZ8gAT6wkU5J+DPQalQguMg12Jzug3q4TbdHiGCmD9EunCwOmsLuLJkz6EcSYXtrlDEnAM+hicw7iergYLLlMXpfTdGxJCWJmP4zqUFeTTmsmhsjGBt7NiEB/9pFFEB3pSbf4iiUukw63Eo8Aqnf4iwob6X1QviCWuc8t0LUlT9vALgh/f2DPVOOmR0RW6bgRvc7DSF20V/omg+YBw==\"")
|
||||
buildConfigField("String", "GENERIC_SERVER_PUBLIC_PARAMS", "\"AByD873dTilmOSG0TjKrvpeaKEsUmIO8Vx9BeMmftwUs9v7ikPwM8P3OHyT0+X3EUMZrSe9VUp26Wai51Q9I8mdk0hX/yo7CeFGJyzoOqn8e/i4Ygbn5HoAyXJx5eXfIbqpc0bIxzju4H/HOQeOpt6h742qii5u/cbwOhFZCsMIbElZTaeU+BWMBQiZHIGHT5IE0qCordQKZ5iPZom0HeFa8Yq0ShuEyAl0WINBiY6xE3H/9WnvzXBbMuuk//eRxXgzO8ieCeK8FwQNxbfXqZm6Ro1cMhCOF3u7xoX83QhpN\"")
|
||||
@@ -272,7 +282,6 @@ android {
|
||||
buildConfigField("String", "STRIPE_PUBLISHABLE_KEY", "\"pk_live_6cmGZopuTsV8novGgJJW9JpC00vLIgtQ1D\"")
|
||||
buildConfigField("boolean", "TRACING_ENABLED", "false")
|
||||
buildConfigField("boolean", "LINK_DEVICE_UX_ENABLED", "false")
|
||||
buildConfigField("boolean", "USE_STRING_ID", "false")
|
||||
|
||||
ndk {
|
||||
abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64")
|
||||
@@ -288,7 +297,11 @@ android {
|
||||
}
|
||||
}
|
||||
|
||||
testInstrumentationRunner = "org.thoughtcrime.securesms.testing.SignalTestRunner"
|
||||
testInstrumentationRunner = if (project.hasProperty("imoTests")) {
|
||||
"org.thoughtcrime.securesms.testing.incomingmessageobserver.IncomingMessageObserverTestRunner"
|
||||
} else {
|
||||
"org.thoughtcrime.securesms.testing.SignalTestRunner"
|
||||
}
|
||||
testInstrumentationRunnerArguments["clearPackageData"] = "true"
|
||||
}
|
||||
|
||||
@@ -337,17 +350,6 @@ android {
|
||||
buildConfigField("String", "BUILD_VARIANT_TYPE", "\"Release\"")
|
||||
}
|
||||
|
||||
create("instrumentation") {
|
||||
initWith(getByName("debug"))
|
||||
isDefault = false
|
||||
isMinifyEnabled = false
|
||||
matchingFallbacks += "debug"
|
||||
applicationIdSuffix = ".instrumentation"
|
||||
|
||||
buildConfigField("String", "BUILD_VARIANT_TYPE", "\"Instrumentation\"")
|
||||
buildConfigField("String", "STRIPE_BASE_URL", "\"http://127.0.0.1:8080/stripe\"")
|
||||
}
|
||||
|
||||
create("spinner") {
|
||||
initWith(getByName("debug"))
|
||||
isDefault = false
|
||||
@@ -516,6 +518,9 @@ android {
|
||||
androidComponents {
|
||||
beforeVariants { variant ->
|
||||
variant.enable = variant.name in selectableVariants
|
||||
if (variant.enable) {
|
||||
(variant as? com.android.build.api.variant.HasUnitTestBuilder)?.enableUnitTest = true
|
||||
}
|
||||
}
|
||||
onVariants(selector().all()) { variant: com.android.build.api.variant.ApplicationVariant ->
|
||||
// Rename APK to include version name
|
||||
@@ -527,9 +532,10 @@ androidComponents {
|
||||
transformationRequest.set(renameRequest)
|
||||
}
|
||||
|
||||
// Include the test-only library on debug builds.
|
||||
if (variant.buildType != "instrumentation") {
|
||||
// Include the test-only library on non-release builds.
|
||||
if (variant.buildType == "release") {
|
||||
variant.packaging.jniLibs.excludes.add("**/libsignal_jni_testing.so")
|
||||
variant.androidResources.ignoreAssetsPatterns.add("libsignal-testing.md")
|
||||
}
|
||||
|
||||
// Starting with minSdk 23, Android leaves native libraries uncompressed, which is fine for the Play Store, but not for our self-distributed APKs.
|
||||
@@ -715,6 +721,7 @@ dependencies {
|
||||
}
|
||||
implementation(libs.dnsjava)
|
||||
implementation(libs.kotlinx.collections.immutable)
|
||||
implementation(libs.arrow.core)
|
||||
implementation(libs.accompanist.permissions)
|
||||
implementation(libs.accompanist.drawablepainter)
|
||||
implementation(libs.kotlin.stdlib.jdk8)
|
||||
@@ -736,7 +743,7 @@ dependencies {
|
||||
|
||||
"canaryImplementation"(libs.square.leakcanary)
|
||||
|
||||
"instrumentationImplementation"(libs.androidx.fragment.testing) {
|
||||
androidTestImplementation(libs.androidx.fragment.testing) {
|
||||
exclude(group = "androidx.test", module = "core")
|
||||
}
|
||||
|
||||
|
||||
+22
-1055
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+36
@@ -0,0 +1,36 @@
|
||||
package org.thoughtcrime.securesms
|
||||
|
||||
import org.signal.core.util.logging.AndroidLogger
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.libsignal.protocol.logging.SignalProtocolLoggerProvider
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencyProvider
|
||||
import org.thoughtcrime.securesms.logging.CustomSignalProtocolLogger
|
||||
import org.thoughtcrime.securesms.testing.incomingmessageobserver.IncomingMessageObserverDependencyProvider
|
||||
import org.thoughtcrime.securesms.testing.incomingmessageobserver.IncomingMessageObserverTestRunner
|
||||
|
||||
/**
|
||||
* Application used when running `IncomingMessageObserver` instrumentation tests. Installs
|
||||
* [IncomingMessageObserverDependencyProvider] so the websocket and job manager are replaced
|
||||
* with test-friendly implementations. Selected by [IncomingMessageObserverTestRunner] when
|
||||
* gradle is invoked with `-PimoTests`.
|
||||
*/
|
||||
class IncomingMessageObserverInstrumentationApplicationContext : ApplicationContext() {
|
||||
|
||||
override fun initializeAppDependencies() {
|
||||
val default = ApplicationDependencyProvider(this)
|
||||
AppDependencies.init(this, IncomingMessageObserverDependencyProvider(this, default))
|
||||
AppDependencies.deadlockDetector.start()
|
||||
}
|
||||
|
||||
override fun initializeLogging() {
|
||||
Log.initialize({ true }, AndroidLogger)
|
||||
SignalProtocolLoggerProvider.setProvider(CustomSignalProtocolLogger())
|
||||
}
|
||||
|
||||
override fun beginJobLoop() = Unit
|
||||
|
||||
fun beginJobLoopForTests() {
|
||||
super.beginJobLoop()
|
||||
}
|
||||
}
|
||||
+7
@@ -1,5 +1,6 @@
|
||||
package org.thoughtcrime.securesms
|
||||
|
||||
import android.content.Context
|
||||
import org.signal.core.util.concurrent.SignalExecutors
|
||||
import org.signal.core.util.logging.AndroidLogger
|
||||
import org.signal.core.util.logging.Log
|
||||
@@ -11,6 +12,7 @@ import org.thoughtcrime.securesms.dependencies.InstrumentationApplicationDepende
|
||||
import org.thoughtcrime.securesms.logging.CustomSignalProtocolLogger
|
||||
import org.thoughtcrime.securesms.logging.PersistentLogger
|
||||
import org.thoughtcrime.securesms.testing.InMemoryLogger
|
||||
import org.thoughtcrime.securesms.util.Environment
|
||||
|
||||
/**
|
||||
* Application context for running instrumentation tests (aka androidTests).
|
||||
@@ -19,6 +21,11 @@ class SignalInstrumentationApplicationContext : ApplicationContext() {
|
||||
|
||||
val inMemoryLogger: InMemoryLogger = InMemoryLogger()
|
||||
|
||||
override fun attachBaseContext(base: Context?) {
|
||||
Environment.IS_INSTRUMENTATION = true
|
||||
super.attachBaseContext(base)
|
||||
}
|
||||
|
||||
override fun initializeAppDependencies() {
|
||||
val default = ApplicationDependencyProvider(this)
|
||||
AppDependencies.init(this, InstrumentationApplicationDependencyProvider(this, default))
|
||||
|
||||
+1
-1
@@ -138,7 +138,7 @@ class ConversationItemPreviewer {
|
||||
private fun attachment(): SignalServiceAttachmentPointer {
|
||||
return SignalServiceAttachmentPointer(
|
||||
Cdn.CDN_3.cdnNumber,
|
||||
SignalServiceAttachmentRemoteId.from(""),
|
||||
SignalServiceAttachmentRemoteId.from("", Cdn.CDN_3.cdnNumber),
|
||||
"image/webp",
|
||||
null,
|
||||
Optional.empty(),
|
||||
|
||||
+393
@@ -0,0 +1,393 @@
|
||||
/*
|
||||
* 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.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.isEqualTo
|
||||
import assertk.assertions.isGreaterThan
|
||||
import assertk.assertions.isGreaterThanOrEqualTo
|
||||
import assertk.assertions.isLessThan
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.thoughtcrime.securesms.MainActivity
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.conversation.ConversationIntents
|
||||
import org.thoughtcrime.securesms.database.MessageType
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.mms.IncomingMessage
|
||||
import org.thoughtcrime.securesms.mms.OutgoingMessage
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.testing.SignalActivityRule
|
||||
import java.util.Collections
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* End-to-end UI test of the unread divider. Seeds a thread with many unread messages and opens it via the notification
|
||||
* path (which enters the conversation with no explicit jump point — functionally "open a chat with X unread"), then
|
||||
* verifies the real pipeline (repository -> view model -> fragment -> decoration) anchors the divider to the oldest
|
||||
* unread message and scrolls there rather than opening at the bottom.
|
||||
*
|
||||
* The launch harness mirrors [org.thoughtcrime.securesms.main.MainNavigationLaunchTest]: ActivityScenario can't track
|
||||
* MainActivity launched with a custom-action intent, so we start it via Application#startActivity and observe lifecycle
|
||||
* callbacks instead.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class UnreadDividerInstrumentationTest {
|
||||
|
||||
@get:Rule
|
||||
val harness = SignalActivityRule(othersCount = 2)
|
||||
|
||||
@Test
|
||||
fun opensScrolledToOldestUnreadWithCorrectDividerState() {
|
||||
val recipientId = harness.others.first()
|
||||
SignalDatabase.recipients.setProfileSharing(recipientId, true)
|
||||
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(Recipient.resolved(recipientId))
|
||||
|
||||
val totalUnread = 50
|
||||
val oldestSentTime = 1000L
|
||||
var oldestUnreadId = -1L
|
||||
for (i in 0 until totalUnread) {
|
||||
val id = insertIncoming(threadId, recipientId, time = oldestSentTime + i, body = "unread $i")
|
||||
if (i == 0) {
|
||||
oldestUnreadId = id
|
||||
}
|
||||
}
|
||||
|
||||
// Derive expectations from the DB the same way the app does, so the test is robust to any extra system rows.
|
||||
val expectedUnreadCount = SignalDatabase.messages.getUnreadCount(threadId)
|
||||
val firstUnreadPosition = SignalDatabase.messages.getMessagePositionByDateReceivedTimestamp(threadId, oldestSentTime, false)
|
||||
|
||||
launch(recipientId).use { launched ->
|
||||
val result = await(timeoutMs = 20_000, description = "conversation scrolled to oldest unread") {
|
||||
val fragment = launched.latestConversationFragment() ?: return@await null
|
||||
val recycler = fragment.view?.findViewById<RecyclerView>(R.id.conversation_item_recycler) ?: return@await null
|
||||
val decoration = recycler.conversationItemDecorations() ?: return@await null
|
||||
val state = decoration.unreadStateForTesting as? ConversationItemDecorations.UnreadState.CompleteUnreadState ?: return@await null
|
||||
val view = recycler.layoutManager?.findViewByPosition(firstUnreadPosition) ?: return@await null
|
||||
Observed(state.unreadCount, state.firstUnreadId, view.top, recycler.height)
|
||||
}
|
||||
|
||||
assertThat(result.unreadCount).isEqualTo(expectedUnreadCount)
|
||||
assertThat(result.firstUnreadId).isEqualTo(oldestUnreadId)
|
||||
// The oldest unread is laid out in the top half -> we scrolled up to it instead of opening at the bottom (where,
|
||||
// with this many messages, it would be off-screen above and findViewByPosition would have returned null).
|
||||
assertThat(result.firstUnreadTop).isGreaterThanOrEqualTo(0)
|
||||
assertThat(result.firstUnreadTop).isLessThan(result.recyclerHeight / 2)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fullyReadConversationOpensAtBottomWithoutDivider() {
|
||||
val recipientId = harness.others.first()
|
||||
SignalDatabase.recipients.setProfileSharing(recipientId, true)
|
||||
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(Recipient.resolved(recipientId))
|
||||
|
||||
val total = 50
|
||||
for (i in 0 until total) {
|
||||
insertIncoming(threadId, recipientId, time = 1000L + i, body = "read $i")
|
||||
}
|
||||
SignalDatabase.threads.setRead(threadId)
|
||||
// Precondition: nothing is unread, so there should be no divider.
|
||||
assertThat(SignalDatabase.messages.getUnreadCount(threadId)).isEqualTo(0)
|
||||
|
||||
launch(recipientId).use { launched ->
|
||||
val result = await(timeoutMs = 20_000, description = "fully-read conversation opened at the bottom") {
|
||||
val fragment = launched.latestConversationFragment() ?: return@await null
|
||||
val recycler = fragment.view?.findViewById<RecyclerView>(R.id.conversation_item_recycler) ?: return@await null
|
||||
val decoration = recycler.conversationItemDecorations() ?: return@await null
|
||||
// The newest message is position 0; if it's laid out, the list loaded and settled at the bottom.
|
||||
val newest = recycler.layoutManager?.findViewByPosition(0) ?: return@await null
|
||||
BottomObserved(decoration.unreadStateForTesting, newest.bottom, recycler.height)
|
||||
}
|
||||
|
||||
assertThat(result.unreadState).isEqualTo(ConversationItemDecorations.UnreadState.None)
|
||||
// Newest message sits in the lower half -> opened at the bottom (with this many messages it would be off-screen
|
||||
// below if we'd opened at the top).
|
||||
assertThat(result.newestBottom).isGreaterThan(result.recyclerHeight / 2)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun outgoingMessageNewerThanUnreadClearsDivider() {
|
||||
val recipientId = harness.others.first()
|
||||
SignalDatabase.recipients.setProfileSharing(recipientId, true)
|
||||
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(Recipient.resolved(recipientId))
|
||||
|
||||
// A few unread incoming messages, then a newer outgoing reply. Kept small so all rows load in the initial page.
|
||||
insertIncoming(threadId, recipientId, time = 1000L, body = "unread 0")
|
||||
insertIncoming(threadId, recipientId, time = 1001L, body = "unread 1")
|
||||
insertIncoming(threadId, recipientId, time = 1002L, body = "unread 2")
|
||||
val outgoing = OutgoingMessage.text(
|
||||
threadRecipient = Recipient.resolved(recipientId),
|
||||
body = "my reply",
|
||||
expiresIn = 0,
|
||||
sentTimeMillis = 1003L
|
||||
)
|
||||
SignalDatabase.messages.insertMessageOutbox(outgoing, threadId)
|
||||
|
||||
// Precondition: the messages are still unread at the DB level, so the divider would show if it weren't for the
|
||||
// newer outgoing message clearing it.
|
||||
assertThat(SignalDatabase.messages.getUnreadCount(threadId)).isGreaterThan(0)
|
||||
|
||||
launch(recipientId).use { launched ->
|
||||
val cleared = await(timeoutMs = 20_000, description = "divider cleared by newer outgoing message") {
|
||||
val fragment = launched.latestConversationFragment() ?: return@await null
|
||||
val recycler = fragment.view?.findViewById<RecyclerView>(R.id.conversation_item_recycler) ?: return@await null
|
||||
val decoration = recycler.conversationItemDecorations() ?: return@await null
|
||||
// Wait until the list has loaded (outgoing at position 0 laid out) before reading the resolved state.
|
||||
recycler.layoutManager?.findViewByPosition(0) ?: return@await null
|
||||
if (decoration.unreadStateForTesting == ConversationItemDecorations.UnreadState.None) true else null
|
||||
}
|
||||
|
||||
assertThat(cleared).isEqualTo(true)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun scrollingToBottomMarksEverythingReadAndDrainsUnreadCount() {
|
||||
val recipientId = harness.others.first()
|
||||
SignalDatabase.recipients.setProfileSharing(recipientId, true)
|
||||
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(Recipient.resolved(recipientId))
|
||||
|
||||
val total = 50
|
||||
for (i in 0 until total) {
|
||||
insertIncoming(threadId, recipientId, time = 1000L + i, body = "unread $i")
|
||||
}
|
||||
|
||||
launch(recipientId).use { launched ->
|
||||
// getUnreadCount is the shared source for the chat-list badge and the scroll-to-bottom button's count, so
|
||||
// asserting on it verifies the number the user sees updating as they scroll.
|
||||
await(timeoutMs = 20_000, description = "conversation loaded") {
|
||||
val recycler = launched.latestConversationFragment()?.view?.findViewById<RecyclerView>(R.id.conversation_item_recycler)
|
||||
if ((recycler?.childCount ?: 0) > 0) true else null
|
||||
}
|
||||
assertThat(SignalDatabase.messages.getUnreadCount(threadId)).isGreaterThan(0)
|
||||
|
||||
// Jump to the newest message; revealing it marks every earlier message read (MarkReadHelper.onViewsRevealed).
|
||||
runOnMain {
|
||||
launched.latestConversationFragment()?.view?.findViewById<RecyclerView>(R.id.conversation_item_recycler)?.scrollToPosition(0)
|
||||
}
|
||||
|
||||
// Scrolling through the thread drains the unread count to 0.
|
||||
await(timeoutMs = 20_000, description = "unread count reaches 0 after scrolling to the bottom") {
|
||||
if (SignalDatabase.messages.getUnreadCount(threadId) == 0) true else null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun scrollingPartwayLeavesExactlyTheUnreadMessagesBelowTheViewport() {
|
||||
val recipientId = harness.others.first()
|
||||
SignalDatabase.recipients.setProfileSharing(recipientId, true)
|
||||
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(Recipient.resolved(recipientId))
|
||||
|
||||
val total = 50
|
||||
for (i in 0 until total) {
|
||||
insertIncoming(threadId, recipientId, time = 1000L + i, body = "unread $i")
|
||||
}
|
||||
|
||||
launch(recipientId).use { launched ->
|
||||
await(timeoutMs = 20_000, description = "conversation loaded") {
|
||||
val recycler = launched.latestConversationFragment()?.view?.findViewById<RecyclerView>(R.id.conversation_item_recycler)
|
||||
if ((recycler?.childCount ?: 0) > 0) true else null
|
||||
}
|
||||
|
||||
// The chat opens at the oldest unread (near the top); scroll down to roughly the middle.
|
||||
runOnMain {
|
||||
val recycler = launched.latestConversationFragment()?.view?.findViewById<RecyclerView>(R.id.conversation_item_recycler)
|
||||
(recycler?.layoutManager as? LinearLayoutManager)?.scrollToPositionWithOffset(total / 2, 0)
|
||||
}
|
||||
|
||||
// Once mark-read settles, the unread count must equal the index of the newest visible message — i.e. exactly the
|
||||
// messages still below the viewport (reverse layout: position 0 = newest, so index N = N newer messages). This is
|
||||
// the number the scroll-to-bottom button and chat-list badge show; it must not over- or under-count mid-scroll.
|
||||
val stableCount = awaitStableUnreadCount(threadId)
|
||||
val newestVisiblePosition = await(timeoutMs = 5_000, description = "newest visible position") {
|
||||
val recycler = launched.latestConversationFragment()?.view?.findViewById<RecyclerView>(R.id.conversation_item_recycler)
|
||||
(recycler?.layoutManager as? LinearLayoutManager)?.findFirstVisibleItemPosition()?.takeIf { it >= 0 }
|
||||
}
|
||||
|
||||
assertThat(stableCount).isEqualTo(newestVisiblePosition)
|
||||
// Sanity: we exercised a genuine mid-scroll point, not the very top or bottom.
|
||||
assertThat(stableCount).isGreaterThan(0)
|
||||
assertThat(stableCount).isLessThan(total)
|
||||
}
|
||||
}
|
||||
|
||||
/** Polls [MessageTable.getUnreadCount] until it holds steady (mark-read is debounced + async), then returns it. */
|
||||
private fun awaitStableUnreadCount(threadId: Long, timeoutMs: Long = 20_000): Int {
|
||||
val deadline = System.currentTimeMillis() + timeoutMs
|
||||
var last = Int.MIN_VALUE
|
||||
var stableSince = System.currentTimeMillis()
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
val current = SignalDatabase.messages.getUnreadCount(threadId)
|
||||
if (current == last) {
|
||||
if (System.currentTimeMillis() - stableSince >= 500) {
|
||||
return current
|
||||
}
|
||||
} else {
|
||||
last = current
|
||||
stableSince = System.currentTimeMillis()
|
||||
}
|
||||
Thread.sleep(100)
|
||||
}
|
||||
throw AssertionError("Unread count never stabilized (last observed = $last)")
|
||||
}
|
||||
|
||||
private data class BottomObserved(
|
||||
val unreadState: ConversationItemDecorations.UnreadState,
|
||||
val newestBottom: Int,
|
||||
val recyclerHeight: Int
|
||||
)
|
||||
|
||||
private fun insertIncoming(threadId: Long, from: RecipientId, time: Long, body: String): Long {
|
||||
val message = IncomingMessage(
|
||||
type = MessageType.NORMAL,
|
||||
from = from,
|
||||
sentTimeMillis = time,
|
||||
serverTimeMillis = time,
|
||||
receivedTimeMillis = time,
|
||||
body = body
|
||||
)
|
||||
return SignalDatabase.messages.insertMessageInbox(message, threadId).get().messageId
|
||||
}
|
||||
|
||||
private data class Observed(
|
||||
val unreadCount: Int,
|
||||
val firstUnreadId: Long,
|
||||
val firstUnreadTop: Int,
|
||||
val recyclerHeight: Int
|
||||
)
|
||||
|
||||
private fun RecyclerView.conversationItemDecorations(): ConversationItemDecorations? {
|
||||
for (i in 0 until itemDecorationCount) {
|
||||
val decoration = getItemDecorationAt(i)
|
||||
if (decoration is ConversationItemDecorations) {
|
||||
return decoration
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun runOnMain(block: () -> Unit) {
|
||||
InstrumentationRegistry.getInstrumentation().runOnMainSync { block() }
|
||||
}
|
||||
|
||||
/** Polls [block] on the main thread until it returns non-null, failing after [timeoutMs]. */
|
||||
private fun <T> await(timeoutMs: Long, pollMs: Long = 100, description: String, block: () -> T?): T {
|
||||
val deadline = System.currentTimeMillis() + timeoutMs
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
var value: T? = null
|
||||
InstrumentationRegistry.getInstrumentation().runOnMainSync { value = block() }
|
||||
if (value != null) {
|
||||
return value!!
|
||||
}
|
||||
Thread.sleep(pollMs)
|
||||
}
|
||||
throw AssertionError("Timed out after ${timeoutMs}ms waiting for $description")
|
||||
}
|
||||
|
||||
private fun launch(recipientId: RecipientId): Launched {
|
||||
val app = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as Application
|
||||
val resumed = CountDownLatch(1)
|
||||
val conversationFragments: MutableList<ConversationFragment> = Collections.synchronizedList(mutableListOf())
|
||||
val allActivities: MutableList<Activity> = Collections.synchronizedList(mutableListOf())
|
||||
|
||||
val fragmentCallbacks = object : FragmentManager.FragmentLifecycleCallbacks() {
|
||||
override fun onFragmentCreated(fm: FragmentManager, f: Fragment, savedInstanceState: Bundle?) {
|
||||
if (f is ConversationFragment) {
|
||||
conversationFragments.add(f)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFragmentDestroyed(fm: FragmentManager, f: Fragment) {
|
||||
if (f is ConversationFragment) {
|
||||
conversationFragments.remove(f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val activityCallbacks = object : Application.ActivityLifecycleCallbacks {
|
||||
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {
|
||||
allActivities.add(activity)
|
||||
if (activity is MainActivity) {
|
||||
activity.supportFragmentManager.registerFragmentLifecycleCallbacks(fragmentCallbacks, true)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onActivityResumed(activity: Activity) {
|
||||
if (activity is MainActivity) {
|
||||
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) {
|
||||
allActivities.remove(activity)
|
||||
}
|
||||
}
|
||||
app.registerActivityLifecycleCallbacks(activityCallbacks)
|
||||
|
||||
// Open the conversation the way a notification tap does: a conversation intent with no starting position.
|
||||
val conversationIntent = ConversationIntents.createBuilder(harness.context, recipientId, -1L).blockingGet().build()
|
||||
val intent = Intent(harness.context, MainActivity::class.java).apply {
|
||||
action = ConversationIntents.ACTION
|
||||
putExtras(conversationIntent)
|
||||
// Application#startActivity from a non-Activity context requires a new task.
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
|
||||
try {
|
||||
app.startActivity(intent)
|
||||
} catch (t: Throwable) {
|
||||
app.unregisterActivityLifecycleCallbacks(activityCallbacks)
|
||||
throw t
|
||||
}
|
||||
|
||||
if (!resumed.await(15, TimeUnit.SECONDS)) {
|
||||
app.unregisterActivityLifecycleCallbacks(activityCallbacks)
|
||||
throw AssertionError("MainActivity did not reach RESUMED within 15s")
|
||||
}
|
||||
|
||||
return Launched(conversationFragments, app, activityCallbacks, allActivities)
|
||||
}
|
||||
|
||||
private class Launched(
|
||||
private val conversationFragments: List<ConversationFragment>,
|
||||
private val app: Application,
|
||||
private val callbacks: Application.ActivityLifecycleCallbacks,
|
||||
private val allActivities: MutableList<Activity>
|
||||
) : AutoCloseable {
|
||||
|
||||
fun latestConversationFragment(): ConversationFragment? = synchronized(conversationFragments) { conversationFragments.lastOrNull() }
|
||||
|
||||
override fun close() {
|
||||
val toFinish = synchronized(allActivities) { allActivities.toList() }
|
||||
if (toFinish.isNotEmpty()) {
|
||||
InstrumentationRegistry.getInstrumentation().runOnMainSync {
|
||||
toFinish.forEach { it.finish() }
|
||||
}
|
||||
}
|
||||
app.unregisterActivityLifecycleCallbacks(callbacks)
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -7,9 +7,9 @@ package org.thoughtcrime.securesms.database
|
||||
|
||||
import org.signal.core.util.Base64
|
||||
import org.signal.core.util.Util
|
||||
import org.signal.network.api.AttachmentUploadResult
|
||||
import org.thoughtcrime.securesms.attachments.AttachmentId
|
||||
import org.thoughtcrime.securesms.attachments.Cdn
|
||||
import org.whispersystems.signalservice.api.attachment.AttachmentUploadResult
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentRemoteId
|
||||
import kotlin.random.Random
|
||||
|
||||
|
||||
@@ -431,7 +431,7 @@ class CallTableTest {
|
||||
|
||||
val call = SignalDatabase.calls.getCallById(callId, groupRecipientId)
|
||||
assertNotNull(call)
|
||||
assertEquals(CallTable.Event.GENERIC_GROUP_CALL, call?.event)
|
||||
assertEquals(CallTable.Event.MISSED, call?.event)
|
||||
assertEquals(1L, call?.timestamp)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.database
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.isEqualTo
|
||||
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.database.model.databaseprotos.InAppPaymentData
|
||||
import org.thoughtcrime.securesms.testing.SignalActivityRule
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class InAppPaymentTableTest {
|
||||
@get:Rule
|
||||
val harness = SignalActivityRule()
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
SignalDatabase.inAppPayments.writableDatabase.deleteAll(InAppPaymentTable.TABLE_NAME)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenACreatedInAppPayment_whenIUpdateToPending_thenIExpectPendingPayment() {
|
||||
val inAppPaymentId = SignalDatabase.inAppPayments.insert(
|
||||
type = InAppPaymentType.ONE_TIME_DONATION,
|
||||
state = InAppPaymentTable.State.CREATED,
|
||||
subscriberId = null,
|
||||
endOfPeriod = null,
|
||||
inAppPaymentData = InAppPaymentData()
|
||||
)
|
||||
|
||||
val paymentBeforeUpdate = SignalDatabase.inAppPayments.getById(inAppPaymentId)
|
||||
assertThat(paymentBeforeUpdate?.state).isEqualTo(InAppPaymentTable.State.CREATED)
|
||||
|
||||
SignalDatabase.inAppPayments.update(
|
||||
inAppPayment = paymentBeforeUpdate!!.copy(state = InAppPaymentTable.State.PENDING)
|
||||
)
|
||||
|
||||
val paymentAfterUpdate = SignalDatabase.inAppPayments.getById(inAppPaymentId)
|
||||
assertThat(paymentAfterUpdate?.state).isEqualTo(InAppPaymentTable.State.PENDING)
|
||||
}
|
||||
}
|
||||
-174
@@ -1,174 +0,0 @@
|
||||
package org.thoughtcrime.securesms.database
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.containsExactlyInAnyOrder
|
||||
import assertk.assertions.isEmpty
|
||||
import assertk.assertions.isEqualTo
|
||||
import assertk.assertions.isFalse
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.signal.core.models.ServiceId.ACI
|
||||
import org.signal.core.util.UuidUtil
|
||||
import org.signal.core.util.deleteAll
|
||||
import org.thoughtcrime.securesms.conversation.colors.AvatarColor
|
||||
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfile
|
||||
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfileId
|
||||
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfileSchedule
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.storage.StorageSyncHelper
|
||||
import org.thoughtcrime.securesms.testing.SignalActivityRule
|
||||
import org.whispersystems.signalservice.api.storage.SignalNotificationProfileRecord
|
||||
import org.whispersystems.signalservice.api.storage.StorageId
|
||||
import java.time.DayOfWeek
|
||||
import java.util.UUID
|
||||
import org.whispersystems.signalservice.internal.storage.protos.NotificationProfile as RemoteNotificationProfile
|
||||
import org.whispersystems.signalservice.internal.storage.protos.Recipient as RemoteRecipient
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class NotificationProfileTablesTest {
|
||||
|
||||
@get:Rule
|
||||
val harness = SignalActivityRule()
|
||||
|
||||
private lateinit var alice: RecipientId
|
||||
private lateinit var profile1: NotificationProfile
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
alice = SignalDatabase.recipients.getOrInsertFromServiceId(ACI.from(UUID.randomUUID()))
|
||||
|
||||
profile1 = NotificationProfile(
|
||||
id = 1,
|
||||
name = "profile1",
|
||||
emoji = "",
|
||||
createdAt = 1000L,
|
||||
schedule = NotificationProfileSchedule(id = 1),
|
||||
allowedMembers = setOf(alice),
|
||||
notificationProfileId = NotificationProfileId.generate(),
|
||||
deletedTimestampMs = 0,
|
||||
storageServiceId = StorageId.forNotificationProfile(byteArrayOf(1, 2, 3))
|
||||
)
|
||||
|
||||
SignalDatabase.notificationProfiles.writableDatabase.deleteAll(NotificationProfileTables.NotificationProfileTable.TABLE_NAME)
|
||||
SignalDatabase.notificationProfiles.writableDatabase.deleteAll(NotificationProfileTables.NotificationProfileScheduleTable.TABLE_NAME)
|
||||
SignalDatabase.notificationProfiles.writableDatabase.deleteAll(NotificationProfileTables.NotificationProfileAllowedMembersTable.TABLE_NAME)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenARemoteProfile_whenIInsertLocally_thenIExpectAListWithThatProfile() {
|
||||
val remoteRecord =
|
||||
SignalNotificationProfileRecord(
|
||||
profile1.storageServiceId!!,
|
||||
RemoteNotificationProfile(
|
||||
id = UuidUtil.toByteArray(profile1.notificationProfileId.uuid).toByteString(),
|
||||
name = "profile1",
|
||||
emoji = "",
|
||||
color = profile1.color.colorInt(),
|
||||
createdAtMs = 1000L,
|
||||
allowedMembers = listOf(RemoteRecipient(RemoteRecipient.Contact(Recipient.resolved(alice).serviceId.get().toString()))),
|
||||
allowAllMentions = false,
|
||||
allowAllCalls = true,
|
||||
scheduleEnabled = false,
|
||||
scheduleStartTime = 900,
|
||||
scheduleEndTime = 1700,
|
||||
scheduleDaysEnabled = emptyList(),
|
||||
deletedAtTimestampMs = 0
|
||||
)
|
||||
)
|
||||
|
||||
SignalDatabase.notificationProfiles.insertNotificationProfileFromStorageSync(remoteRecord)
|
||||
val actualProfiles = SignalDatabase.notificationProfiles.getProfiles()
|
||||
|
||||
assertEquals(listOf(profile1), actualProfiles)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenAProfile_whenIDeleteIt_thenIExpectAnEmptyList() {
|
||||
val profile: NotificationProfile = SignalDatabase.notificationProfiles.createProfile(
|
||||
name = "Profile",
|
||||
emoji = "avatar",
|
||||
color = AvatarColor.A210,
|
||||
createdAt = 1000L
|
||||
).profile
|
||||
|
||||
SignalDatabase.notificationProfiles.deleteProfile(profile.id)
|
||||
|
||||
assertThat(SignalDatabase.notificationProfiles.getProfiles()).isEmpty()
|
||||
assertThat(SignalDatabase.notificationProfiles.getProfile(profile.id))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenADeletedProfile_whenIGetIt_thenIExpectItToStillHaveASchedule() {
|
||||
val profile: NotificationProfile = SignalDatabase.notificationProfiles.createProfile(
|
||||
name = "Profile",
|
||||
emoji = "avatar",
|
||||
color = AvatarColor.A210,
|
||||
createdAt = 1000L
|
||||
).profile
|
||||
|
||||
SignalDatabase.notificationProfiles.deleteProfile(profile.id)
|
||||
|
||||
val deletedProfile = SignalDatabase.notificationProfiles.getProfile(profile.id)!!
|
||||
assertThat(deletedProfile.schedule.enabled).isFalse()
|
||||
assertThat(deletedProfile.schedule.start).isEqualTo(900)
|
||||
assertThat(deletedProfile.schedule.end).isEqualTo(1700)
|
||||
assertThat(deletedProfile.schedule.daysEnabled, "Contains correct default days")
|
||||
.containsExactlyInAnyOrder(DayOfWeek.MONDAY, DayOfWeek.TUESDAY, DayOfWeek.WEDNESDAY, DayOfWeek.THURSDAY, DayOfWeek.FRIDAY)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenNotificationProfiles_whenIUpdateTheirStorageSyncIds_thenIExpectAnUpdatedList() {
|
||||
SignalDatabase.notificationProfiles.createProfile(
|
||||
name = "Profile1",
|
||||
emoji = "avatar",
|
||||
color = AvatarColor.A210,
|
||||
createdAt = 1000L
|
||||
)
|
||||
SignalDatabase.notificationProfiles.createProfile(
|
||||
name = "Profile2",
|
||||
emoji = "avatar",
|
||||
color = AvatarColor.A210,
|
||||
createdAt = 2000L
|
||||
)
|
||||
|
||||
val existingMap = SignalDatabase.notificationProfiles.getStorageSyncIdsMap()
|
||||
existingMap.forEach { (id, _) ->
|
||||
SignalDatabase.notificationProfiles.applyStorageIdUpdate(id, StorageId.forNotificationProfile(StorageSyncHelper.generateKey()))
|
||||
}
|
||||
val updatedMap = SignalDatabase.notificationProfiles.getStorageSyncIdsMap()
|
||||
|
||||
existingMap.forEach { (id, storageId) ->
|
||||
assertNotEquals(storageId, updatedMap[id])
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenAProfileDeletedOver30Days_whenICleanUp_thenIExpectItToNotHaveAStorageId() {
|
||||
val remoteRecord =
|
||||
SignalNotificationProfileRecord(
|
||||
profile1.storageServiceId!!,
|
||||
RemoteNotificationProfile(
|
||||
id = UuidUtil.toByteArray(profile1.notificationProfileId.uuid).toByteString(),
|
||||
name = "profile1",
|
||||
emoji = "",
|
||||
color = profile1.color.colorInt(),
|
||||
createdAtMs = 1000L,
|
||||
deletedAtTimestampMs = 1000L
|
||||
)
|
||||
)
|
||||
|
||||
SignalDatabase.notificationProfiles.insertNotificationProfileFromStorageSync(remoteRecord)
|
||||
SignalDatabase.notificationProfiles.removeStorageIdsFromOldDeletedProfiles(System.currentTimeMillis())
|
||||
assertThat(SignalDatabase.notificationProfiles.getStorageSyncIds()).isEmpty()
|
||||
}
|
||||
|
||||
private val NotificationProfileTables.NotificationProfileChangeResult.profile: NotificationProfile
|
||||
get() = (this as NotificationProfileTables.NotificationProfileChangeResult.Success).notificationProfile
|
||||
}
|
||||
+52
@@ -8,10 +8,17 @@ package org.thoughtcrime.securesms.database
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.isEqualTo
|
||||
import assertk.assertions.isFalse
|
||||
import assertk.assertions.isNotEqualTo
|
||||
import assertk.assertions.isNotNull
|
||||
import assertk.assertions.isNull
|
||||
import assertk.assertions.isTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.signal.core.models.ServiceId.ACI
|
||||
import org.signal.core.models.ServiceId.PNI
|
||||
import org.signal.core.util.nullIfBlank
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.storage.StorageRecordUpdate
|
||||
@@ -19,8 +26,11 @@ import org.thoughtcrime.securesms.storage.StorageSyncModels
|
||||
import org.thoughtcrime.securesms.testing.SignalActivityRule
|
||||
import org.thoughtcrime.securesms.util.MessageTableTestUtils
|
||||
import org.whispersystems.signalservice.api.storage.SignalContactRecord
|
||||
import org.whispersystems.signalservice.api.storage.signalAci
|
||||
import org.whispersystems.signalservice.api.storage.signalPni
|
||||
import org.whispersystems.signalservice.api.storage.toSignalContactRecord
|
||||
import org.whispersystems.signalservice.internal.storage.protos.ContactRecord
|
||||
import java.util.UUID
|
||||
|
||||
@Suppress("ClassName")
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
@@ -60,4 +70,46 @@ class RecipientTableTest_applyStorageSyncContactUpdate {
|
||||
val messages = MessageTableTestUtils.getMessages(SignalDatabase.threads.getThreadIdFor(other.id)!!)
|
||||
assertThat(messages.first().isIdentityDefault).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenAnAlreadySyncedContact_whenMarkedUnregistered_thenItSplitsAndPublishesTheSplit() {
|
||||
// GIVEN a registered contact with aci+pni+e164 that is already in storage service (has a storageId)
|
||||
val aci = ACI.from(UUID.randomUUID())
|
||||
val pni = PNI.from(UUID.randomUUID())
|
||||
val e164 = "+13334445555"
|
||||
|
||||
val id = SignalDatabase.recipients.getAndPossiblyMerge(aci, pni, e164)
|
||||
SignalDatabase.recipients.markRegistered(id, aci)
|
||||
|
||||
val originalStorageId: ByteArray? = SignalDatabase.recipients.getRecord(id).storageId
|
||||
assertThat(originalStorageId).isNotNull()
|
||||
|
||||
// Sanity: the record it currently publishes is whole + registered.
|
||||
val before = StorageSyncModels.localToRemoteRecord(SignalDatabase.recipients.getRecordForSync(id)!!).proto.contact!!
|
||||
assertThat(before.signalAci).isEqualTo(aci)
|
||||
assertThat(before.signalPni).isEqualTo(pni)
|
||||
assertThat(before.unregisteredAtTimestamp).isEqualTo(0L)
|
||||
|
||||
// WHEN it is marked unregistered (which strips its pni/e164 and splits it)
|
||||
SignalDatabase.recipients.markUnregistered(id)
|
||||
|
||||
// THEN its storageId rotates
|
||||
val updatedStorageId: ByteArray? = SignalDatabase.recipients.getRecord(id).storageId
|
||||
assertThat(updatedStorageId).isNotNull()
|
||||
assertThat(originalStorageId!!.contentEquals(updatedStorageId!!)).isFalse()
|
||||
|
||||
// THEN the published record is now ACI-only + unregistered
|
||||
val after = StorageSyncModels.localToRemoteRecord(SignalDatabase.recipients.getRecordForSync(id)!!).proto.contact!!
|
||||
assertThat(after.signalAci).isEqualTo(aci)
|
||||
assertThat(after.signalPni).isNull()
|
||||
assertThat(after.e164.nullIfBlank()).isNull()
|
||||
assertThat(after.unregisteredAtTimestamp > 0L).isTrue()
|
||||
|
||||
// THEN the number now lives on a separate PNI-only recipient, so no whole aci+pni+e164 record remains.
|
||||
val byPni = SignalDatabase.recipients.getByPni(pni).get()
|
||||
assertThat(byPni).isNotEqualTo(id)
|
||||
val pniRecord = StorageSyncModels.localToRemoteRecord(SignalDatabase.recipients.getRecordForSync(byPni)!!).proto.contact!!
|
||||
assertThat(pniRecord.signalAci).isNull()
|
||||
assertThat(pniRecord.signalPni).isEqualTo(pni)
|
||||
}
|
||||
}
|
||||
|
||||
-120
@@ -1,120 +0,0 @@
|
||||
package org.thoughtcrime.securesms.database.helpers.migration
|
||||
|
||||
import android.app.Application
|
||||
import androidx.core.content.contentValuesOf
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.fail
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.signal.core.util.SqlUtil
|
||||
import org.thoughtcrime.securesms.database.DistributionListTables
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.model.DistributionListId
|
||||
import org.thoughtcrime.securesms.testing.SignalDatabaseRule
|
||||
import org.whispersystems.signalservice.api.push.DistributionId
|
||||
import java.util.UUID
|
||||
import org.thoughtcrime.securesms.database.SQLiteDatabase as SignalSQLiteDatabase
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class MyStoryMigrationTest {
|
||||
|
||||
@get:Rule val harness = SignalDatabaseRule(deleteAllThreadsOnEachRun = false)
|
||||
|
||||
@Test
|
||||
fun givenAValidMyStory_whenIMigrate_thenIExpectMyStoryToBeValid() {
|
||||
// GIVEN
|
||||
assertValidMyStoryExists()
|
||||
|
||||
// WHEN
|
||||
runMigration()
|
||||
|
||||
// THEN
|
||||
assertValidMyStoryExists()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenNoMyStory_whenIMigrate_thenIExpectMyStoryToBeCreated() {
|
||||
// GIVEN
|
||||
deleteMyStory()
|
||||
|
||||
// WHEN
|
||||
runMigration()
|
||||
|
||||
// THEN
|
||||
assertValidMyStoryExists()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenA00000000DistributionIdForMyStory_whenIMigrate_thenIExpectMyStoryToBeCreated() {
|
||||
// GIVEN
|
||||
setMyStoryDistributionId("0000-0000")
|
||||
|
||||
// WHEN
|
||||
runMigration()
|
||||
|
||||
// THEN
|
||||
assertValidMyStoryExists()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenARandomDistributionIdForMyStory_whenIMigrate_thenIExpectMyStoryToBeCreated() {
|
||||
// GIVEN
|
||||
setMyStoryDistributionId(UUID.randomUUID().toString())
|
||||
|
||||
// WHEN
|
||||
runMigration()
|
||||
|
||||
// THEN
|
||||
assertValidMyStoryExists()
|
||||
}
|
||||
|
||||
private fun setMyStoryDistributionId(serializedId: String) {
|
||||
SignalDatabase.rawDatabase.update(
|
||||
DistributionListTables.LIST_TABLE_NAME,
|
||||
contentValuesOf(
|
||||
DistributionListTables.DISTRIBUTION_ID to serializedId
|
||||
),
|
||||
"_id = ?",
|
||||
SqlUtil.buildArgs(DistributionListId.MY_STORY)
|
||||
)
|
||||
}
|
||||
|
||||
private fun deleteMyStory() {
|
||||
SignalDatabase.rawDatabase.delete(
|
||||
DistributionListTables.LIST_TABLE_NAME,
|
||||
"_id = ?",
|
||||
SqlUtil.buildArgs(DistributionListId.MY_STORY)
|
||||
)
|
||||
}
|
||||
|
||||
private fun assertValidMyStoryExists() {
|
||||
SignalDatabase.rawDatabase.query(
|
||||
DistributionListTables.LIST_TABLE_NAME,
|
||||
SqlUtil.COUNT,
|
||||
"_id = ? AND ${DistributionListTables.DISTRIBUTION_ID} = ?",
|
||||
SqlUtil.buildArgs(DistributionListId.MY_STORY, DistributionId.MY_STORY.toString()),
|
||||
null,
|
||||
null,
|
||||
null
|
||||
).use {
|
||||
if (it.moveToNext()) {
|
||||
val count = it.getInt(0)
|
||||
assertEquals("assertValidMyStoryExists: Query produced an unexpected count.", 1, count)
|
||||
} else {
|
||||
fail("assertValidMyStoryExists: Query did not produce a count.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun runMigration() {
|
||||
V151_MyStoryMigration.migrate(
|
||||
InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as Application,
|
||||
SignalSQLiteDatabase(SignalDatabase.rawDatabase),
|
||||
0,
|
||||
1
|
||||
)
|
||||
}
|
||||
}
|
||||
+3
-4
@@ -10,11 +10,11 @@ import org.thoughtcrime.securesms.recipients.LiveRecipientCache
|
||||
import org.whispersystems.signalservice.api.SignalServiceDataStore
|
||||
import org.whispersystems.signalservice.api.SignalServiceMessageSender
|
||||
import org.whispersystems.signalservice.api.account.AccountApi
|
||||
import org.whispersystems.signalservice.api.attachment.AttachmentApi
|
||||
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
|
||||
|
||||
/**
|
||||
@@ -41,7 +41,7 @@ class InstrumentationApplicationDependencyProvider(val application: Application,
|
||||
return recipientCache
|
||||
}
|
||||
|
||||
override fun provideArchiveApi(authWebSocket: SignalWebSocket.AuthenticatedWebSocket, unauthWebSocket: SignalWebSocket.UnauthenticatedWebSocket, pushServiceSocket: PushServiceSocket): ArchiveApi {
|
||||
override fun provideArchiveApi(authWebSocket: SignalWebSocket.AuthenticatedWebSocket, unauthWebSocket: SignalWebSocket.UnauthenticatedWebSocket, pushServiceSocket: PushServiceSocket, signalServiceConfiguration: SignalServiceConfiguration): ArchiveApi {
|
||||
return mockk()
|
||||
}
|
||||
|
||||
@@ -52,12 +52,11 @@ class InstrumentationApplicationDependencyProvider(val application: Application,
|
||||
override fun provideSignalServiceMessageSender(
|
||||
protocolStore: SignalServiceDataStore,
|
||||
pushServiceSocket: PushServiceSocket,
|
||||
attachmentApi: AttachmentApi,
|
||||
messageApi: MessageApi,
|
||||
keysApi: KeysApi
|
||||
): SignalServiceMessageSender {
|
||||
if (signalServiceMessageSender == null) {
|
||||
signalServiceMessageSender = spyk(objToCopy = default.provideSignalServiceMessageSender(protocolStore, pushServiceSocket, attachmentApi, messageApi, keysApi))
|
||||
signalServiceMessageSender = spyk(objToCopy = default.provideSignalServiceMessageSender(protocolStore, pushServiceSocket, messageApi, keysApi))
|
||||
}
|
||||
return signalServiceMessageSender!!
|
||||
}
|
||||
|
||||
+16
@@ -75,6 +75,14 @@ class EditMessageSyncProcessorTest {
|
||||
.timestamp(originalTimestamp)
|
||||
.expirationStartTimestamp(originalTimestamp)
|
||||
.message(content.dataMessage)
|
||||
.unidentifiedStatus(
|
||||
listOf(
|
||||
SyncMessage.Sent.UnidentifiedDeliveryStatus.Builder()
|
||||
.destinationServiceIdBinary(toRecipient.requireServiceId().toByteString())
|
||||
.unidentified(true)
|
||||
.build()
|
||||
)
|
||||
)
|
||||
.build()
|
||||
).build()
|
||||
).build()
|
||||
@@ -100,6 +108,14 @@ class EditMessageSyncProcessorTest {
|
||||
.targetSentTimestamp(originalTimestamp)
|
||||
.build()
|
||||
)
|
||||
.unidentifiedStatus(
|
||||
listOf(
|
||||
SyncMessage.Sent.UnidentifiedDeliveryStatus.Builder()
|
||||
.destinationServiceIdBinary(toRecipient.requireServiceId().toByteString())
|
||||
.unidentified(true)
|
||||
.build()
|
||||
)
|
||||
)
|
||||
.build()
|
||||
).build()
|
||||
).build()
|
||||
|
||||
+13
-14
@@ -13,7 +13,6 @@ import assertk.assertions.isEqualTo
|
||||
import assertk.assertions.isGreaterThan
|
||||
import assertk.assertions.isNotEqualTo
|
||||
import assertk.assertions.isNotNull
|
||||
import assertk.assertions.isNull
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Ignore
|
||||
@@ -25,6 +24,7 @@ import org.signal.core.util.Util
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.core.util.update
|
||||
import org.signal.core.util.withinTransaction
|
||||
import org.signal.network.api.AttachmentUploadResult
|
||||
import org.thoughtcrime.securesms.attachments.Attachment
|
||||
import org.thoughtcrime.securesms.attachments.DatabaseAttachment
|
||||
import org.thoughtcrime.securesms.database.AttachmentTable
|
||||
@@ -37,7 +37,6 @@ import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.testing.MessageContentFuzzer.DeleteForMeSync
|
||||
import org.thoughtcrime.securesms.testing.SignalActivityRule
|
||||
import org.thoughtcrime.securesms.util.IdentityUtil
|
||||
import org.whispersystems.signalservice.api.attachment.AttachmentUploadResult
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentRemoteId
|
||||
import java.util.UUID
|
||||
|
||||
@@ -166,7 +165,7 @@ class SyncMessageProcessorTest_synchronizeDeleteForMe {
|
||||
assertThat(messageCount).isEqualTo(0)
|
||||
|
||||
val threadRecord = SignalDatabase.threads.getThreadRecord(threadId)
|
||||
assertThat(threadRecord).isNull()
|
||||
assertThat(threadRecord?.active).isEqualTo(false)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -245,7 +244,7 @@ class SyncMessageProcessorTest_synchronizeDeleteForMe {
|
||||
|
||||
// THEN
|
||||
assertThat(SignalDatabase.messages.getMessageCountForThread(threadId)).isEqualTo(0)
|
||||
assertThat(SignalDatabase.threads.getThreadRecord(threadId)).isNull()
|
||||
assertThat(SignalDatabase.threads.getThreadRecord(threadId)?.active).isEqualTo(false)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -304,7 +303,7 @@ class SyncMessageProcessorTest_synchronizeDeleteForMe {
|
||||
|
||||
// THEN
|
||||
assertThat(SignalDatabase.messages.getMessageCountForThread(threadId)).isEqualTo(0)
|
||||
assertThat(SignalDatabase.threads.getThreadRecord(threadId)).isNull()
|
||||
assertThat(SignalDatabase.threads.getThreadRecord(threadId)?.active).isEqualTo(false)
|
||||
|
||||
harness.inMemoryLogger.flush()
|
||||
assertThat(harness.inMemoryLogger.entries().filter { it.message?.contains("Using backup non-expiring messages") == true }).hasSize(1)
|
||||
@@ -344,7 +343,7 @@ class SyncMessageProcessorTest_synchronizeDeleteForMe {
|
||||
|
||||
// THEN
|
||||
assertThat(SignalDatabase.messages.getMessageCountForThread(threadId)).isEqualTo(0)
|
||||
assertThat(SignalDatabase.threads.getThreadRecord(threadId)).isNull()
|
||||
assertThat(SignalDatabase.threads.getThreadRecord(threadId)?.active).isEqualTo(false)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -376,7 +375,7 @@ class SyncMessageProcessorTest_synchronizeDeleteForMe {
|
||||
|
||||
// THEN
|
||||
assertThat(SignalDatabase.messages.getMessageCountForThread(threadId)).isEqualTo(3)
|
||||
assertThat(SignalDatabase.threads.getThreadRecord(threadId)).isNotNull()
|
||||
assertThat(SignalDatabase.threads.getThreadRecord(threadId)?.active).isEqualTo(true)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -405,7 +404,7 @@ class SyncMessageProcessorTest_synchronizeDeleteForMe {
|
||||
|
||||
// THEN
|
||||
assertThat(SignalDatabase.messages.getMessageCountForThread(threadId)).isEqualTo(0)
|
||||
assertThat(SignalDatabase.threads.getThreadRecord(threadId)).isNull()
|
||||
assertThat(SignalDatabase.threads.getThreadRecord(threadId)?.active).isEqualTo(false)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -435,7 +434,7 @@ class SyncMessageProcessorTest_synchronizeDeleteForMe {
|
||||
// THEN
|
||||
threadIds.forEach {
|
||||
assertThat(SignalDatabase.messages.getMessageCountForThread(it)).isEqualTo(0)
|
||||
assertThat(SignalDatabase.threads.getThreadRecord(it)).isNull()
|
||||
assertThat(SignalDatabase.threads.getThreadRecord(it)?.active).isEqualTo(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -463,7 +462,7 @@ class SyncMessageProcessorTest_synchronizeDeleteForMe {
|
||||
|
||||
// THEN
|
||||
assertThat(SignalDatabase.messages.getMessageCountForThread(aliceThreadId)).isEqualTo(0)
|
||||
assertThat(SignalDatabase.threads.getThreadRecord(aliceThreadId)).isNull()
|
||||
assertThat(SignalDatabase.threads.getThreadRecord(aliceThreadId)?.active).isEqualTo(false)
|
||||
}
|
||||
|
||||
@Ignore("counts are consistent for some reason")
|
||||
@@ -527,10 +526,10 @@ class SyncMessageProcessorTest_synchronizeDeleteForMe {
|
||||
|
||||
// THEN
|
||||
assertThat(SignalDatabase.messages.getMessageCountForThread(aliceThreadId)).isEqualTo(0)
|
||||
assertThat(SignalDatabase.threads.getThreadRecord(aliceThreadId)).isNull()
|
||||
assertThat(SignalDatabase.threads.getThreadRecord(aliceThreadId)?.active).isEqualTo(false)
|
||||
|
||||
assertThat(SignalDatabase.messages.getMessageCountForThread(groupThreadId)).isEqualTo(0)
|
||||
assertThat(SignalDatabase.threads.getThreadRecord(groupThreadId)).isNull()
|
||||
assertThat(SignalDatabase.threads.getThreadRecord(groupThreadId)?.active).isEqualTo(false)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -551,7 +550,7 @@ class SyncMessageProcessorTest_synchronizeDeleteForMe {
|
||||
|
||||
// THEN
|
||||
assertThat(SignalDatabase.messages.getMessageCountForThread(threadId)).isEqualTo(20)
|
||||
assertThat(SignalDatabase.threads.getThreadRecord(threadId)).isNotNull()
|
||||
assertThat(SignalDatabase.threads.getThreadRecord(threadId)?.active).isEqualTo(true)
|
||||
|
||||
harness.inMemoryLogger.flush()
|
||||
assertThat(harness.inMemoryLogger.entries().filter { it.message?.contains("Thread is not local only") == true }).hasSize(1)
|
||||
@@ -665,7 +664,7 @@ class SyncMessageProcessorTest_synchronizeDeleteForMe {
|
||||
updatedAttachments = SignalDatabase.attachments.getAttachmentsForMessage(message1.messageId)
|
||||
assertThat(updatedAttachments).isEmpty()
|
||||
|
||||
assertThat(SignalDatabase.threads.getThreadRecord(threadId)).isNull()
|
||||
assertThat(SignalDatabase.threads.getThreadRecord(threadId)?.active).isEqualTo(false)
|
||||
}
|
||||
|
||||
private fun DatabaseAttachment.copy(
|
||||
|
||||
+353
@@ -0,0 +1,353 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.messages
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.isEqualTo
|
||||
import assertk.assertions.isNotNull
|
||||
import assertk.assertions.isTrue
|
||||
import okio.ByteString
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.signal.core.models.ServiceId
|
||||
import org.signal.core.util.UuidUtil
|
||||
import org.signal.core.util.orNull
|
||||
import org.signal.libsignal.protocol.IdentityKeyPair
|
||||
import org.signal.libsignal.protocol.SignalProtocolAddress
|
||||
import org.signal.libsignal.protocol.state.KyberPreKeyRecord
|
||||
import org.signal.libsignal.protocol.state.SignedPreKeyRecord
|
||||
import org.thoughtcrime.securesms.crypto.PreKeyUtil
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.testing.MessageContentFuzzer
|
||||
import org.thoughtcrime.securesms.testing.SignalActivityRule
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress
|
||||
import org.whispersystems.signalservice.internal.push.Content
|
||||
import org.whispersystems.signalservice.internal.push.SyncMessage
|
||||
import java.util.UUID
|
||||
|
||||
@Suppress("ClassName")
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class SyncMessageProcessorTest_synchronizePniChangeNumber {
|
||||
|
||||
@get:Rule
|
||||
val harness = SignalActivityRule(createGroup = true)
|
||||
|
||||
private lateinit var messageHelper: MessageHelper
|
||||
|
||||
private val newPniUuid: UUID = UUID.randomUUID()
|
||||
private val newPni: ServiceId.PNI = ServiceId.PNI.from(newPniUuid)
|
||||
|
||||
// 16-byte raw UUID — matches the actual wire format the server sends (per proto comment and
|
||||
// iOS/Desktop behavior). Do NOT use `newPni.toByteString()` here — that produces libsignal's
|
||||
// 17-byte ServiceIdBinary form, which is a different format.
|
||||
private val newPniBytes: ByteString = UuidUtil.toByteArray(newPniUuid).toByteString()
|
||||
private val newE164 = "+15555550199"
|
||||
private val newPniIdentity: IdentityKeyPair = IdentityKeyPair.generate()
|
||||
private val newSignedPreKey: SignedPreKeyRecord = PreKeyUtil.generateSignedPreKey(1234, newPniIdentity.privateKey)
|
||||
private val newLastResortKyber: KyberPreKeyRecord = PreKeyUtil.generateLastResortKyberPreKey(5678, newPniIdentity.privateKey)
|
||||
private val newRegistrationId = 4242
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
messageHelper = MessageHelper(harness)
|
||||
SignalStore.account.deviceId = 2
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
messageHelper.tearDown()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun appliesAllStateOnHappyPath() {
|
||||
sendPniChangeNumber()
|
||||
|
||||
assertThat(SignalStore.account.e164).isEqualTo(newE164)
|
||||
assertThat(SignalStore.account.pni).isEqualTo(newPni)
|
||||
assertThat(SignalStore.account.pniRegistrationId).isEqualTo(newRegistrationId)
|
||||
assertThat(SignalStore.account.pniIdentityKey.publicKey.serialize().toByteString())
|
||||
.isEqualTo(newPniIdentity.publicKey.serialize().toByteString())
|
||||
assertThat(SignalStore.account.pniPreKeys.activeSignedPreKeyId).isEqualTo(newSignedPreKey.id)
|
||||
assertThat(SignalStore.account.pniPreKeys.isSignedPreKeyRegistered).isTrue()
|
||||
assertThat(SignalStore.account.pniPreKeys.lastResortKyberPreKeyId).isEqualTo(newLastResortKyber.id)
|
||||
assertThat(SignalStore.misc.forcePniSignedPreKeyRotation).isTrue()
|
||||
|
||||
val self = Recipient.self().fresh()
|
||||
assertThat(self.requireE164()).isEqualTo(newE164)
|
||||
assertThat(self.pni.orNull()).isEqualTo(newPni)
|
||||
|
||||
val pniProtocolStore = AppDependencies.protocolStore.pni()
|
||||
val storedSigned = pniProtocolStore.loadSignedPreKey(newSignedPreKey.id)
|
||||
assertThat(storedSigned.serialize().toByteString()).isEqualTo(newSignedPreKey.serialize().toByteString())
|
||||
val storedKyber = pniProtocolStore.loadLastResortKyberPreKeys().firstOrNull { it.id == newLastResortKyber.id }
|
||||
assertThat(storedKyber).isNotNull()
|
||||
assertThat(storedKyber!!.serialize().toByteString()).isEqualTo(newLastResortKyber.serialize().toByteString())
|
||||
|
||||
// The IdentityTable cache is keyed by ServiceId string, not RecipientId — for self, that's
|
||||
// separate ACI and PNI rows. We want the PNI row, so look it up by the new PNI directly.
|
||||
val selfPniIdentity = pniProtocolStore.getIdentity(SignalProtocolAddress(newPni.toString(), SignalServiceAddress.DEFAULT_DEVICE_ID))
|
||||
assertThat(selfPniIdentity).isNotNull()
|
||||
assertThat(selfPniIdentity!!.publicKey.serialize().toByteString())
|
||||
.isEqualTo(newPniIdentity.publicKey.serialize().toByteString())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun appliesStateWhenLastResortKyberAbsent() {
|
||||
val original = captureOriginalState()
|
||||
|
||||
sendPniChangeNumber(lastResortKyberPreKey = null)
|
||||
|
||||
assertThat(SignalStore.account.e164).isEqualTo(newE164)
|
||||
assertThat(SignalStore.account.pni).isEqualTo(newPni)
|
||||
assertThat(SignalStore.account.pniRegistrationId).isEqualTo(newRegistrationId)
|
||||
assertThat(SignalStore.account.pniPreKeys.activeSignedPreKeyId).isEqualTo(newSignedPreKey.id)
|
||||
assertThat(SignalStore.account.pniPreKeys.isSignedPreKeyRegistered).isTrue()
|
||||
// No kyber was supplied, so kyber metadata should be unchanged.
|
||||
assertThat(SignalStore.account.pniPreKeys.lastResortKyberPreKeyId).isEqualTo(original.lastResortKyberPreKeyId)
|
||||
assertThat(SignalStore.misc.forcePniSignedPreKeyRotation).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bailsWhenPrimaryDevice() {
|
||||
SignalStore.account.deviceId = SignalServiceAddress.DEFAULT_DEVICE_ID
|
||||
val original = captureOriginalState()
|
||||
|
||||
sendPniChangeNumber()
|
||||
|
||||
assertOriginalStatePreserved(original)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bailsWhenSourceIsNotPrimaryDevice() {
|
||||
val original = captureOriginalState()
|
||||
|
||||
sendPniChangeNumber(sourceDeviceId = 3)
|
||||
|
||||
assertOriginalStatePreserved(original)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bailsWhenEnvelopePniMissing() {
|
||||
val original = captureOriginalState()
|
||||
|
||||
sendPniChangeNumber(envelopePniBinary = null)
|
||||
|
||||
assertOriginalStatePreserved(original)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bailsWhenIdentityKeyPairMissing() {
|
||||
val original = captureOriginalState()
|
||||
|
||||
sendPniChangeNumber(identityKeyPair = null)
|
||||
|
||||
assertOriginalStatePreserved(original)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bailsWhenSignedPreKeyMissing() {
|
||||
val original = captureOriginalState()
|
||||
|
||||
sendPniChangeNumber(signedPreKey = null)
|
||||
|
||||
assertOriginalStatePreserved(original)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bailsWhenRegistrationIdMissing() {
|
||||
val original = captureOriginalState()
|
||||
|
||||
sendPniChangeNumber(registrationId = null)
|
||||
|
||||
assertOriginalStatePreserved(original)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bailsWhenRegistrationIdZero() {
|
||||
val original = captureOriginalState()
|
||||
|
||||
sendPniChangeNumber(registrationId = 0)
|
||||
|
||||
assertOriginalStatePreserved(original)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bailsWhenNewE164Missing() {
|
||||
val original = captureOriginalState()
|
||||
|
||||
sendPniChangeNumber(e164 = null)
|
||||
|
||||
assertOriginalStatePreserved(original)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bailsWhenNewE164Empty() {
|
||||
val original = captureOriginalState()
|
||||
|
||||
sendPniChangeNumber(e164 = "")
|
||||
|
||||
assertOriginalStatePreserved(original)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bailsWhenNewE164NotValid() {
|
||||
val original = captureOriginalState()
|
||||
|
||||
sendPniChangeNumber(e164 = "not a phone number")
|
||||
|
||||
assertOriginalStatePreserved(original)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bailsOnMalformedIdentityKeyPair() {
|
||||
val original = captureOriginalState()
|
||||
|
||||
sendPniChangeNumber(identityKeyPair = malformedBytes())
|
||||
|
||||
assertOriginalStatePreserved(original)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bailsOnMalformedSignedPreKey() {
|
||||
val original = captureOriginalState()
|
||||
|
||||
sendPniChangeNumber(signedPreKey = malformedBytes())
|
||||
|
||||
assertOriginalStatePreserved(original)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bailsOnMalformedLastResortKyber() {
|
||||
val original = captureOriginalState()
|
||||
|
||||
sendPniChangeNumber(lastResortKyberPreKey = malformedBytes())
|
||||
|
||||
assertOriginalStatePreserved(original)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun skipsRedeliveryWhenPniAlreadyMatches() {
|
||||
sendPniChangeNumber()
|
||||
val afterFirstApply = captureOriginalState()
|
||||
|
||||
val otherIdentity = IdentityKeyPair.generate()
|
||||
val otherSignedPreKey = PreKeyUtil.generateSignedPreKey(9999, otherIdentity.privateKey)
|
||||
|
||||
sendPniChangeNumber(
|
||||
identityKeyPair = otherIdentity.serialize().toByteString(),
|
||||
signedPreKey = otherSignedPreKey.serialize().toByteString(),
|
||||
e164 = "+15555550100",
|
||||
timestamp = messageHelper.nextStartTime() + 1000
|
||||
)
|
||||
|
||||
assertOriginalStatePreserved(afterFirstApply)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bailsWhenServerTimestampStale() {
|
||||
sendPniChangeNumber()
|
||||
val afterFirstApply = captureOriginalState()
|
||||
|
||||
val otherPniUuid = UUID.randomUUID()
|
||||
val otherPniBytes = UuidUtil.toByteArray(otherPniUuid).toByteString()
|
||||
|
||||
sendPniChangeNumber(
|
||||
envelopePniBinary = otherPniBytes,
|
||||
e164 = "+15555550100",
|
||||
timestamp = messageHelper.nextStartTime() - 100_000L
|
||||
)
|
||||
|
||||
assertOriginalStatePreserved(afterFirstApply)
|
||||
}
|
||||
|
||||
private fun captureOriginalState(): OriginalState {
|
||||
val self = Recipient.self().fresh()
|
||||
return OriginalState(
|
||||
e164 = SignalStore.account.e164,
|
||||
pni = SignalStore.account.pni,
|
||||
pniRegistrationId = SignalStore.account.pniRegistrationId,
|
||||
isSignedPreKeyRegistered = SignalStore.account.pniPreKeys.isSignedPreKeyRegistered,
|
||||
activeSignedPreKeyId = SignalStore.account.pniPreKeys.activeSignedPreKeyId,
|
||||
lastResortKyberPreKeyId = SignalStore.account.pniPreKeys.lastResortKyberPreKeyId,
|
||||
pniIdentityPublicKey = SignalStore.account.pniIdentityKey.publicKey.serialize().toByteString(),
|
||||
selfE164 = self.e164.orNull(),
|
||||
selfPni = self.pni.orNull(),
|
||||
forcePniSignedPreKeyRotation = SignalStore.misc.forcePniSignedPreKeyRotation
|
||||
)
|
||||
}
|
||||
|
||||
private fun assertOriginalStatePreserved(original: OriginalState) {
|
||||
assertThat(SignalStore.account.e164).isEqualTo(original.e164)
|
||||
assertThat(SignalStore.account.pni).isEqualTo(original.pni)
|
||||
assertThat(SignalStore.account.pniRegistrationId).isEqualTo(original.pniRegistrationId)
|
||||
assertThat(SignalStore.account.pniPreKeys.isSignedPreKeyRegistered).isEqualTo(original.isSignedPreKeyRegistered)
|
||||
assertThat(SignalStore.account.pniPreKeys.activeSignedPreKeyId).isEqualTo(original.activeSignedPreKeyId)
|
||||
assertThat(SignalStore.account.pniPreKeys.lastResortKyberPreKeyId).isEqualTo(original.lastResortKyberPreKeyId)
|
||||
assertThat(SignalStore.account.pniIdentityKey.publicKey.serialize().toByteString())
|
||||
.isEqualTo(original.pniIdentityPublicKey)
|
||||
assertThat(SignalStore.misc.forcePniSignedPreKeyRotation).isEqualTo(original.forcePniSignedPreKeyRotation)
|
||||
val self = Recipient.self().fresh()
|
||||
assertThat(self.e164.orNull()).isEqualTo(original.selfE164)
|
||||
assertThat(self.pni.orNull()).isEqualTo(original.selfPni)
|
||||
}
|
||||
|
||||
private data class OriginalState(
|
||||
val e164: String?,
|
||||
val pni: ServiceId.PNI?,
|
||||
val pniRegistrationId: Int,
|
||||
val isSignedPreKeyRegistered: Boolean,
|
||||
val activeSignedPreKeyId: Int,
|
||||
val lastResortKyberPreKeyId: Int,
|
||||
val pniIdentityPublicKey: ByteString,
|
||||
val selfE164: String?,
|
||||
val selfPni: ServiceId.PNI?,
|
||||
val forcePniSignedPreKeyRotation: Boolean
|
||||
)
|
||||
|
||||
private fun malformedBytes(): ByteString = byteArrayOf(0x00, 0x01, 0x02).toByteString()
|
||||
|
||||
private fun sendPniChangeNumber(
|
||||
identityKeyPair: ByteString? = newPniIdentity.serialize().toByteString(),
|
||||
signedPreKey: ByteString? = newSignedPreKey.serialize().toByteString(),
|
||||
lastResortKyberPreKey: ByteString? = newLastResortKyber.serialize().toByteString(),
|
||||
registrationId: Int? = newRegistrationId,
|
||||
e164: String? = newE164,
|
||||
envelopePniBinary: ByteString? = newPniBytes,
|
||||
sourceDeviceId: Int = SignalServiceAddress.DEFAULT_DEVICE_ID,
|
||||
timestamp: Long = messageHelper.nextStartTime()
|
||||
) {
|
||||
val content = Content(
|
||||
syncMessage = SyncMessage(
|
||||
pniChangeNumber = SyncMessage.PniChangeNumber(
|
||||
identityKeyPair = identityKeyPair,
|
||||
signedPreKey = signedPreKey,
|
||||
lastResortKyberPreKey = lastResortKyberPreKey,
|
||||
registrationId = registrationId,
|
||||
newE164 = e164
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val envelope = MessageContentFuzzer.envelope(
|
||||
timestamp = timestamp,
|
||||
updatedPniBinary = envelopePniBinary
|
||||
)
|
||||
|
||||
messageHelper.processor.process(
|
||||
envelope = envelope,
|
||||
content = content,
|
||||
metadata = MessageContentFuzzer.envelopeMetadata(harness.self.id, harness.self.id, sourceDeviceId = sourceDeviceId),
|
||||
serverDeliveredTimestamp = timestamp + 10
|
||||
)
|
||||
}
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
package org.thoughtcrime.securesms.messages.incomingmessageobserver
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.thoughtcrime.securesms.testing.incomingmessageobserver.IncomingMessageObserverAssertions.assertMessageReceived
|
||||
import org.thoughtcrime.securesms.testing.incomingmessageobserver.IncomingMessageObserverAssertions.assertNoMessageReceived
|
||||
import org.thoughtcrime.securesms.testing.incomingmessageobserver.IncomingMessageObserverRule
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class DecryptionErrorTest {
|
||||
|
||||
@get:Rule
|
||||
val rule = IncomingMessageObserverRule(peerCount = 2)
|
||||
|
||||
@Test
|
||||
fun malformedEnvelope_dropsMessage_butPipelineRecovers() {
|
||||
val peer = rule.peers[0]
|
||||
|
||||
rule.deliver { malformedEnvelope() from peer }
|
||||
assertNoMessageReceived(from = peer, body = "subsequent")
|
||||
|
||||
rule.deliver { text("subsequent") from peer }
|
||||
assertMessageReceived(from = peer, body = "subsequent")
|
||||
}
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
package org.thoughtcrime.securesms.messages.incomingmessageobserver
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.thoughtcrime.securesms.testing.incomingmessageobserver.IncomingMessageObserverAssertions.assertGroupMessageReceived
|
||||
import org.thoughtcrime.securesms.testing.incomingmessageobserver.IncomingMessageObserverRule
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class IncomingGroupMessageTest {
|
||||
|
||||
@get:Rule
|
||||
val rule = IncomingMessageObserverRule(peerCount = 5)
|
||||
|
||||
@Test
|
||||
fun deliveredGroupText_isPersistedInGroupThread() {
|
||||
val group = rule.testGroup
|
||||
|
||||
rule.deliver { groupText("hello group", group = group) from rule.peers[0] }
|
||||
|
||||
assertGroupMessageReceived(from = rule.peers[0], group = group, body = "hello group")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun multipleGroupMembers_messagesPersistedFromEach() {
|
||||
val group = rule.testGroup
|
||||
|
||||
rule.deliver {
|
||||
groupText("from peer 0", group = group) from rule.peers[0]
|
||||
groupText("from peer 1", group = group) from rule.peers[1]
|
||||
groupText("from peer 2", group = group) from rule.peers[2]
|
||||
}
|
||||
|
||||
assertGroupMessageReceived(from = rule.peers[0], group = group, body = "from peer 0")
|
||||
assertGroupMessageReceived(from = rule.peers[1], group = group, body = "from peer 1")
|
||||
assertGroupMessageReceived(from = rule.peers[2], group = group, body = "from peer 2")
|
||||
}
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package org.thoughtcrime.securesms.messages.incomingmessageobserver
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.thoughtcrime.securesms.testing.incomingmessageobserver.IncomingMessageObserverAssertions.assertMessageReceived
|
||||
import org.thoughtcrime.securesms.testing.incomingmessageobserver.IncomingMessageObserverRule
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class IncomingTextMessageTest {
|
||||
|
||||
@get:Rule
|
||||
val rule = IncomingMessageObserverRule(peerCount = 2)
|
||||
|
||||
@Test
|
||||
fun deliveredOneToOneText_isPersisted() {
|
||||
rule.deliver { text("hello world") from rule.peers[0] }
|
||||
|
||||
assertMessageReceived(from = rule.peers[0], body = "hello world")
|
||||
}
|
||||
}
|
||||
@@ -8,9 +8,9 @@ package org.thoughtcrime.securesms.testing
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import io.mockk.every
|
||||
import org.junit.rules.ExternalResource
|
||||
import org.signal.core.util.JsonUtils
|
||||
import org.signal.network.NetworkResult
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.util.JsonUtils
|
||||
import org.whispersystems.signalservice.internal.push.SubscriptionsConfiguration
|
||||
|
||||
/**
|
||||
|
||||
@@ -41,11 +41,12 @@ object MessageContentFuzzer {
|
||||
/**
|
||||
* Create an [Envelope].
|
||||
*/
|
||||
fun envelope(timestamp: Long, serverGuid: UUID = UUID.randomUUID()): Envelope {
|
||||
fun envelope(timestamp: Long, serverGuid: UUID = UUID.randomUUID(), updatedPniBinary: ByteString? = null): Envelope {
|
||||
return Envelope.Builder()
|
||||
.clientTimestamp(timestamp)
|
||||
.serverTimestamp(timestamp + 5)
|
||||
.serverGuidBinary(serverGuid.toByteArray().toByteString())
|
||||
.also { if (updatedPniBinary != null) it.updatedPniBinary(updatedPniBinary) }
|
||||
.build()
|
||||
}
|
||||
|
||||
|
||||
@@ -139,7 +139,7 @@ class SignalActivityRule(private val othersCount: Int = 4, private val createGro
|
||||
val recipientId = RecipientId.from(SignalServiceAddress(aci, "+15555551%03d".format(i)))
|
||||
SignalDatabase.recipients.setProfileName(recipientId, ProfileName.fromParts("Buddy", "#$i"))
|
||||
SignalDatabase.recipients.setProfileKeyIfAbsent(recipientId, ProfileKeyUtil.createNew())
|
||||
SignalDatabase.recipients.setCapabilities(recipientId, SignalServiceProfile.Capabilities(true, true))
|
||||
SignalDatabase.recipients.setCapabilities(recipientId, SignalServiceProfile.Capabilities(true, true, true))
|
||||
SignalDatabase.recipients.setProfileSharing(recipientId, true)
|
||||
SignalDatabase.recipients.markRegistered(recipientId, aci)
|
||||
val otherIdentity = IdentityKeyPair.generate()
|
||||
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
package org.thoughtcrime.securesms.testing.incomingmessageobserver
|
||||
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.isEqualTo
|
||||
import assertk.assertions.isNotNull
|
||||
import assertk.assertions.isTrue
|
||||
import org.signal.benchmark.setup.OtherClient
|
||||
import org.thoughtcrime.securesms.database.MessageTable
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress
|
||||
|
||||
/**
|
||||
* Reads database state produced by [IncomingMessageObserverRule]-driven tests. Import members
|
||||
* individually (e.g. `import …IncomingMessageObserverAssertions.assertMessageReceived`) so test
|
||||
* bodies stay terse.
|
||||
*/
|
||||
object IncomingMessageObserverAssertions {
|
||||
|
||||
fun OtherClient.recipientId(): RecipientId = Recipient.externalPush(SignalServiceAddress(serviceId, e164)).id
|
||||
|
||||
fun findIncomingMessage(from: OtherClient, body: String): MessageRecord? {
|
||||
val threadId = SignalDatabase.threads.getThreadIdFor(from.recipientId()) ?: return null
|
||||
return SignalDatabase.messages.getConversation(threadId).use { cursor ->
|
||||
MessageTable.MmsReader(cursor).use { reader -> reader.firstOrNull { it.body == body } }
|
||||
}
|
||||
}
|
||||
|
||||
fun findIncomingGroupMessage(from: OtherClient, group: GroupHandle, body: String): MessageRecord? {
|
||||
val threadId = SignalDatabase.threads.getThreadIdFor(group.recipientId) ?: return null
|
||||
return SignalDatabase.messages.getConversation(threadId).use { cursor ->
|
||||
MessageTable.MmsReader(cursor).use { reader ->
|
||||
reader.firstOrNull { it.body == body && it.fromRecipient.id == from.recipientId() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun assertMessageReceived(from: OtherClient, body: String) {
|
||||
val record = findIncomingMessage(from, body)
|
||||
assertThat(record, "incoming message with body \"$body\" from ${from.serviceId} not found").isNotNull()
|
||||
assertThat(record!!.fromRecipient.id, "incoming message sender mismatch for body \"$body\"").isEqualTo(from.recipientId())
|
||||
}
|
||||
|
||||
fun assertGroupMessageReceived(from: OtherClient, group: GroupHandle, body: String) {
|
||||
val record = findIncomingGroupMessage(from, group, body)
|
||||
assertThat(record, "group message \"$body\" from ${from.serviceId} in ${group.groupId} not found").isNotNull()
|
||||
}
|
||||
|
||||
fun assertNoMessageReceived(from: OtherClient, body: String) {
|
||||
val record = findIncomingMessage(from, body)
|
||||
assertThat(record == null, "expected no message with body \"$body\" from ${from.serviceId}, but found one").isTrue()
|
||||
}
|
||||
|
||||
fun assertNoMessagesInThread(recipientId: RecipientId) {
|
||||
val threadId = SignalDatabase.threads.getThreadIdFor(recipientId) ?: return
|
||||
val count = SignalDatabase.messages.getConversation(threadId).use { cursor -> cursor.count }
|
||||
assertThat(count, "expected thread for $recipientId to be empty, but message count was").isEqualTo(0)
|
||||
}
|
||||
|
||||
fun assertDeliveryReceipt(outgoingMessageId: Long) {
|
||||
val record = SignalDatabase.messages.getMessageRecord(outgoingMessageId)
|
||||
assertThat(record.hasDeliveryReceipt(), "expected delivery receipt on outgoing message $outgoingMessageId, but none recorded").isTrue()
|
||||
}
|
||||
|
||||
fun assertReadReceipt(outgoingMessageId: Long) {
|
||||
val record = SignalDatabase.messages.getMessageRecord(outgoingMessageId)
|
||||
assertThat(record.hasReadReceipt(), "expected read receipt on outgoing message $outgoingMessageId, but none recorded").isTrue()
|
||||
}
|
||||
}
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
package org.thoughtcrime.securesms.testing.incomingmessageobserver
|
||||
|
||||
import android.app.Application
|
||||
import org.signal.benchmark.setup.NoOpJob
|
||||
import org.signal.core.util.UptimeSleepTimer
|
||||
import org.signal.libsignal.net.Network
|
||||
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
|
||||
|
||||
/**
|
||||
* Dependency provider used by [org.thoughtcrime.securesms.IncomingMessageObserverInstrumentationApplicationContext].
|
||||
* Composes [InstrumentationApplicationDependencyProvider] (so existing mocks for the account /
|
||||
* archive / donations / billing APIs are reused) and overrides:
|
||||
*
|
||||
* - the auth and unauth websocket factories with [BenchmarkWebSocketConnection], so tests can
|
||||
* inject encrypted envelopes through the real ingest pipeline;
|
||||
* - the job manager, swapping the startup network jobs handled by [NoOpJob.replaceFactories]
|
||||
* to no-ops so they can't fire against unstubbed mocks during a test.
|
||||
*/
|
||||
class IncomingMessageObserverDependencyProvider(
|
||||
private val application: Application,
|
||||
default: ApplicationDependencyProvider
|
||||
) : AppDependencies.Provider by InstrumentationApplicationDependencyProvider(application, default) {
|
||||
|
||||
override fun provideAuthWebSocket(
|
||||
signalServiceConfigurationSupplier: Supplier<SignalServiceConfiguration>,
|
||||
libSignalNetworkSupplier: Supplier<Network>
|
||||
): SignalWebSocket.AuthenticatedWebSocket {
|
||||
return SignalWebSocket.AuthenticatedWebSocket(
|
||||
connectionFactory = { BenchmarkWebSocketConnection.createAuthInstance() },
|
||||
canConnect = { true },
|
||||
sleepTimer = UptimeSleepTimer(),
|
||||
disconnectTimeoutMs = 15.seconds.inWholeMilliseconds
|
||||
)
|
||||
}
|
||||
|
||||
override fun provideUnauthWebSocket(
|
||||
signalServiceConfigurationSupplier: Supplier<SignalServiceConfiguration>,
|
||||
libSignalNetworkSupplier: Supplier<Network>
|
||||
): SignalWebSocket.UnauthenticatedWebSocket {
|
||||
return SignalWebSocket.UnauthenticatedWebSocket(
|
||||
connectionFactory = { BenchmarkWebSocketConnection.createUnauthInstance() },
|
||||
canConnect = { true },
|
||||
sleepTimer = UptimeSleepTimer(),
|
||||
disconnectTimeoutMs = 15.seconds.inWholeMilliseconds
|
||||
)
|
||||
}
|
||||
|
||||
override fun provideJobManager(configurationBuilder: JobManager.Configuration.Builder): JobManager {
|
||||
val config = configurationBuilder
|
||||
.setJobFactories(NoOpJob.replaceFactories(JobManagerFactories.getJobFactories(application)))
|
||||
.build()
|
||||
return JobManager(application, config)
|
||||
}
|
||||
}
|
||||
+201
@@ -0,0 +1,201 @@
|
||||
package org.thoughtcrime.securesms.testing.incomingmessageobserver
|
||||
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import org.junit.Assume
|
||||
import org.junit.rules.ExternalResource
|
||||
import org.signal.benchmark.setup.Generator
|
||||
import org.signal.benchmark.setup.Harness
|
||||
import org.signal.benchmark.setup.OtherClient
|
||||
import org.signal.benchmark.setup.TestUsers
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.network.websocket.WebSocketRequestMessage
|
||||
import org.thoughtcrime.securesms.IncomingMessageObserverInstrumentationApplicationContext
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.groups.GroupId
|
||||
import org.thoughtcrime.securesms.jobmanager.Job
|
||||
import org.thoughtcrime.securesms.jobmanager.JobTracker
|
||||
import org.thoughtcrime.securesms.jobs.MarkerJob
|
||||
import org.thoughtcrime.securesms.jobs.PushProcessMessageJob
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.whispersystems.signalservice.internal.push.Envelope
|
||||
import org.whispersystems.signalservice.internal.websocket.BenchmarkWebSocketConnection
|
||||
import java.util.concurrent.CopyOnWriteArraySet
|
||||
import kotlin.random.Random
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
/**
|
||||
* JUnit rule that drives [org.thoughtcrime.securesms.messages.IncomingMessageObserver] from
|
||||
* instrumentation tests. Sets up self, registers [peerCount] simulated peers from
|
||||
* [Harness.otherClients], establishes a Signal double-ratchet session with each, and exposes a
|
||||
* small DSL for delivering encrypted envelopes through the real ingest pipeline:
|
||||
*
|
||||
* ```
|
||||
* @get:Rule val rule = IncomingMessageObserverRule(peerCount = 2)
|
||||
*
|
||||
* @Test fun example() {
|
||||
* rule.deliver { text("hi") from rule.peers[0] }
|
||||
* rule.deliver { groupText("hi all", group = rule.testGroup) from rule.peers[0] }
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* Run with `-PimoTests`; tests are skipped under the default runner. Throws on drain timeout.
|
||||
* Mutually exclusive with `SignalDatabaseRule` / `SignalActivityRule` — all three claim the
|
||||
* local identity.
|
||||
*/
|
||||
class IncomingMessageObserverRule(
|
||||
private val peerCount: Int = 2,
|
||||
private val drainTimeout: Duration = 30.seconds
|
||||
) : ExternalResource() {
|
||||
|
||||
lateinit var self: Recipient
|
||||
private set
|
||||
|
||||
lateinit var peers: List<OtherClient>
|
||||
private set
|
||||
|
||||
/** Lazily-created group. Touching this from a test triggers setup; tests that don't use groups pay nothing. */
|
||||
val testGroup: GroupHandle by lazy {
|
||||
val gid = TestUsers.setupGroup(withLabels = false)
|
||||
GroupHandle(gid, Recipient.externalGroupExact(gid).id)
|
||||
}
|
||||
|
||||
override fun before() {
|
||||
Assume.assumeTrue(
|
||||
"IncomingMessageObserverRule requires the IMO test runner — run with -PimoTests",
|
||||
AppDependencies.application is IncomingMessageObserverInstrumentationApplicationContext
|
||||
)
|
||||
|
||||
self = TestUsers.setupSelf()
|
||||
TestUsers.setupTestClients(peerCount)
|
||||
peers = Harness.otherClients.take(peerCount)
|
||||
|
||||
val app = AppDependencies.application as IncomingMessageObserverInstrumentationApplicationContext
|
||||
app.beginJobLoopForTests()
|
||||
|
||||
// IncomingMessageObserver caches `canProcessMessages` from restoreDecisionState at thread
|
||||
// construction. If it was built before setupSelf() flipped the state it will silently drop
|
||||
// every message; reset network so a fresh observer is constructed.
|
||||
AppDependencies.incomingMessageObserver.notifyRestoreDecisionMade()
|
||||
AppDependencies.startNetwork()
|
||||
forceObserverConstruction()
|
||||
|
||||
val handshakeEnvelopes = peers.map { client ->
|
||||
client.encrypt(Generator.encryptedTextMessage(System.currentTimeMillis()))
|
||||
}
|
||||
deliverEnvelopes(handshakeEnvelopes)
|
||||
peers.forEach { it.completeSession() }
|
||||
}
|
||||
|
||||
fun deliver(builder: DeliveryBuilder.() -> Unit) {
|
||||
val collected = DeliveryBuilder().apply(builder).specs
|
||||
if (collected.isEmpty()) return
|
||||
deliverEnvelopes(collected.map { it.materialize() })
|
||||
}
|
||||
|
||||
private fun forceObserverConstruction() {
|
||||
AppDependencies.incomingMessageObserver
|
||||
}
|
||||
|
||||
private fun deliverEnvelopes(envelopes: List<Envelope>) {
|
||||
val jobManager = AppDependencies.jobManager
|
||||
val seenQueues = CopyOnWriteArraySet<String>()
|
||||
val queueListener = object : JobTracker.JobListener {
|
||||
override fun onStateChanged(job: Job, jobState: JobTracker.JobState) {
|
||||
job.parameters.queue?.let { queue ->
|
||||
if (queue.startsWith(PushProcessMessageJob.QUEUE_PREFIX)) {
|
||||
seenQueues += queue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
jobManager.addListener({ job: Job -> job.parameters.queue?.startsWith(PushProcessMessageJob.QUEUE_PREFIX) == true }, queueListener)
|
||||
|
||||
try {
|
||||
BenchmarkWebSocketConnection.addPendingMessages(envelopes.map { it.toWebSocketPayload() })
|
||||
BenchmarkWebSocketConnection.addQueueEmptyMessage()
|
||||
BenchmarkWebSocketConnection.releaseMessages()
|
||||
|
||||
val consumed = BenchmarkWebSocketConnection.awaitAllMessagesConsumed(drainTimeout.inWholeMilliseconds)
|
||||
check(consumed) { "Timed out waiting for benchmark websocket to consume ${envelopes.size} envelope(s)" }
|
||||
|
||||
// PushProcessMessageJob enqueue happens on a background thread after the websocket marks
|
||||
// messages consumed; this tick lets that settle before we snapshot the queues to wait on.
|
||||
Thread.sleep(100)
|
||||
|
||||
val queuesToDrain = seenQueues.toSet()
|
||||
Log.d(TAG, "Awaiting ${queuesToDrain.size} PushProcessMessageJob queue(s): $queuesToDrain")
|
||||
for (queue in queuesToDrain) {
|
||||
val state = jobManager.runSynchronously(MarkerJob(queue), drainTimeout.inWholeMilliseconds)
|
||||
check(state.isPresent) { "Timed out waiting for queue $queue to drain" }
|
||||
}
|
||||
} finally {
|
||||
jobManager.removeListener(queueListener)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(IncomingMessageObserverRule::class)
|
||||
|
||||
private fun Envelope.toWebSocketPayload(): WebSocketRequestMessage = WebSocketRequestMessage(
|
||||
verb = "PUT",
|
||||
path = "/api/v1/message",
|
||||
id = Random.nextLong(),
|
||||
headers = listOf("X-Signal-Timestamp: $serverTimestamp"),
|
||||
body = encodeByteString()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Identifies the test group created by [IncomingMessageObserverRule]. Hold a reference to pass into the [DeliveryBuilder.groupText] DSL. */
|
||||
data class GroupHandle(val groupId: GroupId.V2, val recipientId: RecipientId)
|
||||
|
||||
/**
|
||||
* Receiver of the DSL passed to [IncomingMessageObserverRule.deliver]. Construct content with
|
||||
* [text] / [groupText] / [deliveryReceipts] / [readReceipts] / [malformedEnvelope] and chain
|
||||
* with the [from] infix to attach a sending peer. Each `from` adds the resulting envelope to
|
||||
* the batch that will be delivered when the lambda returns.
|
||||
*/
|
||||
class DeliveryBuilder internal constructor() {
|
||||
internal val specs = mutableListOf<EnvelopeSpec>()
|
||||
|
||||
fun text(body: String, timestamp: Long = System.currentTimeMillis()): EnvelopeContentSpec = EnvelopeContentSpec.Text(body, timestamp, group = null)
|
||||
|
||||
fun groupText(body: String, group: GroupHandle, timestamp: Long = System.currentTimeMillis()): EnvelopeContentSpec = EnvelopeContentSpec.Text(body, timestamp, group)
|
||||
|
||||
fun deliveryReceipts(targets: List<Long>, sentAt: Long = System.currentTimeMillis()): EnvelopeContentSpec = EnvelopeContentSpec.DeliveryReceipt(targets, sentAt)
|
||||
|
||||
fun readReceipts(targets: List<Long>, sentAt: Long = System.currentTimeMillis()): EnvelopeContentSpec = EnvelopeContentSpec.ReadReceipt(targets, sentAt)
|
||||
|
||||
fun malformedEnvelope(timestamp: Long = System.currentTimeMillis()): EnvelopeContentSpec = EnvelopeContentSpec.Malformed(timestamp)
|
||||
|
||||
infix fun EnvelopeContentSpec.from(peer: OtherClient) {
|
||||
specs += EnvelopeSpec(this, peer)
|
||||
}
|
||||
}
|
||||
|
||||
/** Opaque envelope content returned by [DeliveryBuilder]. Tests never construct or inspect variants directly; the type only appears as a return / receiver of the DSL methods. */
|
||||
sealed class EnvelopeContentSpec {
|
||||
internal data class Text(val body: String, val timestamp: Long, val group: GroupHandle?) : EnvelopeContentSpec()
|
||||
internal data class DeliveryReceipt(val targets: List<Long>, val sentAt: Long) : EnvelopeContentSpec()
|
||||
internal data class ReadReceipt(val targets: List<Long>, val sentAt: Long) : EnvelopeContentSpec()
|
||||
internal data class Malformed(val timestamp: Long) : EnvelopeContentSpec()
|
||||
}
|
||||
|
||||
internal data class EnvelopeSpec(val content: EnvelopeContentSpec, val peer: OtherClient) {
|
||||
fun materialize(): Envelope = when (val c = content) {
|
||||
is EnvelopeContentSpec.Text ->
|
||||
peer.encrypt(Generator.encryptedTextMessage(c.timestamp, c.body, c.group?.let { Harness.groupMasterKey }))
|
||||
is EnvelopeContentSpec.DeliveryReceipt ->
|
||||
peer.encrypt(Generator.encryptedDeliveryReceipt(c.sentAt, c.targets), c.sentAt)
|
||||
is EnvelopeContentSpec.ReadReceipt ->
|
||||
peer.encrypt(Generator.encryptedReadReceipt(c.sentAt, c.targets), c.sentAt)
|
||||
is EnvelopeContentSpec.Malformed -> {
|
||||
val valid = peer.encrypt(Generator.encryptedTextMessage(c.timestamp))
|
||||
val original = valid.content ?: error("Encrypted envelope unexpectedly had no content")
|
||||
val corrupted = original.toByteArray().also { it[it.size / 2] = (it[it.size / 2].toInt() xor 0x01).toByte() }
|
||||
valid.copy(content = corrupted.toByteString())
|
||||
}
|
||||
}
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
package org.thoughtcrime.securesms.testing.incomingmessageobserver
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import androidx.test.runner.AndroidJUnitRunner
|
||||
import org.thoughtcrime.securesms.IncomingMessageObserverInstrumentationApplicationContext
|
||||
|
||||
/**
|
||||
* Test runner that swaps in [IncomingMessageObserverInstrumentationApplicationContext] so the
|
||||
* `IncomingMessageObserver` test harness can drive a faked websocket. Selected automatically by
|
||||
* the build when `-PimoTests` is set.
|
||||
*/
|
||||
@Suppress("unused")
|
||||
class IncomingMessageObserverTestRunner : AndroidJUnitRunner() {
|
||||
override fun newApplication(cl: ClassLoader?, className: String?, context: Context?): Application {
|
||||
return super.newApplication(cl, IncomingMessageObserverInstrumentationApplicationContext::class.java.name, context)
|
||||
}
|
||||
}
|
||||
@@ -6,56 +6,14 @@
|
||||
package org.thoughtcrime.securesms
|
||||
|
||||
import android.app.Application
|
||||
import org.signal.benchmark.setup.NoOpJob
|
||||
import org.signal.core.util.UptimeSleepTimer
|
||||
import org.signal.libsignal.net.Network
|
||||
import org.thoughtcrime.securesms.database.JobDatabase
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencyProvider
|
||||
import org.thoughtcrime.securesms.jobmanager.Job
|
||||
import org.thoughtcrime.securesms.jobmanager.JobManager
|
||||
import org.thoughtcrime.securesms.jobmanager.JobMigrator
|
||||
import org.thoughtcrime.securesms.jobmanager.impl.FactoryJobPredicate
|
||||
import org.thoughtcrime.securesms.jobs.AccountConsistencyWorkerJob
|
||||
import org.thoughtcrime.securesms.jobs.ArchiveBackupIdReservationJob
|
||||
import org.thoughtcrime.securesms.jobs.AttachmentCompressionJob
|
||||
import org.thoughtcrime.securesms.jobs.AttachmentUploadJob
|
||||
import org.thoughtcrime.securesms.jobs.AvatarGroupsV2DownloadJob
|
||||
import org.thoughtcrime.securesms.jobs.CreateReleaseChannelJob
|
||||
import org.thoughtcrime.securesms.jobs.DirectoryRefreshJob
|
||||
import org.thoughtcrime.securesms.jobs.DownloadLatestEmojiDataJob
|
||||
import org.thoughtcrime.securesms.jobs.EmojiSearchIndexDownloadJob
|
||||
import org.thoughtcrime.securesms.jobs.FastJobStorage
|
||||
import org.thoughtcrime.securesms.jobs.FontDownloaderJob
|
||||
import org.thoughtcrime.securesms.jobs.GroupCallUpdateSendJob
|
||||
import org.thoughtcrime.securesms.jobs.GroupRingCleanupJob
|
||||
import org.thoughtcrime.securesms.jobs.GroupV2UpdateSelfProfileKeyJob
|
||||
import org.thoughtcrime.securesms.jobs.IndividualSendJob
|
||||
import org.thoughtcrime.securesms.jobs.JobManagerFactories
|
||||
import org.thoughtcrime.securesms.jobs.LinkedDeviceInactiveCheckJob
|
||||
import org.thoughtcrime.securesms.jobs.MarkerJob
|
||||
import org.thoughtcrime.securesms.jobs.MultiDeviceProfileKeyUpdateJob
|
||||
import org.thoughtcrime.securesms.jobs.PostRegistrationBackupRedemptionJob
|
||||
import org.thoughtcrime.securesms.jobs.PreKeysSyncJob
|
||||
import org.thoughtcrime.securesms.jobs.ProfileUploadJob
|
||||
import org.thoughtcrime.securesms.jobs.PushGroupSendJob
|
||||
import org.thoughtcrime.securesms.jobs.PushProcessMessageJob
|
||||
import org.thoughtcrime.securesms.jobs.ReactionSendJob
|
||||
import org.thoughtcrime.securesms.jobs.RefreshAttributesJob
|
||||
import org.thoughtcrime.securesms.jobs.RefreshSvrCredentialsJob
|
||||
import org.thoughtcrime.securesms.jobs.RequestGroupV2InfoJob
|
||||
import org.thoughtcrime.securesms.jobs.ResetSvrGuessCountJob
|
||||
import org.thoughtcrime.securesms.jobs.RestoreOptimizedMediaJob
|
||||
import org.thoughtcrime.securesms.jobs.RetrieveProfileAvatarJob
|
||||
import org.thoughtcrime.securesms.jobs.RetrieveProfileJob
|
||||
import org.thoughtcrime.securesms.jobs.RetrieveRemoteAnnouncementsJob
|
||||
import org.thoughtcrime.securesms.jobs.RotateCertificateJob
|
||||
import org.thoughtcrime.securesms.jobs.SendDeliveryReceiptJob
|
||||
import org.thoughtcrime.securesms.jobs.StickerPackDownloadJob
|
||||
import org.thoughtcrime.securesms.jobs.StorageSyncJob
|
||||
import org.thoughtcrime.securesms.jobs.StoryOnboardingDownloadJob
|
||||
import org.thoughtcrime.securesms.jobs.TypingSendJob
|
||||
import org.thoughtcrime.securesms.net.DeviceTransferBlockingInterceptor
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences
|
||||
import org.whispersystems.signalservice.api.util.UptimeSleepTimer
|
||||
import org.whispersystems.signalservice.api.websocket.SignalWebSocket
|
||||
import org.whispersystems.signalservice.internal.configuration.SignalServiceConfiguration
|
||||
import org.whispersystems.signalservice.internal.websocket.BenchmarkWebSocketConnection
|
||||
@@ -97,85 +55,11 @@ class BenchmarkApplicationContext : ApplicationContext() {
|
||||
)
|
||||
}
|
||||
|
||||
override fun provideJobManager(): JobManager {
|
||||
val config = JobManager.Configuration.Builder()
|
||||
.setJobFactories(filterJobFactories(JobManagerFactories.getJobFactories(application)))
|
||||
.setConstraintFactories(JobManagerFactories.getConstraintFactories(application))
|
||||
.setConstraintObservers(JobManagerFactories.getConstraintObservers(application))
|
||||
.setJobStorage(FastJobStorage(JobDatabase.getInstance(application)))
|
||||
.setJobMigrator(JobMigrator(TextSecurePreferences.getJobManagerVersion(application), JobManager.CURRENT_VERSION, JobManagerFactories.getJobMigrations(application)))
|
||||
.addReservedJobRunner(FactoryJobPredicate(PushProcessMessageJob.KEY, MarkerJob.KEY))
|
||||
.addReservedJobRunner(FactoryJobPredicate(AttachmentUploadJob.KEY, AttachmentCompressionJob.KEY))
|
||||
.addReservedJobRunner(
|
||||
FactoryJobPredicate(
|
||||
IndividualSendJob.KEY,
|
||||
PushGroupSendJob.KEY,
|
||||
ReactionSendJob.KEY,
|
||||
TypingSendJob.KEY,
|
||||
GroupCallUpdateSendJob.KEY,
|
||||
SendDeliveryReceiptJob.KEY
|
||||
)
|
||||
)
|
||||
override fun provideJobManager(configurationBuilder: JobManager.Configuration.Builder): JobManager {
|
||||
val config = configurationBuilder
|
||||
.setJobFactories(NoOpJob.replaceFactories(JobManagerFactories.getJobFactories(application)))
|
||||
.build()
|
||||
return JobManager(application, config)
|
||||
}
|
||||
|
||||
private fun filterJobFactories(jobFactories: Map<String, Job.Factory<*>>): Map<String, Job.Factory<*>> {
|
||||
val blockedJobs = setOf(
|
||||
AccountConsistencyWorkerJob.KEY,
|
||||
ArchiveBackupIdReservationJob.KEY,
|
||||
AvatarGroupsV2DownloadJob.KEY,
|
||||
CreateReleaseChannelJob.KEY,
|
||||
DirectoryRefreshJob.KEY,
|
||||
DownloadLatestEmojiDataJob.KEY,
|
||||
EmojiSearchIndexDownloadJob.KEY,
|
||||
FontDownloaderJob.KEY,
|
||||
GroupRingCleanupJob.KEY,
|
||||
GroupV2UpdateSelfProfileKeyJob.KEY,
|
||||
LinkedDeviceInactiveCheckJob.KEY,
|
||||
MultiDeviceProfileKeyUpdateJob.KEY,
|
||||
PostRegistrationBackupRedemptionJob.KEY,
|
||||
PreKeysSyncJob.KEY,
|
||||
ProfileUploadJob.KEY,
|
||||
RefreshAttributesJob.KEY,
|
||||
RefreshSvrCredentialsJob.KEY,
|
||||
RequestGroupV2InfoJob.KEY,
|
||||
ResetSvrGuessCountJob.KEY,
|
||||
RestoreOptimizedMediaJob.KEY,
|
||||
RetrieveProfileAvatarJob.KEY,
|
||||
RetrieveProfileJob.KEY,
|
||||
RetrieveRemoteAnnouncementsJob.KEY,
|
||||
RotateCertificateJob.KEY,
|
||||
StickerPackDownloadJob.KEY,
|
||||
StorageSyncJob.KEY,
|
||||
StoryOnboardingDownloadJob.KEY
|
||||
)
|
||||
|
||||
return jobFactories.mapValues {
|
||||
if (it.key in blockedJobs) {
|
||||
NoOpJob.Factory()
|
||||
} else {
|
||||
it.value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class NoOpJob(parameters: Parameters) : Job(parameters) {
|
||||
|
||||
companion object {
|
||||
const val KEY = "NoOpJob"
|
||||
}
|
||||
|
||||
override fun serialize(): ByteArray? = null
|
||||
override fun getFactoryKey(): String = KEY
|
||||
override fun run(): Result = Result.success()
|
||||
override fun onFailure() = Unit
|
||||
|
||||
class Factory : Job.Factory<NoOpJob> {
|
||||
override fun create(parameters: Parameters, serializedData: ByteArray?): NoOpJob {
|
||||
return NoOpJob(parameters)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package org.signal.benchmark.setup
|
||||
|
||||
import org.thoughtcrime.securesms.jobmanager.Job
|
||||
import org.thoughtcrime.securesms.jobs.AccountConsistencyWorkerJob
|
||||
import org.thoughtcrime.securesms.jobs.ArchiveBackupIdReservationJob
|
||||
import org.thoughtcrime.securesms.jobs.AvatarGroupsV2DownloadJob
|
||||
import org.thoughtcrime.securesms.jobs.CreateReleaseChannelJob
|
||||
import org.thoughtcrime.securesms.jobs.DirectoryRefreshJob
|
||||
import org.thoughtcrime.securesms.jobs.DownloadLatestEmojiDataJob
|
||||
import org.thoughtcrime.securesms.jobs.EmojiSearchIndexDownloadJob
|
||||
import org.thoughtcrime.securesms.jobs.FontDownloaderJob
|
||||
import org.thoughtcrime.securesms.jobs.GroupRingCleanupJob
|
||||
import org.thoughtcrime.securesms.jobs.GroupV2UpdateSelfProfileKeyJob
|
||||
import org.thoughtcrime.securesms.jobs.LinkedDeviceInactiveCheckJob
|
||||
import org.thoughtcrime.securesms.jobs.MultiDeviceProfileKeyUpdateJob
|
||||
import org.thoughtcrime.securesms.jobs.PostRegistrationBackupRedemptionJob
|
||||
import org.thoughtcrime.securesms.jobs.PreKeysSyncJob
|
||||
import org.thoughtcrime.securesms.jobs.ProfileUploadJob
|
||||
import org.thoughtcrime.securesms.jobs.RefreshAttributesJob
|
||||
import org.thoughtcrime.securesms.jobs.RefreshSvrCredentialsJob
|
||||
import org.thoughtcrime.securesms.jobs.RequestGroupV2InfoJob
|
||||
import org.thoughtcrime.securesms.jobs.ResetSvrGuessCountJob
|
||||
import org.thoughtcrime.securesms.jobs.RestoreOptimizedMediaJob
|
||||
import org.thoughtcrime.securesms.jobs.RetrieveProfileAvatarJob
|
||||
import org.thoughtcrime.securesms.jobs.RetrieveProfileJob
|
||||
import org.thoughtcrime.securesms.jobs.RetrieveRemoteAnnouncementsJob
|
||||
import org.thoughtcrime.securesms.jobs.RotateCertificateJob
|
||||
import org.thoughtcrime.securesms.jobs.StickerPackDownloadJob
|
||||
import org.thoughtcrime.securesms.jobs.StorageSyncJob
|
||||
import org.thoughtcrime.securesms.jobs.StoryOnboardingDownloadJob
|
||||
|
||||
/**
|
||||
* A [Job] that does nothing and always succeeds. Test setups substitute this for jobs whose
|
||||
* real implementations would hit the network at startup (and so would either generate noise
|
||||
* against the [DeviceTransferBlockingInterceptor][org.thoughtcrime.securesms.net.DeviceTransferBlockingInterceptor]
|
||||
* or fail against unstubbed mocks). Use [replaceFactories] to apply the swap.
|
||||
*/
|
||||
class NoOpJob(parameters: Parameters) : Job(parameters) {
|
||||
override fun serialize(): ByteArray? = null
|
||||
override fun getFactoryKey(): String = KEY
|
||||
override fun run(): Result = Result.success()
|
||||
override fun onFailure() = Unit
|
||||
|
||||
class Factory : Job.Factory<NoOpJob> {
|
||||
override fun create(parameters: Parameters, serializedData: ByteArray?): NoOpJob = NoOpJob(parameters)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val KEY = "NoOpJob"
|
||||
|
||||
private val STARTUP_NETWORK_JOB_KEYS: Set<String> = setOf(
|
||||
AccountConsistencyWorkerJob.KEY,
|
||||
ArchiveBackupIdReservationJob.KEY,
|
||||
AvatarGroupsV2DownloadJob.KEY,
|
||||
CreateReleaseChannelJob.KEY,
|
||||
DirectoryRefreshJob.KEY,
|
||||
DownloadLatestEmojiDataJob.KEY,
|
||||
EmojiSearchIndexDownloadJob.KEY,
|
||||
FontDownloaderJob.KEY,
|
||||
GroupRingCleanupJob.KEY,
|
||||
GroupV2UpdateSelfProfileKeyJob.KEY,
|
||||
LinkedDeviceInactiveCheckJob.KEY,
|
||||
MultiDeviceProfileKeyUpdateJob.KEY,
|
||||
PostRegistrationBackupRedemptionJob.KEY,
|
||||
PreKeysSyncJob.KEY,
|
||||
ProfileUploadJob.KEY,
|
||||
RefreshAttributesJob.KEY,
|
||||
RefreshSvrCredentialsJob.KEY,
|
||||
RequestGroupV2InfoJob.KEY,
|
||||
ResetSvrGuessCountJob.KEY,
|
||||
RestoreOptimizedMediaJob.KEY,
|
||||
RetrieveProfileAvatarJob.KEY,
|
||||
RetrieveProfileJob.KEY,
|
||||
RetrieveRemoteAnnouncementsJob.KEY,
|
||||
RotateCertificateJob.KEY,
|
||||
StickerPackDownloadJob.KEY,
|
||||
StorageSyncJob.KEY,
|
||||
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 }
|
||||
}
|
||||
}
|
||||
@@ -146,7 +146,7 @@ object TestMessages {
|
||||
private fun imageAttachment(): SignalServiceAttachmentPointer {
|
||||
return SignalServiceAttachmentPointer(
|
||||
Cdn.S3.cdnNumber,
|
||||
SignalServiceAttachmentRemoteId.from(""),
|
||||
SignalServiceAttachmentRemoteId.from("", Cdn.S3.cdnNumber),
|
||||
"image/webp",
|
||||
null,
|
||||
Optional.empty(),
|
||||
@@ -170,7 +170,7 @@ object TestMessages {
|
||||
private fun voiceAttachment(): SignalServiceAttachmentPointer {
|
||||
return SignalServiceAttachmentPointer(
|
||||
Cdn.S3.cdnNumber,
|
||||
SignalServiceAttachmentRemoteId.from(""),
|
||||
SignalServiceAttachmentRemoteId.from("", Cdn.S3.cdnNumber),
|
||||
"audio/aac",
|
||||
null,
|
||||
Optional.empty(),
|
||||
|
||||
@@ -133,7 +133,7 @@ object TestUsers {
|
||||
val recipientId = RecipientId.from(SignalServiceAddress(aci, "+15555551%03d".format(i)))
|
||||
SignalDatabase.recipients.setProfileName(recipientId, ProfileName.fromParts("Buddy", "#$i"))
|
||||
SignalDatabase.recipients.setProfileKeyIfAbsent(recipientId, ProfileKeyUtil.createNew())
|
||||
SignalDatabase.recipients.setCapabilities(recipientId, SignalServiceProfile.Capabilities(true, true))
|
||||
SignalDatabase.recipients.setCapabilities(recipientId, SignalServiceProfile.Capabilities(true, true, true))
|
||||
SignalDatabase.recipients.setProfileSharing(recipientId, true)
|
||||
SignalDatabase.recipients.markRegistered(recipientId, aci)
|
||||
val otherIdentity = IdentityKeyPair.generate()
|
||||
@@ -157,7 +157,7 @@ object TestUsers {
|
||||
val recipientId = RecipientId.from(SignalServiceAddress(otherClient.serviceId, otherClient.e164))
|
||||
SignalDatabase.recipients.setProfileName(recipientId, ProfileName.fromParts("Buddy", "#$i"))
|
||||
SignalDatabase.recipients.setProfileKeyIfAbsent(recipientId, otherClient.profileKey)
|
||||
SignalDatabase.recipients.setCapabilities(recipientId, SignalServiceProfile.Capabilities(true, true))
|
||||
SignalDatabase.recipients.setCapabilities(recipientId, SignalServiceProfile.Capabilities(true, true, true))
|
||||
SignalDatabase.recipients.setProfileSharing(recipientId, true)
|
||||
SignalDatabase.recipients.markRegistered(recipientId, otherClient.serviceId)
|
||||
AppDependencies.protocolStore.aci().saveIdentity(SignalProtocolAddress(otherClient.serviceId.toString(), 1), otherClient.identityKeyPair.publicKey)
|
||||
|
||||
+13
-1
@@ -11,7 +11,7 @@ import io.reactivex.rxjava3.subjects.BehaviorSubject
|
||||
import org.signal.network.websocket.WebSocketRequestMessage
|
||||
import org.signal.network.websocket.WebSocketResponseMessage
|
||||
import org.signal.network.websocket.WebsocketResponse
|
||||
import org.thoughtcrime.securesms.util.JsonUtils
|
||||
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
|
||||
@@ -68,6 +68,18 @@ class BenchmarkWebSocketConnection : WebSocketConnection {
|
||||
fun addQueueEmptyMessage() {
|
||||
authInstances.filterNot(BenchmarkWebSocketConnection::isShutdown).forEach { it.addQueueEmptyMessage() }
|
||||
}
|
||||
|
||||
fun awaitAllMessagesConsumed(timeoutMs: Long): Boolean {
|
||||
val deadline = System.currentTimeMillis() + timeoutMs
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
val activeInstances = synchronized(this) { authInstances.filterNot(BenchmarkWebSocketConnection::isShutdown).toList() }
|
||||
if (activeInstances.isNotEmpty() && activeInstances.all { it.incomingRequests.isEmpty() && it.incomingSemaphore.availablePermits() == 0 }) {
|
||||
return true
|
||||
}
|
||||
Thread.sleep(25)
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
override val name: String = "bench-${System.identityHashCode(this)}"
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
|
||||
|
||||
<application
|
||||
android:usesCleartextTraffic="true"
|
||||
tools:replace="android:usesCleartextTraffic"
|
||||
tools:ignore="UnusedAttribute" />
|
||||
|
||||
</manifest>
|
||||
@@ -1,5 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/signal_accent_green"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
@@ -1,4 +0,0 @@
|
||||
<?xml version='1.0' encoding='UTF-8'?>
|
||||
<resources>
|
||||
<string name="app_name">Signal (Instrumentation)</string>
|
||||
</resources>
|
||||
@@ -1371,7 +1371,7 @@
|
||||
|
||||
<service
|
||||
android:name=".gcm.FcmReceiveService"
|
||||
android:exported="true">
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="com.google.firebase.MESSAGING_EVENT" />
|
||||
</intent-filter>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -24,6 +24,11 @@ class ConversationLayoutManager(context: Context) : LinearLayoutManager(context,
|
||||
|
||||
private var afterScroll: (() -> Unit)? = null
|
||||
|
||||
// Backing state for scrollToPositionTopAligned; alignTopCorrected guards the one-shot corrective re-scroll.
|
||||
private var alignTopPosition: Int = RecyclerView.NO_POSITION
|
||||
private var alignTopInset: Int = 0
|
||||
private var alignTopCorrected: Boolean = false
|
||||
|
||||
override fun supportsPredictiveItemAnimations(): Boolean {
|
||||
return false
|
||||
}
|
||||
@@ -34,9 +39,23 @@ class ConversationLayoutManager(context: Context) : LinearLayoutManager(context,
|
||||
*/
|
||||
fun scrollToPositionWithOffset(position: Int, offset: Int, afterScroll: () -> Unit) {
|
||||
this.afterScroll = afterScroll
|
||||
alignTopPosition = RecyclerView.NO_POSITION
|
||||
super.scrollToPositionWithOffset(position, offset)
|
||||
}
|
||||
|
||||
/**
|
||||
* Scroll so [position]'s decorated top (including any top decoration, e.g. the unread divider) lands [topInset] px
|
||||
* below the top of the recycler. [afterScroll] fires once the alignment settles.
|
||||
*/
|
||||
fun scrollToPositionTopAligned(position: Int, topInset: Int, afterScroll: () -> Unit) {
|
||||
this.afterScroll = afterScroll
|
||||
alignTopPosition = position
|
||||
alignTopInset = topInset
|
||||
alignTopCorrected = false
|
||||
// Rough first pass: the exact offset needs the item's height, which isn't known until it's laid out (see onLayoutCompleted).
|
||||
super.scrollToPositionWithOffset(position, height - topInset)
|
||||
}
|
||||
|
||||
/**
|
||||
* If a scroll to position request is made and a layout pass occurs prior to the list being populated with via the data source,
|
||||
* the base implementation clears the request as if it was never made.
|
||||
@@ -64,10 +83,26 @@ class ConversationLayoutManager(context: Context) : LinearLayoutManager(context,
|
||||
} else {
|
||||
scrollToPosition(pendingScrollPosition)
|
||||
}
|
||||
} else {
|
||||
afterScroll?.invoke()
|
||||
afterScroll = null
|
||||
return
|
||||
}
|
||||
|
||||
// The target is now laid out, so its height is known. Correct the offset once so the decorated top sits at the
|
||||
// requested inset, then let the next layout settle before notifying via afterScroll.
|
||||
if (alignTopPosition != RecyclerView.NO_POSITION && !alignTopCorrected) {
|
||||
val target = findViewByPosition(alignTopPosition)
|
||||
if (target != null) {
|
||||
alignTopCorrected = true
|
||||
if (getDecoratedTop(target) != alignTopInset) {
|
||||
val correctedOffset = (height - paddingBottom) - alignTopInset - getDecoratedMeasuredHeight(target)
|
||||
super.scrollToPositionWithOffset(alignTopPosition, correctedOffset)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
afterScroll?.invoke()
|
||||
afterScroll = null
|
||||
alignTopPosition = RecyclerView.NO_POSITION
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -13,7 +13,8 @@ object AppCapabilities {
|
||||
storage = storageCapable,
|
||||
versionedExpirationTimer = true,
|
||||
attachmentBackfill = true,
|
||||
spqr = true
|
||||
spqr = true,
|
||||
usernameChangeSyncMessage = false // TODO(michelle): Turn on once all clients support it and add a migration
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ package org.thoughtcrime.securesms;
|
||||
|
||||
import android.app.Application;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
@@ -32,8 +33,10 @@ import net.zetetic.database.Logger;
|
||||
import org.conscrypt.ConscryptSignal;
|
||||
import org.greenrobot.eventbus.EventBus;
|
||||
import org.signal.aesgcmprovider.AesGcmProvider;
|
||||
import org.signal.core.util.AppForegroundObserver;
|
||||
import org.signal.core.util.DiskUtil;
|
||||
import org.signal.core.util.MemoryTracker;
|
||||
import org.signal.core.util.Util;
|
||||
import org.signal.core.util.concurrent.AnrDetector;
|
||||
import org.signal.core.util.concurrent.SignalExecutors;
|
||||
import org.signal.core.util.logging.AndroidLogger;
|
||||
@@ -50,6 +53,7 @@ import org.thoughtcrime.securesms.backup.v2.BackupRepository;
|
||||
import org.thoughtcrime.securesms.crypto.AttachmentSecretProvider;
|
||||
import org.thoughtcrime.securesms.crypto.DatabaseSecretProvider;
|
||||
import org.thoughtcrime.securesms.database.LogDatabase;
|
||||
import org.thoughtcrime.securesms.database.SQLiteDatabase;
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase;
|
||||
import org.thoughtcrime.securesms.database.SqlCipherLibraryLoader;
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies;
|
||||
@@ -58,6 +62,7 @@ import org.thoughtcrime.securesms.emoji.EmojiSource;
|
||||
import org.thoughtcrime.securesms.emoji.JumboEmoji;
|
||||
import org.thoughtcrime.securesms.gcm.FcmFetchManager;
|
||||
import org.thoughtcrime.securesms.glide.SignalGlideComponents;
|
||||
import org.thoughtcrime.securesms.jobmanager.impl.SealedSenderConstraint;
|
||||
import org.thoughtcrime.securesms.jobs.AccountConsistencyWorkerJob;
|
||||
import org.thoughtcrime.securesms.jobs.BackupRefreshJob;
|
||||
import org.thoughtcrime.securesms.jobs.BackupSubscriptionCheckJob;
|
||||
@@ -74,6 +79,7 @@ import org.thoughtcrime.securesms.jobs.GroupV2UpdateSelfProfileKeyJob;
|
||||
import org.thoughtcrime.securesms.jobs.InAppPaymentAuthCheckJob;
|
||||
import org.thoughtcrime.securesms.jobs.InAppPaymentKeepAliveJob;
|
||||
import org.thoughtcrime.securesms.jobs.LinkedDeviceInactiveCheckJob;
|
||||
import org.thoughtcrime.securesms.jobs.MessageSendLogCleanupJob;
|
||||
import org.thoughtcrime.securesms.jobs.MultiDeviceContactUpdateJob;
|
||||
import org.thoughtcrime.securesms.jobs.PreKeysSyncJob;
|
||||
import org.thoughtcrime.securesms.jobs.ProfileUploadJob;
|
||||
@@ -82,13 +88,14 @@ import org.thoughtcrime.securesms.jobs.RefreshSvrCredentialsJob;
|
||||
import org.thoughtcrime.securesms.jobs.RestoreOptimizedMediaJob;
|
||||
import org.thoughtcrime.securesms.jobs.RetrieveProfileJob;
|
||||
import org.thoughtcrime.securesms.jobs.RetrieveRemoteAnnouncementsJob;
|
||||
import org.thoughtcrime.securesms.jobmanager.impl.SealedSenderConstraint;
|
||||
import org.thoughtcrime.securesms.jobs.StoryOnboardingDownloadJob;
|
||||
import org.thoughtcrime.securesms.keyvalue.KeepMessagesDuration;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.logging.CustomSignalProtocolLogger;
|
||||
import org.thoughtcrime.securesms.logging.PersistentLogger;
|
||||
import org.thoughtcrime.securesms.logsubmit.SubmitDebugLogActivity;
|
||||
import org.thoughtcrime.securesms.messageprocessingalarm.RoutineMessageFetchReceiver;
|
||||
import org.thoughtcrime.securesms.messages.IncomingMessageObserver;
|
||||
import org.thoughtcrime.securesms.migrations.ApplicationMigrations;
|
||||
import org.thoughtcrime.securesms.mms.SignalGlideModule;
|
||||
import org.thoughtcrime.securesms.providers.BlobProvider;
|
||||
@@ -104,10 +111,8 @@ import org.thoughtcrime.securesms.service.MessageBackupListener;
|
||||
import org.thoughtcrime.securesms.service.RotateSenderCertificateListener;
|
||||
import org.thoughtcrime.securesms.service.RotateSignedPreKeyListener;
|
||||
import org.thoughtcrime.securesms.service.webrtc.ActiveCallManager;
|
||||
import org.thoughtcrime.securesms.service.webrtc.CallingAssets;
|
||||
import org.thoughtcrime.securesms.service.webrtc.AndroidTelecomUtil;
|
||||
import org.thoughtcrime.securesms.storage.StorageSyncHelper;
|
||||
import org.thoughtcrime.securesms.util.AppForegroundObserver;
|
||||
import org.thoughtcrime.securesms.util.AppStartup;
|
||||
import org.thoughtcrime.securesms.util.DeviceProperties;
|
||||
import org.thoughtcrime.securesms.util.DynamicTheme;
|
||||
@@ -118,7 +123,6 @@ import org.thoughtcrime.securesms.util.SignalLocalMetrics;
|
||||
import org.thoughtcrime.securesms.util.SignalUncaughtExceptionHandler;
|
||||
import org.thoughtcrime.securesms.util.SqlCipherLogTarget;
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
import org.signal.core.util.Util;
|
||||
import org.thoughtcrime.securesms.util.VersionTracker;
|
||||
import org.thoughtcrime.securesms.util.dynamiclanguage.DynamicLanguageContextWrapper;
|
||||
import org.whispersystems.signalservice.api.websocket.SignalWebSocket;
|
||||
@@ -226,7 +230,7 @@ public class ApplicationContext extends Application implements AppForegroundObse
|
||||
.addPostRender(RefreshSvrCredentialsJob::enqueueIfNecessary)
|
||||
.addPostRender(() -> DownloadLatestEmojiDataJob.scheduleIfNecessary(this))
|
||||
.addPostRender(EmojiSearchIndexDownloadJob::scheduleIfNecessary)
|
||||
.addPostRender(() -> SignalDatabase.messageLog().trimOldMessages(System.currentTimeMillis(), RemoteConfig.retryRespondMaxAge()))
|
||||
.addPostRender(MessageSendLogCleanupJob::enqueue)
|
||||
.addPostRender(() -> JumboEmoji.updateCurrentVersion(this))
|
||||
.addPostRender(RetrieveRemoteAnnouncementsJob::enqueue)
|
||||
.addPostRender(AndroidTelecomUtil::registerPhoneAccount)
|
||||
@@ -274,7 +278,7 @@ public class ApplicationContext extends Application implements AppForegroundObse
|
||||
checkFreeDiskSpace();
|
||||
MemoryTracker.start();
|
||||
BackupSubscriptionCheckJob.enqueueIfAble();
|
||||
CheckKeyTransparencyJob.enqueueIfNecessary(true);
|
||||
CheckKeyTransparencyJob.enqueueIfNecessary(true, false);
|
||||
AppDependencies.getAuthWebSocket().registerKeepAliveToken(SignalWebSocket.FOREGROUND_KEEPALIVE);
|
||||
AppDependencies.getUnauthWebSocket().registerKeepAliveToken(SignalWebSocket.FOREGROUND_KEEPALIVE);
|
||||
|
||||
@@ -417,7 +421,12 @@ public class ApplicationContext extends Application implements AppForegroundObse
|
||||
new org.signal.registration.RegistrationDependencies(
|
||||
new org.thoughtcrime.securesms.registration.v2.AppRegistrationNetworkController(this, AppDependencies.getPushServiceSocket()),
|
||||
new org.thoughtcrime.securesms.registration.v2.AppRegistrationStorageController(this),
|
||||
null
|
||||
Environment.IS_LINK_AND_SYNC_AVAILABLE,
|
||||
null,
|
||||
context -> {
|
||||
context.startActivity(new Intent(context, SubmitDebugLogActivity.class));
|
||||
return Unit.INSTANCE;
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -448,22 +457,27 @@ public class ApplicationContext extends Application implements AppForegroundObse
|
||||
PlayServicesUtil.PlayServicesStatus playServicesStatus = PlayServicesUtil.getPlayServicesStatus(this);
|
||||
|
||||
if (playServicesStatus == PlayServicesUtil.PlayServicesStatus.SUCCESS && !SignalStore.account().isFcmEnabled()) {
|
||||
Log.i(TAG, "Play Services are newly-available. Enabling FCM and updating server.");
|
||||
Log.w(TAG, "Play Services are newly-available. Enabling FCM and updating server.");
|
||||
SignalStore.account().setFcmEnabled(true);
|
||||
AppDependencies.getJobManager().startChain(new FcmRefreshJob())
|
||||
.then(new RefreshAttributesJob())
|
||||
.enqueue();
|
||||
AppDependencies.resetNetwork();
|
||||
AppDependencies.startNetwork();
|
||||
IncomingMessageObserver.stopForegroundService(this);
|
||||
} else if (playServicesStatus == PlayServicesUtil.PlayServicesStatus.MISSING && SignalStore.account().isFcmEnabled()) {
|
||||
Log.w(TAG, "Play Services are no longer available. Disabling FCM and updating server.");
|
||||
SignalStore.account().setFcmEnabled(false);
|
||||
SignalStore.account().setFcmToken(null);
|
||||
AppDependencies.getJobManager().add(new RefreshAttributesJob());
|
||||
Log.w(TAG, "Play Services are no longer available. Attempting to get an FCM token anyway.");
|
||||
AppDependencies.getJobManager().add(new FcmRefreshJob());
|
||||
} else if (playServicesStatus == PlayServicesUtil.PlayServicesStatus.MISSING && (System.currentTimeMillis() - SignalStore.misc().getLastMissingPlayServicesFcmVerificationTime()) > TimeUnit.DAYS.toMillis(3)) {
|
||||
Log.i(TAG, "Play Services are unavailable, but it's been long enough that we should check and see if we can get an FCM token anyway.");
|
||||
AppDependencies.getJobManager().add(new FcmRefreshJob());
|
||||
} else if (SignalStore.account().isFcmEnabled()) {
|
||||
long lastSetTime = SignalStore.account().getFcmTokenLastSetTime();
|
||||
long nextSetTime = lastSetTime + TimeUnit.HOURS.toMillis(6);
|
||||
long now = System.currentTimeMillis();
|
||||
|
||||
if (SignalStore.account().getFcmToken() == null || nextSetTime <= now || lastSetTime > now) {
|
||||
Log.i(TAG, "Time for routine FCM token refresh.");
|
||||
AppDependencies.getJobManager().add(new FcmRefreshJob());
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -16,7 +16,7 @@ import androidx.core.app.ActivityOptionsCompat;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies;
|
||||
import org.thoughtcrime.securesms.util.AppStartup;
|
||||
import org.thoughtcrime.securesms.util.ConfigurationUtil;
|
||||
import org.signal.core.util.ConfigurationUtil;
|
||||
import org.thoughtcrime.securesms.util.WindowUtil;
|
||||
import org.thoughtcrime.securesms.util.dynamiclanguage.DynamicLanguageContextWrapper;
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import androidx.lifecycle.LifecycleOwner;
|
||||
import com.bumptech.glide.RequestManager;
|
||||
|
||||
import org.thoughtcrime.securesms.conversationlist.model.ConversationSet;
|
||||
import org.thoughtcrime.securesms.database.model.ThreadRecord;
|
||||
import org.thoughtcrime.securesms.database.model.ThreadWithRecipient;
|
||||
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
@@ -14,7 +14,7 @@ import java.util.Set;
|
||||
public interface BindableConversationListItem extends Unbindable {
|
||||
|
||||
void bind(@NonNull LifecycleOwner lifecycleOwner,
|
||||
@NonNull ThreadRecord thread,
|
||||
@NonNull ThreadWithRecipient thread,
|
||||
@NonNull RequestManager requestManager, @NonNull Locale locale,
|
||||
@NonNull Set<Long> typingThreads,
|
||||
@NonNull ConversationSet selectedConversations,
|
||||
|
||||
@@ -10,8 +10,8 @@ import androidx.activity.result.contract.ActivityResultContract
|
||||
import androidx.biometric.BiometricManager
|
||||
import androidx.biometric.BiometricPrompt
|
||||
import androidx.biometric.BiometricPrompt.PromptInfo
|
||||
import org.signal.core.util.ServiceUtil
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.util.ServiceUtil
|
||||
|
||||
/**
|
||||
* Authentication using phone biometric (face, fingerprint recognition) or device lock (pattern, pin or passphrase).
|
||||
|
||||
@@ -23,45 +23,25 @@ public final class BlockUnblockDialog {
|
||||
private BlockUnblockDialog() {}
|
||||
|
||||
public static void showReportSpamFor(@NonNull Context context,
|
||||
@NonNull Lifecycle lifecycle,
|
||||
@NonNull Recipient recipient,
|
||||
@NonNull Runnable onReportSpam,
|
||||
@Nullable Runnable onBlockAndReportSpam)
|
||||
{
|
||||
SimpleTask.run(lifecycle,
|
||||
() -> buildReportSpamFor(context, recipient, onReportSpam, onBlockAndReportSpam),
|
||||
AlertDialog.Builder::show);
|
||||
buildReportSpamFor(context, recipient, onReportSpam, onBlockAndReportSpam).show();
|
||||
}
|
||||
|
||||
public static void showBlockFor(@NonNull Context context,
|
||||
@NonNull Lifecycle lifecycle,
|
||||
@NonNull Recipient recipient,
|
||||
@NonNull Runnable onBlock)
|
||||
{
|
||||
SimpleTask.run(lifecycle,
|
||||
() -> buildBlockFor(context, recipient, onBlock, null),
|
||||
AlertDialog.Builder::show);
|
||||
}
|
||||
|
||||
public static void showBlockAndReportSpamFor(@NonNull Context context,
|
||||
@NonNull Lifecycle lifecycle,
|
||||
@NonNull Recipient recipient,
|
||||
@NonNull Runnable onBlock,
|
||||
@NonNull Runnable onBlockAndReportSpam)
|
||||
{
|
||||
SimpleTask.run(lifecycle,
|
||||
() -> buildBlockFor(context, recipient, onBlock, onBlockAndReportSpam),
|
||||
AlertDialog.Builder::show);
|
||||
buildBlockFor(context, recipient, onBlock, null).show();
|
||||
}
|
||||
|
||||
public static void showUnblockFor(@NonNull Context context,
|
||||
@NonNull Lifecycle lifecycle,
|
||||
@NonNull Recipient recipient,
|
||||
@NonNull Runnable onUnblock)
|
||||
{
|
||||
SimpleTask.run(lifecycle,
|
||||
() -> buildUnblockFor(context, recipient, onUnblock),
|
||||
AlertDialog.Builder::show);
|
||||
buildUnblockFor(context, recipient, onUnblock).show();
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
|
||||
@@ -34,7 +34,6 @@ 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.ServiceUtil;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.lang.ref.WeakReference;
|
||||
@@ -49,8 +48,7 @@ import java.util.function.Consumer;
|
||||
*/
|
||||
public abstract class ContactSelectionActivity extends PassphraseRequiredActivity
|
||||
implements SwipeRefreshLayout.OnRefreshListener,
|
||||
ContactSelectionListFragment.OnContactSelectedListener,
|
||||
ContactSelectionListFragment.ScrollCallback
|
||||
ContactSelectionListFragment.OnContactSelectedListener
|
||||
{
|
||||
private static final String TAG = Log.tag(ContactSelectionActivity.class);
|
||||
|
||||
@@ -136,17 +134,6 @@ public abstract class ContactSelectionActivity extends PassphraseRequiredActivit
|
||||
@Override
|
||||
public void onContactDeselected(@NonNull Optional<RecipientId> recipientId, String number, @NonNull Optional<ChatType> chatType) {}
|
||||
|
||||
@Override
|
||||
public void onBeginScroll() {
|
||||
hideKeyboard();
|
||||
}
|
||||
|
||||
private void hideKeyboard() {
|
||||
ServiceUtil.getInputMethodManager(this)
|
||||
.hideSoftInputFromWindow(toolbar.getWindowToken(), 0);
|
||||
toolbar.clearFocus();
|
||||
}
|
||||
|
||||
private static class RefreshDirectoryTask extends AsyncTask<Context, Void, Void> {
|
||||
|
||||
private final WeakReference<ContactSelectionActivity> activity;
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
package org.thoughtcrime.securesms
|
||||
|
||||
import android.content.Context
|
||||
import android.view.View
|
||||
import android.widget.TextView
|
||||
import com.google.android.material.button.MaterialButton
|
||||
import org.thoughtcrime.securesms.ContactSelectionListModels.FindByPhoneNumberModel
|
||||
import org.thoughtcrime.securesms.ContactSelectionListModels.FindByUsernameModel
|
||||
import org.thoughtcrime.securesms.ContactSelectionListModels.FindContactsBannerModel
|
||||
import org.thoughtcrime.securesms.ContactSelectionListModels.FindContactsModel
|
||||
import org.thoughtcrime.securesms.ContactSelectionListModels.InviteToSignalModel
|
||||
import org.thoughtcrime.securesms.ContactSelectionListModels.MoreHeaderModel
|
||||
import org.thoughtcrime.securesms.ContactSelectionListModels.NewGroupModel
|
||||
import org.thoughtcrime.securesms.ContactSelectionListModels.RefreshContactsModel
|
||||
import org.thoughtcrime.securesms.contacts.paged.ContactSearchAdapter
|
||||
import org.thoughtcrime.securesms.contacts.paged.ContactSearchConfiguration
|
||||
import org.thoughtcrime.securesms.contacts.paged.ContactSearchData
|
||||
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder
|
||||
|
||||
class ContactSelectionListAdapter(
|
||||
context: Context,
|
||||
@@ -23,152 +26,19 @@ class ContactSelectionListAdapter(
|
||||
) : ContactSearchAdapter(context, fixedContacts, displayOptions, onClickCallbacks, longClickCallbacks, storyContextMenuCallbacks, callButtonClickCallbacks) {
|
||||
|
||||
init {
|
||||
registerFactory(NewGroupModel::class.java, LayoutFactory({ NewGroupViewHolder(it, onClickCallbacks::onNewGroupClicked) }, R.layout.contact_selection_new_group_item))
|
||||
registerFactory(InviteToSignalModel::class.java, LayoutFactory({ InviteToSignalViewHolder(it, onClickCallbacks::onInviteToSignalClicked) }, R.layout.contact_selection_invite_action_item))
|
||||
registerFactory(FindContactsModel::class.java, LayoutFactory({ FindContactsViewHolder(it, onClickCallbacks::onFindContactsClicked) }, R.layout.contact_selection_find_contacts_item))
|
||||
registerFactory(FindContactsBannerModel::class.java, LayoutFactory({ FindContactsBannerViewHolder(it, onClickCallbacks::onDismissFindContactsBannerClicked, onClickCallbacks::onFindContactsClicked) }, R.layout.contact_selection_find_contacts_banner_item))
|
||||
registerFactory(RefreshContactsModel::class.java, LayoutFactory({ RefreshContactsViewHolder(it, onClickCallbacks::onRefreshContactsClicked) }, R.layout.contact_selection_refresh_action_item))
|
||||
registerFactory(MoreHeaderModel::class.java, LayoutFactory({ MoreHeaderViewHolder(it) }, R.layout.contact_search_section_header))
|
||||
registerFactory(EmptyModel::class.java, LayoutFactory({ EmptyViewHolder(it) }, R.layout.contact_selection_empty_state))
|
||||
registerFactory(FindByUsernameModel::class.java, LayoutFactory({ FindByUsernameViewHolder(it, onClickCallbacks::onFindByUsernameClicked) }, R.layout.contact_selection_find_by_username_item))
|
||||
registerFactory(FindByPhoneNumberModel::class.java, LayoutFactory({ FindByPhoneNumberViewHolder(it, onClickCallbacks::onFindByPhoneNumberClicked) }, R.layout.contact_selection_find_by_phone_number_item))
|
||||
}
|
||||
|
||||
class NewGroupModel : MappingModel<NewGroupModel> {
|
||||
override fun areItemsTheSame(newItem: NewGroupModel): Boolean = true
|
||||
override fun areContentsTheSame(newItem: NewGroupModel): Boolean = true
|
||||
}
|
||||
|
||||
class InviteToSignalModel : MappingModel<InviteToSignalModel> {
|
||||
override fun areItemsTheSame(newItem: InviteToSignalModel): Boolean = true
|
||||
override fun areContentsTheSame(newItem: InviteToSignalModel): Boolean = true
|
||||
}
|
||||
|
||||
class RefreshContactsModel : MappingModel<RefreshContactsModel> {
|
||||
override fun areItemsTheSame(newItem: RefreshContactsModel): Boolean = true
|
||||
override fun areContentsTheSame(newItem: RefreshContactsModel): Boolean = true
|
||||
}
|
||||
|
||||
class FindContactsModel : MappingModel<FindContactsModel> {
|
||||
override fun areItemsTheSame(newItem: FindContactsModel): Boolean = true
|
||||
override fun areContentsTheSame(newItem: FindContactsModel): Boolean = true
|
||||
}
|
||||
|
||||
class FindContactsBannerModel : MappingModel<FindContactsBannerModel> {
|
||||
override fun areItemsTheSame(newItem: FindContactsBannerModel): Boolean = true
|
||||
override fun areContentsTheSame(newItem: FindContactsBannerModel): Boolean = true
|
||||
}
|
||||
|
||||
class FindByUsernameModel : MappingModel<FindByUsernameModel> {
|
||||
override fun areItemsTheSame(newItem: FindByUsernameModel): Boolean = true
|
||||
override fun areContentsTheSame(newItem: FindByUsernameModel): Boolean = true
|
||||
}
|
||||
|
||||
class FindByPhoneNumberModel : MappingModel<FindByPhoneNumberModel> {
|
||||
override fun areItemsTheSame(newItem: FindByPhoneNumberModel): Boolean = true
|
||||
override fun areContentsTheSame(newItem: FindByPhoneNumberModel): Boolean = true
|
||||
}
|
||||
|
||||
class MoreHeaderModel : MappingModel<MoreHeaderModel> {
|
||||
override fun areItemsTheSame(newItem: MoreHeaderModel): Boolean = true
|
||||
|
||||
override fun areContentsTheSame(newItem: MoreHeaderModel): Boolean = true
|
||||
}
|
||||
|
||||
private class InviteToSignalViewHolder(itemView: View, onClickListener: () -> Unit) : MappingViewHolder<InviteToSignalModel>(itemView) {
|
||||
init {
|
||||
itemView.setOnClickListener { onClickListener() }
|
||||
}
|
||||
|
||||
override fun bind(model: InviteToSignalModel) = Unit
|
||||
}
|
||||
|
||||
private class NewGroupViewHolder(itemView: View, onClickListener: () -> Unit) : MappingViewHolder<NewGroupModel>(itemView) {
|
||||
init {
|
||||
itemView.setOnClickListener { onClickListener() }
|
||||
}
|
||||
|
||||
override fun bind(model: NewGroupModel) = Unit
|
||||
}
|
||||
|
||||
private class RefreshContactsViewHolder(itemView: View, onClickListener: () -> Unit) : MappingViewHolder<RefreshContactsModel>(itemView) {
|
||||
init {
|
||||
itemView.setOnClickListener { onClickListener() }
|
||||
}
|
||||
|
||||
override fun bind(model: RefreshContactsModel) = Unit
|
||||
}
|
||||
|
||||
private class FindContactsViewHolder(itemView: View, onClickListener: () -> Unit) : MappingViewHolder<FindContactsModel>(itemView) {
|
||||
init {
|
||||
itemView.setOnClickListener { onClickListener() }
|
||||
}
|
||||
|
||||
override fun bind(model: FindContactsModel) = Unit
|
||||
}
|
||||
|
||||
private class FindContactsBannerViewHolder(itemView: View, onDismissListener: () -> Unit, onClickListener: () -> Unit) : MappingViewHolder<FindContactsBannerModel>(itemView) {
|
||||
init {
|
||||
itemView.findViewById<MaterialButton>(R.id.no_thanks_button).setOnClickListener { onDismissListener() }
|
||||
itemView.findViewById<MaterialButton>(R.id.allow_contacts_button).setOnClickListener { onClickListener() }
|
||||
}
|
||||
|
||||
override fun bind(model: FindContactsBannerModel) = Unit
|
||||
}
|
||||
|
||||
private class MoreHeaderViewHolder(itemView: View) : MappingViewHolder<MoreHeaderModel>(itemView) {
|
||||
|
||||
private val headerTextView: TextView = itemView.findViewById(R.id.section_header)
|
||||
|
||||
override fun bind(model: MoreHeaderModel) {
|
||||
headerTextView.setText(R.string.contact_selection_activity__more)
|
||||
}
|
||||
}
|
||||
|
||||
private class EmptyViewHolder(itemView: View) : MappingViewHolder<EmptyModel>(itemView) {
|
||||
|
||||
private val emptyText: TextView = itemView.findViewById(R.id.search_no_results)
|
||||
|
||||
override fun bind(model: EmptyModel) {
|
||||
emptyText.text = context.getString(R.string.SearchFragment_no_results, model.empty.query ?: "")
|
||||
}
|
||||
}
|
||||
|
||||
private class FindByPhoneNumberViewHolder(itemView: View, onClickListener: () -> Unit) : MappingViewHolder<FindByPhoneNumberModel>(itemView) {
|
||||
|
||||
init {
|
||||
itemView.setOnClickListener { onClickListener() }
|
||||
}
|
||||
|
||||
override fun bind(model: FindByPhoneNumberModel) = Unit
|
||||
}
|
||||
|
||||
private class FindByUsernameViewHolder(itemView: View, onClickListener: () -> Unit) : MappingViewHolder<FindByUsernameModel>(itemView) {
|
||||
|
||||
init {
|
||||
itemView.setOnClickListener { onClickListener() }
|
||||
}
|
||||
|
||||
override fun bind(model: FindByUsernameModel) = Unit
|
||||
ContactSelectionListModels.registerNewGroup(this, onClickCallbacks::onNewGroupClicked)
|
||||
ContactSelectionListModels.registerInviteToSignal(this, onClickCallbacks::onInviteToSignalClicked)
|
||||
ContactSelectionListModels.registerFindContacts(this, onClickCallbacks::onFindContactsClicked)
|
||||
ContactSelectionListModels.registerFindContactsBanner(this, onClickCallbacks::onDismissFindContactsBannerClicked, onClickCallbacks::onFindContactsClicked)
|
||||
ContactSelectionListModels.registerRefreshContacts(this, onClickCallbacks::onRefreshContactsClicked)
|
||||
ContactSelectionListModels.registerMoreHeader(this)
|
||||
ContactSelectionListModels.registerEmpty(this)
|
||||
ContactSelectionListModels.registerFindByUsername(this, onClickCallbacks::onFindByUsernameClicked)
|
||||
ContactSelectionListModels.registerFindByPhoneNumber(this, onClickCallbacks::onFindByPhoneNumberClicked)
|
||||
}
|
||||
|
||||
class ArbitraryRepository : org.thoughtcrime.securesms.contacts.paged.ArbitraryRepository {
|
||||
|
||||
enum class ArbitraryRow(val code: String) {
|
||||
NEW_GROUP("new-group"),
|
||||
INVITE_TO_SIGNAL("invite-to-signal"),
|
||||
MORE_HEADING("more-heading"),
|
||||
REFRESH_CONTACTS("refresh-contacts"),
|
||||
FIND_CONTACTS("find-contacts"),
|
||||
FIND_CONTACTS_BANNER("find-contacts-banner"),
|
||||
FIND_BY_USERNAME("find-by-username"),
|
||||
FIND_BY_PHONE_NUMBER("find-by-phone-number");
|
||||
|
||||
companion object {
|
||||
fun fromCode(code: String) = entries.first { it.code == code }
|
||||
}
|
||||
}
|
||||
|
||||
override fun getSize(section: ContactSearchConfiguration.Section.Arbitrary, query: String?): Int {
|
||||
return section.types.size
|
||||
}
|
||||
@@ -179,15 +49,15 @@ class ContactSelectionListAdapter(
|
||||
}
|
||||
|
||||
override fun getMappingModel(arbitrary: ContactSearchData.Arbitrary): MappingModel<*> {
|
||||
return when (ArbitraryRow.fromCode(arbitrary.type)) {
|
||||
ArbitraryRow.NEW_GROUP -> NewGroupModel()
|
||||
ArbitraryRow.INVITE_TO_SIGNAL -> InviteToSignalModel()
|
||||
ArbitraryRow.MORE_HEADING -> MoreHeaderModel()
|
||||
ArbitraryRow.REFRESH_CONTACTS -> RefreshContactsModel()
|
||||
ArbitraryRow.FIND_CONTACTS -> FindContactsModel()
|
||||
ArbitraryRow.FIND_CONTACTS_BANNER -> FindContactsBannerModel()
|
||||
ArbitraryRow.FIND_BY_PHONE_NUMBER -> FindByPhoneNumberModel()
|
||||
ArbitraryRow.FIND_BY_USERNAME -> FindByUsernameModel()
|
||||
return when (ContactSelectionListModels.ArbitraryRow.fromCode(arbitrary.type)) {
|
||||
ContactSelectionListModels.ArbitraryRow.NEW_GROUP -> NewGroupModel()
|
||||
ContactSelectionListModels.ArbitraryRow.INVITE_TO_SIGNAL -> InviteToSignalModel()
|
||||
ContactSelectionListModels.ArbitraryRow.MORE_HEADING -> MoreHeaderModel()
|
||||
ContactSelectionListModels.ArbitraryRow.REFRESH_CONTACTS -> RefreshContactsModel()
|
||||
ContactSelectionListModels.ArbitraryRow.FIND_CONTACTS -> FindContactsModel()
|
||||
ContactSelectionListModels.ArbitraryRow.FIND_CONTACTS_BANNER -> FindContactsBannerModel()
|
||||
ContactSelectionListModels.ArbitraryRow.FIND_BY_PHONE_NUMBER -> FindByPhoneNumberModel()
|
||||
ContactSelectionListModels.ArbitraryRow.FIND_BY_USERNAME -> FindByUsernameModel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,10 +18,8 @@ package org.thoughtcrime.securesms;
|
||||
|
||||
|
||||
import android.Manifest;
|
||||
import org.signal.core.ui.logging.LoggingFragment;
|
||||
import android.annotation.SuppressLint;
|
||||
import android.content.Context;
|
||||
import android.graphics.Rect;
|
||||
import android.os.AsyncTask;
|
||||
import android.os.Bundle;
|
||||
import android.text.TextUtils;
|
||||
@@ -38,27 +36,24 @@ import androidx.appcompat.app.AlertDialog;
|
||||
import androidx.constraintlayout.widget.ConstraintLayout;
|
||||
import androidx.constraintlayout.widget.ConstraintSet;
|
||||
import androidx.lifecycle.ViewModelProvider;
|
||||
import androidx.recyclerview.widget.DefaultItemAnimator;
|
||||
import androidx.recyclerview.widget.LinearLayoutManager;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout;
|
||||
import androidx.transition.AutoTransition;
|
||||
import androidx.transition.TransitionManager;
|
||||
|
||||
import org.thoughtcrime.securesms.contacts.paged.ContactSearchView;
|
||||
import org.thoughtcrime.securesms.contacts.paged.ContactSearchViewModel;
|
||||
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
|
||||
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.signal.core.ui.logging.LoggingFragment;
|
||||
import org.signal.core.ui.permissions.Permissions;
|
||||
import org.signal.core.util.concurrent.LifecycleDisposable;
|
||||
import org.signal.core.util.concurrent.SimpleTask;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.calls.YouAreAlreadyInACallSnackbar;
|
||||
import org.thoughtcrime.securesms.components.RecyclerViewFastScroller;
|
||||
import org.thoughtcrime.securesms.contacts.ContactChipViewModel;
|
||||
import org.thoughtcrime.securesms.contacts.ContactSelectionDisplayMode;
|
||||
import org.thoughtcrime.securesms.contacts.HeaderAction;
|
||||
import org.thoughtcrime.securesms.contacts.LetterHeaderDecoration;
|
||||
import org.thoughtcrime.securesms.contacts.SelectedContact;
|
||||
import org.thoughtcrime.securesms.contacts.SelectedContacts;
|
||||
import org.thoughtcrime.securesms.contacts.paged.ChatType;
|
||||
@@ -71,18 +66,19 @@ import org.thoughtcrime.securesms.contacts.paged.ContactSearchPagedDataSourceRep
|
||||
import org.thoughtcrime.securesms.contacts.paged.ContactSearchRepository;
|
||||
import org.thoughtcrime.securesms.contacts.paged.ContactSearchSortOrder;
|
||||
import org.thoughtcrime.securesms.contacts.paged.ContactSearchState;
|
||||
import org.thoughtcrime.securesms.contacts.paged.ContactSearchView;
|
||||
import org.thoughtcrime.securesms.contacts.paged.ContactSearchViewModel;
|
||||
import org.thoughtcrime.securesms.contacts.selection.ContactSelectionArguments;
|
||||
import org.thoughtcrime.securesms.contacts.sync.ContactDiscovery;
|
||||
import org.thoughtcrime.securesms.database.RecipientTable;
|
||||
import org.thoughtcrime.securesms.groups.SelectionLimits;
|
||||
import org.thoughtcrime.securesms.groups.ui.GroupLimitDialog;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.signal.core.ui.permissions.Permissions;
|
||||
import org.thoughtcrime.securesms.profiles.manage.UsernameRepository;
|
||||
import org.thoughtcrime.securesms.search.SearchRepository;
|
||||
import org.thoughtcrime.securesms.profiles.manage.UsernameRepository.UsernameAciFetchResult;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.search.SearchRepository;
|
||||
import org.thoughtcrime.securesms.util.CommunicationActions;
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
import org.thoughtcrime.securesms.util.UsernameUtil;
|
||||
@@ -92,14 +88,13 @@ import org.thoughtcrime.securesms.util.adapter.mapping.MappingModelList;
|
||||
import org.thoughtcrime.securesms.util.views.SimpleProgressDialog;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import io.reactivex.rxjava3.disposables.Disposable;
|
||||
import kotlin.Unit;
|
||||
@@ -126,22 +121,17 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
private SwipeRefreshLayout swipeRefresh;
|
||||
private String cursorFilter;
|
||||
private ContactSearchView contactSearchView;
|
||||
private RecyclerViewFastScroller fastScroller;
|
||||
private RecyclerView chipRecycler;
|
||||
private OnSelectionLimitReachedListener onSelectionLimitReachedListener;
|
||||
private MappingAdapter contactChipAdapter;
|
||||
private ContactChipViewModel contactChipViewModel;
|
||||
private LifecycleDisposable lifecycleDisposable;
|
||||
private HeaderActionProvider headerActionProvider;
|
||||
private TextView headerActionView;
|
||||
private ContactSearchViewModel contactSearchViewModel;
|
||||
|
||||
@Nullable private RecyclerView innerRecyclerView;
|
||||
@Nullable private LinearLayoutManager innerLayoutManager;
|
||||
@Nullable private NewConversationCallback newConversationCallback;
|
||||
@Nullable private FindByCallback findByCallback;
|
||||
@Nullable private NewCallCallback newCallCallback;
|
||||
@Nullable private ScrollCallback scrollCallback;
|
||||
@Nullable private OnItemLongClickListener onItemLongClickListener;
|
||||
private SelectionLimits selectionLimit = SelectionLimits.NO_LIMITS;
|
||||
private Set<RecipientId> currentSelection;
|
||||
@@ -168,14 +158,6 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
setNewCallCallback((NewCallCallback) context);
|
||||
}
|
||||
|
||||
if (getParentFragment() instanceof ScrollCallback) {
|
||||
setScrollCallback((ScrollCallback) getParentFragment());
|
||||
}
|
||||
|
||||
if (context instanceof ScrollCallback) {
|
||||
setScrollCallback((ScrollCallback) context);
|
||||
}
|
||||
|
||||
if (getParentFragment() instanceof OnContactSelectedListener) {
|
||||
setOnContactSelectedListener((OnContactSelectedListener) getParentFragment());
|
||||
}
|
||||
@@ -221,10 +203,6 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
this.newCallCallback = callback;
|
||||
}
|
||||
|
||||
public void setScrollCallback(@Nullable ScrollCallback callback) {
|
||||
this.scrollCallback = callback;
|
||||
}
|
||||
|
||||
public void setOnContactSelectedListener(@Nullable OnContactSelectedListener listener) {
|
||||
this.onContactSelectedListener = listener;
|
||||
}
|
||||
@@ -259,10 +237,8 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
emptyText = view.findViewById(android.R.id.empty);
|
||||
contactSearchView = view.findViewById(R.id.recycler_view);
|
||||
swipeRefresh = view.findViewById(R.id.swipe_refresh);
|
||||
fastScroller = view.findViewById(R.id.fast_scroller);
|
||||
chipRecycler = view.findViewById(R.id.chipRecycler);
|
||||
constraintLayout = view.findViewById(R.id.container);
|
||||
headerActionView = view.findViewById(R.id.header_action);
|
||||
|
||||
contactChipViewModel = new ViewModelProvider(this).get(ContactChipViewModel.class);
|
||||
contactChipAdapter = new MappingAdapter();
|
||||
@@ -305,137 +281,11 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
new ContactSelectionListAdapter.ArbitraryRepository(),
|
||||
new SearchRepository(requireContext().getString(R.string.note_to_self)),
|
||||
new ContactSearchPagedDataSourceRepository(requireContext()),
|
||||
fixedContacts
|
||||
fixedContacts,
|
||||
false
|
||||
)
|
||||
).get(ContactSearchViewModel.class);
|
||||
|
||||
List<RecyclerView.OnScrollListener> scrollListeners = new ArrayList<>();
|
||||
|
||||
final HeaderAction headerAction;
|
||||
if (headerActionProvider != null) {
|
||||
headerAction = headerActionProvider.getHeaderAction();
|
||||
|
||||
headerActionView.setEnabled(true);
|
||||
headerActionView.setText(headerAction.getLabel());
|
||||
headerActionView.setCompoundDrawablesRelativeWithIntrinsicBounds(headerAction.getIcon(), 0, 0, 0);
|
||||
headerActionView.setOnClickListener(v -> headerAction.getAction().run());
|
||||
scrollListeners.add(new RecyclerView.OnScrollListener() {
|
||||
|
||||
private final Rect bounds = new Rect();
|
||||
|
||||
@Override
|
||||
public void onScrolled(@NonNull RecyclerView rv, int dx, int dy) {
|
||||
if (hideLetterHeaders() || innerLayoutManager == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
int firstPosition = innerLayoutManager.findFirstVisibleItemPosition();
|
||||
if (firstPosition == 0) {
|
||||
View firstChild = rv.getChildAt(0);
|
||||
rv.getDecoratedBoundsWithMargins(firstChild, bounds);
|
||||
headerActionView.setTranslationY(bounds.top);
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
headerActionView.setEnabled(false);
|
||||
}
|
||||
|
||||
scrollListeners.add(new RecyclerView.OnScrollListener() {
|
||||
@Override
|
||||
public void onScrollStateChanged(@NonNull RecyclerView rv, int newState) {
|
||||
if (newState == RecyclerView.SCROLL_STATE_DRAGGING && scrollCallback != null) {
|
||||
scrollCallback.onBeginScroll();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
float contentBottomPaddingDp = fragmentArgs.getRecyclerPadBottom() != -1
|
||||
? fragmentArgs.getRecyclerPadBottom() / getResources().getDisplayMetrics().density
|
||||
: 0f;
|
||||
|
||||
ContactSearchAdapter.AdapterFactory adapterFactory =
|
||||
(context, fc, displayOptions, callbacks, longClickCallbacks, storyContextMenuCallbacks, callButtonClickCallbacks) ->
|
||||
new ContactSelectionListAdapter(
|
||||
context,
|
||||
fc,
|
||||
displayOptions,
|
||||
new ContactSelectionListAdapter.OnContactSelectionClick() {
|
||||
@Override
|
||||
public void onDismissFindContactsBannerClicked() {
|
||||
SignalStore.uiHints().markDismissedContactsPermissionBanner();
|
||||
contactSearchViewModel.refresh();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFindContactsClicked() {
|
||||
requestContactPermissions();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRefreshContactsClicked() {
|
||||
if (onRefreshListener != null && !isRefreshing()) {
|
||||
setRefreshing(true);
|
||||
onRefreshListener.onRefresh();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onNewGroupClicked() {
|
||||
newConversationCallback.onNewGroup(false);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFindByPhoneNumberClicked() {
|
||||
findByCallback.onFindByPhoneNumber();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFindByUsernameClicked() {
|
||||
findByCallback.onFindByUsername();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onInviteToSignalClicked() {
|
||||
if (newConversationCallback != null) {
|
||||
newConversationCallback.onInvite();
|
||||
}
|
||||
|
||||
if (newCallCallback != null) {
|
||||
newCallCallback.onInvite();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onStoryClicked(@NonNull View view1, @NonNull ContactSearchData.Story story, boolean isSelected) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onKnownRecipientClicked(@NonNull View view1, @NonNull ContactSearchData.KnownRecipient knownRecipient, boolean isSelected) {
|
||||
listClickListener.onItemClick(knownRecipient.getContactSearchKey());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onExpandClicked(@NonNull ContactSearchData.Expand expand) {
|
||||
callbacks.onExpandClicked(expand);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onUnknownRecipientClicked(@NonNull View view, @NonNull ContactSearchData.UnknownRecipient unknownRecipient, boolean isSelected) {
|
||||
listClickListener.onItemClick(unknownRecipient.getContactSearchKey());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onChatTypeClicked(@NonNull View view, @NonNull ContactSearchData.ChatTypeRow chatTypeRow, boolean isSelected) {
|
||||
listClickListener.onItemClick(chatTypeRow.getContactSearchKey());
|
||||
}
|
||||
},
|
||||
(anchorView, data) -> listClickListener.onItemLongClick(anchorView, data.getContactSearchKey()),
|
||||
storyContextMenuCallbacks,
|
||||
new CallButtonClickCallbacks()
|
||||
);
|
||||
|
||||
contactSearchView.bind(
|
||||
contactSearchViewModel,
|
||||
getChildFragmentManager(),
|
||||
@@ -452,25 +302,83 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
onLoadFinished(size);
|
||||
}
|
||||
},
|
||||
Collections.singletonList(new LetterHeaderDecoration(requireContext(), this::hideLetterHeaders)),
|
||||
contentBottomPaddingDp,
|
||||
adapterFactory,
|
||||
scrollListeners,
|
||||
rv -> {
|
||||
innerRecyclerView = rv;
|
||||
innerLayoutManager = (LinearLayoutManager) rv.getLayoutManager();
|
||||
rv.setItemAnimator(new DefaultItemAnimator() {
|
||||
@Override
|
||||
public boolean canReuseUpdatedViewHolder(@NonNull RecyclerView.ViewHolder viewHolder) {
|
||||
return true;
|
||||
}
|
||||
ContactSelectionListModels.composeEntries(
|
||||
new ContactSelectionListModels.Callback() {
|
||||
@Override
|
||||
public void onNewGroupClicked() {
|
||||
newConversationCallback.onNewGroup(false);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onAnimationFinished(@NonNull RecyclerView.ViewHolder viewHolder) {
|
||||
contactSearchView.setAlpha(1f);
|
||||
@Override
|
||||
public void onInviteToSignalClicked() {
|
||||
if (newConversationCallback != null) {
|
||||
newConversationCallback.onInvite();
|
||||
}
|
||||
|
||||
if (newCallCallback != null) {
|
||||
newCallCallback.onInvite();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFindContactsClicked() {
|
||||
requestContactPermissions();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDismissFindContactsBannerClicked() {
|
||||
SignalStore.uiHints().markDismissedContactsPermissionBanner();
|
||||
contactSearchViewModel.refresh();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRefreshContactsClicked() {
|
||||
if (onRefreshListener != null && !isRefreshing()) {
|
||||
setRefreshing(true);
|
||||
onRefreshListener.onRefresh();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFindByUsernameClicked() {
|
||||
findByCallback.onFindByUsername();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFindByPhoneNumberClicked() {
|
||||
findByCallback.onFindByPhoneNumber();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
),
|
||||
new ContactSearchAdapter.ClickCallbacks() {
|
||||
@Override
|
||||
public void onStoryClicked(@NotNull View view, ContactSearchData.@NotNull Story story, boolean isSelected) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onKnownRecipientClicked(@NotNull View view, ContactSearchData.@NotNull KnownRecipient knownRecipient, boolean isSelected) {
|
||||
listClickListener.onItemClick(knownRecipient.getContactSearchKey());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onExpandClicked(ContactSearchData.@NotNull Expand expand) {
|
||||
contactSearchViewModel.expandSection(expand.getSectionKey());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onChatTypeClicked(@NotNull View view, ContactSearchData.@NotNull ChatTypeRow chatTypeRow, boolean isSelected) {
|
||||
listClickListener.onItemClick(chatTypeRow.getContactSearchKey());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onUnknownRecipientClicked(@NotNull View view, ContactSearchData.@NotNull UnknownRecipient unknownRecipient, boolean isSelected) {
|
||||
listClickListener.onItemClick(unknownRecipient.getContactSearchKey());
|
||||
}
|
||||
},
|
||||
(anchorView, data) -> listClickListener.onItemLongClick(anchorView, data.getContactSearchKey()),
|
||||
null,
|
||||
new CallButtonClickCallbacks()
|
||||
);
|
||||
|
||||
return view;
|
||||
@@ -595,32 +503,23 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
public void reset() {
|
||||
contactSearchViewModel.clearSelection();
|
||||
contactSearchViewModel.refresh();
|
||||
fastScroller.setVisibility(View.GONE);
|
||||
headerActionView.setVisibility(View.GONE);
|
||||
contactSearchViewModel.setFastScrollEnabled(false);
|
||||
}
|
||||
|
||||
private void onLoadFinished(int count) {
|
||||
if (resetPositionOnCommit && innerRecyclerView != null) {
|
||||
if (resetPositionOnCommit) {
|
||||
resetPositionOnCommit = false;
|
||||
innerRecyclerView.scrollToPosition(0);
|
||||
contactSearchViewModel.requestScrollPosition(0);
|
||||
}
|
||||
|
||||
swipeRefresh.setVisibility(View.VISIBLE);
|
||||
|
||||
emptyText.setText(R.string.contact_selection_group_activity__no_contacts);
|
||||
boolean useFastScroller = count > 20;
|
||||
if (useFastScroller && innerRecyclerView != null) {
|
||||
fastScroller.setVisibility(View.VISIBLE);
|
||||
fastScroller.setRecyclerView(innerRecyclerView);
|
||||
if (useFastScroller) {
|
||||
contactSearchViewModel.setFastScrollEnabled(true);
|
||||
} else {
|
||||
fastScroller.setRecyclerView(null);
|
||||
fastScroller.setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
if (headerActionView.isEnabled() && !hasQueryFilter()) {
|
||||
headerActionView.setVisibility(View.VISIBLE);
|
||||
} else {
|
||||
headerActionView.setVisibility(View.GONE);
|
||||
contactSearchViewModel.setFastScrollEnabled(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -790,8 +689,8 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
}
|
||||
|
||||
public boolean onItemLongClick(View anchorView, ContactSearchKey item) {
|
||||
if (onItemLongClickListener != null && innerRecyclerView != null) {
|
||||
return onItemLongClickListener.onLongClick(anchorView, item, innerRecyclerView);
|
||||
if (onItemLongClickListener != null) {
|
||||
return onItemLongClickListener.onLongClick(anchorView, item, isDisplayingContextMenu -> contactSearchViewModel.setDisplayingContextMenu(isDisplayingContextMenu));
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
@@ -933,19 +832,19 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
!SignalStore.uiHints().getDismissedContactsPermissionBanner() &&
|
||||
!hasQuery)
|
||||
{
|
||||
builder.arbitrary(ContactSelectionListAdapter.ArbitraryRepository.ArbitraryRow.FIND_CONTACTS_BANNER.getCode());
|
||||
builder.arbitrary(ContactSelectionListModels.ArbitraryRow.FIND_CONTACTS_BANNER.getCode());
|
||||
}
|
||||
|
||||
if (fragmentArgs.getEnableCreateNewGroup() && !hasQuery) {
|
||||
builder.arbitrary(ContactSelectionListAdapter.ArbitraryRepository.ArbitraryRow.NEW_GROUP.getCode());
|
||||
builder.arbitrary(ContactSelectionListModels.ArbitraryRow.NEW_GROUP.getCode());
|
||||
}
|
||||
|
||||
if (fragmentArgs.getEnableFindByUsername() && !hasQuery) {
|
||||
builder.arbitrary(ContactSelectionListAdapter.ArbitraryRepository.ArbitraryRow.FIND_BY_USERNAME.getCode());
|
||||
builder.arbitrary(ContactSelectionListModels.ArbitraryRow.FIND_BY_USERNAME.getCode());
|
||||
}
|
||||
|
||||
if (fragmentArgs.getEnableFindByPhoneNumber() && !hasQuery) {
|
||||
builder.arbitrary(ContactSelectionListAdapter.ArbitraryRepository.ArbitraryRow.FIND_BY_PHONE_NUMBER.getCode());
|
||||
builder.arbitrary(ContactSelectionListModels.ArbitraryRow.FIND_BY_PHONE_NUMBER.getCode());
|
||||
}
|
||||
|
||||
if (includeChatTypes && !hasQuery) {
|
||||
@@ -967,10 +866,12 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
}
|
||||
|
||||
boolean hideHeader = newCallCallback != null || (newConversationCallback != null && !hasQuery);
|
||||
HeaderAction sectionHeaderAction = (headerActionProvider != null && !hasQuery) ? headerActionProvider.getHeaderAction() : null;
|
||||
builder.addSection(new ContactSearchConfiguration.Section.Individuals(
|
||||
includeSelf ? new RecipientTable.IncludeSelfMode.IncludeWithRemap(getString(R.string.note_to_self)) : RecipientTable.IncludeSelfMode.Exclude.INSTANCE,
|
||||
transportType,
|
||||
!hideHeader,
|
||||
sectionHeaderAction,
|
||||
null,
|
||||
!hideLetterHeaders(),
|
||||
newConversationCallback != null ? ContactSearchSortOrder.RECENCY : ContactSearchSortOrder.NATURAL
|
||||
@@ -1017,13 +918,13 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
}
|
||||
|
||||
private void addMoreSection(@NonNull ContactSearchConfiguration.Builder builder) {
|
||||
builder.arbitrary(ContactSelectionListAdapter.ArbitraryRepository.ArbitraryRow.MORE_HEADING.getCode());
|
||||
builder.arbitrary(ContactSelectionListModels.ArbitraryRow.MORE_HEADING.getCode());
|
||||
if (hasContactsPermissions(requireContext())) {
|
||||
builder.arbitrary(ContactSelectionListAdapter.ArbitraryRepository.ArbitraryRow.REFRESH_CONTACTS.getCode());
|
||||
builder.arbitrary(ContactSelectionListModels.ArbitraryRow.REFRESH_CONTACTS.getCode());
|
||||
} else if (SignalStore.uiHints().getDismissedContactsPermissionBanner()) {
|
||||
builder.arbitrary(ContactSelectionListAdapter.ArbitraryRepository.ArbitraryRow.FIND_CONTACTS.getCode());
|
||||
builder.arbitrary(ContactSelectionListModels.ArbitraryRow.FIND_CONTACTS.getCode());
|
||||
}
|
||||
builder.arbitrary(ContactSelectionListAdapter.ArbitraryRepository.ArbitraryRow.INVITE_TO_SIGNAL.getCode());
|
||||
builder.arbitrary(ContactSelectionListModels.ArbitraryRow.INVITE_TO_SIGNAL.getCode());
|
||||
}
|
||||
|
||||
private static @Nullable ContactSearchConfiguration.TransportType resolveTransportType(boolean includePushContacts, boolean includeSmsContacts) {
|
||||
@@ -1113,15 +1014,11 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
void onInvite();
|
||||
}
|
||||
|
||||
public interface ScrollCallback {
|
||||
void onBeginScroll();
|
||||
}
|
||||
|
||||
public interface HeaderActionProvider {
|
||||
@NonNull HeaderAction getHeaderAction();
|
||||
}
|
||||
|
||||
public interface OnItemLongClickListener {
|
||||
boolean onLongClick(View anchorView, ContactSearchKey contactSearchKey, RecyclerView recyclerView);
|
||||
boolean onLongClick(View anchorView, ContactSearchKey contactSearchKey, Consumer<Boolean> setIsDisplayingContextMenu);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms
|
||||
|
||||
import android.view.View
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.TextView
|
||||
import com.google.android.material.button.MaterialButton
|
||||
import org.thoughtcrime.securesms.contacts.paged.ContactSearchModels.EmptyModel
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.compose.MappingEntryProvider
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.compose.MappingEntryProviderBuilder
|
||||
|
||||
/**
|
||||
* Holds the [MappingModel]s and [MappingViewHolder]s used by [ContactSelectionListAdapter] on top of
|
||||
* the base set in [org.thoughtcrime.securesms.contacts.paged.ContactSearchModels], along with helpers
|
||||
* for registering them on a [MappingAdapter] (RecyclerView) or building a [MappingEntryProvider]
|
||||
* (Compose).
|
||||
*/
|
||||
object ContactSelectionListModels {
|
||||
|
||||
fun registerNewGroup(mappingAdapter: MappingAdapter, onClick: () -> Unit) {
|
||||
mappingAdapter.registerFactory(
|
||||
NewGroupModel::class.java,
|
||||
LayoutFactory({ NewGroupViewHolder(it, onClick) }, R.layout.contact_selection_new_group_item)
|
||||
)
|
||||
}
|
||||
|
||||
fun registerInviteToSignal(mappingAdapter: MappingAdapter, onClick: () -> Unit) {
|
||||
mappingAdapter.registerFactory(
|
||||
InviteToSignalModel::class.java,
|
||||
LayoutFactory({ InviteToSignalViewHolder(it, onClick) }, R.layout.contact_selection_invite_action_item)
|
||||
)
|
||||
}
|
||||
|
||||
fun registerFindContacts(mappingAdapter: MappingAdapter, onClick: () -> Unit) {
|
||||
mappingAdapter.registerFactory(
|
||||
FindContactsModel::class.java,
|
||||
LayoutFactory({ FindContactsViewHolder(it, onClick) }, R.layout.contact_selection_find_contacts_item)
|
||||
)
|
||||
}
|
||||
|
||||
fun registerFindContactsBanner(mappingAdapter: MappingAdapter, onDismiss: () -> Unit, onClick: () -> Unit) {
|
||||
mappingAdapter.registerFactory(
|
||||
FindContactsBannerModel::class.java,
|
||||
LayoutFactory({ FindContactsBannerViewHolder(it, onDismiss, onClick) }, R.layout.contact_selection_find_contacts_banner_item)
|
||||
)
|
||||
}
|
||||
|
||||
fun registerRefreshContacts(mappingAdapter: MappingAdapter, onClick: () -> Unit) {
|
||||
mappingAdapter.registerFactory(
|
||||
RefreshContactsModel::class.java,
|
||||
LayoutFactory({ RefreshContactsViewHolder(it, onClick) }, R.layout.contact_selection_refresh_action_item)
|
||||
)
|
||||
}
|
||||
|
||||
fun registerMoreHeader(mappingAdapter: MappingAdapter) {
|
||||
mappingAdapter.registerFactory(
|
||||
MoreHeaderModel::class.java,
|
||||
LayoutFactory({ MoreHeaderViewHolder(it) }, R.layout.contact_search_section_header)
|
||||
)
|
||||
}
|
||||
|
||||
fun registerEmpty(mappingAdapter: MappingAdapter) {
|
||||
mappingAdapter.registerFactory(
|
||||
EmptyModel::class.java,
|
||||
LayoutFactory({ EmptyViewHolder(it) }, R.layout.contact_selection_empty_state)
|
||||
)
|
||||
}
|
||||
|
||||
fun registerFindByUsername(mappingAdapter: MappingAdapter, onClick: () -> Unit) {
|
||||
mappingAdapter.registerFactory(
|
||||
FindByUsernameModel::class.java,
|
||||
LayoutFactory({ FindByUsernameViewHolder(it, onClick) }, R.layout.contact_selection_find_by_username_item)
|
||||
)
|
||||
}
|
||||
|
||||
fun registerFindByPhoneNumber(mappingAdapter: MappingAdapter, onClick: () -> Unit) {
|
||||
mappingAdapter.registerFactory(
|
||||
FindByPhoneNumberModel::class.java,
|
||||
LayoutFactory({ FindByPhoneNumberViewHolder(it, onClick) }, R.layout.contact_selection_find_by_phone_number_item)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a [MappingEntryProvider] containing the same set of view holders registered by the
|
||||
* adapter-side `register*` methods, suitable for use with a Compose `MappingLazyColumn`.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun composeEntries(
|
||||
callback: Callback
|
||||
): MappingEntryProvider<Any> {
|
||||
return MappingEntryProviderBuilder<Any>().apply {
|
||||
viewHolder<NewGroupModel> { context ->
|
||||
LayoutFactory(
|
||||
{ view -> NewGroupViewHolder(view, callback::onNewGroupClicked) },
|
||||
R.layout.contact_selection_new_group_item
|
||||
).createViewHolder(FrameLayout(context))
|
||||
}
|
||||
viewHolder<InviteToSignalModel> { context ->
|
||||
LayoutFactory(
|
||||
{ view -> InviteToSignalViewHolder(view, callback::onInviteToSignalClicked) },
|
||||
R.layout.contact_selection_invite_action_item
|
||||
).createViewHolder(FrameLayout(context))
|
||||
}
|
||||
viewHolder<FindContactsModel> { context ->
|
||||
LayoutFactory(
|
||||
{ view -> FindContactsViewHolder(view, callback::onFindContactsClicked) },
|
||||
R.layout.contact_selection_find_contacts_item
|
||||
).createViewHolder(FrameLayout(context))
|
||||
}
|
||||
viewHolder<FindContactsBannerModel> { context ->
|
||||
LayoutFactory(
|
||||
{ view -> FindContactsBannerViewHolder(view, callback::onDismissFindContactsBannerClicked, callback::onFindContactsClicked) },
|
||||
R.layout.contact_selection_find_contacts_banner_item
|
||||
).createViewHolder(FrameLayout(context))
|
||||
}
|
||||
viewHolder<RefreshContactsModel> { context ->
|
||||
LayoutFactory(
|
||||
{ view -> RefreshContactsViewHolder(view, callback::onRefreshContactsClicked) },
|
||||
R.layout.contact_selection_refresh_action_item
|
||||
).createViewHolder(FrameLayout(context))
|
||||
}
|
||||
viewHolder<MoreHeaderModel> { context ->
|
||||
LayoutFactory(
|
||||
{ view -> MoreHeaderViewHolder(view) },
|
||||
R.layout.contact_search_section_header
|
||||
).createViewHolder(FrameLayout(context))
|
||||
}
|
||||
viewHolder<EmptyModel> { context ->
|
||||
LayoutFactory(
|
||||
{ view -> EmptyViewHolder(view) },
|
||||
R.layout.contact_selection_empty_state
|
||||
).createViewHolder(FrameLayout(context))
|
||||
}
|
||||
viewHolder<FindByUsernameModel> { context ->
|
||||
LayoutFactory(
|
||||
{ view -> FindByUsernameViewHolder(view, callback::onFindByUsernameClicked) },
|
||||
R.layout.contact_selection_find_by_username_item
|
||||
).createViewHolder(FrameLayout(context))
|
||||
}
|
||||
viewHolder<FindByPhoneNumberModel> { context ->
|
||||
LayoutFactory(
|
||||
{ view -> FindByPhoneNumberViewHolder(view, callback::onFindByPhoneNumberClicked) },
|
||||
R.layout.contact_selection_find_by_phone_number_item
|
||||
).createViewHolder(FrameLayout(context))
|
||||
}
|
||||
}.build()
|
||||
}
|
||||
|
||||
interface Callback {
|
||||
fun onNewGroupClicked()
|
||||
fun onInviteToSignalClicked()
|
||||
fun onFindContactsClicked()
|
||||
fun onDismissFindContactsBannerClicked()
|
||||
fun onRefreshContactsClicked()
|
||||
fun onFindByUsernameClicked()
|
||||
fun onFindByPhoneNumberClicked()
|
||||
}
|
||||
|
||||
enum class ArbitraryRow(val code: String) {
|
||||
NEW_GROUP("new-group"),
|
||||
INVITE_TO_SIGNAL("invite-to-signal"),
|
||||
MORE_HEADING("more-heading"),
|
||||
REFRESH_CONTACTS("refresh-contacts"),
|
||||
FIND_CONTACTS("find-contacts"),
|
||||
FIND_CONTACTS_BANNER("find-contacts-banner"),
|
||||
FIND_BY_USERNAME("find-by-username"),
|
||||
FIND_BY_PHONE_NUMBER("find-by-phone-number");
|
||||
|
||||
companion object {
|
||||
fun fromCode(code: String) = entries.first { it.code == code }
|
||||
}
|
||||
}
|
||||
|
||||
class NewGroupModel : MappingModel<NewGroupModel> {
|
||||
override fun areItemsTheSame(newItem: NewGroupModel): Boolean = true
|
||||
override fun areContentsTheSame(newItem: NewGroupModel): Boolean = true
|
||||
}
|
||||
|
||||
class InviteToSignalModel : MappingModel<InviteToSignalModel> {
|
||||
override fun areItemsTheSame(newItem: InviteToSignalModel): Boolean = true
|
||||
override fun areContentsTheSame(newItem: InviteToSignalModel): Boolean = true
|
||||
}
|
||||
|
||||
class RefreshContactsModel : MappingModel<RefreshContactsModel> {
|
||||
override fun areItemsTheSame(newItem: RefreshContactsModel): Boolean = true
|
||||
override fun areContentsTheSame(newItem: RefreshContactsModel): Boolean = true
|
||||
}
|
||||
|
||||
class FindContactsModel : MappingModel<FindContactsModel> {
|
||||
override fun areItemsTheSame(newItem: FindContactsModel): Boolean = true
|
||||
override fun areContentsTheSame(newItem: FindContactsModel): Boolean = true
|
||||
}
|
||||
|
||||
class FindContactsBannerModel : MappingModel<FindContactsBannerModel> {
|
||||
override fun areItemsTheSame(newItem: FindContactsBannerModel): Boolean = true
|
||||
override fun areContentsTheSame(newItem: FindContactsBannerModel): Boolean = true
|
||||
}
|
||||
|
||||
class FindByUsernameModel : MappingModel<FindByUsernameModel> {
|
||||
override fun areItemsTheSame(newItem: FindByUsernameModel): Boolean = true
|
||||
override fun areContentsTheSame(newItem: FindByUsernameModel): Boolean = true
|
||||
}
|
||||
|
||||
class FindByPhoneNumberModel : MappingModel<FindByPhoneNumberModel> {
|
||||
override fun areItemsTheSame(newItem: FindByPhoneNumberModel): Boolean = true
|
||||
override fun areContentsTheSame(newItem: FindByPhoneNumberModel): Boolean = true
|
||||
}
|
||||
|
||||
class MoreHeaderModel : MappingModel<MoreHeaderModel> {
|
||||
override fun areItemsTheSame(newItem: MoreHeaderModel): Boolean = true
|
||||
|
||||
override fun areContentsTheSame(newItem: MoreHeaderModel): Boolean = true
|
||||
}
|
||||
|
||||
private class InviteToSignalViewHolder(itemView: View, onClickListener: () -> Unit) : MappingViewHolder<InviteToSignalModel>(itemView) {
|
||||
init {
|
||||
itemView.setOnClickListener { onClickListener() }
|
||||
}
|
||||
|
||||
override fun bind(model: InviteToSignalModel) = Unit
|
||||
}
|
||||
|
||||
private class NewGroupViewHolder(itemView: View, onClickListener: () -> Unit) : MappingViewHolder<NewGroupModel>(itemView) {
|
||||
init {
|
||||
itemView.setOnClickListener { onClickListener() }
|
||||
}
|
||||
|
||||
override fun bind(model: NewGroupModel) = Unit
|
||||
}
|
||||
|
||||
private class RefreshContactsViewHolder(itemView: View, onClickListener: () -> Unit) : MappingViewHolder<RefreshContactsModel>(itemView) {
|
||||
init {
|
||||
itemView.setOnClickListener { onClickListener() }
|
||||
}
|
||||
|
||||
override fun bind(model: RefreshContactsModel) = Unit
|
||||
}
|
||||
|
||||
private class FindContactsViewHolder(itemView: View, onClickListener: () -> Unit) : MappingViewHolder<FindContactsModel>(itemView) {
|
||||
init {
|
||||
itemView.setOnClickListener { onClickListener() }
|
||||
}
|
||||
|
||||
override fun bind(model: FindContactsModel) = Unit
|
||||
}
|
||||
|
||||
private class FindContactsBannerViewHolder(itemView: View, onDismissListener: () -> Unit, onClickListener: () -> Unit) : MappingViewHolder<FindContactsBannerModel>(itemView) {
|
||||
init {
|
||||
itemView.findViewById<MaterialButton>(R.id.no_thanks_button).setOnClickListener { onDismissListener() }
|
||||
itemView.findViewById<MaterialButton>(R.id.allow_contacts_button).setOnClickListener { onClickListener() }
|
||||
}
|
||||
|
||||
override fun bind(model: FindContactsBannerModel) = Unit
|
||||
}
|
||||
|
||||
private class MoreHeaderViewHolder(itemView: View) : MappingViewHolder<MoreHeaderModel>(itemView) {
|
||||
|
||||
private val headerTextView: TextView = itemView.findViewById(R.id.section_header)
|
||||
|
||||
override fun bind(model: MoreHeaderModel) {
|
||||
headerTextView.setText(R.string.contact_selection_activity__more)
|
||||
}
|
||||
}
|
||||
|
||||
private class EmptyViewHolder(itemView: View) : MappingViewHolder<EmptyModel>(itemView) {
|
||||
|
||||
private val emptyText: TextView = itemView.findViewById(R.id.search_no_results)
|
||||
|
||||
override fun bind(model: EmptyModel) {
|
||||
emptyText.text = context.getString(R.string.SearchFragment_no_results, model.empty.query ?: "")
|
||||
}
|
||||
}
|
||||
|
||||
private class FindByPhoneNumberViewHolder(itemView: View, onClickListener: () -> Unit) : MappingViewHolder<FindByPhoneNumberModel>(itemView) {
|
||||
|
||||
init {
|
||||
itemView.setOnClickListener { onClickListener() }
|
||||
}
|
||||
|
||||
override fun bind(model: FindByPhoneNumberModel) = Unit
|
||||
}
|
||||
|
||||
private class FindByUsernameViewHolder(itemView: View, onClickListener: () -> Unit) : MappingViewHolder<FindByUsernameModel>(itemView) {
|
||||
|
||||
init {
|
||||
itemView.setOnClickListener { onClickListener() }
|
||||
}
|
||||
|
||||
override fun bind(model: FindByUsernameModel) = Unit
|
||||
}
|
||||
}
|
||||
@@ -87,6 +87,7 @@ import org.signal.core.ui.compose.Snackbars
|
||||
import org.signal.core.ui.compose.theme.SignalTheme
|
||||
import org.signal.core.ui.permissions.Permissions
|
||||
import org.signal.core.ui.rememberIsSplitPane
|
||||
import org.signal.core.util.AppForegroundObserver
|
||||
import org.signal.core.util.Util
|
||||
import org.signal.core.util.concurrent.LifecycleDisposable
|
||||
import org.signal.core.util.getParcelableCompat
|
||||
@@ -116,6 +117,7 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.GooglePay
|
||||
import org.thoughtcrime.securesms.components.snackbars.LocalSnackbarStateConsumerRegistry
|
||||
import org.thoughtcrime.securesms.components.snackbars.SnackbarHostKey
|
||||
import org.thoughtcrime.securesms.components.snackbars.SnackbarState
|
||||
import org.thoughtcrime.securesms.components.verificationrequested.VerificationCodeRequestedBottomSheet
|
||||
import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaController
|
||||
import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaControllerOwner
|
||||
import org.thoughtcrime.securesms.conversation.ConversationIntents
|
||||
@@ -178,7 +180,6 @@ import org.thoughtcrime.securesms.stories.Stories
|
||||
import org.thoughtcrime.securesms.stories.archive.StoryArchiveActivity
|
||||
import org.thoughtcrime.securesms.stories.landing.StoriesLandingFragment
|
||||
import org.thoughtcrime.securesms.stories.settings.StorySettingsActivity
|
||||
import org.thoughtcrime.securesms.util.AppForegroundObserver
|
||||
import org.thoughtcrime.securesms.util.AppStartup
|
||||
import org.thoughtcrime.securesms.util.CachedInflater
|
||||
import org.thoughtcrime.securesms.util.CommunicationActions
|
||||
@@ -195,6 +196,7 @@ import org.thoughtcrime.securesms.window.AppScaffoldNavigator
|
||||
import org.thoughtcrime.securesms.window.NavigationType
|
||||
import org.thoughtcrime.securesms.window.rememberThreePaneScaffoldNavigatorDelegate
|
||||
import org.whispersystems.signalservice.api.websocket.WebSocketConnectionState
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
import org.signal.core.ui.R as CoreUiR
|
||||
|
||||
class MainActivity :
|
||||
@@ -357,6 +359,25 @@ class MainActivity :
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
launch {
|
||||
repeatOnLifecycle(Lifecycle.State.RESUMED) {
|
||||
SignalStore
|
||||
.account
|
||||
.verificationCodeRequestedAtMsFlow
|
||||
.filter { it > 0L }
|
||||
.collect { requestedAt ->
|
||||
val notificationThreshold = requestedAt + 10.minutes.inWholeMilliseconds
|
||||
if (System.currentTimeMillis() < notificationThreshold) {
|
||||
VerificationCodeRequestedBottomSheet.show(supportFragmentManager, requestedAt)
|
||||
} else {
|
||||
Log.i(TAG, "Verification code requested but is older than 10 minutes, not showing sheet")
|
||||
}
|
||||
|
||||
SignalStore.account.verificationCodeRequestedAtMs = 0L
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
supportFragmentManager.setFragmentResultListener(
|
||||
@@ -1031,6 +1052,13 @@ class MainActivity :
|
||||
|
||||
private fun handleConversationIntent(intent: Intent) {
|
||||
if (ConversationIntents.isConversationIntent(intent)) {
|
||||
if (!isTrustedConversationIntent(intent)) {
|
||||
Log.w(TAG, "Received a conversation intent through an exported entry point. Ignoring its extras.")
|
||||
intent.action = null
|
||||
setIntent(intent)
|
||||
return
|
||||
}
|
||||
|
||||
mainNavigationViewModel.goTo(MainNavigationListLocation.CHATS)
|
||||
mainNavigationViewModel.goTo(MainNavigationDetailLocation.Conversation(ConversationIntents.readArgsFromBundle(intent.extras!!)))
|
||||
intent.action = null
|
||||
@@ -1038,6 +1066,14 @@ class MainActivity :
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* While MainActivity isn't exporting, we have launcher aliases that are, so we verify that someone isn't launching us through those befre
|
||||
* respecting various intent attributes.
|
||||
*/
|
||||
private fun isTrustedConversationIntent(intent: Intent): Boolean {
|
||||
return intent.component?.className == MainActivity::class.java.name
|
||||
}
|
||||
|
||||
private fun handleGroupLinkInIntent(intent: Intent) {
|
||||
intent.data?.let { data ->
|
||||
CommunicationActions.handlePotentialGroupLinkUrl(this, data.toString())
|
||||
|
||||
@@ -33,7 +33,7 @@ import org.thoughtcrime.securesms.registration.ui.RegistrationActivity;
|
||||
import org.thoughtcrime.securesms.util.Environment;
|
||||
import org.thoughtcrime.securesms.restore.RestoreActivity;
|
||||
import org.thoughtcrime.securesms.service.KeyCachingService;
|
||||
import org.thoughtcrime.securesms.util.AppForegroundObserver;
|
||||
import org.signal.core.util.AppForegroundObserver;
|
||||
import org.thoughtcrime.securesms.util.AppStartup;
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageInstaller
|
||||
import android.os.Build
|
||||
import org.signal.core.util.AppForegroundObserver
|
||||
import org.signal.core.util.PendingIntentFlags
|
||||
import org.signal.core.util.StreamUtil
|
||||
import org.signal.core.util.getDownloadManager
|
||||
@@ -18,7 +19,6 @@ import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.jobs.ApkUpdateJob
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.util.AppForegroundObserver
|
||||
import org.thoughtcrime.securesms.util.FileUtils
|
||||
import java.io.FileInputStream
|
||||
import java.io.IOException
|
||||
@@ -61,21 +61,36 @@ object ApkUpdateInstaller {
|
||||
return
|
||||
}
|
||||
|
||||
if (!isMatchingDigest(context, downloadId, digest)) {
|
||||
Log.w(TAG, "DownloadId matches, but digest does not! Bad download or inconsistent state. Failing and clearing state.")
|
||||
SignalStore.apkUpdate.clearDownloadAttributes()
|
||||
ApkUpdateNotifications.showInstallFailed(context, ApkUpdateNotifications.FailureReason.UNKNOWN)
|
||||
return
|
||||
}
|
||||
|
||||
if (!userInitiated && !shouldAutoUpdate()) {
|
||||
if (!isMatchingDigest(context, downloadId, digest)) {
|
||||
Log.w(TAG, "DownloadId matches, but digest does not! Bad download or inconsistent state. Failing and clearing state.")
|
||||
SignalStore.apkUpdate.clearDownloadAttributes()
|
||||
ApkUpdateNotifications.showInstallFailed(context, ApkUpdateNotifications.FailureReason.UNKNOWN)
|
||||
return
|
||||
}
|
||||
|
||||
Log.w(TAG, "Not user-initiated and not eligible for auto-update. Prompting. (API=${Build.VERSION.SDK_INT}, Foreground=${AppForegroundObserver.isForegrounded()}, AutoUpdate=${SignalStore.apkUpdate.autoUpdate})")
|
||||
ApkUpdateNotifications.showInstallPrompt(context, downloadId)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
installApk(context, downloadId, userInitiated)
|
||||
context
|
||||
.getDownloadManager()
|
||||
.openDownloadedFile(downloadId)
|
||||
.use { parcelFileDescriptor ->
|
||||
val stream = FileInputStream(parcelFileDescriptor.fileDescriptor)
|
||||
|
||||
if (!MessageDigest.isEqual(FileUtils.getFileDigest(stream), digest)) {
|
||||
Log.w(TAG, "DownloadId matches, but digest does not! Bad download or inconsistent state. Failing and clearing state.")
|
||||
SignalStore.apkUpdate.clearDownloadAttributes()
|
||||
ApkUpdateNotifications.showInstallFailed(context, ApkUpdateNotifications.FailureReason.UNKNOWN)
|
||||
return
|
||||
}
|
||||
|
||||
stream.channel.position(0)
|
||||
installApk(context, downloadId, stream, userInitiated)
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "Hit IOException when trying to install APK!", e)
|
||||
SignalStore.apkUpdate.clearDownloadAttributes()
|
||||
@@ -88,17 +103,13 @@ object ApkUpdateInstaller {
|
||||
}
|
||||
|
||||
@Throws(IOException::class, SecurityException::class)
|
||||
private fun installApk(context: Context, downloadId: Long, userInitiated: Boolean) {
|
||||
val apkInputStream: InputStream? = getDownloadedApkInputStream(context, downloadId)
|
||||
if (apkInputStream == null) {
|
||||
Log.w(TAG, "Could not open download APK input stream!")
|
||||
return
|
||||
}
|
||||
|
||||
private fun installApk(context: Context, downloadId: Long, apkInputStream: InputStream, userInitiated: Boolean) {
|
||||
Log.d(TAG, "Beginning APK install...")
|
||||
val packageInstaller: PackageInstaller = context.packageManager.packageInstaller
|
||||
|
||||
val sessionParams = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL).apply {
|
||||
// Reject the session if the APK's declared package name doesn't match ours.
|
||||
setAppPackageName(context.packageName)
|
||||
// At this point, we always want to set this if possible, since we've already prompted the user with our own notification when necessary.
|
||||
// This lets us skip the system-generated notification.
|
||||
if (Build.VERSION.SDK_INT >= 31) {
|
||||
@@ -133,15 +144,6 @@ object ApkUpdateInstaller {
|
||||
session.commit(installerPendingIntent.intentSender)
|
||||
}
|
||||
|
||||
private fun getDownloadedApkInputStream(context: Context, downloadId: Long): InputStream? {
|
||||
return try {
|
||||
FileInputStream(context.getDownloadManager().openDownloadedFile(downloadId).fileDescriptor)
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun isDownloadSuccessful(context: Context, downloadId: Long): Boolean {
|
||||
val query = DownloadManager.Query().setFilterById(downloadId)
|
||||
val cursor = context.getDownloadManager().query(query)
|
||||
|
||||
@@ -12,12 +12,12 @@ import android.content.Intent
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import org.signal.core.util.PendingIntentFlags
|
||||
import org.signal.core.util.ServiceUtil
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.MainActivity
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.notifications.NotificationChannels
|
||||
import org.thoughtcrime.securesms.notifications.NotificationIds
|
||||
import org.thoughtcrime.securesms.util.ServiceUtil
|
||||
|
||||
object ApkUpdateNotifications {
|
||||
|
||||
|
||||
@@ -10,11 +10,11 @@ import android.os.Parcelable
|
||||
import androidx.core.os.ParcelCompat
|
||||
import org.signal.blurhash.BlurHash
|
||||
import org.signal.core.models.media.TransformProperties
|
||||
import org.signal.core.util.ParcelUtil
|
||||
import org.signal.core.util.UuidUtil
|
||||
import org.thoughtcrime.securesms.audio.AudioHash
|
||||
import org.thoughtcrime.securesms.database.AttachmentTable
|
||||
import org.thoughtcrime.securesms.stickers.StickerLocator
|
||||
import org.thoughtcrime.securesms.util.ParcelUtil
|
||||
import java.util.UUID
|
||||
import kotlin.time.Duration.Companion.days
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
@@ -31,7 +31,7 @@ fun Attachment.toAttachmentPointer(context: Context): AttachmentPointer? {
|
||||
}
|
||||
|
||||
try {
|
||||
val remoteId = SignalServiceAttachmentRemoteId.from(attachment.remoteLocation!!)
|
||||
val remoteId = SignalServiceAttachmentRemoteId.from(attachment.remoteLocation!!, attachment.cdn.cdnNumber)
|
||||
|
||||
var attachmentWidth = attachment.width
|
||||
var attachmentHeight = attachment.height
|
||||
|
||||
@@ -41,12 +41,16 @@ enum class Cdn(private val value: Int) {
|
||||
}
|
||||
|
||||
fun fromCdnNumber(cdnNumber: Int): Cdn {
|
||||
return fromCdnNumberOrNull(cdnNumber) ?: throw UnsupportedOperationException("Invalid CDN number: $cdnNumber")
|
||||
}
|
||||
|
||||
fun fromCdnNumberOrNull(cdnNumber: Int): Cdn? {
|
||||
return when (cdnNumber) {
|
||||
-1 -> S3
|
||||
0 -> CDN_0
|
||||
2 -> CDN_2
|
||||
3 -> CDN_3
|
||||
else -> throw UnsupportedOperationException("Invalid CDN number: $cdnNumber")
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,11 +5,11 @@ import android.os.Parcel
|
||||
import androidx.core.os.ParcelCompat
|
||||
import org.signal.blurhash.BlurHash
|
||||
import org.signal.core.models.media.TransformProperties
|
||||
import org.signal.core.util.ParcelUtil
|
||||
import org.thoughtcrime.securesms.audio.AudioHash
|
||||
import org.thoughtcrime.securesms.database.AttachmentTable
|
||||
import org.thoughtcrime.securesms.mms.PartAuthority
|
||||
import org.thoughtcrime.securesms.stickers.StickerLocator
|
||||
import org.thoughtcrime.securesms.util.ParcelUtil
|
||||
import java.util.UUID
|
||||
|
||||
class DatabaseAttachment : Attachment {
|
||||
|
||||
@@ -5,6 +5,7 @@ import android.os.Parcel
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import org.signal.blurhash.BlurHash
|
||||
import org.signal.core.util.Base64
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.database.AttachmentTable
|
||||
import org.thoughtcrime.securesms.stickers.StickerLocator
|
||||
import org.whispersystems.signalservice.api.InvalidMessageStructureException
|
||||
@@ -76,6 +77,8 @@ class PointerAttachment : Attachment {
|
||||
override val thumbnailUri: Uri? = null
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(PointerAttachment::class)
|
||||
|
||||
@JvmStatic
|
||||
fun forPointers(pointers: Optional<List<SignalServiceAttachment>>): List<Attachment> {
|
||||
if (!pointers.isPresent) {
|
||||
@@ -102,6 +105,13 @@ class PointerAttachment : Attachment {
|
||||
return Optional.empty()
|
||||
}
|
||||
|
||||
val cdnNumber = pointer.get().asPointer().cdnNumber
|
||||
val cdn = Cdn.fromCdnNumberOrNull(cdnNumber)
|
||||
if (cdn == null) {
|
||||
Log.w(TAG, "Encountered an attachment pointer with an unsupported CDN number ($cdnNumber). Skipping attachment.")
|
||||
return Optional.empty()
|
||||
}
|
||||
|
||||
val encodedKey: String? = pointer.get().asPointer().key?.let { Base64.encodeWithPadding(it) }
|
||||
|
||||
return Optional.of(
|
||||
@@ -110,7 +120,7 @@ class PointerAttachment : Attachment {
|
||||
transferState = transferState,
|
||||
size = pointer.get().asPointer().size.orElse(0).toLong(),
|
||||
fileName = pointer.get().asPointer().fileName.orElse(null),
|
||||
cdn = Cdn.fromCdnNumber(pointer.get().asPointer().cdnNumber),
|
||||
cdn = cdn,
|
||||
location = pointer.get().asPointer().remoteId.toString(),
|
||||
key = encodedKey,
|
||||
iv = null,
|
||||
@@ -145,7 +155,13 @@ class PointerAttachment : Attachment {
|
||||
return Optional.empty()
|
||||
}
|
||||
|
||||
val cdn = Cdn.fromCdnNumber(thumbnail?.asPointer()?.cdnNumber ?: 0)
|
||||
val cdnNumber = thumbnail?.asPointer()?.cdnNumber ?: 0
|
||||
val cdn = Cdn.fromCdnNumberOrNull(cdnNumber)
|
||||
if (cdn == null) {
|
||||
Log.w(TAG, "Encountered a quote thumbnail with an unsupported CDN number ($cdnNumber). Skipping attachment.")
|
||||
return Optional.empty()
|
||||
}
|
||||
|
||||
if (cdn == Cdn.S3) {
|
||||
return Optional.empty()
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.AudioWaveFormData;
|
||||
import org.thoughtcrime.securesms.util.ParcelUtil;
|
||||
import org.signal.core.util.ParcelUtil;
|
||||
import org.signal.core.util.Base64;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
@@ -7,7 +7,7 @@ import android.media.AudioManager
|
||||
import android.media.AudioManager.OnAudioFocusChangeListener
|
||||
import android.os.Build
|
||||
import androidx.annotation.RequiresApi
|
||||
import org.thoughtcrime.securesms.util.ServiceUtil
|
||||
import org.signal.core.util.ServiceUtil
|
||||
|
||||
abstract class AudioRecorderFocusManager(val context: Context) {
|
||||
protected val audioManager: AudioManager = ServiceUtil.getAudioManager(context)
|
||||
|
||||
@@ -38,8 +38,8 @@ object AvatarPickerStorage {
|
||||
.getAllAvatars()
|
||||
.filterIsInstance<Avatar.Photo>()
|
||||
|
||||
val inDatabaseFileNames = photoAvatars.map { PartAuthority.getAvatarPickerFilename(it.uri) }
|
||||
val onDiskFileNames = avatarFiles.map { it.name }
|
||||
val inDatabaseFileNames = photoAvatars.mapTo(mutableSetOf()) { PartAuthority.getAvatarPickerFilename(it.uri) }
|
||||
val onDiskFileNames = avatarFiles.mapTo(mutableSetOf()) { it.name }
|
||||
|
||||
val inDatabaseButNotOnDisk = inDatabaseFileNames - onDiskFileNames
|
||||
val onDiskButNotInDatabase = onDiskFileNames - inDatabaseFileNames
|
||||
|
||||
@@ -6,14 +6,18 @@
|
||||
package org.thoughtcrime.securesms.backup
|
||||
|
||||
import androidx.annotation.WorkerThread
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.flow.shareIn
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.signal.core.util.bytes
|
||||
import org.signal.core.util.logging.Log
|
||||
@@ -46,6 +50,8 @@ object ArchiveUploadProgress {
|
||||
|
||||
private val TAG = Log.tag(ArchiveUploadProgress::class)
|
||||
|
||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
|
||||
private val _progress: MutableSharedFlow<Unit> = MutableSharedFlow(replay = 1)
|
||||
|
||||
private var uploadProgress: ArchiveUploadProgressState = SignalStore.backup.archiveUploadState ?: ArchiveUploadProgressState(
|
||||
@@ -61,7 +67,7 @@ object ArchiveUploadProgress {
|
||||
/**
|
||||
* Observe this to get updates on the current upload progress.
|
||||
*/
|
||||
val progress: Flow<ArchiveUploadProgressState> = _progress
|
||||
val progress: SharedFlow<ArchiveUploadProgressState> = _progress
|
||||
.throttleLatest(500.milliseconds) {
|
||||
uploadProgress.state == ArchiveUploadProgressState.State.None ||
|
||||
(uploadProgress.state == ArchiveUploadProgressState.State.UploadBackupFile && uploadProgress.backupFileUploadedBytes == 0L) ||
|
||||
@@ -114,6 +120,11 @@ object ArchiveUploadProgress {
|
||||
}
|
||||
.onStart { emit(uploadProgress) }
|
||||
.flowOn(Dispatchers.IO)
|
||||
.shareIn(scope, SharingStarted.Eagerly, replay = 1)
|
||||
|
||||
init {
|
||||
_progress.tryEmit(Unit)
|
||||
}
|
||||
|
||||
val inProgress
|
||||
get() = uploadProgress.state != ArchiveUploadProgressState.State.None && uploadProgress.state != ArchiveUploadProgressState.State.UserCanceled
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
package org.thoughtcrime.securesms.backup.v2
|
||||
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import org.signal.core.util.ThrottledDebouncer
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.util.ThrottledDebouncer
|
||||
import java.util.concurrent.ExecutionException
|
||||
import java.util.concurrent.Executors
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
@@ -31,6 +31,9 @@ import org.thoughtcrime.securesms.jobmanager.impl.BatteryNotLowConstraint
|
||||
import org.thoughtcrime.securesms.jobmanager.impl.DiskSpaceNotLowConstraint
|
||||
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint
|
||||
import org.thoughtcrime.securesms.jobmanager.impl.WifiConstraint
|
||||
import org.thoughtcrime.securesms.jobs.CheckRestoreMediaLeftJob
|
||||
import org.thoughtcrime.securesms.jobs.RestoreAttachmentJob
|
||||
import org.thoughtcrime.securesms.jobs.RestoreLocalAttachmentJob
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
@@ -157,6 +160,22 @@ object ArchiveRestoreProgress {
|
||||
update()
|
||||
}
|
||||
|
||||
/**
|
||||
* Self-heal hook for restores that appear active (banner showing, media still remaining) but have no jobs left actually working on them.
|
||||
*/
|
||||
fun checkForStalledRestore() {
|
||||
SignalExecutors.BOUNDED.execute {
|
||||
val stalled = SignalStore.backup.restoreState.isMediaRestoreOperation &&
|
||||
SignalDatabase.attachments.getRemainingRestorableAttachmentSize() > 0L &&
|
||||
AppDependencies.jobManager.areFactoriesEmpty(setOf(RestoreAttachmentJob.KEY, RestoreLocalAttachmentJob.KEY, CheckRestoreMediaLeftJob.KEY))
|
||||
|
||||
if (stalled) {
|
||||
Log.w(TAG, "Detected a stalled media restore with no active jobs. Enqueueing a check job to recover.")
|
||||
CheckRestoreMediaLeftJob.enqueueStalledRecoveryCheck()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun clearLocalRestoreDirectoryError() {
|
||||
SignalStore.backup.localRestoreDirectoryError = false
|
||||
update()
|
||||
|
||||
@@ -40,6 +40,7 @@ import org.signal.core.util.CursorUtil
|
||||
import org.signal.core.util.DiskUtil
|
||||
import org.signal.core.util.EventTimer
|
||||
import org.signal.core.util.PendingIntentFlags.cancelCurrent
|
||||
import org.signal.core.util.ServiceUtil
|
||||
import org.signal.core.util.Stopwatch
|
||||
import org.signal.core.util.bytes
|
||||
import org.signal.core.util.concurrent.LimitedWorker
|
||||
@@ -71,6 +72,7 @@ import org.signal.network.NetworkResult
|
||||
import org.signal.network.StatusCodeErrorAction
|
||||
import org.signal.network.api.SvrBApi
|
||||
import org.signal.network.exceptions.NonSuccessfulResponseCodeException
|
||||
import org.signal.network.rest.toNetworkResult
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.attachments.AttachmentId
|
||||
import org.thoughtcrime.securesms.attachments.Cdn
|
||||
@@ -146,7 +148,6 @@ import org.thoughtcrime.securesms.service.BackupMediaRestoreService
|
||||
import org.thoughtcrime.securesms.service.BackupProgressService
|
||||
import org.thoughtcrime.securesms.storage.StorageSyncHelper
|
||||
import org.thoughtcrime.securesms.util.RemoteConfig
|
||||
import org.thoughtcrime.securesms.util.ServiceUtil
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences
|
||||
import org.thoughtcrime.securesms.util.toMillis
|
||||
import org.whispersystems.signalservice.api.archive.ArchiveGetMediaItemsResponse
|
||||
@@ -1464,6 +1465,7 @@ object BackupRepository {
|
||||
}
|
||||
|
||||
SignalDatabase.remappedRecords.clearCache()
|
||||
SignalDatabase.remappedRecords.trimStaleMappings()
|
||||
AppDependencies.recipientCache.clear()
|
||||
AppDependencies.recipientCache.warmUp()
|
||||
SignalDatabase.threads.clearCache()
|
||||
@@ -1628,19 +1630,6 @@ object BackupRepository {
|
||||
}
|
||||
}
|
||||
|
||||
fun getResumableMessagesBackupUploadSpec(backupFileSize: Long): NetworkResult<ResumableMessagesBackupUploadSpec> {
|
||||
return initBackupAndFetchAuth()
|
||||
.then { credential ->
|
||||
SignalNetwork.archive.getMessageBackupUploadForm(SignalStore.account.requireAci(), credential.messageBackupAccess, backupFileSize)
|
||||
.also { Log.i(TAG, "UploadFormResult: ${it::class.simpleName}") }
|
||||
}
|
||||
.then { form ->
|
||||
SignalNetwork.archive.getBackupResumableUploadUrl(form)
|
||||
.also { Log.i(TAG, "ResumableUploadUrlResult: ${it::class.simpleName}") }
|
||||
.map { ResumableMessagesBackupUploadSpec(attachmentUploadForm = form, resumableUri = it) }
|
||||
}
|
||||
}
|
||||
|
||||
fun getMessageBackupUploadForm(backupFileSize: Long): NetworkResult<AttachmentUploadForm> {
|
||||
return initBackupAndFetchAuth()
|
||||
.then { credential ->
|
||||
|
||||
+1
-1
@@ -123,7 +123,7 @@ fun DatabaseAttachment.createArchiveAttachmentPointer(useArchiveCdn: Boolean): S
|
||||
throw InvalidAttachmentException("empty content id")
|
||||
}
|
||||
|
||||
SignalServiceAttachmentRemoteId.from(remoteLocation) to cdn.cdnNumber
|
||||
SignalServiceAttachmentRemoteId.from(remoteLocation, cdn.cdnNumber) to cdn.cdnNumber
|
||||
}
|
||||
|
||||
val key = Base64.decode(remoteKey)
|
||||
|
||||
+1
-1
@@ -61,7 +61,7 @@ class ChatArchiveExporter(private val cursor: Cursor, private val db: SignalData
|
||||
expirationTimerMs = cursor.requireLong(RecipientTable.MESSAGE_EXPIRATION_TIME).seconds.inWholeMilliseconds.takeIf { it > 0 },
|
||||
expireTimerVersion = cursor.requireInt(RecipientTable.MESSAGE_EXPIRATION_TIME_VERSION),
|
||||
muteUntilMs = cursor.requireLong(RecipientTable.MUTE_UNTIL).takeIf { it > 0 },
|
||||
markedUnread = ThreadTable.ReadStatus.deserialize(cursor.requireInt(ThreadTable.READ)) == ThreadTable.ReadStatus.FORCED_UNREAD,
|
||||
markedUnread = ThreadTable.ReadStatus.deserialize(cursor.requireInt(ThreadTable.READ)) == ThreadTable.ReadStatus.ForcedUnread,
|
||||
dontNotifyForMentionsIfMuted = RecipientTable.NotificationSetting.DO_NOT_NOTIFY.id == cursor.requireInt(RecipientTable.MENTION_SETTING),
|
||||
style = ChatStyleConverter.constructRemoteChatStyle(
|
||||
db = db,
|
||||
|
||||
+4
-4
@@ -45,6 +45,7 @@ import org.signal.core.models.ServiceId
|
||||
import org.signal.core.util.Base64
|
||||
import org.signal.core.util.EventTimer
|
||||
import org.signal.core.util.Hex
|
||||
import org.signal.core.util.JsonUtils
|
||||
import org.signal.core.util.ParallelEventTimer
|
||||
import org.signal.core.util.StringUtil
|
||||
import org.signal.core.util.UuidUtil
|
||||
@@ -105,7 +106,6 @@ import org.thoughtcrime.securesms.payments.FailureReason
|
||||
import org.thoughtcrime.securesms.payments.State
|
||||
import org.thoughtcrime.securesms.polls.PollRecord
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.util.JsonUtils
|
||||
import org.thoughtcrime.securesms.util.MediaUtil
|
||||
import org.thoughtcrime.securesms.util.mb
|
||||
import java.io.Closeable
|
||||
@@ -435,7 +435,7 @@ class ChatItemArchiveExporter(
|
||||
|
||||
else -> {
|
||||
val attachments = extraData.attachmentsById[record.id]
|
||||
val sticker = attachments?.firstOrNull { dbAttachment -> dbAttachment.isSticker }
|
||||
val sticker = attachments?.firstOrNull { dbAttachment -> dbAttachment.isSticker && !dbAttachment.quote }
|
||||
|
||||
if (sticker?.stickerLocator != null) {
|
||||
builder.stickerMessage = sticker.toRemoteStickerMessage(sentTimestamp = record.dateSent, reactions = extraData.reactionsById[id], exportState = exportState)
|
||||
@@ -852,8 +852,8 @@ private fun BackupMessageRecord.toRemotePaymentNotificationUpdate(db: SignalData
|
||||
PaymentNotification()
|
||||
} else {
|
||||
PaymentNotification(
|
||||
amountMob = payment.amount.serializeAmountString(),
|
||||
feeMob = payment.fee.serializeAmountString(),
|
||||
amountMob = payment.amount.requireMobileCoin().amountDecimalString,
|
||||
feeMob = payment.fee.requireMobileCoin().amountDecimalString,
|
||||
note = payment.note.takeUnless { it.isEmpty() },
|
||||
transactionDetails = payment.toRemoteTransactionDetails()
|
||||
)
|
||||
|
||||
+1
-1
@@ -46,7 +46,7 @@ object ChatArchiveImporter {
|
||||
ThreadTable.RECIPIENT_ID to recipientId.serialize(),
|
||||
ThreadTable.PINNED_ORDER to chat.pinnedOrder,
|
||||
ThreadTable.ARCHIVED to chat.archived.toInt(),
|
||||
ThreadTable.READ to if (chat.markedUnread) ThreadTable.ReadStatus.FORCED_UNREAD.serialize() else ThreadTable.ReadStatus.READ.serialize(),
|
||||
ThreadTable.READ to if (chat.markedUnread) ThreadTable.ReadStatus.ForcedUnread.serialize() else ThreadTable.ReadStatus.Read.serialize(),
|
||||
ThreadTable.ACTIVE to 1
|
||||
)
|
||||
.run()
|
||||
|
||||
+9
-21
@@ -27,6 +27,7 @@ import org.signal.archive.proto.ViewOnceMessage
|
||||
import org.signal.core.models.ServiceId
|
||||
import org.signal.core.util.Base64
|
||||
import org.signal.core.util.Hex
|
||||
import org.signal.core.util.JsonUtils
|
||||
import org.signal.core.util.SqlUtil
|
||||
import org.signal.core.util.UuidUtil
|
||||
import org.signal.core.util.asList
|
||||
@@ -59,7 +60,6 @@ import org.thoughtcrime.securesms.database.documents.NetworkFailureSet
|
||||
import org.thoughtcrime.securesms.database.model.GroupCallUpdateDetailsUtil
|
||||
import org.thoughtcrime.securesms.database.model.Mention
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.CryptoValue
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.GV2UpdateDescription
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.MessageExtras
|
||||
@@ -81,11 +81,10 @@ import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.stickers.StickerLocator
|
||||
import org.thoughtcrime.securesms.util.Environment
|
||||
import org.thoughtcrime.securesms.util.JsonUtils
|
||||
import org.thoughtcrime.securesms.util.MessageUtil
|
||||
import org.whispersystems.signalservice.api.payments.Money
|
||||
import org.whispersystems.signalservice.internal.push.DataMessage
|
||||
import java.math.BigInteger
|
||||
import java.math.BigDecimal
|
||||
import java.sql.SQLException
|
||||
import java.util.Optional
|
||||
import java.util.UUID
|
||||
@@ -1064,8 +1063,8 @@ class ChatItemArchiveImporter(
|
||||
|
||||
private fun ContentValues.addPaymentTombstoneNoMetadata(paymentNotification: PaymentNotification) {
|
||||
put(MessageTable.TYPE, getAsLong(MessageTable.TYPE) or MessageTypes.SPECIAL_TYPE_PAYMENTS_TOMBSTONE)
|
||||
val amount = tryParseCryptoValue(paymentNotification.amountMob)
|
||||
val fee = tryParseCryptoValue(paymentNotification.feeMob)
|
||||
val amount = paymentNotification.amountMob?.tryParseMoney()?.let { CryptoValueUtil.moneyToCryptoValue(it) }
|
||||
val fee = paymentNotification.feeMob?.tryParseMoney()?.let { CryptoValueUtil.moneyToCryptoValue(it) }
|
||||
put(
|
||||
MessageTable.MESSAGE_EXTRAS,
|
||||
MessageExtras(
|
||||
@@ -1119,26 +1118,15 @@ class ChatItemArchiveImporter(
|
||||
return null
|
||||
}
|
||||
|
||||
val amountCryptoValue = tryParseCryptoValue(this)
|
||||
return if (amountCryptoValue != null) {
|
||||
CryptoValueUtil.cryptoValueToMoney(amountCryptoValue)
|
||||
} else {
|
||||
return try {
|
||||
Money.mobileCoin(BigDecimal(this))
|
||||
} catch (e: NumberFormatException) {
|
||||
null
|
||||
} catch (e: ArithmeticException) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun tryParseCryptoValue(bigIntegerString: String?): CryptoValue? {
|
||||
if (bigIntegerString == null) {
|
||||
return null
|
||||
}
|
||||
val amount = try {
|
||||
BigInteger(bigIntegerString).toString()
|
||||
} catch (e: NumberFormatException) {
|
||||
return null
|
||||
}
|
||||
return CryptoValue(mobileCoinValue = CryptoValue.MobileCoinValue(picoMobileCoin = amount))
|
||||
}
|
||||
|
||||
private fun ContentValues.addQuote(quote: Quote) {
|
||||
this.put(MessageTable.QUOTE_ID, quote.targetSentTimestamp ?: MessageTable.QUOTE_TARGET_MISSING_ID)
|
||||
this.put(MessageTable.QUOTE_AUTHOR, importState.requireLocalRecipientId(quote.authorId).serialize())
|
||||
|
||||
+2
-3
@@ -156,8 +156,7 @@ object AccountDataArchiveProcessor {
|
||||
navigationBarSize = signalStore.settingsValues.useCompactNavigationBar.toRemoteNavigationBarSize()
|
||||
).takeUnless { Environment.IS_INSTRUMENTATION && SignalStore.backup.importedEmptyAndroidSettings },
|
||||
bioText = selfRecord.about ?: "",
|
||||
bioEmoji = selfRecord.aboutEmoji ?: "",
|
||||
keyTransparencyData = selfRecord.keyTransparencyData?.toByteString()
|
||||
bioEmoji = selfRecord.aboutEmoji ?: ""
|
||||
)
|
||||
)
|
||||
)
|
||||
@@ -251,7 +250,7 @@ object AccountDataArchiveProcessor {
|
||||
SignalStore.account.usernameLink = null
|
||||
}
|
||||
|
||||
SignalDatabase.recipients.setKeyTransparencyData(Recipient.self().aci.get(), accountData.keyTransparencyData?.toByteArray())
|
||||
SignalDatabase.recipients.clearSelfKeyTransparencyData()
|
||||
|
||||
SignalDatabase.runPostSuccessfulTransaction { ProfileUtil.handleSelfProfileKeyChange() }
|
||||
|
||||
|
||||
+46
-1
@@ -48,6 +48,7 @@ import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import org.signal.core.ui.compose.BottomSheets
|
||||
import org.signal.core.ui.compose.Buttons
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Dialogs
|
||||
@@ -59,11 +60,15 @@ import org.signal.core.ui.compose.horizontalGutters
|
||||
import org.signal.core.ui.compose.theme.SignalTheme
|
||||
import org.signal.core.util.Util
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.backup.v2.ui.warning.ClipStage
|
||||
import org.thoughtcrime.securesms.backup.v2.ui.warning.RecoveryKeyWarningSheetContent
|
||||
import org.thoughtcrime.securesms.backup.v2.ui.warning.RecoveryKeyWarningSheetEvent
|
||||
import org.thoughtcrime.securesms.components.TemporaryScreenshotSecurity
|
||||
import org.thoughtcrime.securesms.components.settings.app.backups.remote.BackupKeyCredentialManagerHandler
|
||||
import org.thoughtcrime.securesms.components.settings.app.backups.remote.BackupKeySaveState
|
||||
import org.thoughtcrime.securesms.fonts.MonoTypeface
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.util.CommunicationActions
|
||||
import org.thoughtcrime.securesms.util.storage.AndroidCredentialRepository
|
||||
import org.thoughtcrime.securesms.util.storage.CredentialManagerError
|
||||
import org.thoughtcrime.securesms.util.storage.CredentialManagerResult
|
||||
@@ -120,6 +125,7 @@ fun MessageBackupsKeyRecordScreen(
|
||||
* Screen displaying the backup key allowing the user to write it down
|
||||
* or copy it.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun MessageBackupsKeyRecordScreen(
|
||||
backupKey: String,
|
||||
@@ -145,6 +151,39 @@ fun MessageBackupsKeyRecordScreen(
|
||||
RecordScreenBackHandler()
|
||||
}
|
||||
|
||||
var displayRecoveryKeyCopyWarning by remember { mutableStateOf(false) }
|
||||
if (displayRecoveryKeyCopyWarning) {
|
||||
val context = LocalContext.current
|
||||
val url = stringResource(R.string.recovery_key_phishing_support_url)
|
||||
val events: (RecoveryKeyWarningSheetEvent) -> Unit = {
|
||||
when (it) {
|
||||
RecoveryKeyWarningSheetEvent.DoNotShareClick -> error("Not supported")
|
||||
RecoveryKeyWarningSheetEvent.GotItClick -> {
|
||||
onCopyToClipboardClick(backupKeyString)
|
||||
displayRecoveryKeyCopyWarning = false
|
||||
}
|
||||
RecoveryKeyWarningSheetEvent.LearnMoreClick -> {
|
||||
CommunicationActions.openBrowserLink(context, url)
|
||||
displayRecoveryKeyCopyWarning = false
|
||||
}
|
||||
|
||||
RecoveryKeyWarningSheetEvent.PasteKeyClick -> error("Not supported")
|
||||
RecoveryKeyWarningSheetEvent.ShareKeyClick -> error("Not supported")
|
||||
}
|
||||
}
|
||||
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = { displayRecoveryKeyCopyWarning = false },
|
||||
dragHandle = { BottomSheets.Handle() },
|
||||
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
) {
|
||||
RecoveryKeyWarningSheetContent(
|
||||
clipStage = ClipStage.COPY,
|
||||
events = events
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Scaffolds.Settings(
|
||||
title = "",
|
||||
navigationIcon = SignalIcons.ArrowStart.imageVector,
|
||||
@@ -227,7 +266,13 @@ fun MessageBackupsKeyRecordScreen(
|
||||
|
||||
item {
|
||||
Buttons.Small(
|
||||
onClick = { onCopyToClipboardClick(backupKeyString) }
|
||||
onClick = {
|
||||
if (mode is MessageBackupsKeyRecordMode.CreateNewKey) {
|
||||
displayRecoveryKeyCopyWarning = true
|
||||
} else {
|
||||
onCopyToClipboardClick(backupKeyString)
|
||||
}
|
||||
}
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.MessageBackupsKeyRecordScreen__copy_to_clipboard)
|
||||
|
||||
+1
-1
@@ -52,6 +52,7 @@ 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.util.ByteUnit
|
||||
import org.signal.core.util.billing.BillingResponseCode
|
||||
import org.signal.core.util.bytes
|
||||
import org.signal.core.util.money.FiatMoney
|
||||
@@ -60,7 +61,6 @@ 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 org.thoughtcrime.securesms.util.ByteUnit
|
||||
import java.math.BigDecimal
|
||||
import java.util.Currency
|
||||
import kotlin.time.Duration.Companion.days
|
||||
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.backup.v2.ui.warning
|
||||
|
||||
import org.signal.core.models.AccountEntropyPool
|
||||
|
||||
/**
|
||||
* Detects whether a block of text contains the user's own [AccountEntropyPool] (recovery key).
|
||||
*
|
||||
* We scan anywhere within the text and try to match the key in as many forms as possible:
|
||||
* upper/lowercase, with or without grouping spaces, and with or without the display characters
|
||||
* (e.g. '#'/'=') used to disambiguate 'O'/'0'. Matching against the user's actual key (rather than
|
||||
* just the AEP shape) avoids false positives on any 64-character in-alphabet string.
|
||||
*/
|
||||
object RecoveryKeyDetector {
|
||||
|
||||
/**
|
||||
* @param text the text to scan
|
||||
* @param recoveryKey the user's own recovery key, or null if they don't have one yet
|
||||
* @return true if [text] contains [recoveryKey] in any of its accepted forms. Always false when
|
||||
* [recoveryKey] is null, so callers can bypass the check entirely for users without a key.
|
||||
*/
|
||||
fun containsRecoveryKey(text: String?, recoveryKey: AccountEntropyPool?): Boolean {
|
||||
if (recoveryKey == null || text.isNullOrBlank() || text.length < AccountEntropyPool.LENGTH) {
|
||||
return false
|
||||
}
|
||||
|
||||
val normalized = AccountEntropyPool.removeIllegalCharacters(AccountEntropyPool.formatForStorage(text)).lowercase()
|
||||
|
||||
return normalized.contains(recoveryKey.value)
|
||||
}
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.backup.v2.ui.warning
|
||||
|
||||
import androidx.fragment.app.Fragment
|
||||
import org.thoughtcrime.securesms.components.ComposeText
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
|
||||
/**
|
||||
* Wires this [ComposeText] so that pasting the user's own recovery key first shows
|
||||
* [RecoveryKeyPasteWarningFragment], warning against sharing it. The paste only completes if the
|
||||
* user explicitly confirms via that warning.
|
||||
*
|
||||
* Must be called once the [host]'s view has been created, as it registers a fragment result
|
||||
* listener scoped to the host's view lifecycle.
|
||||
*
|
||||
* @param onWarningShown invoked just before the warning is shown. Hosts that auto-dismiss when the
|
||||
* keyboard hides (e.g. [org.thoughtcrime.securesms.components.KeyboardEntryDialogFragment]) can use
|
||||
* this to suppress that behavior while the warning is up.
|
||||
* @param onWarningDismissed invoked when the warning is dismissed by any path, after the paste (if
|
||||
* any) has been applied. Hosts can use this to restore the suppressed state and re-focus the input.
|
||||
*/
|
||||
fun ComposeText.guardAgainstRecoveryKeyPaste(
|
||||
host: Fragment,
|
||||
onWarningShown: () -> Unit = {},
|
||||
onWarningDismissed: () -> Unit = {}
|
||||
) {
|
||||
var pendingPaste: CharSequence? = null
|
||||
|
||||
host.childFragmentManager.setFragmentResultListener(RecoveryKeyPasteWarningFragment.REQUEST_KEY, host.viewLifecycleOwner) { _, bundle ->
|
||||
if (bundle.getBoolean(RecoveryKeyPasteWarningFragment.REQUEST_KEY)) {
|
||||
pendingPaste?.let { insertText(it) }
|
||||
}
|
||||
pendingPaste = null
|
||||
onWarningDismissed()
|
||||
}
|
||||
|
||||
setOnPasteListener { pasteText ->
|
||||
if (RecoveryKeyDetector.containsRecoveryKey(pasteText?.toString(), SignalStore.account.accountEntropyPoolOrNull)) {
|
||||
pendingPaste = pasteText
|
||||
onWarningShown()
|
||||
RecoveryKeyPasteWarningFragment().show(host.childFragmentManager, null)
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.backup.v2.ui.warning
|
||||
|
||||
import android.app.Dialog
|
||||
import android.content.DialogInterface
|
||||
import android.graphics.Color
|
||||
import android.graphics.drawable.ColorDrawable
|
||||
import android.os.Bundle
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.fragment.app.setFragmentResult
|
||||
import org.signal.core.ui.compose.BottomSheets
|
||||
import org.signal.core.ui.compose.ComposeFullScreenDialogFragment
|
||||
|
||||
/**
|
||||
* Displayed via the [org.thoughtcrime.securesms.components.settings.conversation.ConversationSettingsFragment] whenever the user
|
||||
* attempts to paste their recovery key into the input field.
|
||||
*
|
||||
* A result is always delivered to [REQUEST_KEY] when this fragment is dismissed, with the boolean
|
||||
* indicating whether the user chose to proceed with the paste. The host can rely on this firing for
|
||||
* every dismissal path (paste, decline, or cancel) to restore its own state.
|
||||
*/
|
||||
class RecoveryKeyPasteWarningFragment : ComposeFullScreenDialogFragment() {
|
||||
|
||||
companion object {
|
||||
const val REQUEST_KEY = "recovery_key_request"
|
||||
}
|
||||
|
||||
private var shouldPaste = false
|
||||
|
||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||
return super.onCreateDialog(savedInstanceState).apply {
|
||||
window?.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT))
|
||||
window?.setWindowAnimations(0)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDismiss(dialog: DialogInterface) {
|
||||
setFragmentResult(
|
||||
REQUEST_KEY,
|
||||
Bundle().apply {
|
||||
putBoolean(REQUEST_KEY, shouldPaste)
|
||||
}
|
||||
)
|
||||
|
||||
super.onDismiss(dialog)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
override fun DialogContent() {
|
||||
var isDisplayingFinalWarningDialog by remember { mutableStateOf(false) }
|
||||
|
||||
val eventHandler: (RecoveryKeyWarningSheetEvent) -> Unit = {
|
||||
when (it) {
|
||||
RecoveryKeyWarningSheetEvent.DoNotShareClick -> {
|
||||
dismissAllowingStateLoss()
|
||||
}
|
||||
|
||||
RecoveryKeyWarningSheetEvent.GotItClick -> error("Not supported for paste")
|
||||
RecoveryKeyWarningSheetEvent.LearnMoreClick -> error("Not supported for paste")
|
||||
RecoveryKeyWarningSheetEvent.PasteKeyClick -> {
|
||||
shouldPaste = true
|
||||
dismissAllowingStateLoss()
|
||||
}
|
||||
|
||||
RecoveryKeyWarningSheetEvent.ShareKeyClick -> {
|
||||
isDisplayingFinalWarningDialog = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isDisplayingFinalWarningDialog) {
|
||||
RecoveryKeyWarningDialog(
|
||||
events = eventHandler
|
||||
)
|
||||
} else {
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = { dismissAllowingStateLoss() },
|
||||
dragHandle = { BottomSheets.Handle() },
|
||||
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
) {
|
||||
RecoveryKeyWarningSheetContent(
|
||||
clipStage = ClipStage.PASTE,
|
||||
events = eventHandler
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+185
@@ -0,0 +1,185 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.backup.v2.ui.warning
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.signal.core.ui.compose.Buttons
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Dialogs
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.horizontalGutters
|
||||
import org.thoughtcrime.securesms.R
|
||||
|
||||
@Composable
|
||||
fun RecoveryKeyWarningSheetContent(
|
||||
clipStage: ClipStage,
|
||||
events: (RecoveryKeyWarningSheetEvent) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier.horizontalGutters(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Icon(
|
||||
imageVector = ImageVector.vectorResource(R.drawable.ic_warning_40),
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.padding(top = 20.dp, bottom = 16.dp)
|
||||
.size(80.dp)
|
||||
.background(color = MaterialTheme.colorScheme.errorContainer, shape = CircleShape)
|
||||
.padding(20.dp)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.RecoveryKeyWarningSheetContent__do_not_share_your_recovery_key),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(bottom = 12.dp)
|
||||
)
|
||||
|
||||
val signalWillNeverMessageYou = stringResource(R.string.RecoveryKeyWarningSheetContent__signal_will_never_message_you)
|
||||
val recoveryKeyWarningBody = stringResource(R.string.RecoveryKeyWarningSheetContent__for_your_recovery_key_never_respond)
|
||||
|
||||
Text(
|
||||
text = buildAnnotatedString {
|
||||
withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) {
|
||||
append(signalWillNeverMessageYou)
|
||||
}
|
||||
|
||||
append(" ")
|
||||
append(recoveryKeyWarningBody)
|
||||
},
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(bottom = 75.dp)
|
||||
)
|
||||
|
||||
when (clipStage) {
|
||||
ClipStage.COPY -> CopyActionButtons(events = events)
|
||||
ClipStage.PASTE -> PasteActionButtons(events = events)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.size(16.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CopyActionButtons(events: (RecoveryKeyWarningSheetEvent) -> Unit) {
|
||||
Buttons.LargeTonal(onClick = {
|
||||
events(RecoveryKeyWarningSheetEvent.GotItClick)
|
||||
}) {
|
||||
Text(text = stringResource(R.string.RecoveryKeyWarningSheetContent__got_it))
|
||||
}
|
||||
|
||||
TextButton(onClick = {
|
||||
events(RecoveryKeyWarningSheetEvent.LearnMoreClick)
|
||||
}) {
|
||||
Text(text = stringResource(R.string.RecoveryKeyWarningSheetContent__learn_more))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PasteActionButtons(events: (RecoveryKeyWarningSheetEvent) -> Unit) {
|
||||
Buttons.LargeTonal(onClick = {
|
||||
events(RecoveryKeyWarningSheetEvent.DoNotShareClick)
|
||||
}) {
|
||||
Text(text = stringResource(R.string.RecoveryKeyWarningSheetContent__do_not_share_key))
|
||||
}
|
||||
|
||||
TextButton(onClick = {
|
||||
events(RecoveryKeyWarningSheetEvent.ShareKeyClick)
|
||||
}) {
|
||||
Text(text = stringResource(R.string.RecoveryKeyWarningSheetContent__share_key))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RecoveryKeyWarningDialog(events: (RecoveryKeyWarningSheetEvent) -> Unit) {
|
||||
val bodyIntro = stringResource(R.string.RecoveryKeyWarningDialog__do_not_share_your_recovery_key_with_anyone)
|
||||
val bodyEmphasis = stringResource(R.string.RecoveryKeyWarningDialog__signal_will_never_message_you_for_your_recovery_key)
|
||||
val bodyOutro = stringResource(R.string.RecoveryKeyWarningDialog__never_respond_to_a_chat)
|
||||
|
||||
Dialogs.SimpleAlertDialog(
|
||||
title = AnnotatedString(stringResource(R.string.RecoveryKeyWarningDialog__do_not_share_recovery_key)),
|
||||
body = buildAnnotatedString {
|
||||
append(bodyIntro)
|
||||
append(" ")
|
||||
|
||||
withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) {
|
||||
append(bodyEmphasis)
|
||||
}
|
||||
|
||||
append(" ")
|
||||
append(bodyOutro)
|
||||
},
|
||||
confirm = AnnotatedString(stringResource(R.string.RecoveryKeyWarningDialog__paste_key)),
|
||||
confirmColor = MaterialTheme.colorScheme.error,
|
||||
dismiss = AnnotatedString(stringResource(R.string.RecoveryKeyWarningDialog__dont_share)),
|
||||
onConfirm = { events(RecoveryKeyWarningSheetEvent.PasteKeyClick) },
|
||||
onDeny = { events(RecoveryKeyWarningSheetEvent.DoNotShareClick) }
|
||||
)
|
||||
}
|
||||
|
||||
enum class ClipStage {
|
||||
COPY,
|
||||
PASTE
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun RecoveryKeyWarningSheetContentCopyPreview() {
|
||||
Previews.BottomSheetPreview {
|
||||
RecoveryKeyWarningSheetContent(
|
||||
clipStage = ClipStage.COPY,
|
||||
events = {},
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun RecoveryKeyWarningSheetContentPastePreview() {
|
||||
Previews.BottomSheetPreview {
|
||||
RecoveryKeyWarningSheetContent(
|
||||
clipStage = ClipStage.PASTE,
|
||||
events = {},
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun RecoveryKeyWarningDialogPreview() {
|
||||
Previews.Preview {
|
||||
RecoveryKeyWarningDialog(events = {})
|
||||
}
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.backup.v2.ui.warning
|
||||
|
||||
sealed interface RecoveryKeyWarningSheetEvent {
|
||||
data object DoNotShareClick : RecoveryKeyWarningSheetEvent
|
||||
data object ShareKeyClick : RecoveryKeyWarningSheetEvent
|
||||
data object PasteKeyClick : RecoveryKeyWarningSheetEvent
|
||||
data object GotItClick : RecoveryKeyWarningSheetEvent
|
||||
data object LearnMoreClick : RecoveryKeyWarningSheetEvent
|
||||
}
|
||||
+11
-2
@@ -11,6 +11,7 @@ import org.signal.archive.proto.FilePointer
|
||||
import org.signal.core.util.Base64
|
||||
import org.signal.core.util.UuidUtil
|
||||
import org.signal.core.util.isNotNullOrBlank
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.core.util.nullIfBlank
|
||||
import org.signal.core.util.orNull
|
||||
import org.signal.libsignal.usernames.BaseUsernameException
|
||||
@@ -32,6 +33,8 @@ import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentRemo
|
||||
import java.util.Optional
|
||||
import org.signal.archive.proto.AvatarColor as RemoteAvatarColor
|
||||
|
||||
private const val TAG = "ArchiveConverter"
|
||||
|
||||
/**
|
||||
* Converts a [FilePointer] to a local [Attachment] object for inserting into the database.
|
||||
*/
|
||||
@@ -58,10 +61,16 @@ fun FilePointer?.toLocalAttachment(
|
||||
|
||||
return when (attachmentType) {
|
||||
AttachmentType.ARCHIVE -> {
|
||||
val cdnNumber = locatorInfo.transitCdnNumber ?: Cdn.CDN_0.cdnNumber
|
||||
if (Cdn.fromCdnNumberOrNull(cdnNumber) == null) {
|
||||
Log.w(TAG, "Encountered an archived attachment with an unsupported CDN number ($cdnNumber). Skipping attachment.")
|
||||
return null
|
||||
}
|
||||
|
||||
ArchivedAttachment(
|
||||
contentType = contentType,
|
||||
size = locatorInfo.size.toLong(),
|
||||
cdn = locatorInfo.transitCdnNumber ?: Cdn.CDN_0.cdnNumber,
|
||||
cdn = cdnNumber,
|
||||
uploadTimestamp = locatorInfo.transitTierUploadTimestamp ?: 0,
|
||||
key = locatorInfo.key.toByteArray(),
|
||||
cdnKey = locatorInfo.transitCdnKey?.nullIfBlank(),
|
||||
@@ -87,7 +96,7 @@ fun FilePointer?.toLocalAttachment(
|
||||
AttachmentType.TRANSIT -> {
|
||||
val signalAttachmentPointer = SignalServiceAttachmentPointer(
|
||||
cdnNumber = locatorInfo.transitCdnNumber ?: Cdn.CDN_0.cdnNumber,
|
||||
remoteId = SignalServiceAttachmentRemoteId.from(locatorInfo.transitCdnKey!!),
|
||||
remoteId = SignalServiceAttachmentRemoteId.from(locatorInfo.transitCdnKey!!, locatorInfo.transitCdnNumber ?: Cdn.CDN_0.cdnNumber),
|
||||
contentType = contentType,
|
||||
key = locatorInfo.key.toByteArray(),
|
||||
size = Optional.ofNullable(locatorInfo.size),
|
||||
|
||||
+2
-4
@@ -13,6 +13,7 @@ import com.google.android.material.button.MaterialButton
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.subjects.PublishSubject
|
||||
import org.signal.core.util.Debouncer
|
||||
import org.signal.core.util.concurrent.LifecycleDisposable
|
||||
import org.signal.core.util.getParcelableCompat
|
||||
import org.signal.core.util.money.FiatMoney
|
||||
@@ -39,7 +40,6 @@ import org.thoughtcrime.securesms.keyboard.emoji.EmojiKeyboardPageFragment
|
||||
import org.thoughtcrime.securesms.keyboard.emoji.search.EmojiSearchFragment
|
||||
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
|
||||
import org.thoughtcrime.securesms.payments.currency.CurrencyUtil
|
||||
import org.thoughtcrime.securesms.util.Debouncer
|
||||
import org.thoughtcrime.securesms.util.activityViewModel
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
|
||||
import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
||||
@@ -306,7 +306,5 @@ class GiftFlowConfirmationFragment :
|
||||
|
||||
override fun navigateToDonationPending(inAppPayment: InAppPaymentTable.InAppPayment) = error("Not supported for gifts")
|
||||
|
||||
override fun exitCheckoutFlow() {
|
||||
requireActivity().finishAfterTransition()
|
||||
}
|
||||
override fun exitCheckoutFlow() = Unit
|
||||
}
|
||||
|
||||
+7
-8
@@ -4,7 +4,6 @@ import android.graphics.Color
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.appcompat.widget.Toolbar
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import androidx.navigation.fragment.findNavController
|
||||
@@ -19,29 +18,25 @@ import org.thoughtcrime.securesms.conversation.mutiselect.forward.SearchConfigur
|
||||
import org.thoughtcrime.securesms.database.RecipientTable
|
||||
import org.thoughtcrime.securesms.util.activityViewModel
|
||||
import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
||||
import org.thoughtcrime.securesms.util.viewModel
|
||||
import kotlin.getValue
|
||||
|
||||
/**
|
||||
* Allows the user to select a recipient to send a gift to.
|
||||
*/
|
||||
class GiftFlowRecipientSelectionFragment : Fragment(R.layout.gift_flow_recipient_selection_fragment), MultiselectForwardFragment.Callback, SearchConfigurationProvider {
|
||||
class GiftFlowRecipientSelectionFragment : Fragment(R.layout.multiselect_forward_activity), MultiselectForwardFragment.Callback, SearchConfigurationProvider {
|
||||
|
||||
private val viewModel: GiftFlowViewModel by activityViewModel {
|
||||
GiftFlowViewModel()
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
val toolbar = view.findViewById<Toolbar>(R.id.toolbar)
|
||||
toolbar.setNavigationOnClickListener { requireActivity().onBackPressed() }
|
||||
|
||||
if (savedInstanceState == null) {
|
||||
childFragmentManager.beginTransaction()
|
||||
.replace(
|
||||
R.id.multiselect_container,
|
||||
R.id.fragment_container,
|
||||
MultiselectForwardFragment.create(
|
||||
MultiselectForwardFragmentArgs(
|
||||
multiShareArgs = emptyList(),
|
||||
title = R.string.GiftFlowRecipientSelectionFragment__choose_recipient,
|
||||
forceDisableAddMessage = true,
|
||||
selectSingleRecipient = true
|
||||
)
|
||||
@@ -79,6 +74,10 @@ class GiftFlowRecipientSelectionFragment : Fragment(R.layout.gift_flow_recipient
|
||||
|
||||
override fun exitFlow() = Unit
|
||||
|
||||
override fun navigateUp() {
|
||||
requireActivity().onBackPressedDispatcher.onBackPressed()
|
||||
}
|
||||
|
||||
override fun onSearchInputFocused() = Unit
|
||||
|
||||
override fun setResult(bundle: Bundle) {
|
||||
|
||||
+2
@@ -10,6 +10,7 @@ import androidx.compose.runtime.Composable
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import org.thoughtcrime.securesms.backup.v2.ArchiveRestoreProgress
|
||||
import org.thoughtcrime.securesms.backup.v2.ArchiveRestoreProgressState
|
||||
import org.thoughtcrime.securesms.backup.v2.ArchiveRestoreProgressState.RestoreStatus
|
||||
@@ -25,6 +26,7 @@ class ArchiveRestoreStatusBanner(private val listener: RestoreProgressBannerList
|
||||
override val dataFlow: Flow<ArchiveRestoreProgressState> by lazy {
|
||||
ArchiveRestoreProgress
|
||||
.stateFlow
|
||||
.onStart { ArchiveRestoreProgress.checkForStalledRestore() }
|
||||
.filter {
|
||||
it.restoreStatus != RestoreStatus.NONE && (it.restoreState.isMediaRestoreOperation || it.restoreStatus == RestoreStatus.FINISHED)
|
||||
}
|
||||
|
||||
@@ -14,13 +14,13 @@ import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.util.ServiceUtil
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.banner.Banner
|
||||
import org.thoughtcrime.securesms.banner.ui.compose.Action
|
||||
import org.thoughtcrime.securesms.banner.ui.compose.DefaultBanner
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.util.PowerManagerCompat
|
||||
import org.thoughtcrime.securesms.util.ServiceUtil
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences
|
||||
|
||||
class DozeBanner(private val context: Context, private val onDismissListener: () -> Unit) : Banner<Unit>() {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user