mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-06-10 17:26:02 +01:00
Compare commits
180 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -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,13 +27,26 @@ 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 }}
|
||||
|
||||
- name: Build with Gradle
|
||||
env:
|
||||
SIGNAL_BUILD_CACHE_URL: ${{ secrets.SIGNAL_BUILD_CACHE_URL }}
|
||||
SIGNAL_BUILD_CACHE_USER: ${{ secrets.SIGNAL_BUILD_CACHE_USER }}
|
||||
SIGNAL_BUILD_CACHE_PASSWORD: ${{ secrets.SIGNAL_BUILD_CACHE_PASSWORD }}
|
||||
SIGNAL_BUILD_CACHE_PUSH: ${{ startsWith(github.ref, 'refs/heads/8.') }}
|
||||
run: ./gradlew qa
|
||||
|
||||
- name: Archive reports for failed build
|
||||
|
||||
@@ -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 = 1692
|
||||
val canonicalVersionName = "8.11.4"
|
||||
val canonicalVersionCode = 1703
|
||||
val canonicalVersionName = "8.14.3"
|
||||
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
+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(),
|
||||
|
||||
+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
|
||||
|
||||
|
||||
+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>
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
@@ -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 {
|
||||
@@ -498,6 +512,8 @@ public class ApplicationContext extends Application implements AppForegroundObse
|
||||
if (RemoteConfig.internalUser()) {
|
||||
Tracer.getInstance().setMaxBufferSize(35_000);
|
||||
}
|
||||
|
||||
SQLiteDatabase.setSlowWriteLoggingEnabled(RemoteConfig.slowDatabaseNotifications());
|
||||
}
|
||||
|
||||
private void initializePeriodicTasks() {
|
||||
|
||||
@@ -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();
|
||||
@@ -309,133 +285,6 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
)
|
||||
).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 +301,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 +502,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 +688,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 +831,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 +865,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 +917,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 +1013,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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+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()
|
||||
|
||||
+1
-1
@@ -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
|
||||
@@ -81,7 +82,6 @@ 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
|
||||
|
||||
+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
|
||||
|
||||
+1
-1
@@ -87,7 +87,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),
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+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>() {
|
||||
|
||||
@@ -78,7 +78,7 @@ public class BlockedUsersFragment extends Fragment {
|
||||
}
|
||||
|
||||
private void handleRecipientClicked(@NonNull Recipient recipient) {
|
||||
BlockUnblockDialog.showUnblockFor(requireContext(), getViewLifecycleOwner().getLifecycle(), recipient, () -> {
|
||||
BlockUnblockDialog.showUnblockFor(requireContext(), recipient, () -> {
|
||||
viewModel.unblock(recipient.getId());
|
||||
});
|
||||
}
|
||||
|
||||
@@ -9,14 +9,14 @@ import android.os.Bundle
|
||||
import android.os.ResultReceiver
|
||||
import androidx.lifecycle.DefaultLifecycleObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import org.signal.core.util.ThrottledDebouncer
|
||||
import org.signal.core.util.concurrent.SerialExecutor
|
||||
import org.signal.core.util.concurrent.SignalExecutors
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.service.webrtc.ActiveCallData
|
||||
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId
|
||||
import org.thoughtcrime.securesms.util.ThrottledDebouncer
|
||||
import org.thoughtcrime.securesms.util.concurrent.SerialExecutor
|
||||
import java.util.concurrent.Executor
|
||||
import kotlin.time.Duration.Companion.days
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
@@ -21,7 +21,7 @@ import androidx.core.widget.TextViewCompat;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.util.EditTextExtensionsKt;
|
||||
import org.thoughtcrime.securesms.util.ServiceUtil;
|
||||
import org.signal.core.util.ServiceUtil;
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
import org.thoughtcrime.securesms.util.ViewUtil;
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import org.signal.core.util.ContextUtil;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.components.emoji.SimpleEmojiTextView;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.util.DrawableUtil;
|
||||
import org.signal.core.util.DrawableUtil;
|
||||
import org.thoughtcrime.securesms.util.RemoteConfig;
|
||||
import org.thoughtcrime.securesms.util.SpanUtil;
|
||||
import org.thoughtcrime.securesms.util.ViewUtil;
|
||||
|
||||
@@ -8,7 +8,7 @@ import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.thoughtcrime.securesms.components.KeyboardAwareLinearLayout.OnKeyboardShownListener;
|
||||
import org.thoughtcrime.securesms.util.ServiceUtil;
|
||||
import org.signal.core.util.ServiceUtil;
|
||||
|
||||
public class InputAwareLayout extends KeyboardAwareLinearLayout implements OnKeyboardShownListener {
|
||||
private InputView current;
|
||||
|
||||
@@ -38,10 +38,12 @@ import com.bumptech.glide.RequestManager;
|
||||
import com.bumptech.glide.load.engine.DiskCacheStrategy;
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
|
||||
|
||||
import org.signal.core.ui.view.Stub;
|
||||
import org.signal.core.util.ThreadUtil;
|
||||
import org.signal.core.util.concurrent.ListenableFuture;
|
||||
import org.signal.core.util.concurrent.SettableFuture;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.signal.glide.decryptableuri.DecryptableUri;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.animation.AnimationCompleteListener;
|
||||
import org.thoughtcrime.securesms.animation.AnimationStartListener;
|
||||
@@ -63,7 +65,6 @@ import org.thoughtcrime.securesms.database.model.StickerRecord;
|
||||
import org.thoughtcrime.securesms.keyboard.KeyboardPage;
|
||||
import org.thoughtcrime.securesms.linkpreview.LinkPreview;
|
||||
import org.thoughtcrime.securesms.linkpreview.LinkPreviewRepository;
|
||||
import org.signal.glide.decryptableuri.DecryptableUri;
|
||||
import org.thoughtcrime.securesms.mms.QuoteModel;
|
||||
import org.thoughtcrime.securesms.mms.Slide;
|
||||
import org.thoughtcrime.securesms.mms.SlideDeck;
|
||||
@@ -91,32 +92,33 @@ public class InputPanel extends ConstraintLayout
|
||||
private static final long QUOTE_REVEAL_DURATION_MILLIS = 150;
|
||||
private static final int FADE_TIME = 150;
|
||||
|
||||
private RecyclerView stickerSuggestion;
|
||||
private QuoteView quoteView;
|
||||
private LinkPreviewView linkPreview;
|
||||
private EmojiToggle mediaKeyboard;
|
||||
private ComposeText composeText;
|
||||
private ImageButton quickCameraToggle;
|
||||
private ImageButton quickAudioToggle;
|
||||
private AnimatingToggle buttonToggle;
|
||||
private SendButton sendButton;
|
||||
private View recordingContainer;
|
||||
private View recordLockCancel;
|
||||
private View composeContainer;
|
||||
private View editMessageCancel;
|
||||
private ImageView editMessageThumbnail;
|
||||
private View editMessageTitle;
|
||||
private FrameLayout composeTextContainer;
|
||||
private RecyclerView stickerSuggestion;
|
||||
private Stub<QuoteView> quoteViewStub;
|
||||
private Stub<LinkPreviewView> linkPreviewStub;
|
||||
private EmojiToggle mediaKeyboard;
|
||||
private ComposeText composeText;
|
||||
private ImageButton quickCameraToggle;
|
||||
private ImageButton quickAudioToggle;
|
||||
private AnimatingToggle buttonToggle;
|
||||
private SendButton sendButton;
|
||||
private View recordingContainer;
|
||||
private View recordLockCancel;
|
||||
private View composeContainer;
|
||||
private View editMessageCancel;
|
||||
private ImageView editMessageThumbnail;
|
||||
private View editMessageTitle;
|
||||
private FrameLayout composeTextContainer;
|
||||
|
||||
private MicrophoneRecorderView microphoneRecorderView;
|
||||
private SlideToCancel slideToCancel;
|
||||
private RecordTime recordTime;
|
||||
private ValueAnimator quoteAnimator;
|
||||
private ValueAnimator editMessageAnimator;
|
||||
private VoiceNoteDraftView voiceNoteDraftView;
|
||||
private MicrophoneRecorderView microphoneRecorderView;
|
||||
private SlideToCancel slideToCancel;
|
||||
private RecordTime recordTime;
|
||||
private ValueAnimator quoteAnimator;
|
||||
private ValueAnimator editMessageAnimator;
|
||||
private Stub<VoiceNoteDraftView> voiceNoteDraftViewStub;
|
||||
|
||||
private @Nullable Listener listener;
|
||||
private boolean emojiVisible;
|
||||
private boolean wallpaperEnabled;
|
||||
|
||||
private boolean hideForMessageRequestState;
|
||||
private boolean hideForGroupState;
|
||||
@@ -127,6 +129,12 @@ public class InputPanel extends ConstraintLayout
|
||||
private ConversationStickerSuggestionAdapter stickerSuggestionAdapter;
|
||||
private MessageRecord messageToEdit;
|
||||
|
||||
private final Observer<VoiceNotePlaybackState> playbackStateObserverProxy = state -> {
|
||||
if (voiceNoteDraftViewStub.resolved()) {
|
||||
voiceNoteDraftViewStub.get().getPlaybackStateObserver().onChanged(state);
|
||||
}
|
||||
};
|
||||
|
||||
public InputPanel(Context context) {
|
||||
super(context);
|
||||
}
|
||||
@@ -143,12 +151,10 @@ public class InputPanel extends ConstraintLayout
|
||||
public void onFinishInflate() {
|
||||
super.onFinishInflate();
|
||||
|
||||
View quoteDismiss = findViewById(R.id.quote_dismiss_stub);
|
||||
|
||||
this.composeContainer = findViewById(R.id.compose_bubble);
|
||||
this.stickerSuggestion = findViewById(R.id.input_panel_sticker_suggestion);
|
||||
this.quoteView = findViewById(R.id.quote_view);
|
||||
this.linkPreview = findViewById(R.id.link_preview);
|
||||
this.quoteViewStub = new Stub<>(findViewById(R.id.quote_view));
|
||||
this.linkPreviewStub = new Stub<>(findViewById(R.id.link_preview));
|
||||
this.mediaKeyboard = findViewById(R.id.emoji_toggle);
|
||||
this.composeText = findViewById(R.id.embedded_text_editor);
|
||||
this.composeTextContainer = findViewById(R.id.embedded_text_editor_container);
|
||||
@@ -158,7 +164,7 @@ public class InputPanel extends ConstraintLayout
|
||||
this.sendButton = findViewById(R.id.send_button);
|
||||
this.recordingContainer = findViewById(R.id.recording_container);
|
||||
this.recordLockCancel = findViewById(R.id.record_cancel);
|
||||
this.voiceNoteDraftView = findViewById(R.id.voice_note_draft_view);
|
||||
this.voiceNoteDraftViewStub = new Stub<>(findViewById(R.id.voice_note_draft_view_stub));
|
||||
this.slideToCancel = new SlideToCancel(findViewById(R.id.slide_to_cancel));
|
||||
this.microphoneRecorderView = findViewById(R.id.recorder_view);
|
||||
this.microphoneRecorderView.setHandler(this);
|
||||
@@ -175,14 +181,6 @@ public class InputPanel extends ConstraintLayout
|
||||
mediaKeyboard.setVisibility(View.VISIBLE);
|
||||
emojiVisible = true;
|
||||
|
||||
quoteDismiss.setOnClickListener(v -> clearQuote());
|
||||
|
||||
linkPreview.setCloseClickedListener(() -> {
|
||||
if (listener != null) {
|
||||
listener.onLinkPreviewCanceled();
|
||||
}
|
||||
});
|
||||
|
||||
stickerSuggestionAdapter = new ConversationStickerSuggestionAdapter(Glide.with(this), this);
|
||||
|
||||
stickerSuggestion.setLayoutManager(new LinearLayoutManager(getContext(), LinearLayoutManager.HORIZONTAL, false));
|
||||
@@ -197,7 +195,6 @@ public class InputPanel extends ConstraintLayout
|
||||
this.listener = listener;
|
||||
|
||||
mediaKeyboard.setOnClickListener(v -> listener.onEmojiToggle());
|
||||
voiceNoteDraftView.setListener(listener);
|
||||
|
||||
if (Camera.getNumberOfCameras() > 0) {
|
||||
quickCameraToggle.setOnClickListener(v -> listener.onQuickCameraToggleClicked());
|
||||
@@ -214,34 +211,35 @@ public class InputPanel extends ConstraintLayout
|
||||
@NonNull SlideDeck attachments,
|
||||
@NonNull QuoteModel.Type quoteType)
|
||||
{
|
||||
this.quoteView.setQuote(requestManager, id, author, body, false, attachments, null, quoteType, true, null);
|
||||
QuoteView quoteView = requireQuoteView();
|
||||
|
||||
quoteView.setQuote(requestManager, id, author, body, false, attachments, null, quoteType, true, null);
|
||||
if (listener != null) {
|
||||
this.quoteView.setOnClickListener(v -> listener.onQuoteClicked(id, author.getId()));
|
||||
quoteView.setOnClickListener(v -> listener.onQuoteClicked(id, author.getId()));
|
||||
}
|
||||
|
||||
int originalHeight = this.quoteView.getVisibility() == VISIBLE ? this.quoteView.getMeasuredHeight()
|
||||
: 0;
|
||||
int originalHeight = quoteView.getVisibility() == VISIBLE ? quoteView.getMeasuredHeight() : 0;
|
||||
|
||||
this.quoteView.setVisibility(VISIBLE);
|
||||
quoteView.setVisibility(VISIBLE);
|
||||
|
||||
int maxWidth = composeContainer.getWidth();
|
||||
if (quoteView.getLayoutParams() instanceof MarginLayoutParams) {
|
||||
MarginLayoutParams layoutParams = (MarginLayoutParams) quoteView.getLayoutParams();
|
||||
maxWidth -= layoutParams.leftMargin + layoutParams.rightMargin;
|
||||
}
|
||||
this.quoteView.measure(MeasureSpec.makeMeasureSpec(maxWidth, MeasureSpec.AT_MOST), 0);
|
||||
quoteView.measure(MeasureSpec.makeMeasureSpec(maxWidth, MeasureSpec.AT_MOST), 0);
|
||||
|
||||
if (quoteAnimator != null) {
|
||||
quoteAnimator.cancel();
|
||||
}
|
||||
|
||||
quoteAnimator = createHeightAnimator(quoteView, originalHeight, this.quoteView.getMeasuredHeight(), null);
|
||||
quoteAnimator = createHeightAnimator(quoteView, originalHeight, quoteView.getMeasuredHeight(), null);
|
||||
|
||||
quoteAnimator.start();
|
||||
|
||||
if (this.linkPreview.getVisibility() == View.VISIBLE) {
|
||||
if (linkPreviewStub.getVisibility() == View.VISIBLE) {
|
||||
int cornerRadius = readDimen(R.dimen.message_corner_collapse_radius);
|
||||
this.linkPreview.setCorners(cornerRadius, cornerRadius);
|
||||
linkPreviewStub.get().setCorners(cornerRadius, cornerRadius);
|
||||
}
|
||||
|
||||
if (listener != null) {
|
||||
@@ -250,6 +248,12 @@ public class InputPanel extends ConstraintLayout
|
||||
}
|
||||
|
||||
public void clearQuote() {
|
||||
if (!quoteViewStub.resolved()) {
|
||||
return;
|
||||
}
|
||||
|
||||
QuoteView quoteView = quoteViewStub.get();
|
||||
|
||||
if (quoteAnimator != null) {
|
||||
quoteAnimator.cancel();
|
||||
}
|
||||
@@ -259,9 +263,9 @@ public class InputPanel extends ConstraintLayout
|
||||
public void onAnimationEnd(Animator animation) {
|
||||
quoteView.dismiss();
|
||||
|
||||
if (linkPreview.getVisibility() == View.VISIBLE) {
|
||||
if (linkPreviewStub.getVisibility() == View.VISIBLE) {
|
||||
int cornerRadius = readDimen(R.dimen.message_corner_radius);
|
||||
linkPreview.setCorners(cornerRadius, cornerRadius);
|
||||
linkPreviewStub.get().setCorners(cornerRadius, cornerRadius);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -273,6 +277,20 @@ public class InputPanel extends ConstraintLayout
|
||||
}
|
||||
}
|
||||
|
||||
private @NonNull QuoteView requireQuoteView() {
|
||||
boolean wasResolved = quoteViewStub.resolved();
|
||||
QuoteView quoteView = quoteViewStub.get();
|
||||
if (!wasResolved) {
|
||||
quoteView.setWallpaperEnabled(wallpaperEnabled);
|
||||
View quoteDismiss = quoteView.findViewById(R.id.quote_dismiss_stub);
|
||||
if (quoteDismiss != null) {
|
||||
quoteDismiss.setOnClickListener(v -> clearQuote());
|
||||
}
|
||||
}
|
||||
|
||||
return quoteView;
|
||||
}
|
||||
|
||||
private static ValueAnimator createHeightAnimator(@NonNull View view,
|
||||
int originalHeight,
|
||||
int finalHeight,
|
||||
@@ -294,11 +312,12 @@ public class InputPanel extends ConstraintLayout
|
||||
return animator;
|
||||
}
|
||||
|
||||
public boolean hasSaveableContent() {
|
||||
return getQuote().isPresent() || voiceNoteDraftView.getDraft() != null;
|
||||
}
|
||||
|
||||
public Optional<QuoteModel> getQuote() {
|
||||
if (!quoteViewStub.resolved()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
QuoteView quoteView = quoteViewStub.get();
|
||||
if (quoteView.getQuoteId() > 0 && quoteView.getVisibility() == View.VISIBLE) {
|
||||
return Optional.of(new QuoteModel(quoteView.getQuoteId(),
|
||||
quoteView.getAuthor().getId(),
|
||||
@@ -314,41 +333,53 @@ public class InputPanel extends ConstraintLayout
|
||||
}
|
||||
|
||||
public boolean hasLinkPreview() {
|
||||
return linkPreview.getVisibility() == View.VISIBLE;
|
||||
return linkPreviewStub.getVisibility() == View.VISIBLE;
|
||||
}
|
||||
|
||||
public void setLinkPreviewLoading() {
|
||||
this.linkPreview.setVisibility(View.VISIBLE);
|
||||
this.linkPreview.setLoading();
|
||||
LinkPreviewView linkPreview = requireLinkPreview();
|
||||
linkPreview.setVisibility(View.VISIBLE);
|
||||
linkPreview.setLoading();
|
||||
}
|
||||
|
||||
public void setLinkPreviewNoPreview(@Nullable LinkPreviewRepository.Error customError) {
|
||||
this.linkPreview.setVisibility(View.VISIBLE);
|
||||
this.linkPreview.setNoPreview(customError);
|
||||
LinkPreviewView linkPreview = requireLinkPreview();
|
||||
linkPreview.setVisibility(View.VISIBLE);
|
||||
linkPreview.setNoPreview(customError);
|
||||
}
|
||||
|
||||
public void setLinkPreview(@NonNull RequestManager requestManager, @NonNull Optional<LinkPreview> preview) {
|
||||
if (preview.isPresent()) {
|
||||
this.linkPreview.setVisibility(View.VISIBLE);
|
||||
this.linkPreview.setLinkPreview(requestManager, preview.get(), true);
|
||||
LinkPreviewView linkPreview = requireLinkPreview();
|
||||
linkPreview.setVisibility(View.VISIBLE);
|
||||
linkPreview.setLinkPreview(requestManager, preview.get(), true);
|
||||
} else {
|
||||
this.linkPreview.setVisibility(View.GONE);
|
||||
linkPreviewStub.setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
int cornerRadius = quoteView.getVisibility() == VISIBLE ? readDimen(R.dimen.message_corner_collapse_radius)
|
||||
: readDimen(R.dimen.message_corner_radius);
|
||||
if (linkPreviewStub.resolved()) {
|
||||
int cornerRadius = quoteViewStub.getVisibility() == VISIBLE ? readDimen(R.dimen.message_corner_collapse_radius) : readDimen(R.dimen.message_corner_radius);
|
||||
linkPreviewStub.get().setCorners(cornerRadius, cornerRadius);
|
||||
}
|
||||
}
|
||||
|
||||
this.linkPreview.setCorners(cornerRadius, cornerRadius);
|
||||
private @NonNull LinkPreviewView requireLinkPreview() {
|
||||
boolean wasResolved = linkPreviewStub.resolved();
|
||||
LinkPreviewView view = linkPreviewStub.get();
|
||||
|
||||
if (!wasResolved) {
|
||||
view.setCloseClickedListener(() -> {
|
||||
if (listener != null) listener.onLinkPreviewCanceled();
|
||||
});
|
||||
}
|
||||
|
||||
return view;
|
||||
}
|
||||
|
||||
public void clickOnComposeInput() {
|
||||
composeText.performClick();
|
||||
}
|
||||
|
||||
public void setMediaKeyboard(@NonNull MediaKeyboard mediaKeyboard) {
|
||||
this.mediaKeyboard.attach(mediaKeyboard);
|
||||
}
|
||||
|
||||
public void setStickerSuggestions(@NonNull List<StickerRecord> stickers) {
|
||||
stickerSuggestion.setVisibility(stickers.isEmpty() ? View.GONE : View.VISIBLE);
|
||||
stickerSuggestionAdapter.setStickers(stickers);
|
||||
@@ -403,7 +434,10 @@ public class InputPanel extends ConstraintLayout
|
||||
quickCameraToggle.setColorFilter(iconTint);
|
||||
composeText.setTextColor(textColor);
|
||||
composeText.setHintTextColor(textHintColor);
|
||||
quoteView.setWallpaperEnabled(enabled);
|
||||
wallpaperEnabled = enabled;
|
||||
if (quoteViewStub.resolved()) {
|
||||
quoteViewStub.get().setWallpaperEnabled(enabled);
|
||||
}
|
||||
}
|
||||
|
||||
public void enterEditModeIfPossible(@NonNull RequestManager requestManager, @NonNull ConversationMessage conversationMessageToEdit, boolean fromDraft, boolean clearQuote) {
|
||||
@@ -493,7 +527,9 @@ public class InputPanel extends ConstraintLayout
|
||||
if (messageToEdit != null) {
|
||||
composeText.setText("");
|
||||
messageToEdit = null;
|
||||
quoteView.setMessageType(QuoteView.MessageType.PREVIEW);
|
||||
if (quoteViewStub.resolved()) {
|
||||
quoteViewStub.get().setMessageType(QuoteView.MessageType.PREVIEW);
|
||||
}
|
||||
clearQuote();
|
||||
}
|
||||
updateEditModeUi();
|
||||
@@ -647,7 +683,7 @@ public class InputPanel extends ConstraintLayout
|
||||
}
|
||||
|
||||
public @NonNull Observer<VoiceNotePlaybackState> getPlaybackStateObserver() {
|
||||
return voiceNoteDraftView.getPlaybackStateObserver();
|
||||
return playbackStateObserverProxy;
|
||||
}
|
||||
|
||||
public void setEnabled(boolean enabled) {
|
||||
@@ -666,7 +702,7 @@ public class InputPanel extends ConstraintLayout
|
||||
future.addListener(new AssertedSuccessListener<Void>() {
|
||||
@Override
|
||||
public void onSuccess(Void result) {
|
||||
if (voiceNoteDraftView.getDraft() == null) {
|
||||
if (!voiceNoteDraftViewStub.resolved() || voiceNoteDraftViewStub.get().getDraft() == null) {
|
||||
fadeInNormalComposeViews();
|
||||
}
|
||||
}
|
||||
@@ -680,10 +716,6 @@ public class InputPanel extends ConstraintLayout
|
||||
mediaKeyboard.setToMedia();
|
||||
}
|
||||
|
||||
public void setToIme() {
|
||||
mediaKeyboard.setToIme();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onKeyEvent(KeyEvent keyEvent) {
|
||||
composeText.dispatchKeyEvent(keyEvent);
|
||||
@@ -715,20 +747,35 @@ public class InputPanel extends ConstraintLayout
|
||||
|
||||
public void setVoiceNoteDraft(@Nullable DraftTable.Draft voiceNoteDraft) {
|
||||
if (voiceNoteDraft != null) {
|
||||
VoiceNoteDraftView voiceNoteDraftView = requireVoiceNoteDraft();
|
||||
voiceNoteDraftView.setDraft(voiceNoteDraft);
|
||||
voiceNoteDraftView.setVisibility(VISIBLE);
|
||||
hideNormalComposeViews();
|
||||
fadeIn(buttonToggle);
|
||||
buttonToggle.displayQuick(sendButton);
|
||||
} else {
|
||||
voiceNoteDraftView.clearDraft();
|
||||
ViewUtil.fadeOut(voiceNoteDraftView, FADE_TIME);
|
||||
if (voiceNoteDraftViewStub.resolved()) {
|
||||
VoiceNoteDraftView voiceNoteDraftView = voiceNoteDraftViewStub.get();
|
||||
voiceNoteDraftView.clearDraft();
|
||||
ViewUtil.fadeOut(voiceNoteDraftView, FADE_TIME);
|
||||
}
|
||||
fadeInNormalComposeViews();
|
||||
}
|
||||
}
|
||||
|
||||
public @Nullable DraftTable.Draft getVoiceNoteDraft() {
|
||||
return voiceNoteDraftView.getDraft();
|
||||
if (!voiceNoteDraftViewStub.resolved()) return null;
|
||||
return voiceNoteDraftViewStub.get().getDraft();
|
||||
}
|
||||
|
||||
private @NonNull VoiceNoteDraftView requireVoiceNoteDraft() {
|
||||
boolean wasResolved = voiceNoteDraftViewStub.resolved();
|
||||
VoiceNoteDraftView voiceNoteDraftView = voiceNoteDraftViewStub.get();
|
||||
if (!wasResolved) {
|
||||
voiceNoteDraftView.setListener(listener);
|
||||
}
|
||||
|
||||
return voiceNoteDraftView;
|
||||
}
|
||||
|
||||
private void hideNormalComposeViews() {
|
||||
|
||||
+1
-1
@@ -30,7 +30,7 @@ import androidx.appcompat.widget.LinearLayoutCompat;
|
||||
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.util.ServiceUtil;
|
||||
import org.signal.core.util.ServiceUtil;
|
||||
import org.signal.core.util.Util;
|
||||
import org.thoughtcrime.securesms.util.ViewUtil;
|
||||
|
||||
|
||||
@@ -4,8 +4,6 @@ import android.content.Context;
|
||||
import android.content.res.TypedArray;
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.Color;
|
||||
import android.os.Bundle;
|
||||
import android.os.Parcelable;
|
||||
import android.util.AttributeSet;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
@@ -43,9 +41,6 @@ import okhttp3.HttpUrl;
|
||||
*/
|
||||
public class LinkPreviewView extends FrameLayout {
|
||||
|
||||
private static final String STATE_ROOT = "linkPreviewView.state.root";
|
||||
private static final String STATE_STATE = "linkPreviewView.state.state";
|
||||
|
||||
private static final int TYPE_CONVERSATION = 0;
|
||||
private static final int TYPE_COMPOSE = 1;
|
||||
|
||||
@@ -114,30 +109,6 @@ public class LinkPreviewView extends FrameLayout {
|
||||
setWillNotDraw(false);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected @NonNull Parcelable onSaveInstanceState() {
|
||||
Parcelable root = super.onSaveInstanceState();
|
||||
Bundle bundle = new Bundle();
|
||||
|
||||
bundle.putParcelable(STATE_ROOT, root);
|
||||
bundle.putParcelable(STATE_STATE, thumbnailState);
|
||||
|
||||
return bundle;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onRestoreInstanceState(Parcelable state) {
|
||||
if (state instanceof Bundle) {
|
||||
Parcelable root = ((Bundle) state).getParcelable(STATE_ROOT);
|
||||
thumbnailState = ((Bundle) state).getParcelable(STATE_STATE);
|
||||
|
||||
thumbnailState.applyState(thumbnail);
|
||||
super.onRestoreInstanceState(root);
|
||||
} else {
|
||||
super.onRestoreInstanceState(state);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void dispatchDraw(Canvas canvas) {
|
||||
super.dispatchDraw(canvas);
|
||||
@@ -251,7 +222,7 @@ public class LinkPreviewView extends FrameLayout {
|
||||
thumbnailState.applyState(thumbnail);
|
||||
} else {
|
||||
cornerMask.setRadii(topStart, topEnd, 0, 0);
|
||||
thumbnailState.copy(
|
||||
thumbnailState = thumbnailState.copy(
|
||||
topStart,
|
||||
defaultRadius,
|
||||
defaultRadius,
|
||||
|
||||
@@ -434,6 +434,7 @@ public class QuoteView extends ConstraintLayout implements RecipientForeverObser
|
||||
|
||||
if (TextUtils.isEmpty(quoteTargetContentType)) {
|
||||
thumbnailView.setVisibility(GONE);
|
||||
attachmentVideoOVerlayStub.setVisibility(GONE);
|
||||
attachmentNameViewStub.setVisibility(GONE);
|
||||
|
||||
if (dismissStub.resolved()) {
|
||||
|
||||
@@ -12,8 +12,11 @@ import androidx.appcompat.app.AlertDialog
|
||||
import androidx.constraintlayout.widget.ConstraintLayout
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import com.google.android.material.progressindicator.CircularProgressIndicator
|
||||
import org.signal.core.util.dp
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.util.ViewUtil
|
||||
import org.thoughtcrime.securesms.util.padding
|
||||
import org.thoughtcrime.securesms.util.visible
|
||||
|
||||
/**
|
||||
* Wraps a normal progress dialog for showing blocking in-progress UI.
|
||||
@@ -81,9 +84,15 @@ class SignalProgressDialog private constructor(
|
||||
val progressView: CircularProgressIndicator = customView.findViewById(R.id.progress_dialog_progressbar)
|
||||
|
||||
titleView.text = title
|
||||
titleView.visible = title != null
|
||||
messageView.text = message
|
||||
messageView.visible = message != null
|
||||
progressView.isIndeterminate = indeterminate
|
||||
|
||||
if (title == null && message == null) {
|
||||
progressView.padding(top = 32.dp, bottom = 32.dp)
|
||||
}
|
||||
|
||||
builder.setView(customView)
|
||||
val dialog = builder.show()
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiProvider.EmojiDrawable;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.util.EditTextExtensionsKt;
|
||||
import org.thoughtcrime.securesms.util.ServiceUtil;
|
||||
import org.signal.core.util.ServiceUtil;
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
import org.signal.core.util.Util;
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiPageViewGridAdapter.EmojiHeader;
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiPageViewGridAdapter.EmojiNoResultsModel;
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiPageViewGridAdapter.VariationSelectorListener;
|
||||
import org.thoughtcrime.securesms.util.DrawableUtil;
|
||||
import org.signal.core.util.DrawableUtil;
|
||||
import org.thoughtcrime.securesms.util.ViewUtil;
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel;
|
||||
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@ import com.fasterxml.jackson.databind.type.TypeFactory;
|
||||
import org.signal.core.util.concurrent.SignalExecutors;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.util.JsonUtils;
|
||||
import org.signal.core.util.JsonUtils;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
|
||||
+1
-1
@@ -6,9 +6,9 @@ import android.text.Spanned
|
||||
import android.text.TextUtils
|
||||
import android.util.AttributeSet
|
||||
import androidx.appcompat.widget.AppCompatTextView
|
||||
import org.signal.core.util.ThrottledDebouncer
|
||||
import org.thoughtcrime.securesms.components.spoiler.SpoilerRendererDelegate
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.util.ThrottledDebouncer
|
||||
import java.util.Optional
|
||||
|
||||
open class SimpleEmojiTextView @JvmOverloads constructor(
|
||||
|
||||
@@ -13,7 +13,7 @@ import com.google.android.gms.maps.model.LatLng;
|
||||
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.maps.AddressData;
|
||||
import org.thoughtcrime.securesms.util.JsonUtils;
|
||||
import org.signal.core.util.JsonUtils;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ import androidx.core.graphics.drawable.DrawableCompat;
|
||||
import org.signal.core.util.ContextUtil;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.components.spoiler.SpoilerAnnotation;
|
||||
import org.thoughtcrime.securesms.util.DrawableUtil;
|
||||
import org.signal.core.util.DrawableUtil;
|
||||
import org.signal.core.util.Util;
|
||||
import org.thoughtcrime.securesms.util.ViewUtil;
|
||||
|
||||
|
||||
+4
@@ -86,6 +86,7 @@ class AppSettingsActivity : DSLSettingsActivity(), GooglePayComponent {
|
||||
is AppSettingsRoute.BackupsRoute.Backups -> AppSettingsFragmentDirections.actionDirectToBackupsSettingsFragment().setLaunchCheckoutFlow(appSettingsRoute.launchCheckoutFlow)
|
||||
AppSettingsRoute.Invite -> AppSettingsFragmentDirections.actionDirectToInviteFragment()
|
||||
AppSettingsRoute.DataAndStorageRoute.DataAndStorage -> AppSettingsFragmentDirections.actionDirectToStoragePreferenceFragment()
|
||||
AppSettingsRoute.AccountRoute.Account -> AppSettingsFragmentDirections.actionDirectToAccountSettingsFragment()
|
||||
else -> error("Unsupported start location: ${appSettingsRoute?.javaClass?.name}")
|
||||
}
|
||||
}
|
||||
@@ -177,6 +178,9 @@ class AppSettingsActivity : DSLSettingsActivity(), GooglePayComponent {
|
||||
@JvmStatic
|
||||
fun changeNumber(context: Context): Intent = getIntentForStartLocation(context, AppSettingsRoute.ChangeNumberRoute.Start)
|
||||
|
||||
@JvmStatic
|
||||
fun account(context: Context): Intent = getIntentForStartLocation(context, AppSettingsRoute.AccountRoute.Account)
|
||||
|
||||
@JvmStatic
|
||||
fun subscriptions(context: Context): Intent = getIntentForStartLocation(context, AppSettingsRoute.DonationsRoute.Donations(directToCheckoutType = InAppPaymentType.RECURRING_DONATION))
|
||||
|
||||
|
||||
+3
-2
@@ -81,6 +81,7 @@ import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.util.CommunicationActions
|
||||
import org.thoughtcrime.securesms.util.SignalE164Util
|
||||
import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
||||
import org.signal.core.ui.R as CoreUiR
|
||||
|
||||
class AppSettingsFragment : ComposeFragment(), Callbacks {
|
||||
|
||||
@@ -295,7 +296,7 @@ private fun AppSettingsContent(
|
||||
item {
|
||||
Rows.TextRow(
|
||||
text = stringResource(R.string.AccountSettingsFragment__account),
|
||||
icon = painterResource(R.drawable.symbol_person_circle_24),
|
||||
icon = painterResource(CoreUiR.drawable.symbol_person_circle_24),
|
||||
onClick = {
|
||||
callbacks.navigate(AppSettingsRoute.AccountRoute.Account)
|
||||
}
|
||||
@@ -305,7 +306,7 @@ private fun AppSettingsContent(
|
||||
item {
|
||||
Rows.TextRow(
|
||||
text = stringResource(R.string.preferences__linked_devices),
|
||||
icon = painterResource(R.drawable.symbol_devices_24),
|
||||
icon = painterResource(CoreUiR.drawable.symbol_devices_24),
|
||||
onClick = {
|
||||
callbacks.navigate(AppSettingsRoute.LinkDeviceRoute.LinkDevice)
|
||||
},
|
||||
|
||||
+1
-1
@@ -45,6 +45,7 @@ import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.Rows
|
||||
import org.signal.core.ui.compose.Scaffolds
|
||||
import org.signal.core.ui.compose.Texts
|
||||
import org.signal.core.util.ServiceUtil
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.compose.rememberStatusBarColorNestedScrollModifier
|
||||
import org.thoughtcrime.securesms.contactshare.SimpleTextWatcher
|
||||
@@ -56,7 +57,6 @@ import org.thoughtcrime.securesms.lock.v2.SvrConstants
|
||||
import org.thoughtcrime.securesms.pin.RegistrationLockV2Dialog
|
||||
import org.thoughtcrime.securesms.registration.ui.RegistrationActivity
|
||||
import org.thoughtcrime.securesms.util.PlayStoreUtil
|
||||
import org.thoughtcrime.securesms.util.ServiceUtil
|
||||
import org.thoughtcrime.securesms.util.ViewUtil
|
||||
import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
||||
import org.whispersystems.signalservice.api.kbs.PinHashUtil
|
||||
|
||||
+1
-1
@@ -5,10 +5,10 @@ import com.fasterxml.jackson.databind.JsonNode
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import org.signal.core.util.JsonUtils
|
||||
import org.signal.network.NetworkResult
|
||||
import org.thoughtcrime.securesms.net.SignalNetwork
|
||||
import org.thoughtcrime.securesms.providers.BlobProvider
|
||||
import org.thoughtcrime.securesms.util.JsonUtils
|
||||
|
||||
class ExportAccountDataRepository {
|
||||
|
||||
|
||||
+1
-1
@@ -242,7 +242,7 @@ private fun BackupsSettingsContent(
|
||||
item {
|
||||
Rows.TextRow(
|
||||
text = stringResource(R.string.RemoteBackupsSettingsFragment__on_device_backups),
|
||||
icon = ImageVector.vectorResource(R.drawable.symbol_device_phone_24),
|
||||
icon = ImageVector.vectorResource(CoreUiR.drawable.symbol_device_phone_24),
|
||||
label = stringResource(R.string.RemoteBackupsSettingsFragment__save_your_backups_to),
|
||||
onClick = onOnDeviceBackupsRowClick
|
||||
)
|
||||
|
||||
+16
-3
@@ -33,6 +33,7 @@ import org.thoughtcrime.securesms.registration.sms.ReceivedSmsEvent
|
||||
import org.thoughtcrime.securesms.util.concurrent.AssertedSuccessListener
|
||||
import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
||||
import org.thoughtcrime.securesms.util.visible
|
||||
import kotlin.math.ceil
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
/**
|
||||
@@ -168,7 +169,7 @@ class ChangeNumberEnterCodeFragment : LoggingFragment(R.layout.fragment_change_n
|
||||
is ChangeNumberResult.RegistrationLocked -> presentRegistrationLocked(result.timeRemaining)
|
||||
is ChangeNumberResult.AuthorizationFailed -> presentIncorrectCodeDialog()
|
||||
is ChangeNumberResult.AttemptsExhausted -> presentAccountLocked()
|
||||
is ChangeNumberResult.RateLimited -> presentRateLimitedDialog()
|
||||
is ChangeNumberResult.RateLimited -> presentRateLimitedDialog(result.timeRemaining)
|
||||
|
||||
else -> presentGenericError(result)
|
||||
}
|
||||
@@ -195,13 +196,25 @@ class ChangeNumberEnterCodeFragment : LoggingFragment(R.layout.fragment_change_n
|
||||
)
|
||||
}
|
||||
|
||||
private fun presentRateLimitedDialog() {
|
||||
private fun presentRateLimitedDialog(retryAfterSeconds: Long = 0) {
|
||||
binding.codeEntryLayout.keyboard.displayFailure().addListener(
|
||||
object : AssertedSuccessListener<Boolean?>() {
|
||||
override fun onSuccess(result: Boolean?) {
|
||||
MaterialAlertDialogBuilder(requireContext()).apply {
|
||||
setTitle(R.string.RegistrationActivity_too_many_attempts)
|
||||
setMessage(R.string.RegistrationActivity_you_have_made_too_many_attempts_please_try_again_later)
|
||||
if (retryAfterSeconds > 0) {
|
||||
val minutes = ceil(retryAfterSeconds / 60.0).toInt().coerceAtLeast(1)
|
||||
setMessage(
|
||||
if (minutes >= 60) {
|
||||
val hours = ceil(minutes / 60.0).toInt()
|
||||
resources.getQuantityString(R.plurals.ChangeNumberEnterCodeFragment__too_many_attempts_try_again_in_hours, hours, hours)
|
||||
} else {
|
||||
resources.getQuantityString(R.plurals.ChangeNumberEnterCodeFragment__too_many_attempts_try_again_in_minutes, minutes, minutes)
|
||||
}
|
||||
)
|
||||
} else {
|
||||
setMessage(R.string.RegistrationActivity_you_have_made_too_many_attempts_please_try_again_later)
|
||||
}
|
||||
setPositiveButton(android.R.string.ok) { _: DialogInterface?, _: Int ->
|
||||
binding.codeEntryLayout.callMeCountDown.visibility = View.VISIBLE
|
||||
binding.codeEntryLayout.resendSmsCountDown.visibility = View.VISIBLE
|
||||
|
||||
+20
-4
@@ -23,7 +23,6 @@ import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.NavController
|
||||
@@ -34,7 +33,10 @@ import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.Scaffolds
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.util.RemoteConfig
|
||||
import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
class ChangeNumberFragment : ComposeFragment() {
|
||||
|
||||
@@ -46,10 +48,25 @@ class ChangeNumberFragment : ComposeFragment() {
|
||||
navController.popBackStack()
|
||||
},
|
||||
onContinueClick = {
|
||||
navController.safeNavigate(ChangeNumberFragmentDirections.actionChangePhoneNumberFragmentToEnterPhoneNumberChangeFragment())
|
||||
val remainingWaitSeconds = remainingPostRegistrationWaitSeconds()
|
||||
if (remainingWaitSeconds > 0) {
|
||||
ChangeNumberPostRegistrationWaitSheet.show(parentFragmentManager, remainingWaitSeconds)
|
||||
} else {
|
||||
navController.safeNavigate(ChangeNumberFragmentDirections.actionChangePhoneNumberFragmentToEnterPhoneNumberChangeFragment())
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun remainingPostRegistrationWaitSeconds(): Long {
|
||||
val registeredAt = SignalStore.account.registeredAtTimestamp
|
||||
if (registeredAt <= 0) {
|
||||
return 0
|
||||
}
|
||||
val waitingPeriodSeconds = RemoteConfig.changeNumberPostRegistrationWaitingPeriodSeconds
|
||||
val elapsedSeconds = (System.currentTimeMillis() - registeredAt).milliseconds.inWholeSeconds
|
||||
return (waitingPeriodSeconds - elapsedSeconds).coerceAtLeast(0)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -73,7 +90,7 @@ fun ChangeNumberScreen(
|
||||
.padding(horizontal = 32.dp)
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.change_number_hero_image),
|
||||
painter = painterResource(id = R.drawable.change_number),
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.padding(top = 20.dp)
|
||||
@@ -83,7 +100,6 @@ fun ChangeNumberScreen(
|
||||
text = stringResource(id = R.string.AccountSettingsFragment__change_phone_number),
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(top = 24.dp)
|
||||
)
|
||||
|
||||
|
||||
+151
@@ -0,0 +1,151 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.settings.app.changenumber
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import org.signal.core.ui.BottomSheetUtil
|
||||
import org.signal.core.ui.compose.BottomSheets
|
||||
import org.signal.core.ui.compose.Buttons
|
||||
import org.signal.core.ui.compose.ComposeBottomSheetDialogFragment
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.horizontalGutters
|
||||
import org.thoughtcrime.securesms.R
|
||||
import kotlin.math.ceil
|
||||
|
||||
/**
|
||||
* Sheet shown when the user attempts to change their phone number before the
|
||||
* post-registration waiting period has elapsed.
|
||||
*/
|
||||
class ChangeNumberPostRegistrationWaitSheet : ComposeBottomSheetDialogFragment() {
|
||||
|
||||
companion object {
|
||||
private const val ARG_REMAINING_SECONDS = "arg.remaining_seconds"
|
||||
|
||||
@JvmStatic
|
||||
fun show(fragmentManager: FragmentManager, remainingSeconds: Long) {
|
||||
ChangeNumberPostRegistrationWaitSheet().apply {
|
||||
arguments = Bundle().apply {
|
||||
putLong(ARG_REMAINING_SECONDS, remainingSeconds)
|
||||
}
|
||||
}.show(fragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
|
||||
}
|
||||
}
|
||||
|
||||
private val remainingSeconds: Long
|
||||
get() = requireArguments().getLong(ARG_REMAINING_SECONDS)
|
||||
|
||||
@Composable
|
||||
override fun SheetContent() {
|
||||
SheetContent(
|
||||
remainingSeconds = remainingSeconds,
|
||||
onDismiss = { dismissAllowingStateLoss() }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SheetContent(
|
||||
remainingSeconds: Long,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.horizontalGutters(gutterSize = 36.dp)
|
||||
) {
|
||||
BottomSheets.Handle()
|
||||
|
||||
Image(
|
||||
painter = painterResource(R.drawable.change_number_error),
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.padding(top = 26.dp)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.ChangeNumberPostRegistrationWaitSheet__title),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
modifier = Modifier.padding(top = 16.dp)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.ChangeNumberPostRegistrationWaitSheet__body),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 12.dp)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = formatTryAgainIn(remainingSeconds),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 16.dp)
|
||||
)
|
||||
|
||||
Buttons.LargeTonal(
|
||||
onClick = onDismiss,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 32.dp, bottom = 24.dp, start = 12.dp, end = 12.dp)
|
||||
) {
|
||||
Text(stringResource(R.string.ChangeNumberPostRegistrationWaitSheet__ok))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun SheetContentMinutesPreview() {
|
||||
Previews.BottomSheetContentPreview {
|
||||
SheetContent(
|
||||
remainingSeconds = 25 * 60,
|
||||
onDismiss = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun SheetContentHoursPreview() {
|
||||
Previews.BottomSheetContentPreview {
|
||||
SheetContent(
|
||||
remainingSeconds = 2 * 60 * 60,
|
||||
onDismiss = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun formatTryAgainIn(remainingSeconds: Long): String {
|
||||
val minutes = ceil(remainingSeconds / 60.0).toInt().coerceAtLeast(1)
|
||||
return if (minutes >= 60) {
|
||||
val hours = ceil(minutes / 60.0).toInt()
|
||||
pluralStringResource(R.plurals.ChangeNumberPostRegistrationWaitSheet__try_again_in_hours, hours, hours)
|
||||
} else {
|
||||
pluralStringResource(R.plurals.ChangeNumberPostRegistrationWaitSheet__try_again_in_minutes, minutes, minutes)
|
||||
}
|
||||
}
|
||||
+73
-37
@@ -103,20 +103,6 @@ class ChangeNumberRepository(
|
||||
|
||||
@WorkerThread
|
||||
fun changeLocalNumber(e164: String, pni: ServiceId.PNI) {
|
||||
SignalDatabase.recipients.updateSelfE164(e164, pni)
|
||||
AppDependencies.recipientCache.clear()
|
||||
|
||||
if (e164 != SignalStore.account.requireE164()) {
|
||||
SignalDatabase.recipients.rotateStorageId(Recipient.self().fresh().id)
|
||||
StorageSyncHelper.scheduleSyncForDataChange()
|
||||
}
|
||||
|
||||
SignalStore.account.setE164(e164)
|
||||
SignalStore.account.setPni(pni)
|
||||
AppDependencies.resetProtocolStores()
|
||||
|
||||
AppDependencies.groupsV2Authorization.clear()
|
||||
|
||||
val metadata: PendingChangeNumberMetadata? = SignalStore.misc.pendingChangeNumberMetadata
|
||||
if (metadata == null) {
|
||||
Log.w(TAG, "No change number metadata, this shouldn't happen")
|
||||
@@ -125,25 +111,32 @@ class ChangeNumberRepository(
|
||||
|
||||
val pniIdentityKeyPair = IdentityKeyPair(metadata.pniIdentityKeyPair.toByteArray())
|
||||
val pniRegistrationId = metadata.pniRegistrationId
|
||||
val pniSignedPreyKeyId = metadata.pniSignedPreKeyId
|
||||
val pniSignedPreKeyId = metadata.pniSignedPreKeyId
|
||||
val pniLastResortKyberPreKeyId = metadata.pniLastResortKyberPreKeyId
|
||||
|
||||
// Prekeys were generated and stored during createChangeNumberRequest; reload them so we can pass them through and reuse for the upload below.
|
||||
val preResetPniStore = AppDependencies.protocolStore.pni()
|
||||
val signedPreKey = preResetPniStore.loadSignedPreKey(pniSignedPreKeyId)
|
||||
val lastResortKyberPreKey = preResetPniStore.loadLastResortKyberPreKeys().firstOrNull { it.id == pniLastResortKyberPreKeyId }
|
||||
|
||||
applyLocalNumberChange(
|
||||
e164 = e164,
|
||||
pni = pni,
|
||||
pniIdentityKeyPair = pniIdentityKeyPair,
|
||||
pniSignedPreKey = signedPreKey,
|
||||
pniLastResortKyberPreKey = lastResortKyberPreKey,
|
||||
pniRegistrationId = pniRegistrationId
|
||||
)
|
||||
|
||||
AppDependencies.resetNetwork()
|
||||
AppDependencies.startNetwork()
|
||||
|
||||
val pniProtocolStore = AppDependencies.protocolStore.pni()
|
||||
val pniMetadataStore = SignalStore.account.pniPreKeys
|
||||
|
||||
SignalStore.account.pniRegistrationId = pniRegistrationId
|
||||
SignalStore.account.setPniIdentityKeyAfterChangeNumber(pniIdentityKeyPair)
|
||||
|
||||
val signedPreKey = pniProtocolStore.loadSignedPreKey(pniSignedPreyKeyId)
|
||||
val oneTimeEcPreKeys = PreKeyUtil.generateAndStoreOneTimeEcPreKeys(pniProtocolStore, pniMetadataStore)
|
||||
val lastResortKyberPreKey = pniProtocolStore.loadLastResortKyberPreKeys().firstOrNull { it.id == pniLastResortKyberPreKeyId }
|
||||
val oneTimeKyberPreKeys = PreKeyUtil.generateAndStoreOneTimeKyberPreKeys(pniProtocolStore, pniMetadataStore)
|
||||
|
||||
if (lastResortKyberPreKey == null) {
|
||||
Log.w(TAG, "Last-resort kyber prekey is missing!")
|
||||
}
|
||||
|
||||
pniMetadataStore.activeSignedPreKeyId = signedPreKey.id
|
||||
Log.i(TAG, "Submitting prekeys with PNI identity key: ${pniIdentityKeyPair.publicKey.fingerprint}")
|
||||
|
||||
retryChangeLocalNumberNetworkOperation {
|
||||
@@ -161,6 +154,61 @@ class ChangeNumberRepository(
|
||||
pniMetadataStore.isSignedPreKeyRegistered = true
|
||||
pniMetadataStore.lastResortKyberPreKeyId = pniLastResortKyberPreKeyId
|
||||
|
||||
SignalStore.misc.hasPniInitializedDevices = true
|
||||
|
||||
AppDependencies.jobManager.add(RefreshAttributesJob())
|
||||
|
||||
rotateCertificates()
|
||||
|
||||
SignalStore.misc.unlockChangeNumber()
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the local state for a successful number change: self recipient row, account values,
|
||||
* PNI protocol store, and identity entry.
|
||||
*
|
||||
* Does NOT reset the network — callers must do so before any subsequent traffic that needs to
|
||||
* use the new PNI. Does NOT make any server requests and does NOT flag prekeys as registered
|
||||
* server-side — the caller is responsible for that once it can attest to server state.
|
||||
*/
|
||||
@WorkerThread
|
||||
fun applyLocalNumberChange(
|
||||
e164: String,
|
||||
pni: ServiceId.PNI,
|
||||
pniIdentityKeyPair: IdentityKeyPair,
|
||||
pniSignedPreKey: SignedPreKeyRecord,
|
||||
pniLastResortKyberPreKey: KyberPreKeyRecord?,
|
||||
pniRegistrationId: Int
|
||||
) {
|
||||
SignalDatabase.recipients.updateSelfE164(e164, pni)
|
||||
AppDependencies.recipientCache.clear()
|
||||
|
||||
if (e164 != SignalStore.account.requireE164()) {
|
||||
SignalDatabase.recipients.rotateStorageId(Recipient.self().fresh().id)
|
||||
StorageSyncHelper.scheduleSyncForDataChange()
|
||||
}
|
||||
|
||||
SignalStore.account.setE164(e164)
|
||||
SignalStore.account.setPni(pni)
|
||||
AppDependencies.resetProtocolStores()
|
||||
|
||||
AppDependencies.groupsV2Authorization.clear()
|
||||
|
||||
val pniProtocolStore = AppDependencies.protocolStore.pni()
|
||||
val pniMetadataStore = SignalStore.account.pniPreKeys
|
||||
|
||||
SignalStore.account.pniRegistrationId = pniRegistrationId
|
||||
SignalStore.account.setPniIdentityKeyAfterChangeNumber(pniIdentityKeyPair)
|
||||
|
||||
PreKeyUtil.storeSignedPreKey(pniProtocolStore, pniMetadataStore, pniSignedPreKey)
|
||||
pniMetadataStore.activeSignedPreKeyId = pniSignedPreKey.id
|
||||
|
||||
if (pniLastResortKyberPreKey != null) {
|
||||
PreKeyUtil.storeLastResortKyberPreKey(pniProtocolStore, pniMetadataStore, pniLastResortKyberPreKey)
|
||||
} else {
|
||||
Log.w(TAG, "Last-resort kyber prekey is missing!")
|
||||
}
|
||||
|
||||
pniProtocolStore.identities().saveIdentityWithoutSideEffects(
|
||||
Recipient.self().id,
|
||||
pni,
|
||||
@@ -171,20 +219,8 @@ class ChangeNumberRepository(
|
||||
true
|
||||
)
|
||||
|
||||
SignalStore.misc.hasPniInitializedDevices = true
|
||||
AppDependencies.groupsV2Authorization.clear()
|
||||
|
||||
Recipient.self().fresh()
|
||||
StorageSyncHelper.scheduleSyncForDataChange()
|
||||
|
||||
AppDependencies.resetNetwork()
|
||||
AppDependencies.startNetwork()
|
||||
|
||||
AppDependencies.jobManager.add(RefreshAttributesJob())
|
||||
|
||||
rotateCertificates()
|
||||
|
||||
SignalStore.misc.unlockChangeNumber()
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
|
||||
+1
-1
@@ -8,6 +8,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import org.signal.core.util.ThrottledDebouncer
|
||||
import org.thoughtcrime.securesms.backup.LocalExportProgress
|
||||
import org.thoughtcrime.securesms.components.settings.app.chats.folders.ChatFoldersRepository
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
@@ -17,7 +18,6 @@ import org.thoughtcrime.securesms.util.BackupUtil
|
||||
import org.thoughtcrime.securesms.util.ConversationUtil
|
||||
import org.thoughtcrime.securesms.util.RemoteConfig
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences
|
||||
import org.thoughtcrime.securesms.util.ThrottledDebouncer
|
||||
|
||||
class ChatsSettingsViewModel @JvmOverloads constructor(
|
||||
private val repository: ChatsSettingsRepository = ChatsSettingsRepository()
|
||||
|
||||
+44
-1
@@ -17,6 +17,7 @@ import androidx.navigation.fragment.findNavController
|
||||
import androidx.preference.PreferenceManager
|
||||
import org.signal.core.ui.compose.ComposeFragment
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Dialogs
|
||||
import org.signal.core.ui.compose.Dividers
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.Rows
|
||||
@@ -90,6 +91,18 @@ class DataAndStorageSettingsFragment : ComposeFragment() {
|
||||
override fun onRoamingDataAutoDownloadSelectionChanged(selection: Array<String>) {
|
||||
viewModel.setRoamingAutoDownloadValues(selection.toSet())
|
||||
}
|
||||
|
||||
override fun onForceWebsocketModeChanged(enabled: Boolean) {
|
||||
viewModel.onForceWebsocketModeToggled(enabled)
|
||||
}
|
||||
|
||||
override fun onConfirmStayConnectedInBackground() {
|
||||
viewModel.confirmStayConnectedInBackground()
|
||||
}
|
||||
|
||||
override fun onDismissStayConnectedInBackgroundDialog() {
|
||||
viewModel.dismissStayConnectedInBackgroundDialog()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,6 +115,9 @@ private interface DataAndStorageSettingsCallbacks {
|
||||
fun onMobileDataAutoDownloadSelectionChanged(selection: Array<String>) = Unit
|
||||
fun onWifiDataAutoDownloadSelectionChanged(selection: Array<String>) = Unit
|
||||
fun onRoamingDataAutoDownloadSelectionChanged(selection: Array<String>) = Unit
|
||||
fun onForceWebsocketModeChanged(enabled: Boolean) = Unit
|
||||
fun onConfirmStayConnectedInBackground() = Unit
|
||||
fun onDismissStayConnectedInBackgroundDialog() = Unit
|
||||
|
||||
object Empty : DataAndStorageSettingsCallbacks
|
||||
}
|
||||
@@ -252,6 +268,19 @@ private fun DataAndStorageSettingsScreen(
|
||||
Dividers.Default()
|
||||
}
|
||||
|
||||
item {
|
||||
Rows.ToggleRow(
|
||||
checked = state.forceWebsocketMode || !state.playServicesAvailable,
|
||||
text = stringResource(R.string.DataAndStorageSettingsFragment__stay_connected_in_background),
|
||||
enabled = state.playServicesAvailable,
|
||||
onCheckChanged = callbacks::onForceWebsocketModeChanged
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Dividers.Default()
|
||||
}
|
||||
|
||||
item {
|
||||
Texts.SectionHeader(stringResource(R.string.preferences_proxy))
|
||||
}
|
||||
@@ -264,6 +293,17 @@ private fun DataAndStorageSettingsScreen(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (state.showStayConnectedDialog) {
|
||||
Dialogs.SimpleAlertDialog(
|
||||
title = "",
|
||||
body = stringResource(R.string.DataAndStorageSettingsFragment__staying_connected_while_in_the_background_will_likely_result_in_increased_battery_usage),
|
||||
confirm = stringResource(R.string.DataAndStorageSettingsFragment__enable),
|
||||
dismiss = stringResource(android.R.string.cancel),
|
||||
onConfirm = callbacks::onConfirmStayConnectedInBackground,
|
||||
onDismiss = callbacks::onDismissStayConnectedInBackgroundDialog
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -279,7 +319,10 @@ private fun DataAndStorageSettingsScreenPreview() {
|
||||
roamingAutoDownloadValues = setOf(),
|
||||
callDataMode = CallDataMode.HIGH_ALWAYS,
|
||||
isProxyEnabled = false,
|
||||
sentMediaQuality = SentMediaQuality.STANDARD
|
||||
sentMediaQuality = SentMediaQuality.STANDARD,
|
||||
forceWebsocketMode = false,
|
||||
playServicesAvailable = true,
|
||||
showStayConnectedDialog = false
|
||||
),
|
||||
callbacks = DataAndStorageSettingsCallbacks.Empty
|
||||
)
|
||||
|
||||
+4
-1
@@ -10,5 +10,8 @@ data class DataAndStorageSettingsState(
|
||||
val roamingAutoDownloadValues: Set<String>,
|
||||
val callDataMode: CallDataMode,
|
||||
val isProxyEnabled: Boolean,
|
||||
val sentMediaQuality: SentMediaQuality
|
||||
val sentMediaQuality: SentMediaQuality,
|
||||
val forceWebsocketMode: Boolean,
|
||||
val playServicesAvailable: Boolean,
|
||||
val showStayConnectedDialog: Boolean
|
||||
)
|
||||
|
||||
+35
-3
@@ -7,8 +7,11 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.keyvalue.SettingsValues.ForceWebsocketMode
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.messages.IncomingMessageObserver
|
||||
import org.thoughtcrime.securesms.mms.SentMediaQuality
|
||||
import org.thoughtcrime.securesms.util.PlayServicesUtil
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences
|
||||
import org.thoughtcrime.securesms.webrtc.CallDataMode
|
||||
|
||||
@@ -23,7 +26,7 @@ class DataAndStorageSettingsViewModel(
|
||||
|
||||
fun refresh() {
|
||||
repository.getTotalStorageUse { totalStorageUse ->
|
||||
store.update { getState().copy(totalStorageUse = totalStorageUse) }
|
||||
store.update { getState().copy(totalStorageUse = totalStorageUse, showStayConnectedDialog = it.showStayConnectedDialog) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,8 +56,34 @@ class DataAndStorageSettingsViewModel(
|
||||
getStateAndCopyStorageUsage()
|
||||
}
|
||||
|
||||
fun onForceWebsocketModeToggled(enabled: Boolean) {
|
||||
if (enabled) {
|
||||
store.update { it.copy(showStayConnectedDialog = true) }
|
||||
} else {
|
||||
applyForceWebsocketMode(false)
|
||||
}
|
||||
}
|
||||
|
||||
fun confirmStayConnectedInBackground() {
|
||||
applyForceWebsocketMode(true)
|
||||
}
|
||||
|
||||
fun dismissStayConnectedInBackgroundDialog() {
|
||||
store.update { it.copy(showStayConnectedDialog = false) }
|
||||
}
|
||||
|
||||
private fun applyForceWebsocketMode(enabled: Boolean) {
|
||||
SignalStore.settings.forceWebsocketMode = if (enabled) ForceWebsocketMode.ENABLED_BY_USER else ForceWebsocketMode.DISABLED
|
||||
if (!enabled) {
|
||||
IncomingMessageObserver.stopForegroundService(AppDependencies.application)
|
||||
}
|
||||
AppDependencies.resetNetwork()
|
||||
AppDependencies.startNetwork()
|
||||
getStateAndCopyStorageUsage()
|
||||
}
|
||||
|
||||
private fun getStateAndCopyStorageUsage() {
|
||||
store.update { getState().copy(totalStorageUse = it.totalStorageUse) }
|
||||
store.update { getState().copy(totalStorageUse = it.totalStorageUse, showStayConnectedDialog = it.showStayConnectedDialog) }
|
||||
}
|
||||
|
||||
private fun getState() = DataAndStorageSettingsState(
|
||||
@@ -70,7 +99,10 @@ class DataAndStorageSettingsViewModel(
|
||||
),
|
||||
callDataMode = SignalStore.settings.callDataMode,
|
||||
isProxyEnabled = SignalStore.proxy.isProxyEnabled,
|
||||
sentMediaQuality = SignalStore.settings.sentMediaQuality
|
||||
sentMediaQuality = SignalStore.settings.sentMediaQuality,
|
||||
forceWebsocketMode = SignalStore.settings.forceWebsocketMode.isEnabled,
|
||||
playServicesAvailable = PlayServicesUtil.getPlayServicesStatus(AppDependencies.application) == PlayServicesUtil.PlayServicesStatus.SUCCESS,
|
||||
showStayConnectedDialog = false
|
||||
)
|
||||
|
||||
class Factory(
|
||||
|
||||
+7
-1
@@ -5,6 +5,7 @@
|
||||
|
||||
package org.thoughtcrime.securesms.components.settings.app.help
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
@@ -26,6 +27,7 @@ import org.signal.core.ui.compose.Rows.TextAndLabel
|
||||
import org.signal.core.ui.compose.Rows.defaultPadding
|
||||
import org.signal.core.ui.compose.Scaffolds
|
||||
import org.signal.core.ui.compose.SignalIcons
|
||||
import org.signal.core.util.Util
|
||||
import org.thoughtcrime.securesms.BuildConfig
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.util.CommunicationActions
|
||||
@@ -74,7 +76,11 @@ class HelpSettingsFragment : ComposeFragment() {
|
||||
item {
|
||||
Rows.TextRow(
|
||||
text = stringResource(R.string.HelpSettingsFragment__version),
|
||||
label = BuildConfig.VERSION_NAME
|
||||
label = BuildConfig.VERSION_NAME,
|
||||
onLongClick = {
|
||||
Util.copyToClipboard(context, BuildConfig.VERSION_NAME)
|
||||
Toast.makeText(context, R.string.HelpSettingsFragment__copied_to_clipboard, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+85
-29
@@ -4,15 +4,23 @@ import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.content.DialogInterface
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import android.widget.EditText
|
||||
import android.widget.Toast
|
||||
import androidx.appcompat.widget.SearchView
|
||||
import androidx.appcompat.widget.Toolbar
|
||||
import androidx.fragment.app.setFragmentResultListener
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.signal.core.ui.BottomSheetUtil
|
||||
import org.signal.core.ui.permissions.PermissionDeniedBottomSheet
|
||||
import org.signal.core.ui.permissions.RationaleDialog
|
||||
@@ -69,8 +77,10 @@ import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.registration.data.QuickstartCredentialExporter
|
||||
import org.thoughtcrime.securesms.storage.StorageSyncHelper
|
||||
import org.thoughtcrime.securesms.util.ConversationUtil
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
|
||||
import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
||||
import org.thoughtcrime.securesms.util.setIncognitoKeyboardEnabled
|
||||
import org.whispersystems.signalservice.api.push.UsernameLinkComponents
|
||||
import java.util.Optional
|
||||
import java.util.UUID
|
||||
@@ -78,15 +88,15 @@ import java.util.concurrent.TimeUnit
|
||||
import kotlin.math.max
|
||||
import kotlin.random.Random
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__internal_preferences) {
|
||||
class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__internal_preferences, R.menu.internal_settings) {
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(InternalSettingsFragment::class.java)
|
||||
}
|
||||
|
||||
private lateinit var viewModel: InternalSettingsViewModel
|
||||
private var searchMenuItem: MenuItem? = null
|
||||
|
||||
private var scrollToPosition: Int = 0
|
||||
private val layoutManager: LinearLayoutManager?
|
||||
@@ -103,6 +113,7 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
scrollToPosition = SignalStore.internal.lastScrollPosition
|
||||
initializeSearch(view)
|
||||
|
||||
setFragmentResultListener(CallQualityBottomSheetFragment.REQUEST_KEY) { _, bundle ->
|
||||
if (bundle.getBoolean(CallQualityBottomSheetFragment.REQUEST_KEY, false)) {
|
||||
@@ -121,8 +132,11 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
|
||||
viewModel = ViewModelProvider(this, factory)[InternalSettingsViewModel::class.java]
|
||||
|
||||
viewModel.state.observe(viewLifecycleOwner) {
|
||||
adapter.submitList(getConfiguration(it).toMappingModelList()) {
|
||||
if (scrollToPosition != 0) {
|
||||
val mappingModelList = getConfiguration(it).toMappingModelList()
|
||||
val filteredList = viewModel.filterPreferences(requireContext(), mappingModelList, it.searchQuery)
|
||||
|
||||
adapter.submitList(filteredList) {
|
||||
if (scrollToPosition != 0 && it.searchQuery.isBlank()) {
|
||||
layoutManager?.scrollToPositionWithOffset(scrollToPosition, 0)
|
||||
scrollToPosition = 0
|
||||
}
|
||||
@@ -130,6 +144,56 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
|
||||
}
|
||||
}
|
||||
|
||||
override fun onToolbarNavigationClicked() {
|
||||
if (searchMenuItem?.isActionViewExpanded == true) {
|
||||
searchMenuItem?.collapseActionView()
|
||||
} else {
|
||||
super.onToolbarNavigationClicked()
|
||||
}
|
||||
}
|
||||
|
||||
private fun initializeSearch(view: View) {
|
||||
val toolbar: Toolbar = view.findViewById(R.id.toolbar)
|
||||
searchMenuItem = toolbar.menu.findItem(R.id.menu_search)
|
||||
|
||||
val searchView: SearchView = searchMenuItem?.actionView as? SearchView ?: return
|
||||
val queryListener = object : SearchView.OnQueryTextListener {
|
||||
override fun onQueryTextSubmit(query: String?): Boolean {
|
||||
searchView.clearFocus()
|
||||
viewModel.setSearchQuery(query.orEmpty())
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onQueryTextChange(newText: String?): Boolean {
|
||||
viewModel.setSearchQuery(newText.orEmpty())
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
searchView.maxWidth = Integer.MAX_VALUE
|
||||
searchView.queryHint = getString(R.string.CameraContacts__menu_search)
|
||||
|
||||
searchMenuItem?.setOnActionExpandListener(object : MenuItem.OnActionExpandListener {
|
||||
override fun onMenuItemActionExpand(item: MenuItem): Boolean {
|
||||
searchView.setIncognitoKeyboardEnabled(TextSecurePreferences.isIncognitoKeyboardEnabled(requireContext()))
|
||||
searchView.setOnQueryTextListener(queryListener)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onMenuItemActionCollapse(item: MenuItem): Boolean {
|
||||
searchView.setOnQueryTextListener(null)
|
||||
searchView.setQuery("", false)
|
||||
viewModel.setSearchQuery("")
|
||||
return true
|
||||
}
|
||||
})
|
||||
|
||||
val currentQuery = viewModel.state.value?.searchQuery.orEmpty()
|
||||
if (currentQuery.isNotBlank() && searchMenuItem?.expandActionView() == true) {
|
||||
searchView.setQuery(currentQuery, false)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getConfiguration(state: InternalSettingsState): DSLConfiguration {
|
||||
return configure {
|
||||
sectionHeaderPref(DSLSettingsText.from("Account"))
|
||||
@@ -506,25 +570,6 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
|
||||
|
||||
sectionHeaderPref(DSLSettingsText.from("Network"))
|
||||
|
||||
switchPref(
|
||||
title = DSLSettingsText.from("Force websocket mode"),
|
||||
summary = DSLSettingsText.from("Pretend you have no Play Services. Ignores websocket messages and keeps the websocket open in a foreground service. You have to manually force-stop the app for changes to take effect."),
|
||||
isChecked = state.forceWebsocketMode,
|
||||
onClick = {
|
||||
viewModel.setForceWebsocketMode(!state.forceWebsocketMode)
|
||||
SimpleTask.run({
|
||||
val jobState = AppDependencies.jobManager.runSynchronously(RefreshAttributesJob(), 10.seconds.inWholeMilliseconds)
|
||||
return@run jobState.isPresent && jobState.get().isComplete
|
||||
}, { success ->
|
||||
if (success) {
|
||||
Toast.makeText(context, "Successfully refreshed attributes. Force-stop the app for changes to take effect.", Toast.LENGTH_SHORT).show()
|
||||
} else {
|
||||
Toast.makeText(context, "Failed to refresh attributes.", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
switchPref(
|
||||
title = DSLSettingsText.from("Allow censorship circumvention toggle"),
|
||||
summary = DSLSettingsText.from("Allow changing the censorship circumvention toggle regardless of network connectivity."),
|
||||
@@ -1092,15 +1137,26 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
|
||||
}
|
||||
|
||||
private fun refreshRemoteValues() {
|
||||
Toast.makeText(context, "Running remote config refresh, app will restart after completion.", Toast.LENGTH_LONG).show()
|
||||
SignalExecutors.BOUNDED.execute {
|
||||
val starterToast = Toast.makeText(context, "Running remote config refresh, app will restart after completion.", Toast.LENGTH_LONG).apply { show() }
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
SignalStore.remoteConfig.eTag = ""
|
||||
val result: Optional<JobTracker.JobState> = AppDependencies.jobManager.runSynchronously(RemoteConfigRefreshJob(), TimeUnit.SECONDS.toMillis(10))
|
||||
|
||||
if (result.isPresent && result.get() == JobTracker.JobState.SUCCESS) {
|
||||
AppUtil.restart(requireContext())
|
||||
} else {
|
||||
Toast.makeText(context, "Failed to refresh config remote config.", Toast.LENGTH_SHORT).show()
|
||||
withContext(Dispatchers.Main) {
|
||||
starterToast.cancel()
|
||||
if (result.isPresent && result.get() == JobTracker.JobState.SUCCESS) {
|
||||
val toast = Toast.makeText(context, "Refresh successful. Restarting...", Toast.LENGTH_SHORT)
|
||||
if (Build.VERSION.SDK_INT >= 30) {
|
||||
toast.addCallback(object : Toast.Callback() {
|
||||
override fun onToastHidden() {
|
||||
AppUtil.restart(requireContext())
|
||||
}
|
||||
})
|
||||
}
|
||||
toast.show()
|
||||
} else {
|
||||
Toast.makeText(context, "Failed to refresh config remote config.", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -10,7 +10,6 @@ data class InternalSettingsState(
|
||||
val gv2forceInvites: Boolean,
|
||||
val gv2ignoreP2PChanges: Boolean,
|
||||
val allowCensorshipSetting: Boolean,
|
||||
val forceWebsocketMode: Boolean,
|
||||
val callingServer: String,
|
||||
val callingDataMode: CallManager.DataMode,
|
||||
val callingDisableTelecom: Boolean,
|
||||
@@ -33,5 +32,6 @@ data class InternalSettingsState(
|
||||
val forceSplitPane: Boolean,
|
||||
val forceSinglePane: Boolean,
|
||||
val useNewMediaActivity: Boolean,
|
||||
val disableInternalUser: Boolean
|
||||
val disableInternalUser: Boolean,
|
||||
val searchQuery: String = ""
|
||||
)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user