mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-06-13 10:46:04 +01:00
Compare commits
233 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5e8cebdc87 | |||
| c8f9c41cea | |||
| 647dc23de6 | |||
| b0531247c3 | |||
| f08a20d0a6 | |||
| 16232e2f9f | |||
| fc856dd500 | |||
| 73f81075ce | |||
| c5efb2ce6c | |||
| c7b5738787 | |||
| df88b7fe35 | |||
| 46213b38e7 | |||
| f8b53378c6 | |||
| bbb09eb7a0 | |||
| 52ceb4062d | |||
| f7eaa1cb51 | |||
| c62c15c810 | |||
| 2389279cfd | |||
| 3e92fca26d | |||
| 526928ce0a | |||
| 2ea2f561ae | |||
| c88f048049 | |||
| 204a233235 | |||
| 2e4abd8ed3 | |||
| a2a0b11c98 | |||
| 96f893652b | |||
| d4924d2a13 | |||
| fa6b512cfc | |||
| d447af36ba | |||
| 41260f37c9 | |||
| 8950f7f7f9 | |||
| 14916d068f | |||
| 33022baaa2 | |||
| 4cdd1f70ac | |||
| 9e3ee16e65 | |||
| bf912e14d9 | |||
| 56f1a9e0ec | |||
| e468156c4c | |||
| 029b91066f | |||
| 5909a1b92a | |||
| 9478cdf049 | |||
| 6d260ab63d | |||
| 4bc11fcf0d | |||
| 33619fe463 | |||
| 2b17868797 | |||
| 582a464a52 | |||
| 812a858761 | |||
| 6ea96795cb | |||
| 70ab0baa3c | |||
| 5041057bab | |||
| 6210d1b397 | |||
| f1accae295 | |||
| 5f6d20453c | |||
| d33385d1b2 | |||
| 3d05bc3471 | |||
| 0514a5c6c8 | |||
| 798ba11e62 | |||
| 848a61787b | |||
| ccfbb27695 | |||
| 26b1d3a0f8 | |||
| c5785c086e | |||
| 6aeb145024 | |||
| 7e8c6228d8 | |||
| 4c08b94b88 | |||
| 135bc6e560 | |||
| 0ebeb5aa92 | |||
| aaa7a18190 | |||
| bfc1c4ebfa | |||
| b4cf59f9c2 | |||
| 15e7b30fa1 | |||
| a5359e05a3 | |||
| 73557ae72a | |||
| 2505049e39 | |||
| a4ae6581ef | |||
| 6399a2d899 | |||
| 56af57db9e | |||
| ddf0de52b1 | |||
| 925e2c1705 | |||
| cb719dff1a | |||
| f2fd3e63c8 | |||
| 8560ab0515 | |||
| d586eff80b | |||
| 27ddd62d7a | |||
| 38f31528ff | |||
| 335fcd72f3 | |||
| 118231a328 | |||
| 754dd15c94 | |||
| 566c2d5838 | |||
| eae894152c | |||
| 53c4069c64 | |||
| 65082893db | |||
| 595364b522 | |||
| 7cce504f16 | |||
| 337afb11db | |||
| 6027d58fb5 | |||
| 132eaa5c70 | |||
| ba3e15ea6d | |||
| ceee5f714d | |||
| 4c942f39b0 | |||
| f3a5bba3f2 | |||
| 8af7606e3f | |||
| 2adf84a895 | |||
| 30ed0aa11a | |||
| ec9ae9e3b1 | |||
| 6a23896077 | |||
| f5a1d79eb5 | |||
| 4f0f0938d8 | |||
| 0136971963 | |||
| f810d731dd | |||
| 7c7c364fef | |||
| aa9591211b | |||
| bbd48547e5 | |||
| 757b521744 | |||
| a6311c87c1 | |||
| 045bd9287b | |||
| f1a72dd01a | |||
| af4d0a0ef0 | |||
| 7dcaa933f2 | |||
| 2c88945e6b | |||
| f9b9ce6c14 | |||
| 1d8fbad17e | |||
| 6872a14378 | |||
| 3a1eb4bd88 | |||
| d9f93294e4 | |||
| f063c43b52 | |||
| a7ed672634 | |||
| 1371663163 | |||
| 1f0c24a5d5 | |||
| b732cbe00b | |||
| 85d60dd0da | |||
| c020bfeb7a | |||
| 3ad446c6c9 | |||
| bc9c560f96 | |||
| 2b54dc4715 | |||
| 1443457eca | |||
| ffbc4465bb | |||
| 4e5ddad78f | |||
| 47a69d667c | |||
| 9d3a51def2 | |||
| b8c964846c | |||
| b02210c166 | |||
| c2f8261419 | |||
| 089d47936b | |||
| 48f55bba0a | |||
| 348387f2d0 | |||
| 30c0ef255a | |||
| 64d3ba9e5b | |||
| 930a2f052a | |||
| efec070728 | |||
| 99aa8a602b | |||
| 4a68e0c469 | |||
| be80619a3b | |||
| a0d605d1b1 | |||
| 64f30bff47 | |||
| 843b656fb6 | |||
| f5f5bf0a67 | |||
| bf73954f42 | |||
| 1fd651ee50 | |||
| f76292769a | |||
| 51c4afe5f5 | |||
| fe435433fd | |||
| fa8098a9aa | |||
| 81e09e65cb | |||
| 1059fcafba | |||
| 91d3fa8ad5 | |||
| 89bffe39ae | |||
| de2a5ea440 | |||
| a54d62c09d | |||
| e8785218a5 | |||
| e6e6075c9b | |||
| 95b69faa58 | |||
| b7f09ef923 | |||
| 50884e144e | |||
| 274feb168e | |||
| 5b1f5a2a20 | |||
| 2cb9685024 | |||
| 0f4fc74829 | |||
| f5e8e15785 | |||
| 3ff1501090 | |||
| 0907898105 | |||
| 5fd8101180 | |||
| 7e6ad92ca8 | |||
| ae2477356b | |||
| 8d992fa7db | |||
| 71c33fc579 | |||
| bdefc274b9 | |||
| f926d9c893 | |||
| ec02d802f2 | |||
| 7925041982 | |||
| de42d748dc | |||
| 3e1df5b8ac | |||
| f1d722c5cd | |||
| abcd65603c | |||
| 7b23110cac | |||
| baa4dd3c86 | |||
| 0beda1e615 | |||
| b3acefdb08 | |||
| 9600366422 | |||
| d85a57adce | |||
| cb1b878198 | |||
| 4a2e2e0137 | |||
| b2f8445e1d | |||
| f5f686fece | |||
| 1864534174 | |||
| 54c4bda4f2 | |||
| 3cb61e3e8a | |||
| 83a83f65ef | |||
| 122e1770b7 | |||
| 68eb4d3c82 | |||
| ce4d68a20f | |||
| 5d45914a08 | |||
| b2f450d849 | |||
| ed73dc2c24 | |||
| 71967b278e | |||
| faadc9854f | |||
| f206487ede | |||
| 0284da2d0f | |||
| 15a3a8efde | |||
| b429d08c5e | |||
| 3892113a0e | |||
| 41f52ed886 | |||
| ec07b7805e | |||
| 333e514a2f | |||
| 15a17adf1a | |||
| 13888bab0a | |||
| 16fc81f715 | |||
| b4f2d8682f | |||
| a05ce88bc0 | |||
| 59c38147c7 | |||
| da85d0b4eb | |||
| 5aa7e3a7c1 | |||
| c92b0505df | |||
| ca8c494fc4 |
@@ -5,7 +5,7 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- 'main'
|
||||
- '7.**'
|
||||
- '8.**'
|
||||
|
||||
permissions:
|
||||
contents: read # to fetch code (actions/checkout)
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
runs-on: ubuntu-latest-8-cores
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
# gh api repos/actions/checkout/commits/v6 --jq '.sha'
|
||||
with:
|
||||
submodules: true
|
||||
@@ -27,14 +27,29 @@ jobs:
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 17
|
||||
cache: gradle
|
||||
|
||||
- name: Validate Gradle Wrapper
|
||||
uses: gradle/actions/wrapper-validation@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6
|
||||
# gh api repos/gradle/actions/commits/v6 --jq '.sha'
|
||||
|
||||
- name: Set up Gradle
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6
|
||||
# gh api repos/gradle/actions/commits/v6 --jq '.sha'
|
||||
with:
|
||||
# Only 8.** branch builds write to the cache; everything else (PRs, etc.) reads only.
|
||||
cache-read-only: ${{ !startsWith(github.ref, 'refs/heads/8.') }}
|
||||
# Required to persist the Gradle configuration cache across runs.
|
||||
cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
|
||||
|
||||
# Pull requests run the fast custom linter (ci); pushes to main / 8.x branches run the full
|
||||
# Android lint (qa).
|
||||
- name: Build with Gradle
|
||||
run: ./gradlew qa
|
||||
env:
|
||||
SIGNAL_BUILD_CACHE_URL: ${{ secrets.SIGNAL_BUILD_CACHE_URL }}
|
||||
SIGNAL_BUILD_CACHE_USER: ${{ secrets.SIGNAL_BUILD_CACHE_USER }}
|
||||
SIGNAL_BUILD_CACHE_PASSWORD: ${{ secrets.SIGNAL_BUILD_CACHE_PASSWORD }}
|
||||
SIGNAL_BUILD_CACHE_PUSH: ${{ startsWith(github.ref, 'refs/heads/8.') }}
|
||||
run: ./gradlew ${{ github.event_name == 'pull_request' && 'ci' || 'qa' }}
|
||||
|
||||
- name: Archive reports for failed build
|
||||
if: ${{ failure() }}
|
||||
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
runs-on: ubuntu-latest-8-cores
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
# gh api repos/actions/checkout/commits/v6 --jq '.sha'
|
||||
with:
|
||||
submodules: true
|
||||
@@ -28,7 +28,15 @@ jobs:
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 17
|
||||
cache: gradle
|
||||
|
||||
- name: Set up Gradle
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6
|
||||
# gh api repos/gradle/actions/commits/v6 --jq '.sha'
|
||||
with:
|
||||
# PR-only workflow: always read from the cache, never write.
|
||||
cache-read-only: true
|
||||
# Required to read the Gradle configuration cache persisted by 8.** builds.
|
||||
cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
|
||||
|
||||
- name: Install NDK
|
||||
run: echo "y" | ${ANDROID_SDK_ROOT}/cmdline-tools/latest/bin/sdkmanager --install "ndk;${{ env.NDK_VERSION }}"
|
||||
@@ -53,7 +61,7 @@ jobs:
|
||||
if: steps.cache-base.outputs.cache-hit != 'true'
|
||||
run: mv app/build/outputs/apk/playProd/release/*arm64*.apk diffuse-base.apk
|
||||
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
# gh api repos/actions/checkout/commits/v6 --jq '.sha'
|
||||
with:
|
||||
submodules: true
|
||||
|
||||
@@ -11,7 +11,7 @@ jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
# gh api repos/actions/checkout/commits/v6 --jq '.sha'
|
||||
- name: Build image
|
||||
run: |
|
||||
|
||||
@@ -14,7 +14,7 @@ jobs:
|
||||
actions: write
|
||||
|
||||
steps:
|
||||
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10
|
||||
- uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10
|
||||
# gh api repos/actions/stale/commits/v10 --jq '.sha'
|
||||
with:
|
||||
days-before-stale: 60
|
||||
|
||||
+18
-22
@@ -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 = 1695
|
||||
val canonicalVersionName = "8.12.2"
|
||||
val canonicalVersionCode = 1706
|
||||
val canonicalVersionName = "8.15.2"
|
||||
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",
|
||||
@@ -132,7 +135,6 @@ android {
|
||||
ndkVersion = libs.versions.ndk.get()
|
||||
|
||||
flavorDimensions += listOf("distribution", "environment")
|
||||
testBuildType = "instrumentation"
|
||||
|
||||
android.bundle.language.enableSplit = false
|
||||
|
||||
@@ -219,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()
|
||||
|
||||
@@ -253,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\"")
|
||||
@@ -344,18 +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\"")
|
||||
buildConfigField("String[]", "UNIDENTIFIED_SENDER_TRUST_ROOTS", "new String[]{ \"BVT/2gHqbrG1xzuIypLIOjFgMtihrMld1/5TGADL6Dhv\"}")
|
||||
}
|
||||
|
||||
create("spinner") {
|
||||
initWith(getByName("debug"))
|
||||
isDefault = false
|
||||
@@ -538,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.
|
||||
@@ -726,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)
|
||||
@@ -747,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
+7
@@ -1,5 +1,6 @@
|
||||
package org.thoughtcrime.securesms
|
||||
|
||||
import android.content.Context
|
||||
import org.signal.core.util.concurrent.SignalExecutors
|
||||
import org.signal.core.util.logging.AndroidLogger
|
||||
import org.signal.core.util.logging.Log
|
||||
@@ -11,6 +12,7 @@ import org.thoughtcrime.securesms.dependencies.InstrumentationApplicationDepende
|
||||
import org.thoughtcrime.securesms.logging.CustomSignalProtocolLogger
|
||||
import org.thoughtcrime.securesms.logging.PersistentLogger
|
||||
import org.thoughtcrime.securesms.testing.InMemoryLogger
|
||||
import org.thoughtcrime.securesms.util.Environment
|
||||
|
||||
/**
|
||||
* Application context for running instrumentation tests (aka androidTests).
|
||||
@@ -19,6 +21,11 @@ class SignalInstrumentationApplicationContext : ApplicationContext() {
|
||||
|
||||
val inMemoryLogger: InMemoryLogger = InMemoryLogger()
|
||||
|
||||
override fun attachBaseContext(base: Context?) {
|
||||
Environment.IS_INSTRUMENTATION = true
|
||||
super.attachBaseContext(base)
|
||||
}
|
||||
|
||||
override fun initializeAppDependencies() {
|
||||
val default = ApplicationDependencyProvider(this)
|
||||
AppDependencies.init(this, InstrumentationApplicationDependencyProvider(this, default))
|
||||
|
||||
+1
-1
@@ -138,7 +138,7 @@ class ConversationItemPreviewer {
|
||||
private fun attachment(): SignalServiceAttachmentPointer {
|
||||
return SignalServiceAttachmentPointer(
|
||||
Cdn.CDN_3.cdnNumber,
|
||||
SignalServiceAttachmentRemoteId.from(""),
|
||||
SignalServiceAttachmentRemoteId.from("", Cdn.CDN_3.cdnNumber),
|
||||
"image/webp",
|
||||
null,
|
||||
Optional.empty(),
|
||||
|
||||
+393
@@ -0,0 +1,393 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.conversation.v2
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.Application
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.isEqualTo
|
||||
import assertk.assertions.isGreaterThan
|
||||
import assertk.assertions.isGreaterThanOrEqualTo
|
||||
import assertk.assertions.isLessThan
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.thoughtcrime.securesms.MainActivity
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.conversation.ConversationIntents
|
||||
import org.thoughtcrime.securesms.database.MessageType
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.mms.IncomingMessage
|
||||
import org.thoughtcrime.securesms.mms.OutgoingMessage
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.testing.SignalActivityRule
|
||||
import java.util.Collections
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* End-to-end UI test of the unread divider. Seeds a thread with many unread messages and opens it via the notification
|
||||
* path (which enters the conversation with no explicit jump point — functionally "open a chat with X unread"), then
|
||||
* verifies the real pipeline (repository -> view model -> fragment -> decoration) anchors the divider to the oldest
|
||||
* unread message and scrolls there rather than opening at the bottom.
|
||||
*
|
||||
* The launch harness mirrors [org.thoughtcrime.securesms.main.MainNavigationLaunchTest]: ActivityScenario can't track
|
||||
* MainActivity launched with a custom-action intent, so we start it via Application#startActivity and observe lifecycle
|
||||
* callbacks instead.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class UnreadDividerInstrumentationTest {
|
||||
|
||||
@get:Rule
|
||||
val harness = SignalActivityRule(othersCount = 2)
|
||||
|
||||
@Test
|
||||
fun opensScrolledToOldestUnreadWithCorrectDividerState() {
|
||||
val recipientId = harness.others.first()
|
||||
SignalDatabase.recipients.setProfileSharing(recipientId, true)
|
||||
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(Recipient.resolved(recipientId))
|
||||
|
||||
val totalUnread = 50
|
||||
val oldestSentTime = 1000L
|
||||
var oldestUnreadId = -1L
|
||||
for (i in 0 until totalUnread) {
|
||||
val id = insertIncoming(threadId, recipientId, time = oldestSentTime + i, body = "unread $i")
|
||||
if (i == 0) {
|
||||
oldestUnreadId = id
|
||||
}
|
||||
}
|
||||
|
||||
// Derive expectations from the DB the same way the app does, so the test is robust to any extra system rows.
|
||||
val expectedUnreadCount = SignalDatabase.messages.getUnreadCount(threadId)
|
||||
val firstUnreadPosition = SignalDatabase.messages.getMessagePositionByDateReceivedTimestamp(threadId, oldestSentTime, false)
|
||||
|
||||
launch(recipientId).use { launched ->
|
||||
val result = await(timeoutMs = 20_000, description = "conversation scrolled to oldest unread") {
|
||||
val fragment = launched.latestConversationFragment() ?: return@await null
|
||||
val recycler = fragment.view?.findViewById<RecyclerView>(R.id.conversation_item_recycler) ?: return@await null
|
||||
val decoration = recycler.conversationItemDecorations() ?: return@await null
|
||||
val state = decoration.unreadStateForTesting as? ConversationItemDecorations.UnreadState.CompleteUnreadState ?: return@await null
|
||||
val view = recycler.layoutManager?.findViewByPosition(firstUnreadPosition) ?: return@await null
|
||||
Observed(state.unreadCount, state.firstUnreadId, view.top, recycler.height)
|
||||
}
|
||||
|
||||
assertThat(result.unreadCount).isEqualTo(expectedUnreadCount)
|
||||
assertThat(result.firstUnreadId).isEqualTo(oldestUnreadId)
|
||||
// The oldest unread is laid out in the top half -> we scrolled up to it instead of opening at the bottom (where,
|
||||
// with this many messages, it would be off-screen above and findViewByPosition would have returned null).
|
||||
assertThat(result.firstUnreadTop).isGreaterThanOrEqualTo(0)
|
||||
assertThat(result.firstUnreadTop).isLessThan(result.recyclerHeight / 2)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fullyReadConversationOpensAtBottomWithoutDivider() {
|
||||
val recipientId = harness.others.first()
|
||||
SignalDatabase.recipients.setProfileSharing(recipientId, true)
|
||||
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(Recipient.resolved(recipientId))
|
||||
|
||||
val total = 50
|
||||
for (i in 0 until total) {
|
||||
insertIncoming(threadId, recipientId, time = 1000L + i, body = "read $i")
|
||||
}
|
||||
SignalDatabase.threads.setRead(threadId)
|
||||
// Precondition: nothing is unread, so there should be no divider.
|
||||
assertThat(SignalDatabase.messages.getUnreadCount(threadId)).isEqualTo(0)
|
||||
|
||||
launch(recipientId).use { launched ->
|
||||
val result = await(timeoutMs = 20_000, description = "fully-read conversation opened at the bottom") {
|
||||
val fragment = launched.latestConversationFragment() ?: return@await null
|
||||
val recycler = fragment.view?.findViewById<RecyclerView>(R.id.conversation_item_recycler) ?: return@await null
|
||||
val decoration = recycler.conversationItemDecorations() ?: return@await null
|
||||
// The newest message is position 0; if it's laid out, the list loaded and settled at the bottom.
|
||||
val newest = recycler.layoutManager?.findViewByPosition(0) ?: return@await null
|
||||
BottomObserved(decoration.unreadStateForTesting, newest.bottom, recycler.height)
|
||||
}
|
||||
|
||||
assertThat(result.unreadState).isEqualTo(ConversationItemDecorations.UnreadState.None)
|
||||
// Newest message sits in the lower half -> opened at the bottom (with this many messages it would be off-screen
|
||||
// below if we'd opened at the top).
|
||||
assertThat(result.newestBottom).isGreaterThan(result.recyclerHeight / 2)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun outgoingMessageNewerThanUnreadClearsDivider() {
|
||||
val recipientId = harness.others.first()
|
||||
SignalDatabase.recipients.setProfileSharing(recipientId, true)
|
||||
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(Recipient.resolved(recipientId))
|
||||
|
||||
// A few unread incoming messages, then a newer outgoing reply. Kept small so all rows load in the initial page.
|
||||
insertIncoming(threadId, recipientId, time = 1000L, body = "unread 0")
|
||||
insertIncoming(threadId, recipientId, time = 1001L, body = "unread 1")
|
||||
insertIncoming(threadId, recipientId, time = 1002L, body = "unread 2")
|
||||
val outgoing = OutgoingMessage.text(
|
||||
threadRecipient = Recipient.resolved(recipientId),
|
||||
body = "my reply",
|
||||
expiresIn = 0,
|
||||
sentTimeMillis = 1003L
|
||||
)
|
||||
SignalDatabase.messages.insertMessageOutbox(outgoing, threadId)
|
||||
|
||||
// Precondition: the messages are still unread at the DB level, so the divider would show if it weren't for the
|
||||
// newer outgoing message clearing it.
|
||||
assertThat(SignalDatabase.messages.getUnreadCount(threadId)).isGreaterThan(0)
|
||||
|
||||
launch(recipientId).use { launched ->
|
||||
val cleared = await(timeoutMs = 20_000, description = "divider cleared by newer outgoing message") {
|
||||
val fragment = launched.latestConversationFragment() ?: return@await null
|
||||
val recycler = fragment.view?.findViewById<RecyclerView>(R.id.conversation_item_recycler) ?: return@await null
|
||||
val decoration = recycler.conversationItemDecorations() ?: return@await null
|
||||
// Wait until the list has loaded (outgoing at position 0 laid out) before reading the resolved state.
|
||||
recycler.layoutManager?.findViewByPosition(0) ?: return@await null
|
||||
if (decoration.unreadStateForTesting == ConversationItemDecorations.UnreadState.None) true else null
|
||||
}
|
||||
|
||||
assertThat(cleared).isEqualTo(true)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun scrollingToBottomMarksEverythingReadAndDrainsUnreadCount() {
|
||||
val recipientId = harness.others.first()
|
||||
SignalDatabase.recipients.setProfileSharing(recipientId, true)
|
||||
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(Recipient.resolved(recipientId))
|
||||
|
||||
val total = 50
|
||||
for (i in 0 until total) {
|
||||
insertIncoming(threadId, recipientId, time = 1000L + i, body = "unread $i")
|
||||
}
|
||||
|
||||
launch(recipientId).use { launched ->
|
||||
// getUnreadCount is the shared source for the chat-list badge and the scroll-to-bottom button's count, so
|
||||
// asserting on it verifies the number the user sees updating as they scroll.
|
||||
await(timeoutMs = 20_000, description = "conversation loaded") {
|
||||
val recycler = launched.latestConversationFragment()?.view?.findViewById<RecyclerView>(R.id.conversation_item_recycler)
|
||||
if ((recycler?.childCount ?: 0) > 0) true else null
|
||||
}
|
||||
assertThat(SignalDatabase.messages.getUnreadCount(threadId)).isGreaterThan(0)
|
||||
|
||||
// Jump to the newest message; revealing it marks every earlier message read (MarkReadHelper.onViewsRevealed).
|
||||
runOnMain {
|
||||
launched.latestConversationFragment()?.view?.findViewById<RecyclerView>(R.id.conversation_item_recycler)?.scrollToPosition(0)
|
||||
}
|
||||
|
||||
// Scrolling through the thread drains the unread count to 0.
|
||||
await(timeoutMs = 20_000, description = "unread count reaches 0 after scrolling to the bottom") {
|
||||
if (SignalDatabase.messages.getUnreadCount(threadId) == 0) true else null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun scrollingPartwayLeavesExactlyTheUnreadMessagesBelowTheViewport() {
|
||||
val recipientId = harness.others.first()
|
||||
SignalDatabase.recipients.setProfileSharing(recipientId, true)
|
||||
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(Recipient.resolved(recipientId))
|
||||
|
||||
val total = 50
|
||||
for (i in 0 until total) {
|
||||
insertIncoming(threadId, recipientId, time = 1000L + i, body = "unread $i")
|
||||
}
|
||||
|
||||
launch(recipientId).use { launched ->
|
||||
await(timeoutMs = 20_000, description = "conversation loaded") {
|
||||
val recycler = launched.latestConversationFragment()?.view?.findViewById<RecyclerView>(R.id.conversation_item_recycler)
|
||||
if ((recycler?.childCount ?: 0) > 0) true else null
|
||||
}
|
||||
|
||||
// The chat opens at the oldest unread (near the top); scroll down to roughly the middle.
|
||||
runOnMain {
|
||||
val recycler = launched.latestConversationFragment()?.view?.findViewById<RecyclerView>(R.id.conversation_item_recycler)
|
||||
(recycler?.layoutManager as? LinearLayoutManager)?.scrollToPositionWithOffset(total / 2, 0)
|
||||
}
|
||||
|
||||
// Once mark-read settles, the unread count must equal the index of the newest visible message — i.e. exactly the
|
||||
// messages still below the viewport (reverse layout: position 0 = newest, so index N = N newer messages). This is
|
||||
// the number the scroll-to-bottom button and chat-list badge show; it must not over- or under-count mid-scroll.
|
||||
val stableCount = awaitStableUnreadCount(threadId)
|
||||
val newestVisiblePosition = await(timeoutMs = 5_000, description = "newest visible position") {
|
||||
val recycler = launched.latestConversationFragment()?.view?.findViewById<RecyclerView>(R.id.conversation_item_recycler)
|
||||
(recycler?.layoutManager as? LinearLayoutManager)?.findFirstVisibleItemPosition()?.takeIf { it >= 0 }
|
||||
}
|
||||
|
||||
assertThat(stableCount).isEqualTo(newestVisiblePosition)
|
||||
// Sanity: we exercised a genuine mid-scroll point, not the very top or bottom.
|
||||
assertThat(stableCount).isGreaterThan(0)
|
||||
assertThat(stableCount).isLessThan(total)
|
||||
}
|
||||
}
|
||||
|
||||
/** Polls [MessageTable.getUnreadCount] until it holds steady (mark-read is debounced + async), then returns it. */
|
||||
private fun awaitStableUnreadCount(threadId: Long, timeoutMs: Long = 20_000): Int {
|
||||
val deadline = System.currentTimeMillis() + timeoutMs
|
||||
var last = Int.MIN_VALUE
|
||||
var stableSince = System.currentTimeMillis()
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
val current = SignalDatabase.messages.getUnreadCount(threadId)
|
||||
if (current == last) {
|
||||
if (System.currentTimeMillis() - stableSince >= 500) {
|
||||
return current
|
||||
}
|
||||
} else {
|
||||
last = current
|
||||
stableSince = System.currentTimeMillis()
|
||||
}
|
||||
Thread.sleep(100)
|
||||
}
|
||||
throw AssertionError("Unread count never stabilized (last observed = $last)")
|
||||
}
|
||||
|
||||
private data class BottomObserved(
|
||||
val unreadState: ConversationItemDecorations.UnreadState,
|
||||
val newestBottom: Int,
|
||||
val recyclerHeight: Int
|
||||
)
|
||||
|
||||
private fun insertIncoming(threadId: Long, from: RecipientId, time: Long, body: String): Long {
|
||||
val message = IncomingMessage(
|
||||
type = MessageType.NORMAL,
|
||||
from = from,
|
||||
sentTimeMillis = time,
|
||||
serverTimeMillis = time,
|
||||
receivedTimeMillis = time,
|
||||
body = body
|
||||
)
|
||||
return SignalDatabase.messages.insertMessageInbox(message, threadId).get().messageId
|
||||
}
|
||||
|
||||
private data class Observed(
|
||||
val unreadCount: Int,
|
||||
val firstUnreadId: Long,
|
||||
val firstUnreadTop: Int,
|
||||
val recyclerHeight: Int
|
||||
)
|
||||
|
||||
private fun RecyclerView.conversationItemDecorations(): ConversationItemDecorations? {
|
||||
for (i in 0 until itemDecorationCount) {
|
||||
val decoration = getItemDecorationAt(i)
|
||||
if (decoration is ConversationItemDecorations) {
|
||||
return decoration
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun runOnMain(block: () -> Unit) {
|
||||
InstrumentationRegistry.getInstrumentation().runOnMainSync { block() }
|
||||
}
|
||||
|
||||
/** Polls [block] on the main thread until it returns non-null, failing after [timeoutMs]. */
|
||||
private fun <T> await(timeoutMs: Long, pollMs: Long = 100, description: String, block: () -> T?): T {
|
||||
val deadline = System.currentTimeMillis() + timeoutMs
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
var value: T? = null
|
||||
InstrumentationRegistry.getInstrumentation().runOnMainSync { value = block() }
|
||||
if (value != null) {
|
||||
return value!!
|
||||
}
|
||||
Thread.sleep(pollMs)
|
||||
}
|
||||
throw AssertionError("Timed out after ${timeoutMs}ms waiting for $description")
|
||||
}
|
||||
|
||||
private fun launch(recipientId: RecipientId): Launched {
|
||||
val app = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as Application
|
||||
val resumed = CountDownLatch(1)
|
||||
val conversationFragments: MutableList<ConversationFragment> = Collections.synchronizedList(mutableListOf())
|
||||
val allActivities: MutableList<Activity> = Collections.synchronizedList(mutableListOf())
|
||||
|
||||
val fragmentCallbacks = object : FragmentManager.FragmentLifecycleCallbacks() {
|
||||
override fun onFragmentCreated(fm: FragmentManager, f: Fragment, savedInstanceState: Bundle?) {
|
||||
if (f is ConversationFragment) {
|
||||
conversationFragments.add(f)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFragmentDestroyed(fm: FragmentManager, f: Fragment) {
|
||||
if (f is ConversationFragment) {
|
||||
conversationFragments.remove(f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val activityCallbacks = object : Application.ActivityLifecycleCallbacks {
|
||||
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {
|
||||
allActivities.add(activity)
|
||||
if (activity is MainActivity) {
|
||||
activity.supportFragmentManager.registerFragmentLifecycleCallbacks(fragmentCallbacks, true)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onActivityResumed(activity: Activity) {
|
||||
if (activity is MainActivity) {
|
||||
resumed.countDown()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onActivityStarted(activity: Activity) = Unit
|
||||
override fun onActivityPaused(activity: Activity) = Unit
|
||||
override fun onActivityStopped(activity: Activity) = Unit
|
||||
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) = Unit
|
||||
override fun onActivityDestroyed(activity: Activity) {
|
||||
allActivities.remove(activity)
|
||||
}
|
||||
}
|
||||
app.registerActivityLifecycleCallbacks(activityCallbacks)
|
||||
|
||||
// Open the conversation the way a notification tap does: a conversation intent with no starting position.
|
||||
val conversationIntent = ConversationIntents.createBuilder(harness.context, recipientId, -1L).blockingGet().build()
|
||||
val intent = Intent(harness.context, MainActivity::class.java).apply {
|
||||
action = ConversationIntents.ACTION
|
||||
putExtras(conversationIntent)
|
||||
// Application#startActivity from a non-Activity context requires a new task.
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
|
||||
try {
|
||||
app.startActivity(intent)
|
||||
} catch (t: Throwable) {
|
||||
app.unregisterActivityLifecycleCallbacks(activityCallbacks)
|
||||
throw t
|
||||
}
|
||||
|
||||
if (!resumed.await(15, TimeUnit.SECONDS)) {
|
||||
app.unregisterActivityLifecycleCallbacks(activityCallbacks)
|
||||
throw AssertionError("MainActivity did not reach RESUMED within 15s")
|
||||
}
|
||||
|
||||
return Launched(conversationFragments, app, activityCallbacks, allActivities)
|
||||
}
|
||||
|
||||
private class Launched(
|
||||
private val conversationFragments: List<ConversationFragment>,
|
||||
private val app: Application,
|
||||
private val callbacks: Application.ActivityLifecycleCallbacks,
|
||||
private val allActivities: MutableList<Activity>
|
||||
) : AutoCloseable {
|
||||
|
||||
fun latestConversationFragment(): ConversationFragment? = synchronized(conversationFragments) { conversationFragments.lastOrNull() }
|
||||
|
||||
override fun close() {
|
||||
val toFinish = synchronized(allActivities) { allActivities.toList() }
|
||||
if (toFinish.isNotEmpty()) {
|
||||
InstrumentationRegistry.getInstrumentation().runOnMainSync {
|
||||
toFinish.forEach { it.finish() }
|
||||
}
|
||||
}
|
||||
app.unregisterActivityLifecycleCallbacks(callbacks)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -431,7 +431,7 @@ class CallTableTest {
|
||||
|
||||
val call = SignalDatabase.calls.getCallById(callId, groupRecipientId)
|
||||
assertNotNull(call)
|
||||
assertEquals(CallTable.Event.GENERIC_GROUP_CALL, call?.event)
|
||||
assertEquals(CallTable.Event.MISSED, call?.event)
|
||||
assertEquals(1L, call?.timestamp)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.database
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.isEqualTo
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.signal.core.util.deleteAll
|
||||
import org.signal.donations.InAppPaymentType
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
|
||||
import org.thoughtcrime.securesms.testing.SignalActivityRule
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class InAppPaymentTableTest {
|
||||
@get:Rule
|
||||
val harness = SignalActivityRule()
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
SignalDatabase.inAppPayments.writableDatabase.deleteAll(InAppPaymentTable.TABLE_NAME)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenACreatedInAppPayment_whenIUpdateToPending_thenIExpectPendingPayment() {
|
||||
val inAppPaymentId = SignalDatabase.inAppPayments.insert(
|
||||
type = InAppPaymentType.ONE_TIME_DONATION,
|
||||
state = InAppPaymentTable.State.CREATED,
|
||||
subscriberId = null,
|
||||
endOfPeriod = null,
|
||||
inAppPaymentData = InAppPaymentData()
|
||||
)
|
||||
|
||||
val paymentBeforeUpdate = SignalDatabase.inAppPayments.getById(inAppPaymentId)
|
||||
assertThat(paymentBeforeUpdate?.state).isEqualTo(InAppPaymentTable.State.CREATED)
|
||||
|
||||
SignalDatabase.inAppPayments.update(
|
||||
inAppPayment = paymentBeforeUpdate!!.copy(state = InAppPaymentTable.State.PENDING)
|
||||
)
|
||||
|
||||
val paymentAfterUpdate = SignalDatabase.inAppPayments.getById(inAppPaymentId)
|
||||
assertThat(paymentAfterUpdate?.state).isEqualTo(InAppPaymentTable.State.PENDING)
|
||||
}
|
||||
}
|
||||
-174
@@ -1,174 +0,0 @@
|
||||
package org.thoughtcrime.securesms.database
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.containsExactlyInAnyOrder
|
||||
import assertk.assertions.isEmpty
|
||||
import assertk.assertions.isEqualTo
|
||||
import assertk.assertions.isFalse
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.signal.core.models.ServiceId.ACI
|
||||
import org.signal.core.util.UuidUtil
|
||||
import org.signal.core.util.deleteAll
|
||||
import org.thoughtcrime.securesms.conversation.colors.AvatarColor
|
||||
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfile
|
||||
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfileId
|
||||
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfileSchedule
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.storage.StorageSyncHelper
|
||||
import org.thoughtcrime.securesms.testing.SignalActivityRule
|
||||
import org.whispersystems.signalservice.api.storage.SignalNotificationProfileRecord
|
||||
import org.whispersystems.signalservice.api.storage.StorageId
|
||||
import java.time.DayOfWeek
|
||||
import java.util.UUID
|
||||
import org.whispersystems.signalservice.internal.storage.protos.NotificationProfile as RemoteNotificationProfile
|
||||
import org.whispersystems.signalservice.internal.storage.protos.Recipient as RemoteRecipient
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class NotificationProfileTablesTest {
|
||||
|
||||
@get:Rule
|
||||
val harness = SignalActivityRule()
|
||||
|
||||
private lateinit var alice: RecipientId
|
||||
private lateinit var profile1: NotificationProfile
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
alice = SignalDatabase.recipients.getOrInsertFromServiceId(ACI.from(UUID.randomUUID()))
|
||||
|
||||
profile1 = NotificationProfile(
|
||||
id = 1,
|
||||
name = "profile1",
|
||||
emoji = "",
|
||||
createdAt = 1000L,
|
||||
schedule = NotificationProfileSchedule(id = 1),
|
||||
allowedMembers = setOf(alice),
|
||||
notificationProfileId = NotificationProfileId.generate(),
|
||||
deletedTimestampMs = 0,
|
||||
storageServiceId = StorageId.forNotificationProfile(byteArrayOf(1, 2, 3))
|
||||
)
|
||||
|
||||
SignalDatabase.notificationProfiles.writableDatabase.deleteAll(NotificationProfileTables.NotificationProfileTable.TABLE_NAME)
|
||||
SignalDatabase.notificationProfiles.writableDatabase.deleteAll(NotificationProfileTables.NotificationProfileScheduleTable.TABLE_NAME)
|
||||
SignalDatabase.notificationProfiles.writableDatabase.deleteAll(NotificationProfileTables.NotificationProfileAllowedMembersTable.TABLE_NAME)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenARemoteProfile_whenIInsertLocally_thenIExpectAListWithThatProfile() {
|
||||
val remoteRecord =
|
||||
SignalNotificationProfileRecord(
|
||||
profile1.storageServiceId!!,
|
||||
RemoteNotificationProfile(
|
||||
id = UuidUtil.toByteArray(profile1.notificationProfileId.uuid).toByteString(),
|
||||
name = "profile1",
|
||||
emoji = "",
|
||||
color = profile1.color.colorInt(),
|
||||
createdAtMs = 1000L,
|
||||
allowedMembers = listOf(RemoteRecipient(RemoteRecipient.Contact(Recipient.resolved(alice).serviceId.get().toString()))),
|
||||
allowAllMentions = false,
|
||||
allowAllCalls = true,
|
||||
scheduleEnabled = false,
|
||||
scheduleStartTime = 900,
|
||||
scheduleEndTime = 1700,
|
||||
scheduleDaysEnabled = emptyList(),
|
||||
deletedAtTimestampMs = 0
|
||||
)
|
||||
)
|
||||
|
||||
SignalDatabase.notificationProfiles.insertNotificationProfileFromStorageSync(remoteRecord)
|
||||
val actualProfiles = SignalDatabase.notificationProfiles.getProfiles()
|
||||
|
||||
assertEquals(listOf(profile1), actualProfiles)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenAProfile_whenIDeleteIt_thenIExpectAnEmptyList() {
|
||||
val profile: NotificationProfile = SignalDatabase.notificationProfiles.createProfile(
|
||||
name = "Profile",
|
||||
emoji = "avatar",
|
||||
color = AvatarColor.A210,
|
||||
createdAt = 1000L
|
||||
).profile
|
||||
|
||||
SignalDatabase.notificationProfiles.deleteProfile(profile.id)
|
||||
|
||||
assertThat(SignalDatabase.notificationProfiles.getProfiles()).isEmpty()
|
||||
assertThat(SignalDatabase.notificationProfiles.getProfile(profile.id))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenADeletedProfile_whenIGetIt_thenIExpectItToStillHaveASchedule() {
|
||||
val profile: NotificationProfile = SignalDatabase.notificationProfiles.createProfile(
|
||||
name = "Profile",
|
||||
emoji = "avatar",
|
||||
color = AvatarColor.A210,
|
||||
createdAt = 1000L
|
||||
).profile
|
||||
|
||||
SignalDatabase.notificationProfiles.deleteProfile(profile.id)
|
||||
|
||||
val deletedProfile = SignalDatabase.notificationProfiles.getProfile(profile.id)!!
|
||||
assertThat(deletedProfile.schedule.enabled).isFalse()
|
||||
assertThat(deletedProfile.schedule.start).isEqualTo(900)
|
||||
assertThat(deletedProfile.schedule.end).isEqualTo(1700)
|
||||
assertThat(deletedProfile.schedule.daysEnabled, "Contains correct default days")
|
||||
.containsExactlyInAnyOrder(DayOfWeek.MONDAY, DayOfWeek.TUESDAY, DayOfWeek.WEDNESDAY, DayOfWeek.THURSDAY, DayOfWeek.FRIDAY)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenNotificationProfiles_whenIUpdateTheirStorageSyncIds_thenIExpectAnUpdatedList() {
|
||||
SignalDatabase.notificationProfiles.createProfile(
|
||||
name = "Profile1",
|
||||
emoji = "avatar",
|
||||
color = AvatarColor.A210,
|
||||
createdAt = 1000L
|
||||
)
|
||||
SignalDatabase.notificationProfiles.createProfile(
|
||||
name = "Profile2",
|
||||
emoji = "avatar",
|
||||
color = AvatarColor.A210,
|
||||
createdAt = 2000L
|
||||
)
|
||||
|
||||
val existingMap = SignalDatabase.notificationProfiles.getStorageSyncIdsMap()
|
||||
existingMap.forEach { (id, _) ->
|
||||
SignalDatabase.notificationProfiles.applyStorageIdUpdate(id, StorageId.forNotificationProfile(StorageSyncHelper.generateKey()))
|
||||
}
|
||||
val updatedMap = SignalDatabase.notificationProfiles.getStorageSyncIdsMap()
|
||||
|
||||
existingMap.forEach { (id, storageId) ->
|
||||
assertNotEquals(storageId, updatedMap[id])
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenAProfileDeletedOver30Days_whenICleanUp_thenIExpectItToNotHaveAStorageId() {
|
||||
val remoteRecord =
|
||||
SignalNotificationProfileRecord(
|
||||
profile1.storageServiceId!!,
|
||||
RemoteNotificationProfile(
|
||||
id = UuidUtil.toByteArray(profile1.notificationProfileId.uuid).toByteString(),
|
||||
name = "profile1",
|
||||
emoji = "",
|
||||
color = profile1.color.colorInt(),
|
||||
createdAtMs = 1000L,
|
||||
deletedAtTimestampMs = 1000L
|
||||
)
|
||||
)
|
||||
|
||||
SignalDatabase.notificationProfiles.insertNotificationProfileFromStorageSync(remoteRecord)
|
||||
SignalDatabase.notificationProfiles.removeStorageIdsFromOldDeletedProfiles(System.currentTimeMillis())
|
||||
assertThat(SignalDatabase.notificationProfiles.getStorageSyncIds()).isEmpty()
|
||||
}
|
||||
|
||||
private val NotificationProfileTables.NotificationProfileChangeResult.profile: NotificationProfile
|
||||
get() = (this as NotificationProfileTables.NotificationProfileChangeResult.Success).notificationProfile
|
||||
}
|
||||
+52
@@ -8,10 +8,17 @@ package org.thoughtcrime.securesms.database
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.isEqualTo
|
||||
import assertk.assertions.isFalse
|
||||
import assertk.assertions.isNotEqualTo
|
||||
import assertk.assertions.isNotNull
|
||||
import assertk.assertions.isNull
|
||||
import assertk.assertions.isTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.signal.core.models.ServiceId.ACI
|
||||
import org.signal.core.models.ServiceId.PNI
|
||||
import org.signal.core.util.nullIfBlank
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.storage.StorageRecordUpdate
|
||||
@@ -19,8 +26,11 @@ import org.thoughtcrime.securesms.storage.StorageSyncModels
|
||||
import org.thoughtcrime.securesms.testing.SignalActivityRule
|
||||
import org.thoughtcrime.securesms.util.MessageTableTestUtils
|
||||
import org.whispersystems.signalservice.api.storage.SignalContactRecord
|
||||
import org.whispersystems.signalservice.api.storage.signalAci
|
||||
import org.whispersystems.signalservice.api.storage.signalPni
|
||||
import org.whispersystems.signalservice.api.storage.toSignalContactRecord
|
||||
import org.whispersystems.signalservice.internal.storage.protos.ContactRecord
|
||||
import java.util.UUID
|
||||
|
||||
@Suppress("ClassName")
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
@@ -60,4 +70,46 @@ class RecipientTableTest_applyStorageSyncContactUpdate {
|
||||
val messages = MessageTableTestUtils.getMessages(SignalDatabase.threads.getThreadIdFor(other.id)!!)
|
||||
assertThat(messages.first().isIdentityDefault).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenAnAlreadySyncedContact_whenMarkedUnregistered_thenItSplitsAndPublishesTheSplit() {
|
||||
// GIVEN a registered contact with aci+pni+e164 that is already in storage service (has a storageId)
|
||||
val aci = ACI.from(UUID.randomUUID())
|
||||
val pni = PNI.from(UUID.randomUUID())
|
||||
val e164 = "+13334445555"
|
||||
|
||||
val id = SignalDatabase.recipients.getAndPossiblyMerge(aci, pni, e164)
|
||||
SignalDatabase.recipients.markRegistered(id, aci)
|
||||
|
||||
val originalStorageId: ByteArray? = SignalDatabase.recipients.getRecord(id).storageId
|
||||
assertThat(originalStorageId).isNotNull()
|
||||
|
||||
// Sanity: the record it currently publishes is whole + registered.
|
||||
val before = StorageSyncModels.localToRemoteRecord(SignalDatabase.recipients.getRecordForSync(id)!!).proto.contact!!
|
||||
assertThat(before.signalAci).isEqualTo(aci)
|
||||
assertThat(before.signalPni).isEqualTo(pni)
|
||||
assertThat(before.unregisteredAtTimestamp).isEqualTo(0L)
|
||||
|
||||
// WHEN it is marked unregistered (which strips its pni/e164 and splits it)
|
||||
SignalDatabase.recipients.markUnregistered(id)
|
||||
|
||||
// THEN its storageId rotates
|
||||
val updatedStorageId: ByteArray? = SignalDatabase.recipients.getRecord(id).storageId
|
||||
assertThat(updatedStorageId).isNotNull()
|
||||
assertThat(originalStorageId!!.contentEquals(updatedStorageId!!)).isFalse()
|
||||
|
||||
// THEN the published record is now ACI-only + unregistered
|
||||
val after = StorageSyncModels.localToRemoteRecord(SignalDatabase.recipients.getRecordForSync(id)!!).proto.contact!!
|
||||
assertThat(after.signalAci).isEqualTo(aci)
|
||||
assertThat(after.signalPni).isNull()
|
||||
assertThat(after.e164.nullIfBlank()).isNull()
|
||||
assertThat(after.unregisteredAtTimestamp > 0L).isTrue()
|
||||
|
||||
// THEN the number now lives on a separate PNI-only recipient, so no whole aci+pni+e164 record remains.
|
||||
val byPni = SignalDatabase.recipients.getByPni(pni).get()
|
||||
assertThat(byPni).isNotEqualTo(id)
|
||||
val pniRecord = StorageSyncModels.localToRemoteRecord(SignalDatabase.recipients.getRecordForSync(byPni)!!).proto.contact!!
|
||||
assertThat(pniRecord.signalAci).isNull()
|
||||
assertThat(pniRecord.signalPni).isEqualTo(pni)
|
||||
}
|
||||
}
|
||||
|
||||
-120
@@ -1,120 +0,0 @@
|
||||
package org.thoughtcrime.securesms.database.helpers.migration
|
||||
|
||||
import android.app.Application
|
||||
import androidx.core.content.contentValuesOf
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.fail
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.signal.core.util.SqlUtil
|
||||
import org.thoughtcrime.securesms.database.DistributionListTables
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.model.DistributionListId
|
||||
import org.thoughtcrime.securesms.testing.SignalDatabaseRule
|
||||
import org.whispersystems.signalservice.api.push.DistributionId
|
||||
import java.util.UUID
|
||||
import org.thoughtcrime.securesms.database.SQLiteDatabase as SignalSQLiteDatabase
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class MyStoryMigrationTest {
|
||||
|
||||
@get:Rule val harness = SignalDatabaseRule(deleteAllThreadsOnEachRun = false)
|
||||
|
||||
@Test
|
||||
fun givenAValidMyStory_whenIMigrate_thenIExpectMyStoryToBeValid() {
|
||||
// GIVEN
|
||||
assertValidMyStoryExists()
|
||||
|
||||
// WHEN
|
||||
runMigration()
|
||||
|
||||
// THEN
|
||||
assertValidMyStoryExists()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenNoMyStory_whenIMigrate_thenIExpectMyStoryToBeCreated() {
|
||||
// GIVEN
|
||||
deleteMyStory()
|
||||
|
||||
// WHEN
|
||||
runMigration()
|
||||
|
||||
// THEN
|
||||
assertValidMyStoryExists()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenA00000000DistributionIdForMyStory_whenIMigrate_thenIExpectMyStoryToBeCreated() {
|
||||
// GIVEN
|
||||
setMyStoryDistributionId("0000-0000")
|
||||
|
||||
// WHEN
|
||||
runMigration()
|
||||
|
||||
// THEN
|
||||
assertValidMyStoryExists()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenARandomDistributionIdForMyStory_whenIMigrate_thenIExpectMyStoryToBeCreated() {
|
||||
// GIVEN
|
||||
setMyStoryDistributionId(UUID.randomUUID().toString())
|
||||
|
||||
// WHEN
|
||||
runMigration()
|
||||
|
||||
// THEN
|
||||
assertValidMyStoryExists()
|
||||
}
|
||||
|
||||
private fun setMyStoryDistributionId(serializedId: String) {
|
||||
SignalDatabase.rawDatabase.update(
|
||||
DistributionListTables.LIST_TABLE_NAME,
|
||||
contentValuesOf(
|
||||
DistributionListTables.DISTRIBUTION_ID to serializedId
|
||||
),
|
||||
"_id = ?",
|
||||
SqlUtil.buildArgs(DistributionListId.MY_STORY)
|
||||
)
|
||||
}
|
||||
|
||||
private fun deleteMyStory() {
|
||||
SignalDatabase.rawDatabase.delete(
|
||||
DistributionListTables.LIST_TABLE_NAME,
|
||||
"_id = ?",
|
||||
SqlUtil.buildArgs(DistributionListId.MY_STORY)
|
||||
)
|
||||
}
|
||||
|
||||
private fun assertValidMyStoryExists() {
|
||||
SignalDatabase.rawDatabase.query(
|
||||
DistributionListTables.LIST_TABLE_NAME,
|
||||
SqlUtil.COUNT,
|
||||
"_id = ? AND ${DistributionListTables.DISTRIBUTION_ID} = ?",
|
||||
SqlUtil.buildArgs(DistributionListId.MY_STORY, DistributionId.MY_STORY.toString()),
|
||||
null,
|
||||
null,
|
||||
null
|
||||
).use {
|
||||
if (it.moveToNext()) {
|
||||
val count = it.getInt(0)
|
||||
assertEquals("assertValidMyStoryExists: Query produced an unexpected count.", 1, count)
|
||||
} else {
|
||||
fail("assertValidMyStoryExists: Query did not produce a count.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun runMigration() {
|
||||
V151_MyStoryMigration.migrate(
|
||||
InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as Application,
|
||||
SignalSQLiteDatabase(SignalDatabase.rawDatabase),
|
||||
0,
|
||||
1
|
||||
)
|
||||
}
|
||||
}
|
||||
+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()
|
||||
|
||||
+12
-13
@@ -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
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
/**
|
||||
|
||||
@@ -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()
|
||||
|
||||
+1
-1
@@ -2,13 +2,13 @@ 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.util.UptimeSleepTimer
|
||||
import org.whispersystems.signalservice.api.websocket.SignalWebSocket
|
||||
import org.whispersystems.signalservice.internal.configuration.SignalServiceConfiguration
|
||||
import org.whispersystems.signalservice.internal.websocket.BenchmarkWebSocketConnection
|
||||
|
||||
@@ -7,13 +7,13 @@ 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.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencyProvider
|
||||
import org.thoughtcrime.securesms.jobmanager.JobManager
|
||||
import org.thoughtcrime.securesms.jobs.JobManagerFactories
|
||||
import org.thoughtcrime.securesms.net.DeviceTransferBlockingInterceptor
|
||||
import org.whispersystems.signalservice.api.util.UptimeSleepTimer
|
||||
import org.whispersystems.signalservice.api.websocket.SignalWebSocket
|
||||
import org.whispersystems.signalservice.internal.configuration.SignalServiceConfiguration
|
||||
import org.whispersystems.signalservice.internal.websocket.BenchmarkWebSocketConnection
|
||||
|
||||
@@ -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)
|
||||
|
||||
+1
-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
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
|
||||
|
||||
<application
|
||||
android:usesCleartextTraffic="true"
|
||||
tools:replace="android:usesCleartextTraffic"
|
||||
tools:ignore="UnusedAttribute" />
|
||||
|
||||
</manifest>
|
||||
@@ -1,5 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/signal_accent_green"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
@@ -1,4 +0,0 @@
|
||||
<?xml version='1.0' encoding='UTF-8'?>
|
||||
<resources>
|
||||
<string name="app_name">Signal (Instrumentation)</string>
|
||||
</resources>
|
||||
@@ -1371,7 +1371,7 @@
|
||||
|
||||
<service
|
||||
android:name=".gcm.FcmReceiveService"
|
||||
android:exported="true">
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="com.google.firebase.MESSAGING_EVENT" />
|
||||
</intent-filter>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -24,6 +24,11 @@ class ConversationLayoutManager(context: Context) : LinearLayoutManager(context,
|
||||
|
||||
private var afterScroll: (() -> Unit)? = null
|
||||
|
||||
// Backing state for scrollToPositionTopAligned; alignTopCorrected guards the one-shot corrective re-scroll.
|
||||
private var alignTopPosition: Int = RecyclerView.NO_POSITION
|
||||
private var alignTopInset: Int = 0
|
||||
private var alignTopCorrected: Boolean = false
|
||||
|
||||
override fun supportsPredictiveItemAnimations(): Boolean {
|
||||
return false
|
||||
}
|
||||
@@ -34,9 +39,23 @@ class ConversationLayoutManager(context: Context) : LinearLayoutManager(context,
|
||||
*/
|
||||
fun scrollToPositionWithOffset(position: Int, offset: Int, afterScroll: () -> Unit) {
|
||||
this.afterScroll = afterScroll
|
||||
alignTopPosition = RecyclerView.NO_POSITION
|
||||
super.scrollToPositionWithOffset(position, offset)
|
||||
}
|
||||
|
||||
/**
|
||||
* Scroll so [position]'s decorated top (including any top decoration, e.g. the unread divider) lands [topInset] px
|
||||
* below the top of the recycler. [afterScroll] fires once the alignment settles.
|
||||
*/
|
||||
fun scrollToPositionTopAligned(position: Int, topInset: Int, afterScroll: () -> Unit) {
|
||||
this.afterScroll = afterScroll
|
||||
alignTopPosition = position
|
||||
alignTopInset = topInset
|
||||
alignTopCorrected = false
|
||||
// Rough first pass: the exact offset needs the item's height, which isn't known until it's laid out (see onLayoutCompleted).
|
||||
super.scrollToPositionWithOffset(position, height - topInset)
|
||||
}
|
||||
|
||||
/**
|
||||
* If a scroll to position request is made and a layout pass occurs prior to the list being populated with via the data source,
|
||||
* the base implementation clears the request as if it was never made.
|
||||
@@ -64,10 +83,26 @@ class ConversationLayoutManager(context: Context) : LinearLayoutManager(context,
|
||||
} else {
|
||||
scrollToPosition(pendingScrollPosition)
|
||||
}
|
||||
} else {
|
||||
afterScroll?.invoke()
|
||||
afterScroll = null
|
||||
return
|
||||
}
|
||||
|
||||
// The target is now laid out, so its height is known. Correct the offset once so the decorated top sits at the
|
||||
// requested inset, then let the next layout settle before notifying via afterScroll.
|
||||
if (alignTopPosition != RecyclerView.NO_POSITION && !alignTopCorrected) {
|
||||
val target = findViewByPosition(alignTopPosition)
|
||||
if (target != null) {
|
||||
alignTopCorrected = true
|
||||
if (getDecoratedTop(target) != alignTopInset) {
|
||||
val correctedOffset = (height - paddingBottom) - alignTopInset - getDecoratedMeasuredHeight(target)
|
||||
super.scrollToPositionWithOffset(alignTopPosition, correctedOffset)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
afterScroll?.invoke()
|
||||
afterScroll = null
|
||||
alignTopPosition = RecyclerView.NO_POSITION
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -13,7 +13,8 @@ object AppCapabilities {
|
||||
storage = storageCapable,
|
||||
versionedExpirationTimer = true,
|
||||
attachmentBackfill = true,
|
||||
spqr = true
|
||||
spqr = true,
|
||||
usernameChangeSyncMessage = false // TODO(michelle): Turn on once all clients support it and add a migration
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ package org.thoughtcrime.securesms;
|
||||
|
||||
import android.app.Application;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
@@ -32,8 +33,10 @@ import net.zetetic.database.Logger;
|
||||
import org.conscrypt.ConscryptSignal;
|
||||
import org.greenrobot.eventbus.EventBus;
|
||||
import org.signal.aesgcmprovider.AesGcmProvider;
|
||||
import org.signal.core.util.AppForegroundObserver;
|
||||
import org.signal.core.util.DiskUtil;
|
||||
import org.signal.core.util.MemoryTracker;
|
||||
import org.signal.core.util.Util;
|
||||
import org.signal.core.util.concurrent.AnrDetector;
|
||||
import org.signal.core.util.concurrent.SignalExecutors;
|
||||
import org.signal.core.util.logging.AndroidLogger;
|
||||
@@ -50,6 +53,7 @@ import org.thoughtcrime.securesms.backup.v2.BackupRepository;
|
||||
import org.thoughtcrime.securesms.crypto.AttachmentSecretProvider;
|
||||
import org.thoughtcrime.securesms.crypto.DatabaseSecretProvider;
|
||||
import org.thoughtcrime.securesms.database.LogDatabase;
|
||||
import org.thoughtcrime.securesms.database.SQLiteDatabase;
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase;
|
||||
import org.thoughtcrime.securesms.database.SqlCipherLibraryLoader;
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies;
|
||||
@@ -58,6 +62,7 @@ import org.thoughtcrime.securesms.emoji.EmojiSource;
|
||||
import org.thoughtcrime.securesms.emoji.JumboEmoji;
|
||||
import org.thoughtcrime.securesms.gcm.FcmFetchManager;
|
||||
import org.thoughtcrime.securesms.glide.SignalGlideComponents;
|
||||
import org.thoughtcrime.securesms.jobmanager.impl.SealedSenderConstraint;
|
||||
import org.thoughtcrime.securesms.jobs.AccountConsistencyWorkerJob;
|
||||
import org.thoughtcrime.securesms.jobs.BackupRefreshJob;
|
||||
import org.thoughtcrime.securesms.jobs.BackupSubscriptionCheckJob;
|
||||
@@ -74,6 +79,7 @@ import org.thoughtcrime.securesms.jobs.GroupV2UpdateSelfProfileKeyJob;
|
||||
import org.thoughtcrime.securesms.jobs.InAppPaymentAuthCheckJob;
|
||||
import org.thoughtcrime.securesms.jobs.InAppPaymentKeepAliveJob;
|
||||
import org.thoughtcrime.securesms.jobs.LinkedDeviceInactiveCheckJob;
|
||||
import org.thoughtcrime.securesms.jobs.MessageSendLogCleanupJob;
|
||||
import org.thoughtcrime.securesms.jobs.MultiDeviceContactUpdateJob;
|
||||
import org.thoughtcrime.securesms.jobs.PreKeysSyncJob;
|
||||
import org.thoughtcrime.securesms.jobs.ProfileUploadJob;
|
||||
@@ -82,13 +88,14 @@ import org.thoughtcrime.securesms.jobs.RefreshSvrCredentialsJob;
|
||||
import org.thoughtcrime.securesms.jobs.RestoreOptimizedMediaJob;
|
||||
import org.thoughtcrime.securesms.jobs.RetrieveProfileJob;
|
||||
import org.thoughtcrime.securesms.jobs.RetrieveRemoteAnnouncementsJob;
|
||||
import org.thoughtcrime.securesms.jobmanager.impl.SealedSenderConstraint;
|
||||
import org.thoughtcrime.securesms.jobs.StoryOnboardingDownloadJob;
|
||||
import org.thoughtcrime.securesms.keyvalue.KeepMessagesDuration;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.logging.CustomSignalProtocolLogger;
|
||||
import org.thoughtcrime.securesms.logging.PersistentLogger;
|
||||
import org.thoughtcrime.securesms.logsubmit.SubmitDebugLogActivity;
|
||||
import org.thoughtcrime.securesms.messageprocessingalarm.RoutineMessageFetchReceiver;
|
||||
import org.thoughtcrime.securesms.messages.IncomingMessageObserver;
|
||||
import org.thoughtcrime.securesms.migrations.ApplicationMigrations;
|
||||
import org.thoughtcrime.securesms.mms.SignalGlideModule;
|
||||
import org.thoughtcrime.securesms.providers.BlobProvider;
|
||||
@@ -104,10 +111,8 @@ import org.thoughtcrime.securesms.service.MessageBackupListener;
|
||||
import org.thoughtcrime.securesms.service.RotateSenderCertificateListener;
|
||||
import org.thoughtcrime.securesms.service.RotateSignedPreKeyListener;
|
||||
import org.thoughtcrime.securesms.service.webrtc.ActiveCallManager;
|
||||
import org.thoughtcrime.securesms.service.webrtc.CallingAssets;
|
||||
import org.thoughtcrime.securesms.service.webrtc.AndroidTelecomUtil;
|
||||
import org.thoughtcrime.securesms.storage.StorageSyncHelper;
|
||||
import org.thoughtcrime.securesms.util.AppForegroundObserver;
|
||||
import org.thoughtcrime.securesms.util.AppStartup;
|
||||
import org.thoughtcrime.securesms.util.DeviceProperties;
|
||||
import org.thoughtcrime.securesms.util.DynamicTheme;
|
||||
@@ -118,7 +123,6 @@ import org.thoughtcrime.securesms.util.SignalLocalMetrics;
|
||||
import org.thoughtcrime.securesms.util.SignalUncaughtExceptionHandler;
|
||||
import org.thoughtcrime.securesms.util.SqlCipherLogTarget;
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
import org.signal.core.util.Util;
|
||||
import org.thoughtcrime.securesms.util.VersionTracker;
|
||||
import org.thoughtcrime.securesms.util.dynamiclanguage.DynamicLanguageContextWrapper;
|
||||
import org.whispersystems.signalservice.api.websocket.SignalWebSocket;
|
||||
@@ -226,7 +230,7 @@ public class ApplicationContext extends Application implements AppForegroundObse
|
||||
.addPostRender(RefreshSvrCredentialsJob::enqueueIfNecessary)
|
||||
.addPostRender(() -> DownloadLatestEmojiDataJob.scheduleIfNecessary(this))
|
||||
.addPostRender(EmojiSearchIndexDownloadJob::scheduleIfNecessary)
|
||||
.addPostRender(() -> SignalDatabase.messageLog().trimOldMessages(System.currentTimeMillis(), RemoteConfig.retryRespondMaxAge()))
|
||||
.addPostRender(MessageSendLogCleanupJob::enqueue)
|
||||
.addPostRender(() -> JumboEmoji.updateCurrentVersion(this))
|
||||
.addPostRender(RetrieveRemoteAnnouncementsJob::enqueue)
|
||||
.addPostRender(AndroidTelecomUtil::registerPhoneAccount)
|
||||
@@ -274,7 +278,7 @@ public class ApplicationContext extends Application implements AppForegroundObse
|
||||
checkFreeDiskSpace();
|
||||
MemoryTracker.start();
|
||||
BackupSubscriptionCheckJob.enqueueIfAble();
|
||||
CheckKeyTransparencyJob.enqueueIfNecessary(true);
|
||||
CheckKeyTransparencyJob.enqueueIfNecessary(true, false);
|
||||
AppDependencies.getAuthWebSocket().registerKeepAliveToken(SignalWebSocket.FOREGROUND_KEEPALIVE);
|
||||
AppDependencies.getUnauthWebSocket().registerKeepAliveToken(SignalWebSocket.FOREGROUND_KEEPALIVE);
|
||||
|
||||
@@ -417,7 +421,12 @@ public class ApplicationContext extends Application implements AppForegroundObse
|
||||
new org.signal.registration.RegistrationDependencies(
|
||||
new org.thoughtcrime.securesms.registration.v2.AppRegistrationNetworkController(this, AppDependencies.getPushServiceSocket()),
|
||||
new org.thoughtcrime.securesms.registration.v2.AppRegistrationStorageController(this),
|
||||
null
|
||||
Environment.IS_LINK_AND_SYNC_AVAILABLE,
|
||||
null,
|
||||
context -> {
|
||||
context.startActivity(new Intent(context, SubmitDebugLogActivity.class));
|
||||
return Unit.INSTANCE;
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -448,22 +457,27 @@ public class ApplicationContext extends Application implements AppForegroundObse
|
||||
PlayServicesUtil.PlayServicesStatus playServicesStatus = PlayServicesUtil.getPlayServicesStatus(this);
|
||||
|
||||
if (playServicesStatus == PlayServicesUtil.PlayServicesStatus.SUCCESS && !SignalStore.account().isFcmEnabled()) {
|
||||
Log.i(TAG, "Play Services are newly-available. Enabling FCM and updating server.");
|
||||
Log.w(TAG, "Play Services are newly-available. Enabling FCM and updating server.");
|
||||
SignalStore.account().setFcmEnabled(true);
|
||||
AppDependencies.getJobManager().startChain(new FcmRefreshJob())
|
||||
.then(new RefreshAttributesJob())
|
||||
.enqueue();
|
||||
AppDependencies.resetNetwork();
|
||||
AppDependencies.startNetwork();
|
||||
IncomingMessageObserver.stopForegroundService(this);
|
||||
} else if (playServicesStatus == PlayServicesUtil.PlayServicesStatus.MISSING && SignalStore.account().isFcmEnabled()) {
|
||||
Log.w(TAG, "Play Services are no longer available. Disabling FCM and updating server.");
|
||||
SignalStore.account().setFcmEnabled(false);
|
||||
SignalStore.account().setFcmToken(null);
|
||||
AppDependencies.getJobManager().add(new RefreshAttributesJob());
|
||||
Log.w(TAG, "Play Services are no longer available. Attempting to get an FCM token anyway.");
|
||||
AppDependencies.getJobManager().add(new FcmRefreshJob());
|
||||
} else if (playServicesStatus == PlayServicesUtil.PlayServicesStatus.MISSING && (System.currentTimeMillis() - SignalStore.misc().getLastMissingPlayServicesFcmVerificationTime()) > TimeUnit.DAYS.toMillis(3)) {
|
||||
Log.i(TAG, "Play Services are unavailable, but it's been long enough that we should check and see if we can get an FCM token anyway.");
|
||||
AppDependencies.getJobManager().add(new FcmRefreshJob());
|
||||
} else if (SignalStore.account().isFcmEnabled()) {
|
||||
long lastSetTime = SignalStore.account().getFcmTokenLastSetTime();
|
||||
long nextSetTime = lastSetTime + TimeUnit.HOURS.toMillis(6);
|
||||
long now = System.currentTimeMillis();
|
||||
|
||||
if (SignalStore.account().getFcmToken() == null || nextSetTime <= now || lastSetTime > now) {
|
||||
Log.i(TAG, "Time for routine FCM token refresh.");
|
||||
AppDependencies.getJobManager().add(new FcmRefreshJob());
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -16,7 +16,7 @@ import androidx.core.app.ActivityOptionsCompat;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies;
|
||||
import org.thoughtcrime.securesms.util.AppStartup;
|
||||
import org.thoughtcrime.securesms.util.ConfigurationUtil;
|
||||
import org.signal.core.util.ConfigurationUtil;
|
||||
import org.thoughtcrime.securesms.util.WindowUtil;
|
||||
import org.thoughtcrime.securesms.util.dynamiclanguage.DynamicLanguageContextWrapper;
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import androidx.lifecycle.LifecycleOwner;
|
||||
import com.bumptech.glide.RequestManager;
|
||||
|
||||
import org.thoughtcrime.securesms.conversationlist.model.ConversationSet;
|
||||
import org.thoughtcrime.securesms.database.model.ThreadRecord;
|
||||
import org.thoughtcrime.securesms.database.model.ThreadWithRecipient;
|
||||
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
@@ -14,7 +14,7 @@ import java.util.Set;
|
||||
public interface BindableConversationListItem extends Unbindable {
|
||||
|
||||
void bind(@NonNull LifecycleOwner lifecycleOwner,
|
||||
@NonNull ThreadRecord thread,
|
||||
@NonNull ThreadWithRecipient thread,
|
||||
@NonNull RequestManager requestManager, @NonNull Locale locale,
|
||||
@NonNull Set<Long> typingThreads,
|
||||
@NonNull ConversationSet selectedConversations,
|
||||
|
||||
@@ -10,8 +10,8 @@ import androidx.activity.result.contract.ActivityResultContract
|
||||
import androidx.biometric.BiometricManager
|
||||
import androidx.biometric.BiometricPrompt
|
||||
import androidx.biometric.BiometricPrompt.PromptInfo
|
||||
import org.signal.core.util.ServiceUtil
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.util.ServiceUtil
|
||||
|
||||
/**
|
||||
* Authentication using phone biometric (face, fingerprint recognition) or device lock (pattern, pin or passphrase).
|
||||
|
||||
@@ -23,45 +23,25 @@ public final class BlockUnblockDialog {
|
||||
private BlockUnblockDialog() {}
|
||||
|
||||
public static void showReportSpamFor(@NonNull Context context,
|
||||
@NonNull Lifecycle lifecycle,
|
||||
@NonNull Recipient recipient,
|
||||
@NonNull Runnable onReportSpam,
|
||||
@Nullable Runnable onBlockAndReportSpam)
|
||||
{
|
||||
SimpleTask.run(lifecycle,
|
||||
() -> buildReportSpamFor(context, recipient, onReportSpam, onBlockAndReportSpam),
|
||||
AlertDialog.Builder::show);
|
||||
buildReportSpamFor(context, recipient, onReportSpam, onBlockAndReportSpam).show();
|
||||
}
|
||||
|
||||
public static void showBlockFor(@NonNull Context context,
|
||||
@NonNull Lifecycle lifecycle,
|
||||
@NonNull Recipient recipient,
|
||||
@NonNull Runnable onBlock)
|
||||
{
|
||||
SimpleTask.run(lifecycle,
|
||||
() -> buildBlockFor(context, recipient, onBlock, null),
|
||||
AlertDialog.Builder::show);
|
||||
}
|
||||
|
||||
public static void showBlockAndReportSpamFor(@NonNull Context context,
|
||||
@NonNull Lifecycle lifecycle,
|
||||
@NonNull Recipient recipient,
|
||||
@NonNull Runnable onBlock,
|
||||
@NonNull Runnable onBlockAndReportSpam)
|
||||
{
|
||||
SimpleTask.run(lifecycle,
|
||||
() -> buildBlockFor(context, recipient, onBlock, onBlockAndReportSpam),
|
||||
AlertDialog.Builder::show);
|
||||
buildBlockFor(context, recipient, onBlock, null).show();
|
||||
}
|
||||
|
||||
public static void showUnblockFor(@NonNull Context context,
|
||||
@NonNull Lifecycle lifecycle,
|
||||
@NonNull Recipient recipient,
|
||||
@NonNull Runnable onUnblock)
|
||||
{
|
||||
SimpleTask.run(lifecycle,
|
||||
() -> buildUnblockFor(context, recipient, onUnblock),
|
||||
AlertDialog.Builder::show);
|
||||
buildUnblockFor(context, recipient, onUnblock).show();
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
|
||||
@@ -281,7 +281,8 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
new ContactSelectionListAdapter.ArbitraryRepository(),
|
||||
new SearchRepository(requireContext().getString(R.string.note_to_self)),
|
||||
new ContactSearchPagedDataSourceRepository(requireContext()),
|
||||
fixedContacts
|
||||
fixedContacts,
|
||||
false
|
||||
)
|
||||
).get(ContactSearchViewModel.class);
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -179,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
|
||||
@@ -1052,6 +1052,13 @@ class MainActivity :
|
||||
|
||||
private fun handleConversationIntent(intent: Intent) {
|
||||
if (ConversationIntents.isConversationIntent(intent)) {
|
||||
if (!isTrustedConversationIntent(intent)) {
|
||||
Log.w(TAG, "Received a conversation intent through an exported entry point. Ignoring its extras.")
|
||||
intent.action = null
|
||||
setIntent(intent)
|
||||
return
|
||||
}
|
||||
|
||||
mainNavigationViewModel.goTo(MainNavigationListLocation.CHATS)
|
||||
mainNavigationViewModel.goTo(MainNavigationDetailLocation.Conversation(ConversationIntents.readArgsFromBundle(intent.extras!!)))
|
||||
intent.action = null
|
||||
@@ -1059,6 +1066,14 @@ class MainActivity :
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* While MainActivity isn't exporting, we have launcher aliases that are, so we verify that someone isn't launching us through those befre
|
||||
* respecting various intent attributes.
|
||||
*/
|
||||
private fun isTrustedConversationIntent(intent: Intent): Boolean {
|
||||
return intent.component?.className == MainActivity::class.java.name
|
||||
}
|
||||
|
||||
private fun handleGroupLinkInIntent(intent: Intent) {
|
||||
intent.data?.let { data ->
|
||||
CommunicationActions.handlePotentialGroupLinkUrl(this, data.toString())
|
||||
|
||||
@@ -33,7 +33,7 @@ import org.thoughtcrime.securesms.registration.ui.RegistrationActivity;
|
||||
import org.thoughtcrime.securesms.util.Environment;
|
||||
import org.thoughtcrime.securesms.restore.RestoreActivity;
|
||||
import org.thoughtcrime.securesms.service.KeyCachingService;
|
||||
import org.thoughtcrime.securesms.util.AppForegroundObserver;
|
||||
import org.signal.core.util.AppForegroundObserver;
|
||||
import org.thoughtcrime.securesms.util.AppStartup;
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageInstaller
|
||||
import android.os.Build
|
||||
import org.signal.core.util.AppForegroundObserver
|
||||
import org.signal.core.util.PendingIntentFlags
|
||||
import org.signal.core.util.StreamUtil
|
||||
import org.signal.core.util.getDownloadManager
|
||||
@@ -18,7 +19,6 @@ import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.jobs.ApkUpdateJob
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.util.AppForegroundObserver
|
||||
import org.thoughtcrime.securesms.util.FileUtils
|
||||
import java.io.FileInputStream
|
||||
import java.io.IOException
|
||||
@@ -61,21 +61,36 @@ object ApkUpdateInstaller {
|
||||
return
|
||||
}
|
||||
|
||||
if (!isMatchingDigest(context, downloadId, digest)) {
|
||||
Log.w(TAG, "DownloadId matches, but digest does not! Bad download or inconsistent state. Failing and clearing state.")
|
||||
SignalStore.apkUpdate.clearDownloadAttributes()
|
||||
ApkUpdateNotifications.showInstallFailed(context, ApkUpdateNotifications.FailureReason.UNKNOWN)
|
||||
return
|
||||
}
|
||||
|
||||
if (!userInitiated && !shouldAutoUpdate()) {
|
||||
if (!isMatchingDigest(context, downloadId, digest)) {
|
||||
Log.w(TAG, "DownloadId matches, but digest does not! Bad download or inconsistent state. Failing and clearing state.")
|
||||
SignalStore.apkUpdate.clearDownloadAttributes()
|
||||
ApkUpdateNotifications.showInstallFailed(context, ApkUpdateNotifications.FailureReason.UNKNOWN)
|
||||
return
|
||||
}
|
||||
|
||||
Log.w(TAG, "Not user-initiated and not eligible for auto-update. Prompting. (API=${Build.VERSION.SDK_INT}, Foreground=${AppForegroundObserver.isForegrounded()}, AutoUpdate=${SignalStore.apkUpdate.autoUpdate})")
|
||||
ApkUpdateNotifications.showInstallPrompt(context, downloadId)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
installApk(context, downloadId, userInitiated)
|
||||
context
|
||||
.getDownloadManager()
|
||||
.openDownloadedFile(downloadId)
|
||||
.use { parcelFileDescriptor ->
|
||||
val stream = FileInputStream(parcelFileDescriptor.fileDescriptor)
|
||||
|
||||
if (!MessageDigest.isEqual(FileUtils.getFileDigest(stream), digest)) {
|
||||
Log.w(TAG, "DownloadId matches, but digest does not! Bad download or inconsistent state. Failing and clearing state.")
|
||||
SignalStore.apkUpdate.clearDownloadAttributes()
|
||||
ApkUpdateNotifications.showInstallFailed(context, ApkUpdateNotifications.FailureReason.UNKNOWN)
|
||||
return
|
||||
}
|
||||
|
||||
stream.channel.position(0)
|
||||
installApk(context, downloadId, stream, userInitiated)
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "Hit IOException when trying to install APK!", e)
|
||||
SignalStore.apkUpdate.clearDownloadAttributes()
|
||||
@@ -88,17 +103,13 @@ object ApkUpdateInstaller {
|
||||
}
|
||||
|
||||
@Throws(IOException::class, SecurityException::class)
|
||||
private fun installApk(context: Context, downloadId: Long, userInitiated: Boolean) {
|
||||
val apkInputStream: InputStream? = getDownloadedApkInputStream(context, downloadId)
|
||||
if (apkInputStream == null) {
|
||||
Log.w(TAG, "Could not open download APK input stream!")
|
||||
return
|
||||
}
|
||||
|
||||
private fun installApk(context: Context, downloadId: Long, apkInputStream: InputStream, userInitiated: Boolean) {
|
||||
Log.d(TAG, "Beginning APK install...")
|
||||
val packageInstaller: PackageInstaller = context.packageManager.packageInstaller
|
||||
|
||||
val sessionParams = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL).apply {
|
||||
// Reject the session if the APK's declared package name doesn't match ours.
|
||||
setAppPackageName(context.packageName)
|
||||
// At this point, we always want to set this if possible, since we've already prompted the user with our own notification when necessary.
|
||||
// This lets us skip the system-generated notification.
|
||||
if (Build.VERSION.SDK_INT >= 31) {
|
||||
@@ -133,15 +144,6 @@ object ApkUpdateInstaller {
|
||||
session.commit(installerPendingIntent.intentSender)
|
||||
}
|
||||
|
||||
private fun getDownloadedApkInputStream(context: Context, downloadId: Long): InputStream? {
|
||||
return try {
|
||||
FileInputStream(context.getDownloadManager().openDownloadedFile(downloadId).fileDescriptor)
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun isDownloadSuccessful(context: Context, downloadId: Long): Boolean {
|
||||
val query = DownloadManager.Query().setFilterById(downloadId)
|
||||
val cursor = context.getDownloadManager().query(query)
|
||||
|
||||
@@ -12,12 +12,12 @@ import android.content.Intent
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import org.signal.core.util.PendingIntentFlags
|
||||
import org.signal.core.util.ServiceUtil
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.MainActivity
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.notifications.NotificationChannels
|
||||
import org.thoughtcrime.securesms.notifications.NotificationIds
|
||||
import org.thoughtcrime.securesms.util.ServiceUtil
|
||||
|
||||
object ApkUpdateNotifications {
|
||||
|
||||
|
||||
@@ -10,11 +10,11 @@ import android.os.Parcelable
|
||||
import androidx.core.os.ParcelCompat
|
||||
import org.signal.blurhash.BlurHash
|
||||
import org.signal.core.models.media.TransformProperties
|
||||
import org.signal.core.util.ParcelUtil
|
||||
import org.signal.core.util.UuidUtil
|
||||
import org.thoughtcrime.securesms.audio.AudioHash
|
||||
import org.thoughtcrime.securesms.database.AttachmentTable
|
||||
import org.thoughtcrime.securesms.stickers.StickerLocator
|
||||
import org.thoughtcrime.securesms.util.ParcelUtil
|
||||
import java.util.UUID
|
||||
import kotlin.time.Duration.Companion.days
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
@@ -31,7 +31,7 @@ fun Attachment.toAttachmentPointer(context: Context): AttachmentPointer? {
|
||||
}
|
||||
|
||||
try {
|
||||
val remoteId = SignalServiceAttachmentRemoteId.from(attachment.remoteLocation!!)
|
||||
val remoteId = SignalServiceAttachmentRemoteId.from(attachment.remoteLocation!!, attachment.cdn.cdnNumber)
|
||||
|
||||
var attachmentWidth = attachment.width
|
||||
var attachmentHeight = attachment.height
|
||||
|
||||
@@ -41,12 +41,16 @@ enum class Cdn(private val value: Int) {
|
||||
}
|
||||
|
||||
fun fromCdnNumber(cdnNumber: Int): Cdn {
|
||||
return fromCdnNumberOrNull(cdnNumber) ?: throw UnsupportedOperationException("Invalid CDN number: $cdnNumber")
|
||||
}
|
||||
|
||||
fun fromCdnNumberOrNull(cdnNumber: Int): Cdn? {
|
||||
return when (cdnNumber) {
|
||||
-1 -> S3
|
||||
0 -> CDN_0
|
||||
2 -> CDN_2
|
||||
3 -> CDN_3
|
||||
else -> throw UnsupportedOperationException("Invalid CDN number: $cdnNumber")
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,11 +5,11 @@ import android.os.Parcel
|
||||
import androidx.core.os.ParcelCompat
|
||||
import org.signal.blurhash.BlurHash
|
||||
import org.signal.core.models.media.TransformProperties
|
||||
import org.signal.core.util.ParcelUtil
|
||||
import org.thoughtcrime.securesms.audio.AudioHash
|
||||
import org.thoughtcrime.securesms.database.AttachmentTable
|
||||
import org.thoughtcrime.securesms.mms.PartAuthority
|
||||
import org.thoughtcrime.securesms.stickers.StickerLocator
|
||||
import org.thoughtcrime.securesms.util.ParcelUtil
|
||||
import java.util.UUID
|
||||
|
||||
class DatabaseAttachment : Attachment {
|
||||
|
||||
@@ -5,6 +5,7 @@ import android.os.Parcel
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import org.signal.blurhash.BlurHash
|
||||
import org.signal.core.util.Base64
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.database.AttachmentTable
|
||||
import org.thoughtcrime.securesms.stickers.StickerLocator
|
||||
import org.whispersystems.signalservice.api.InvalidMessageStructureException
|
||||
@@ -76,6 +77,8 @@ class PointerAttachment : Attachment {
|
||||
override val thumbnailUri: Uri? = null
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(PointerAttachment::class)
|
||||
|
||||
@JvmStatic
|
||||
fun forPointers(pointers: Optional<List<SignalServiceAttachment>>): List<Attachment> {
|
||||
if (!pointers.isPresent) {
|
||||
@@ -102,6 +105,13 @@ class PointerAttachment : Attachment {
|
||||
return Optional.empty()
|
||||
}
|
||||
|
||||
val cdnNumber = pointer.get().asPointer().cdnNumber
|
||||
val cdn = Cdn.fromCdnNumberOrNull(cdnNumber)
|
||||
if (cdn == null) {
|
||||
Log.w(TAG, "Encountered an attachment pointer with an unsupported CDN number ($cdnNumber). Skipping attachment.")
|
||||
return Optional.empty()
|
||||
}
|
||||
|
||||
val encodedKey: String? = pointer.get().asPointer().key?.let { Base64.encodeWithPadding(it) }
|
||||
|
||||
return Optional.of(
|
||||
@@ -110,7 +120,7 @@ class PointerAttachment : Attachment {
|
||||
transferState = transferState,
|
||||
size = pointer.get().asPointer().size.orElse(0).toLong(),
|
||||
fileName = pointer.get().asPointer().fileName.orElse(null),
|
||||
cdn = Cdn.fromCdnNumber(pointer.get().asPointer().cdnNumber),
|
||||
cdn = cdn,
|
||||
location = pointer.get().asPointer().remoteId.toString(),
|
||||
key = encodedKey,
|
||||
iv = null,
|
||||
@@ -145,7 +155,13 @@ class PointerAttachment : Attachment {
|
||||
return Optional.empty()
|
||||
}
|
||||
|
||||
val cdn = Cdn.fromCdnNumber(thumbnail?.asPointer()?.cdnNumber ?: 0)
|
||||
val cdnNumber = thumbnail?.asPointer()?.cdnNumber ?: 0
|
||||
val cdn = Cdn.fromCdnNumberOrNull(cdnNumber)
|
||||
if (cdn == null) {
|
||||
Log.w(TAG, "Encountered a quote thumbnail with an unsupported CDN number ($cdnNumber). Skipping attachment.")
|
||||
return Optional.empty()
|
||||
}
|
||||
|
||||
if (cdn == Cdn.S3) {
|
||||
return Optional.empty()
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.AudioWaveFormData;
|
||||
import org.thoughtcrime.securesms.util.ParcelUtil;
|
||||
import org.signal.core.util.ParcelUtil;
|
||||
import org.signal.core.util.Base64;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
@@ -7,7 +7,7 @@ import android.media.AudioManager
|
||||
import android.media.AudioManager.OnAudioFocusChangeListener
|
||||
import android.os.Build
|
||||
import androidx.annotation.RequiresApi
|
||||
import org.thoughtcrime.securesms.util.ServiceUtil
|
||||
import org.signal.core.util.ServiceUtil
|
||||
|
||||
abstract class AudioRecorderFocusManager(val context: Context) {
|
||||
protected val audioManager: AudioManager = ServiceUtil.getAudioManager(context)
|
||||
|
||||
@@ -38,8 +38,8 @@ object AvatarPickerStorage {
|
||||
.getAllAvatars()
|
||||
.filterIsInstance<Avatar.Photo>()
|
||||
|
||||
val inDatabaseFileNames = photoAvatars.map { PartAuthority.getAvatarPickerFilename(it.uri) }
|
||||
val onDiskFileNames = avatarFiles.map { it.name }
|
||||
val inDatabaseFileNames = photoAvatars.mapTo(mutableSetOf()) { PartAuthority.getAvatarPickerFilename(it.uri) }
|
||||
val onDiskFileNames = avatarFiles.mapTo(mutableSetOf()) { it.name }
|
||||
|
||||
val inDatabaseButNotOnDisk = inDatabaseFileNames - onDiskFileNames
|
||||
val onDiskButNotInDatabase = onDiskFileNames - inDatabaseFileNames
|
||||
|
||||
@@ -6,14 +6,18 @@
|
||||
package org.thoughtcrime.securesms.backup
|
||||
|
||||
import androidx.annotation.WorkerThread
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.flow.shareIn
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.signal.core.util.bytes
|
||||
import org.signal.core.util.logging.Log
|
||||
@@ -46,6 +50,8 @@ object ArchiveUploadProgress {
|
||||
|
||||
private val TAG = Log.tag(ArchiveUploadProgress::class)
|
||||
|
||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
|
||||
private val _progress: MutableSharedFlow<Unit> = MutableSharedFlow(replay = 1)
|
||||
|
||||
private var uploadProgress: ArchiveUploadProgressState = SignalStore.backup.archiveUploadState ?: ArchiveUploadProgressState(
|
||||
@@ -61,7 +67,7 @@ object ArchiveUploadProgress {
|
||||
/**
|
||||
* Observe this to get updates on the current upload progress.
|
||||
*/
|
||||
val progress: Flow<ArchiveUploadProgressState> = _progress
|
||||
val progress: SharedFlow<ArchiveUploadProgressState> = _progress
|
||||
.throttleLatest(500.milliseconds) {
|
||||
uploadProgress.state == ArchiveUploadProgressState.State.None ||
|
||||
(uploadProgress.state == ArchiveUploadProgressState.State.UploadBackupFile && uploadProgress.backupFileUploadedBytes == 0L) ||
|
||||
@@ -114,6 +120,11 @@ object ArchiveUploadProgress {
|
||||
}
|
||||
.onStart { emit(uploadProgress) }
|
||||
.flowOn(Dispatchers.IO)
|
||||
.shareIn(scope, SharingStarted.Eagerly, replay = 1)
|
||||
|
||||
init {
|
||||
_progress.tryEmit(Unit)
|
||||
}
|
||||
|
||||
val inProgress
|
||||
get() = uploadProgress.state != ArchiveUploadProgressState.State.None && uploadProgress.state != ArchiveUploadProgressState.State.UserCanceled
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
package org.thoughtcrime.securesms.backup.v2
|
||||
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import org.signal.core.util.ThrottledDebouncer
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.util.ThrottledDebouncer
|
||||
import java.util.concurrent.ExecutionException
|
||||
import java.util.concurrent.Executors
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
@@ -31,6 +31,9 @@ import org.thoughtcrime.securesms.jobmanager.impl.BatteryNotLowConstraint
|
||||
import org.thoughtcrime.securesms.jobmanager.impl.DiskSpaceNotLowConstraint
|
||||
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint
|
||||
import org.thoughtcrime.securesms.jobmanager.impl.WifiConstraint
|
||||
import org.thoughtcrime.securesms.jobs.CheckRestoreMediaLeftJob
|
||||
import org.thoughtcrime.securesms.jobs.RestoreAttachmentJob
|
||||
import org.thoughtcrime.securesms.jobs.RestoreLocalAttachmentJob
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
@@ -157,6 +160,22 @@ object ArchiveRestoreProgress {
|
||||
update()
|
||||
}
|
||||
|
||||
/**
|
||||
* Self-heal hook for restores that appear active (banner showing, media still remaining) but have no jobs left actually working on them.
|
||||
*/
|
||||
fun checkForStalledRestore() {
|
||||
SignalExecutors.BOUNDED.execute {
|
||||
val stalled = SignalStore.backup.restoreState.isMediaRestoreOperation &&
|
||||
SignalDatabase.attachments.getRemainingRestorableAttachmentSize() > 0L &&
|
||||
AppDependencies.jobManager.areFactoriesEmpty(setOf(RestoreAttachmentJob.KEY, RestoreLocalAttachmentJob.KEY, CheckRestoreMediaLeftJob.KEY))
|
||||
|
||||
if (stalled) {
|
||||
Log.w(TAG, "Detected a stalled media restore with no active jobs. Enqueueing a check job to recover.")
|
||||
CheckRestoreMediaLeftJob.enqueueStalledRecoveryCheck()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun clearLocalRestoreDirectoryError() {
|
||||
SignalStore.backup.localRestoreDirectoryError = false
|
||||
update()
|
||||
|
||||
@@ -40,6 +40,7 @@ import org.signal.core.util.CursorUtil
|
||||
import org.signal.core.util.DiskUtil
|
||||
import org.signal.core.util.EventTimer
|
||||
import org.signal.core.util.PendingIntentFlags.cancelCurrent
|
||||
import org.signal.core.util.ServiceUtil
|
||||
import org.signal.core.util.Stopwatch
|
||||
import org.signal.core.util.bytes
|
||||
import org.signal.core.util.concurrent.LimitedWorker
|
||||
@@ -147,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
|
||||
@@ -1465,6 +1465,7 @@ object BackupRepository {
|
||||
}
|
||||
|
||||
SignalDatabase.remappedRecords.clearCache()
|
||||
SignalDatabase.remappedRecords.trimStaleMappings()
|
||||
AppDependencies.recipientCache.clear()
|
||||
AppDependencies.recipientCache.warmUp()
|
||||
SignalDatabase.threads.clearCache()
|
||||
|
||||
+1
-1
@@ -123,7 +123,7 @@ fun DatabaseAttachment.createArchiveAttachmentPointer(useArchiveCdn: Boolean): S
|
||||
throw InvalidAttachmentException("empty content id")
|
||||
}
|
||||
|
||||
SignalServiceAttachmentRemoteId.from(remoteLocation) to cdn.cdnNumber
|
||||
SignalServiceAttachmentRemoteId.from(remoteLocation, cdn.cdnNumber) to cdn.cdnNumber
|
||||
}
|
||||
|
||||
val key = Base64.decode(remoteKey)
|
||||
|
||||
+1
-1
@@ -61,7 +61,7 @@ class ChatArchiveExporter(private val cursor: Cursor, private val db: SignalData
|
||||
expirationTimerMs = cursor.requireLong(RecipientTable.MESSAGE_EXPIRATION_TIME).seconds.inWholeMilliseconds.takeIf { it > 0 },
|
||||
expireTimerVersion = cursor.requireInt(RecipientTable.MESSAGE_EXPIRATION_TIME_VERSION),
|
||||
muteUntilMs = cursor.requireLong(RecipientTable.MUTE_UNTIL).takeIf { it > 0 },
|
||||
markedUnread = ThreadTable.ReadStatus.deserialize(cursor.requireInt(ThreadTable.READ)) == ThreadTable.ReadStatus.FORCED_UNREAD,
|
||||
markedUnread = ThreadTable.ReadStatus.deserialize(cursor.requireInt(ThreadTable.READ)) == ThreadTable.ReadStatus.ForcedUnread,
|
||||
dontNotifyForMentionsIfMuted = RecipientTable.NotificationSetting.DO_NOT_NOTIFY.id == cursor.requireInt(RecipientTable.MENTION_SETTING),
|
||||
style = ChatStyleConverter.constructRemoteChatStyle(
|
||||
db = db,
|
||||
|
||||
+4
-4
@@ -45,6 +45,7 @@ import org.signal.core.models.ServiceId
|
||||
import org.signal.core.util.Base64
|
||||
import org.signal.core.util.EventTimer
|
||||
import org.signal.core.util.Hex
|
||||
import org.signal.core.util.JsonUtils
|
||||
import org.signal.core.util.ParallelEventTimer
|
||||
import org.signal.core.util.StringUtil
|
||||
import org.signal.core.util.UuidUtil
|
||||
@@ -105,7 +106,6 @@ import org.thoughtcrime.securesms.payments.FailureReason
|
||||
import org.thoughtcrime.securesms.payments.State
|
||||
import org.thoughtcrime.securesms.polls.PollRecord
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.util.JsonUtils
|
||||
import org.thoughtcrime.securesms.util.MediaUtil
|
||||
import org.thoughtcrime.securesms.util.mb
|
||||
import java.io.Closeable
|
||||
@@ -435,7 +435,7 @@ class ChatItemArchiveExporter(
|
||||
|
||||
else -> {
|
||||
val attachments = extraData.attachmentsById[record.id]
|
||||
val sticker = attachments?.firstOrNull { dbAttachment -> dbAttachment.isSticker }
|
||||
val sticker = attachments?.firstOrNull { dbAttachment -> dbAttachment.isSticker && !dbAttachment.quote }
|
||||
|
||||
if (sticker?.stickerLocator != null) {
|
||||
builder.stickerMessage = sticker.toRemoteStickerMessage(sentTimestamp = record.dateSent, reactions = extraData.reactionsById[id], exportState = exportState)
|
||||
@@ -852,8 +852,8 @@ private fun BackupMessageRecord.toRemotePaymentNotificationUpdate(db: SignalData
|
||||
PaymentNotification()
|
||||
} else {
|
||||
PaymentNotification(
|
||||
amountMob = payment.amount.serializeAmountString(),
|
||||
feeMob = payment.fee.serializeAmountString(),
|
||||
amountMob = payment.amount.requireMobileCoin().amountDecimalString,
|
||||
feeMob = payment.fee.requireMobileCoin().amountDecimalString,
|
||||
note = payment.note.takeUnless { it.isEmpty() },
|
||||
transactionDetails = payment.toRemoteTransactionDetails()
|
||||
)
|
||||
|
||||
+1
-1
@@ -46,7 +46,7 @@ object ChatArchiveImporter {
|
||||
ThreadTable.RECIPIENT_ID to recipientId.serialize(),
|
||||
ThreadTable.PINNED_ORDER to chat.pinnedOrder,
|
||||
ThreadTable.ARCHIVED to chat.archived.toInt(),
|
||||
ThreadTable.READ to if (chat.markedUnread) ThreadTable.ReadStatus.FORCED_UNREAD.serialize() else ThreadTable.ReadStatus.READ.serialize(),
|
||||
ThreadTable.READ to if (chat.markedUnread) ThreadTable.ReadStatus.ForcedUnread.serialize() else ThreadTable.ReadStatus.Read.serialize(),
|
||||
ThreadTable.ACTIVE to 1
|
||||
)
|
||||
.run()
|
||||
|
||||
+9
-21
@@ -27,6 +27,7 @@ import org.signal.archive.proto.ViewOnceMessage
|
||||
import org.signal.core.models.ServiceId
|
||||
import org.signal.core.util.Base64
|
||||
import org.signal.core.util.Hex
|
||||
import org.signal.core.util.JsonUtils
|
||||
import org.signal.core.util.SqlUtil
|
||||
import org.signal.core.util.UuidUtil
|
||||
import org.signal.core.util.asList
|
||||
@@ -59,7 +60,6 @@ import org.thoughtcrime.securesms.database.documents.NetworkFailureSet
|
||||
import org.thoughtcrime.securesms.database.model.GroupCallUpdateDetailsUtil
|
||||
import org.thoughtcrime.securesms.database.model.Mention
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.CryptoValue
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.GV2UpdateDescription
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.MessageExtras
|
||||
@@ -81,11 +81,10 @@ import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.stickers.StickerLocator
|
||||
import org.thoughtcrime.securesms.util.Environment
|
||||
import org.thoughtcrime.securesms.util.JsonUtils
|
||||
import org.thoughtcrime.securesms.util.MessageUtil
|
||||
import org.whispersystems.signalservice.api.payments.Money
|
||||
import org.whispersystems.signalservice.internal.push.DataMessage
|
||||
import java.math.BigInteger
|
||||
import java.math.BigDecimal
|
||||
import java.sql.SQLException
|
||||
import java.util.Optional
|
||||
import java.util.UUID
|
||||
@@ -1064,8 +1063,8 @@ class ChatItemArchiveImporter(
|
||||
|
||||
private fun ContentValues.addPaymentTombstoneNoMetadata(paymentNotification: PaymentNotification) {
|
||||
put(MessageTable.TYPE, getAsLong(MessageTable.TYPE) or MessageTypes.SPECIAL_TYPE_PAYMENTS_TOMBSTONE)
|
||||
val amount = tryParseCryptoValue(paymentNotification.amountMob)
|
||||
val fee = tryParseCryptoValue(paymentNotification.feeMob)
|
||||
val amount = paymentNotification.amountMob?.tryParseMoney()?.let { CryptoValueUtil.moneyToCryptoValue(it) }
|
||||
val fee = paymentNotification.feeMob?.tryParseMoney()?.let { CryptoValueUtil.moneyToCryptoValue(it) }
|
||||
put(
|
||||
MessageTable.MESSAGE_EXTRAS,
|
||||
MessageExtras(
|
||||
@@ -1119,26 +1118,15 @@ class ChatItemArchiveImporter(
|
||||
return null
|
||||
}
|
||||
|
||||
val amountCryptoValue = tryParseCryptoValue(this)
|
||||
return if (amountCryptoValue != null) {
|
||||
CryptoValueUtil.cryptoValueToMoney(amountCryptoValue)
|
||||
} else {
|
||||
return try {
|
||||
Money.mobileCoin(BigDecimal(this))
|
||||
} catch (e: NumberFormatException) {
|
||||
null
|
||||
} catch (e: ArithmeticException) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun tryParseCryptoValue(bigIntegerString: String?): CryptoValue? {
|
||||
if (bigIntegerString == null) {
|
||||
return null
|
||||
}
|
||||
val amount = try {
|
||||
BigInteger(bigIntegerString).toString()
|
||||
} catch (e: NumberFormatException) {
|
||||
return null
|
||||
}
|
||||
return CryptoValue(mobileCoinValue = CryptoValue.MobileCoinValue(picoMobileCoin = amount))
|
||||
}
|
||||
|
||||
private fun ContentValues.addQuote(quote: Quote) {
|
||||
this.put(MessageTable.QUOTE_ID, quote.targetSentTimestamp ?: MessageTable.QUOTE_TARGET_MISSING_ID)
|
||||
this.put(MessageTable.QUOTE_AUTHOR, importState.requireLocalRecipientId(quote.authorId).serialize())
|
||||
|
||||
+45
-1
@@ -48,6 +48,7 @@ import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import org.signal.core.ui.compose.BottomSheets
|
||||
import org.signal.core.ui.compose.Buttons
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Dialogs
|
||||
@@ -59,11 +60,15 @@ import org.signal.core.ui.compose.horizontalGutters
|
||||
import org.signal.core.ui.compose.theme.SignalTheme
|
||||
import org.signal.core.util.Util
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.backup.v2.ui.warning.ClipStage
|
||||
import org.thoughtcrime.securesms.backup.v2.ui.warning.RecoveryKeyWarningSheetContent
|
||||
import org.thoughtcrime.securesms.backup.v2.ui.warning.RecoveryKeyWarningSheetEvent
|
||||
import org.thoughtcrime.securesms.components.TemporaryScreenshotSecurity
|
||||
import org.thoughtcrime.securesms.components.settings.app.backups.remote.BackupKeyCredentialManagerHandler
|
||||
import org.thoughtcrime.securesms.components.settings.app.backups.remote.BackupKeySaveState
|
||||
import org.thoughtcrime.securesms.fonts.MonoTypeface
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.util.CommunicationActions
|
||||
import org.thoughtcrime.securesms.util.storage.AndroidCredentialRepository
|
||||
import org.thoughtcrime.securesms.util.storage.CredentialManagerError
|
||||
import org.thoughtcrime.securesms.util.storage.CredentialManagerResult
|
||||
@@ -120,6 +125,7 @@ fun MessageBackupsKeyRecordScreen(
|
||||
* Screen displaying the backup key allowing the user to write it down
|
||||
* or copy it.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun MessageBackupsKeyRecordScreen(
|
||||
backupKey: String,
|
||||
@@ -145,6 +151,38 @@ fun MessageBackupsKeyRecordScreen(
|
||||
RecordScreenBackHandler()
|
||||
}
|
||||
|
||||
var displayRecoveryKeyCopyWarning by remember { mutableStateOf(false) }
|
||||
if (displayRecoveryKeyCopyWarning) {
|
||||
val context = LocalContext.current
|
||||
val url = stringResource(R.string.recovery_key_phishing_support_url)
|
||||
val events: (RecoveryKeyWarningSheetEvent) -> Unit = {
|
||||
when (it) {
|
||||
RecoveryKeyWarningSheetEvent.GotItClick -> {
|
||||
onCopyToClipboardClick(backupKeyString)
|
||||
displayRecoveryKeyCopyWarning = false
|
||||
}
|
||||
RecoveryKeyWarningSheetEvent.LearnMoreClick -> {
|
||||
CommunicationActions.openBrowserLink(context, url)
|
||||
displayRecoveryKeyCopyWarning = false
|
||||
}
|
||||
|
||||
RecoveryKeyWarningSheetEvent.DoNotShareClick -> error("Not supported")
|
||||
RecoveryKeyWarningSheetEvent.ShareKeyClick -> error("Not supported")
|
||||
}
|
||||
}
|
||||
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = { displayRecoveryKeyCopyWarning = false },
|
||||
dragHandle = { BottomSheets.Handle() },
|
||||
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
) {
|
||||
RecoveryKeyWarningSheetContent(
|
||||
clipStage = ClipStage.COPY,
|
||||
events = events
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Scaffolds.Settings(
|
||||
title = "",
|
||||
navigationIcon = SignalIcons.ArrowStart.imageVector,
|
||||
@@ -227,7 +265,13 @@ fun MessageBackupsKeyRecordScreen(
|
||||
|
||||
item {
|
||||
Buttons.Small(
|
||||
onClick = { onCopyToClipboardClick(backupKeyString) }
|
||||
onClick = {
|
||||
if (mode is MessageBackupsKeyRecordMode.CreateNewKey) {
|
||||
displayRecoveryKeyCopyWarning = true
|
||||
} else {
|
||||
onCopyToClipboardClick(backupKeyString)
|
||||
}
|
||||
}
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.MessageBackupsKeyRecordScreen__copy_to_clipboard)
|
||||
|
||||
+1
-1
@@ -52,6 +52,7 @@ import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.Scaffolds
|
||||
import org.signal.core.ui.compose.SignalIcons
|
||||
import org.signal.core.ui.compose.theme.SignalTheme
|
||||
import org.signal.core.util.ByteUnit
|
||||
import org.signal.core.util.billing.BillingResponseCode
|
||||
import org.signal.core.util.bytes
|
||||
import org.signal.core.util.money.FiatMoney
|
||||
@@ -60,7 +61,6 @@ import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
|
||||
import org.thoughtcrime.securesms.fonts.SignalSymbols
|
||||
import org.thoughtcrime.securesms.fonts.SignalSymbols.signalSymbolText
|
||||
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
|
||||
import org.thoughtcrime.securesms.util.ByteUnit
|
||||
import java.math.BigDecimal
|
||||
import java.util.Currency
|
||||
import kotlin.time.Duration.Companion.days
|
||||
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.backup.v2.ui.warning
|
||||
|
||||
import org.signal.core.models.AccountEntropyPool
|
||||
|
||||
/**
|
||||
* Detects whether a block of text contains the user's own [AccountEntropyPool] (recovery key).
|
||||
*
|
||||
* We scan anywhere within the text and try to match the key in as many forms as possible:
|
||||
* upper/lowercase, with or without grouping spaces, and with or without the display characters
|
||||
* (e.g. '#'/'=') used to disambiguate 'O'/'0'. Matching against the user's actual key (rather than
|
||||
* just the AEP shape) avoids false positives on any 64-character in-alphabet string.
|
||||
*/
|
||||
object RecoveryKeyDetector {
|
||||
|
||||
/**
|
||||
* @param text the text to scan
|
||||
* @param recoveryKey the user's own recovery key, or null if they don't have one yet
|
||||
* @return true if [text] contains [recoveryKey] in any of its accepted forms. Always false when
|
||||
* [recoveryKey] is null, so callers can bypass the check entirely for users without a key.
|
||||
*/
|
||||
fun containsRecoveryKey(text: String?, recoveryKey: AccountEntropyPool?): Boolean {
|
||||
if (recoveryKey == null || text.isNullOrBlank() || text.length < AccountEntropyPool.LENGTH) {
|
||||
return false
|
||||
}
|
||||
|
||||
val normalized = AccountEntropyPool.removeIllegalCharacters(AccountEntropyPool.formatForStorage(text)).lowercase()
|
||||
|
||||
return normalized.contains(recoveryKey.value)
|
||||
}
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.backup.v2.ui.warning
|
||||
|
||||
import androidx.fragment.app.Fragment
|
||||
import org.thoughtcrime.securesms.components.ComposeText
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
|
||||
/**
|
||||
* Wires this [ComposeText] so that pasting the user's own recovery key first shows
|
||||
* [RecoveryKeyPasteWarningFragment], warning against sharing it. The paste only completes if the
|
||||
* user explicitly confirms via that warning.
|
||||
*
|
||||
* Must be called once the [host]'s view has been created, as it registers a fragment result
|
||||
* listener scoped to the host's view lifecycle.
|
||||
*
|
||||
* @param onWarningShown invoked just before the warning is shown. Hosts that auto-dismiss when the
|
||||
* keyboard hides (e.g. [org.thoughtcrime.securesms.components.KeyboardEntryDialogFragment]) can use
|
||||
* this to suppress that behavior while the warning is up.
|
||||
* @param onWarningDismissed invoked when the warning is dismissed by any path, after the paste (if
|
||||
* any) has been applied. Hosts can use this to restore the suppressed state and re-focus the input.
|
||||
*/
|
||||
fun ComposeText.guardAgainstRecoveryKeyPaste(
|
||||
host: Fragment,
|
||||
onWarningShown: () -> Unit = {},
|
||||
onWarningDismissed: () -> Unit = {}
|
||||
) {
|
||||
var pendingPaste: CharSequence? = null
|
||||
|
||||
host.childFragmentManager.setFragmentResultListener(RecoveryKeyPasteWarningFragment.REQUEST_KEY, host.viewLifecycleOwner) { _, bundle ->
|
||||
if (bundle.getBoolean(RecoveryKeyPasteWarningFragment.REQUEST_KEY)) {
|
||||
pendingPaste?.let { insertText(it) }
|
||||
}
|
||||
pendingPaste = null
|
||||
onWarningDismissed()
|
||||
}
|
||||
|
||||
setOnPasteListener { pasteText ->
|
||||
if (RecoveryKeyDetector.containsRecoveryKey(pasteText?.toString(), SignalStore.account.accountEntropyPoolOrNull)) {
|
||||
pendingPaste = pasteText
|
||||
onWarningShown()
|
||||
RecoveryKeyPasteWarningFragment().show(host.childFragmentManager, null)
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.backup.v2.ui.warning
|
||||
|
||||
import android.content.DialogInterface
|
||||
import android.os.Bundle
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.fragment.app.setFragmentResult
|
||||
import org.signal.core.ui.compose.ComposeBottomSheetDialogFragment
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.backup.v2.ui.warning.RecoveryKeyPasteWarningFragment.Companion.REQUEST_KEY
|
||||
import org.thoughtcrime.securesms.util.CommunicationActions
|
||||
|
||||
/**
|
||||
* Displayed via the [org.thoughtcrime.securesms.components.settings.conversation.ConversationSettingsFragment] whenever the user
|
||||
* attempts to paste their recovery key into the input field.
|
||||
*
|
||||
* A result is always delivered to [REQUEST_KEY] when this fragment is dismissed, with the boolean
|
||||
* indicating whether the user chose to proceed with the paste. The host can rely on this firing for
|
||||
* every dismissal path (paste, decline, or cancel) to restore its own state.
|
||||
*/
|
||||
class RecoveryKeyPasteWarningFragment : ComposeBottomSheetDialogFragment() {
|
||||
|
||||
companion object {
|
||||
const val REQUEST_KEY = "recovery_key_request"
|
||||
}
|
||||
|
||||
private var shouldPaste = false
|
||||
|
||||
override fun onDismiss(dialog: DialogInterface) {
|
||||
setFragmentResult(
|
||||
REQUEST_KEY,
|
||||
Bundle().apply {
|
||||
putBoolean(REQUEST_KEY, shouldPaste)
|
||||
}
|
||||
)
|
||||
|
||||
super.onDismiss(dialog)
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun SheetContent() {
|
||||
val context = LocalContext.current
|
||||
val url = stringResource(R.string.recovery_key_phishing_support_url)
|
||||
|
||||
val eventHandler: (RecoveryKeyWarningSheetEvent) -> Unit = {
|
||||
when (it) {
|
||||
RecoveryKeyWarningSheetEvent.DoNotShareClick -> {
|
||||
dismissAllowingStateLoss()
|
||||
}
|
||||
|
||||
RecoveryKeyWarningSheetEvent.GotItClick -> {
|
||||
error("Not supported for paste")
|
||||
}
|
||||
|
||||
RecoveryKeyWarningSheetEvent.LearnMoreClick -> {
|
||||
CommunicationActions.openBrowserLink(context, url)
|
||||
dismissAllowingStateLoss()
|
||||
}
|
||||
|
||||
RecoveryKeyWarningSheetEvent.ShareKeyClick -> {
|
||||
shouldPaste = true
|
||||
dismissAllowingStateLoss()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
RecoveryKeyWarningSheetContent(
|
||||
clipStage = ClipStage.PASTE,
|
||||
events = eventHandler
|
||||
)
|
||||
}
|
||||
}
|
||||
+172
@@ -0,0 +1,172 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.backup.v2.ui.warning
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.text.LinkAnnotation
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.TextLinkStyles
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.withLink
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.signal.core.ui.compose.Buttons
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.horizontalGutters
|
||||
import org.thoughtcrime.securesms.R
|
||||
|
||||
@Composable
|
||||
fun RecoveryKeyWarningSheetContent(
|
||||
clipStage: ClipStage,
|
||||
events: (RecoveryKeyWarningSheetEvent) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier.horizontalGutters(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Icon(
|
||||
imageVector = ImageVector.vectorResource(R.drawable.ic_warning_40),
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.padding(top = 20.dp, bottom = 16.dp)
|
||||
.size(80.dp)
|
||||
.background(color = MaterialTheme.colorScheme.errorContainer, shape = CircleShape)
|
||||
.padding(20.dp)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.RecoveryKeyWarningSheetContent__do_not_share_your_recovery_key),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(bottom = 12.dp)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = buildAnnotatedString {
|
||||
withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) {
|
||||
append(stringResource(R.string.RecoveryKeyWarningSheetContent__signal_will_never_message_you))
|
||||
}
|
||||
|
||||
append(" ")
|
||||
append(stringResource(R.string.RecoveryKeyWarningSheetContent__for_your_recovery_key_never_respond))
|
||||
|
||||
if (clipStage == ClipStage.PASTE) {
|
||||
append(" ")
|
||||
withLink(
|
||||
link = LinkAnnotation.Clickable(
|
||||
tag = "learn-more",
|
||||
styles = TextLinkStyles(
|
||||
style = SpanStyle(
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
)
|
||||
) {
|
||||
events(RecoveryKeyWarningSheetEvent.LearnMoreClick)
|
||||
}
|
||||
) {
|
||||
append(stringResource(R.string.RecoveryKeyWarningSheetContent__learn_more_period))
|
||||
}
|
||||
}
|
||||
},
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(bottom = 75.dp)
|
||||
)
|
||||
|
||||
when (clipStage) {
|
||||
ClipStage.COPY -> CopyActionButtons(events = events)
|
||||
ClipStage.PASTE -> PasteActionButtons(events = events)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.size(16.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CopyActionButtons(events: (RecoveryKeyWarningSheetEvent) -> Unit) {
|
||||
Buttons.LargeTonal(onClick = {
|
||||
events(RecoveryKeyWarningSheetEvent.GotItClick)
|
||||
}) {
|
||||
Text(text = stringResource(R.string.RecoveryKeyWarningSheetContent__got_it))
|
||||
}
|
||||
|
||||
TextButton(onClick = {
|
||||
events(RecoveryKeyWarningSheetEvent.LearnMoreClick)
|
||||
}) {
|
||||
Text(text = stringResource(R.string.RecoveryKeyWarningSheetContent__learn_more))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PasteActionButtons(events: (RecoveryKeyWarningSheetEvent) -> Unit) {
|
||||
Buttons.LargeTonal(onClick = {
|
||||
events(RecoveryKeyWarningSheetEvent.DoNotShareClick)
|
||||
}) {
|
||||
Text(text = stringResource(R.string.RecoveryKeyWarningSheetContent__do_not_share_key))
|
||||
}
|
||||
|
||||
TextButton(
|
||||
colors = ButtonDefaults.textButtonColors(
|
||||
contentColor = MaterialTheme.colorScheme.error
|
||||
),
|
||||
onClick = {
|
||||
events(RecoveryKeyWarningSheetEvent.ShareKeyClick)
|
||||
}
|
||||
) {
|
||||
Text(text = stringResource(R.string.RecoveryKeyWarningSheetContent__share_key))
|
||||
}
|
||||
}
|
||||
|
||||
enum class ClipStage {
|
||||
COPY,
|
||||
PASTE
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun RecoveryKeyWarningSheetContentCopyPreview() {
|
||||
Previews.BottomSheetPreview {
|
||||
RecoveryKeyWarningSheetContent(
|
||||
clipStage = ClipStage.COPY,
|
||||
events = {},
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun RecoveryKeyWarningSheetContentPastePreview() {
|
||||
Previews.BottomSheetPreview {
|
||||
RecoveryKeyWarningSheetContent(
|
||||
clipStage = ClipStage.PASTE,
|
||||
events = {},
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.backup.v2.ui.warning
|
||||
|
||||
sealed interface RecoveryKeyWarningSheetEvent {
|
||||
data object DoNotShareClick : RecoveryKeyWarningSheetEvent
|
||||
data object ShareKeyClick : RecoveryKeyWarningSheetEvent
|
||||
data object GotItClick : RecoveryKeyWarningSheetEvent
|
||||
data object LearnMoreClick : RecoveryKeyWarningSheetEvent
|
||||
}
|
||||
+11
-2
@@ -11,6 +11,7 @@ import org.signal.archive.proto.FilePointer
|
||||
import org.signal.core.util.Base64
|
||||
import org.signal.core.util.UuidUtil
|
||||
import org.signal.core.util.isNotNullOrBlank
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.core.util.nullIfBlank
|
||||
import org.signal.core.util.orNull
|
||||
import org.signal.libsignal.usernames.BaseUsernameException
|
||||
@@ -32,6 +33,8 @@ import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentRemo
|
||||
import java.util.Optional
|
||||
import org.signal.archive.proto.AvatarColor as RemoteAvatarColor
|
||||
|
||||
private const val TAG = "ArchiveConverter"
|
||||
|
||||
/**
|
||||
* Converts a [FilePointer] to a local [Attachment] object for inserting into the database.
|
||||
*/
|
||||
@@ -58,10 +61,16 @@ fun FilePointer?.toLocalAttachment(
|
||||
|
||||
return when (attachmentType) {
|
||||
AttachmentType.ARCHIVE -> {
|
||||
val cdnNumber = locatorInfo.transitCdnNumber ?: Cdn.CDN_0.cdnNumber
|
||||
if (Cdn.fromCdnNumberOrNull(cdnNumber) == null) {
|
||||
Log.w(TAG, "Encountered an archived attachment with an unsupported CDN number ($cdnNumber). Skipping attachment.")
|
||||
return null
|
||||
}
|
||||
|
||||
ArchivedAttachment(
|
||||
contentType = contentType,
|
||||
size = locatorInfo.size.toLong(),
|
||||
cdn = locatorInfo.transitCdnNumber ?: Cdn.CDN_0.cdnNumber,
|
||||
cdn = cdnNumber,
|
||||
uploadTimestamp = locatorInfo.transitTierUploadTimestamp ?: 0,
|
||||
key = locatorInfo.key.toByteArray(),
|
||||
cdnKey = locatorInfo.transitCdnKey?.nullIfBlank(),
|
||||
@@ -87,7 +96,7 @@ fun FilePointer?.toLocalAttachment(
|
||||
AttachmentType.TRANSIT -> {
|
||||
val signalAttachmentPointer = SignalServiceAttachmentPointer(
|
||||
cdnNumber = locatorInfo.transitCdnNumber ?: Cdn.CDN_0.cdnNumber,
|
||||
remoteId = SignalServiceAttachmentRemoteId.from(locatorInfo.transitCdnKey!!),
|
||||
remoteId = SignalServiceAttachmentRemoteId.from(locatorInfo.transitCdnKey!!, locatorInfo.transitCdnNumber ?: Cdn.CDN_0.cdnNumber),
|
||||
contentType = contentType,
|
||||
key = locatorInfo.key.toByteArray(),
|
||||
size = Optional.ofNullable(locatorInfo.size),
|
||||
|
||||
+2
-4
@@ -13,6 +13,7 @@ import com.google.android.material.button.MaterialButton
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.subjects.PublishSubject
|
||||
import org.signal.core.util.Debouncer
|
||||
import org.signal.core.util.concurrent.LifecycleDisposable
|
||||
import org.signal.core.util.getParcelableCompat
|
||||
import org.signal.core.util.money.FiatMoney
|
||||
@@ -39,7 +40,6 @@ import org.thoughtcrime.securesms.keyboard.emoji.EmojiKeyboardPageFragment
|
||||
import org.thoughtcrime.securesms.keyboard.emoji.search.EmojiSearchFragment
|
||||
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
|
||||
import org.thoughtcrime.securesms.payments.currency.CurrencyUtil
|
||||
import org.thoughtcrime.securesms.util.Debouncer
|
||||
import org.thoughtcrime.securesms.util.activityViewModel
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
|
||||
import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
||||
@@ -306,7 +306,5 @@ class GiftFlowConfirmationFragment :
|
||||
|
||||
override fun navigateToDonationPending(inAppPayment: InAppPaymentTable.InAppPayment) = error("Not supported for gifts")
|
||||
|
||||
override fun exitCheckoutFlow() {
|
||||
requireActivity().finishAfterTransition()
|
||||
}
|
||||
override fun exitCheckoutFlow() = Unit
|
||||
}
|
||||
|
||||
+7
-8
@@ -4,7 +4,6 @@ import android.graphics.Color
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.appcompat.widget.Toolbar
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import androidx.navigation.fragment.findNavController
|
||||
@@ -19,29 +18,25 @@ import org.thoughtcrime.securesms.conversation.mutiselect.forward.SearchConfigur
|
||||
import org.thoughtcrime.securesms.database.RecipientTable
|
||||
import org.thoughtcrime.securesms.util.activityViewModel
|
||||
import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
||||
import org.thoughtcrime.securesms.util.viewModel
|
||||
import kotlin.getValue
|
||||
|
||||
/**
|
||||
* Allows the user to select a recipient to send a gift to.
|
||||
*/
|
||||
class GiftFlowRecipientSelectionFragment : Fragment(R.layout.gift_flow_recipient_selection_fragment), MultiselectForwardFragment.Callback, SearchConfigurationProvider {
|
||||
class GiftFlowRecipientSelectionFragment : Fragment(R.layout.multiselect_forward_activity), MultiselectForwardFragment.Callback, SearchConfigurationProvider {
|
||||
|
||||
private val viewModel: GiftFlowViewModel by activityViewModel {
|
||||
GiftFlowViewModel()
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
val toolbar = view.findViewById<Toolbar>(R.id.toolbar)
|
||||
toolbar.setNavigationOnClickListener { requireActivity().onBackPressed() }
|
||||
|
||||
if (savedInstanceState == null) {
|
||||
childFragmentManager.beginTransaction()
|
||||
.replace(
|
||||
R.id.multiselect_container,
|
||||
R.id.fragment_container,
|
||||
MultiselectForwardFragment.create(
|
||||
MultiselectForwardFragmentArgs(
|
||||
multiShareArgs = emptyList(),
|
||||
title = R.string.GiftFlowRecipientSelectionFragment__choose_recipient,
|
||||
forceDisableAddMessage = true,
|
||||
selectSingleRecipient = true
|
||||
)
|
||||
@@ -79,6 +74,10 @@ class GiftFlowRecipientSelectionFragment : Fragment(R.layout.gift_flow_recipient
|
||||
|
||||
override fun exitFlow() = Unit
|
||||
|
||||
override fun navigateUp() {
|
||||
requireActivity().onBackPressedDispatcher.onBackPressed()
|
||||
}
|
||||
|
||||
override fun onSearchInputFocused() = Unit
|
||||
|
||||
override fun setResult(bundle: Bundle) {
|
||||
|
||||
+2
@@ -10,6 +10,7 @@ import androidx.compose.runtime.Composable
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import org.thoughtcrime.securesms.backup.v2.ArchiveRestoreProgress
|
||||
import org.thoughtcrime.securesms.backup.v2.ArchiveRestoreProgressState
|
||||
import org.thoughtcrime.securesms.backup.v2.ArchiveRestoreProgressState.RestoreStatus
|
||||
@@ -25,6 +26,7 @@ class ArchiveRestoreStatusBanner(private val listener: RestoreProgressBannerList
|
||||
override val dataFlow: Flow<ArchiveRestoreProgressState> by lazy {
|
||||
ArchiveRestoreProgress
|
||||
.stateFlow
|
||||
.onStart { ArchiveRestoreProgress.checkForStalledRestore() }
|
||||
.filter {
|
||||
it.restoreStatus != RestoreStatus.NONE && (it.restoreState.isMediaRestoreOperation || it.restoreStatus == RestoreStatus.FINISHED)
|
||||
}
|
||||
|
||||
@@ -14,13 +14,13 @@ import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.util.ServiceUtil
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.banner.Banner
|
||||
import org.thoughtcrime.securesms.banner.ui.compose.Action
|
||||
import org.thoughtcrime.securesms.banner.ui.compose.DefaultBanner
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.util.PowerManagerCompat
|
||||
import org.thoughtcrime.securesms.util.ServiceUtil
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences
|
||||
|
||||
class DozeBanner(private val context: Context, private val onDismissListener: () -> Unit) : Banner<Unit>() {
|
||||
|
||||
@@ -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,6 +21,7 @@ import com.bumptech.glide.RequestManager;
|
||||
import org.signal.core.ui.view.Stub;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.components.transfercontrols.TransferControlView;
|
||||
import org.thoughtcrime.securesms.components.transfercontrols.TransferControls;
|
||||
import org.thoughtcrime.securesms.mms.Slide;
|
||||
import org.thoughtcrime.securesms.mms.SlideClickListener;
|
||||
import org.thoughtcrime.securesms.mms.SlidesClickedListener;
|
||||
@@ -263,7 +264,7 @@ public class AlbumThumbnailView extends FrameLayout {
|
||||
}
|
||||
|
||||
private void showSlides(@NonNull RequestManager requestManager, @NonNull List<Slide> slides) {
|
||||
boolean showControls = TransferControlView.containsPlayableSlides(slides);
|
||||
boolean showControls = TransferControls.containsPlayableSlides(slides);
|
||||
setSlide(requestManager, slides.get(0), R.id.album_cell_1, showControls);
|
||||
setSlide(requestManager, slides.get(1), R.id.album_cell_2, showControls);
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.thoughtcrime.securesms.components;
|
||||
|
||||
import android.content.ClipData;
|
||||
import android.content.Context;
|
||||
import android.content.res.Configuration;
|
||||
import android.graphics.Canvas;
|
||||
@@ -19,6 +20,7 @@ import android.view.Menu;
|
||||
import android.view.MenuItem;
|
||||
import android.view.inputmethod.EditorInfo;
|
||||
import android.view.inputmethod.InputConnection;
|
||||
import android.view.inputmethod.InputConnectionWrapper;
|
||||
|
||||
import androidx.annotation.IdRes;
|
||||
import androidx.annotation.NonNull;
|
||||
@@ -26,6 +28,7 @@ import androidx.annotation.Nullable;
|
||||
import androidx.annotation.VisibleForTesting;
|
||||
import androidx.core.content.ContextCompat;
|
||||
|
||||
import org.signal.core.util.ServiceUtil;
|
||||
import org.signal.core.util.StringUtil;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
@@ -68,6 +71,7 @@ public class ComposeText extends EmojiEditText {
|
||||
@Nullable private CursorPositionChangedListener cursorPositionChangedListener;
|
||||
@Nullable private InlineQueryChangedListener inlineQueryChangedListener;
|
||||
@Nullable private StylingChangedListener stylingChangedListener;
|
||||
@Nullable private OnPasteListener onPasteListener;
|
||||
|
||||
public ComposeText(Context context) {
|
||||
super(context);
|
||||
@@ -213,6 +217,41 @@ public class ComposeText extends EmojiEditText {
|
||||
stylingChangedListener = listener;
|
||||
}
|
||||
|
||||
public void setOnPasteListener(@Nullable OnPasteListener listener) {
|
||||
onPasteListener = listener;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inserts the given text at the current selection (replacing any selected text), as if pasted.
|
||||
* This goes directly through the underlying {@link Editable}, so it does not pass through the
|
||||
* {@link OnPasteListener}. Used to complete a paste the listener previously intercepted, replaying
|
||||
* the exact text that was intercepted rather than re-reading the clipboard — the intercepted text
|
||||
* may have come from an IME suggestion (e.g. the keyboard's clipboard chip) that is not the
|
||||
* current clipboard contents.
|
||||
*/
|
||||
public void insertText(@NonNull CharSequence text) {
|
||||
Editable editable = getText();
|
||||
if (editable == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
int selectionStart = getSelectionStart();
|
||||
int selectionEnd = getSelectionEnd();
|
||||
|
||||
int start;
|
||||
int end;
|
||||
if (selectionStart < 0 || selectionEnd < 0) {
|
||||
start = editable.length();
|
||||
end = editable.length();
|
||||
} else {
|
||||
start = Math.min(selectionStart, selectionEnd);
|
||||
end = Math.max(selectionStart, selectionEnd);
|
||||
}
|
||||
|
||||
editable.replace(start, end, text);
|
||||
setSelection(start + text.length());
|
||||
}
|
||||
|
||||
private boolean isLandscape() {
|
||||
return getResources().getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE;
|
||||
}
|
||||
@@ -242,7 +281,19 @@ public class ComposeText extends EmojiEditText {
|
||||
editorInfo.imeOptions &= ~EditorInfo.IME_FLAG_NO_ENTER_ACTION;
|
||||
}
|
||||
|
||||
return inputConnection;
|
||||
if (inputConnection == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new InputConnectionWrapper(inputConnection, true) {
|
||||
@Override
|
||||
public boolean commitText(CharSequence text, int newCursorPosition) {
|
||||
if (onPasteListener != null && text != null && onPasteListener.onPaste(text)) {
|
||||
return true;
|
||||
}
|
||||
return super.commitText(text, newCursorPosition);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public boolean hasMentions() {
|
||||
@@ -479,6 +530,20 @@ public class ComposeText extends EmojiEditText {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onTextContextMenuItem(int id) {
|
||||
if ((id == android.R.id.paste || id == android.R.id.pasteAsPlainText) && onPasteListener != null) {
|
||||
ClipData clipData = ServiceUtil.getClipboardManager(getContext()).getPrimaryClip();
|
||||
CharSequence pasteText = clipData != null && clipData.getItemCount() > 0 ? clipData.getItemAt(0).coerceToText(getContext()) : null;
|
||||
|
||||
if (onPasteListener.onPaste(pasteText)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return super.onTextContextMenuItem(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return true if we think the user may be inputting a time.
|
||||
*/
|
||||
@@ -576,4 +641,15 @@ public class ComposeText extends EmojiEditText {
|
||||
public interface StylingChangedListener {
|
||||
void onStylingChanged();
|
||||
}
|
||||
|
||||
public interface OnPasteListener {
|
||||
/**
|
||||
* Invoked before a paste is applied to the field, giving an observer the chance to intercept it.
|
||||
*
|
||||
* @param pasteText the text currently on the clipboard, or {@code null} if it could not be read
|
||||
* @return true to consume the paste (the listener will handle it, e.g. by prompting the user),
|
||||
* or false to let the paste proceed normally
|
||||
*/
|
||||
boolean onPaste(@Nullable CharSequence pasteText);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -5,7 +5,6 @@ import android.content.res.TypedArray;
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.Color;
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.util.AttributeSet;
|
||||
import android.view.View;
|
||||
import android.widget.ImageView;
|
||||
@@ -69,7 +68,7 @@ public class SharedContactView extends LinearLayout implements RecipientForeverO
|
||||
initialize(attrs);
|
||||
}
|
||||
|
||||
@RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
|
||||
@RequiresApi(api = 21)
|
||||
public SharedContactView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
|
||||
super(context, attrs, defStyleAttr, defStyleRes);
|
||||
initialize(attrs);
|
||||
|
||||
@@ -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.
|
||||
@@ -34,7 +37,7 @@ class SignalProgressDialog private constructor(
|
||||
|
||||
var progress: Int
|
||||
get() = progressBar.progress
|
||||
set(value) = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
set(value) = if (Build.VERSION.SDK_INT >= 24) {
|
||||
progressBar.setProgress(value, true)
|
||||
} else {
|
||||
progressBar.setProgress(value)
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.attachments.Attachment;
|
||||
import org.thoughtcrime.securesms.attachments.DatabaseAttachment;
|
||||
import org.thoughtcrime.securesms.components.transfercontrols.TransferControlView;
|
||||
import org.thoughtcrime.securesms.components.transfercontrols.TransferControls;
|
||||
import org.thoughtcrime.securesms.database.AttachmentTable;
|
||||
import org.thoughtcrime.securesms.glide.targets.GlideBitmapListeningTarget;
|
||||
import org.thoughtcrime.securesms.glide.targets.GlideDrawableListeningTarget;
|
||||
@@ -384,7 +385,7 @@ public class ThumbnailView extends FrameLayout {
|
||||
}
|
||||
transferControlViewStub.get().setSlides(List.of(slide));
|
||||
}
|
||||
int transferState = TransferControlView.getTransferState(List.of(slide));
|
||||
int transferState = TransferControls.getTransferState(List.of(slide));
|
||||
boolean isOffloadedImage = (transferState == AttachmentTable.TRANSFER_RESTORE_OFFLOADED && MediaUtil.isImageType(slide.getContentType())) && AttachmentUtil.isRestoreOnOpenPermitted(getContext(), slide.asAttachment());
|
||||
|
||||
if (!showControls ||
|
||||
|
||||
@@ -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(
|
||||
|
||||
+1
-1
@@ -48,7 +48,7 @@ class SystemEmojiDrawable(emoji: CharSequence) : Drawable() {
|
||||
companion object {
|
||||
private val textPaint: TextPaint = TextPaint()
|
||||
|
||||
private fun getStaticLayout(emoji: CharSequence): StaticLayout = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
private fun getStaticLayout(emoji: CharSequence): StaticLayout = if (Build.VERSION.SDK_INT >= 23) {
|
||||
StaticLayout.Builder.obtain(emoji, 0, emoji.length, textPaint, Int.MAX_VALUE).build()
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
+1
-2
@@ -3,7 +3,6 @@ package org.thoughtcrime.securesms.components.registration;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.PorterDuff;
|
||||
import android.os.Build;
|
||||
import android.util.AttributeSet;
|
||||
import android.view.View;
|
||||
import android.view.animation.Animation;
|
||||
@@ -49,7 +48,7 @@ public class VerificationPinKeyboard extends FrameLayout {
|
||||
initialize();
|
||||
}
|
||||
|
||||
@RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
|
||||
@RequiresApi(api = 21)
|
||||
public VerificationPinKeyboard(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr, int defStyleRes) {
|
||||
super(context, attrs, defStyleAttr, defStyleRes);
|
||||
initialize();
|
||||
|
||||
+6
-2
@@ -25,12 +25,12 @@ open class DSLSettingsActivity : PassphraseRequiredActivity() {
|
||||
setContentView(R.layout.dsl_settings_activity)
|
||||
|
||||
if (savedInstanceState == null) {
|
||||
val navGraphId = intent.getIntExtra(ARG_NAV_GRAPH, -1)
|
||||
val navGraphId = resolveNavGraphId()
|
||||
if (navGraphId == -1) {
|
||||
throw IllegalStateException("No navgraph id was passed to activity")
|
||||
}
|
||||
|
||||
val fragment: NavHostFragment = NavHostFragment.create(navGraphId, intent.getBundleExtra(ARG_START_BUNDLE))
|
||||
val fragment: NavHostFragment = NavHostFragment.create(navGraphId, resolveStartBundle())
|
||||
|
||||
supportFragmentManager.beginTransaction()
|
||||
.replace(R.id.nav_host_fragment, fragment)
|
||||
@@ -64,6 +64,10 @@ open class DSLSettingsActivity : PassphraseRequiredActivity() {
|
||||
|
||||
protected open fun onWillFinish() {}
|
||||
|
||||
protected open fun resolveNavGraphId(): Int = intent.getIntExtra(ARG_NAV_GRAPH, -1)
|
||||
|
||||
protected open fun resolveStartBundle(): Bundle? = intent.getBundleExtra(ARG_START_BUNDLE)
|
||||
|
||||
companion object {
|
||||
const val ARG_NAV_GRAPH = "nav_graph"
|
||||
const val ARG_START_BUNDLE = "start_bundle"
|
||||
|
||||
+12
-4
@@ -2,12 +2,15 @@ package org.thoughtcrime.securesms.components.settings.app
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.Process
|
||||
import androidx.navigation.NavDirections
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import io.reactivex.rxjava3.subjects.PublishSubject
|
||||
import io.reactivex.rxjava3.subjects.Subject
|
||||
import org.signal.core.util.getParcelableExtraCompat
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.donations.InAppPaymentType
|
||||
import org.thoughtcrime.securesms.MainActivity
|
||||
import org.thoughtcrime.securesms.R
|
||||
@@ -34,20 +37,21 @@ private const val EXTRA_PERFORM_ACTION_ON_CREATE = "extra_perform_action_on_crea
|
||||
|
||||
class AppSettingsActivity : DSLSettingsActivity(), GooglePayComponent {
|
||||
|
||||
private val TAG = Log.tag(AppSettingsActivity::class)
|
||||
|
||||
private var wasConfigurationUpdated = false
|
||||
|
||||
override val googlePayRepository: GooglePayRepository by lazy { GooglePayRepository(this) }
|
||||
override val googlePayResultPublisher: Subject<GooglePayComponent.GooglePayResult> = PublishSubject.create()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) {
|
||||
if (intent?.hasExtra(ARG_NAV_GRAPH) != true) {
|
||||
intent?.putExtra(ARG_NAV_GRAPH, R.navigation.app_settings_with_change_number)
|
||||
}
|
||||
|
||||
super.onCreate(savedInstanceState, ready)
|
||||
|
||||
val startingAction: NavDirections? = if (intent?.categories?.contains(NOTIFICATION_CATEGORY) == true) {
|
||||
AppSettingsFragmentDirections.actionDirectToNotificationsSettingsFragment()
|
||||
} else if (Build.VERSION.SDK_INT >= 34 && getLaunchedFromUid() != Process.myUid()) {
|
||||
Log.w(TAG, "Settings was launched by an external process. Ignoring starting route.")
|
||||
null
|
||||
} else {
|
||||
when (val appSettingsRoute: AppSettingsRoute? = intent?.getParcelableExtraCompat(START_ROUTE, AppSettingsRoute::class.java)) {
|
||||
AppSettingsRoute.Empty -> null
|
||||
@@ -144,6 +148,10 @@ class AppSettingsActivity : DSLSettingsActivity(), GooglePayComponent {
|
||||
}
|
||||
}
|
||||
|
||||
override fun resolveNavGraphId(): Int = R.navigation.app_settings_with_change_number
|
||||
|
||||
override fun resolveStartBundle(): Bundle? = null
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||
super.onActivityResult(requestCode, resultCode, data)
|
||||
|
||||
+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
|
||||
)
|
||||
|
||||
+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
|
||||
)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user