Compare commits

..

2 Commits

Author SHA1 Message Date
jeffrey-signal 906801c199 Bump version to 8.10.3 2026-05-19 11:32:59 -04:00
Alex Hart e876451ccc Fix display bug with donation type. 2026-05-19 11:08:27 -03:00
2500 changed files with 94854 additions and 122906 deletions
-1
View File
@@ -1,2 +1 @@
*.ai binary
**/src/screenshotTest*/reference/**/*.png filter=lfs diff=lfs merge=lfs -text
+9 -29
View File
@@ -5,7 +5,7 @@ on:
push:
branches:
- 'main'
- '8.**'
- '7.**'
permissions:
contents: read # to fetch code (actions/checkout)
@@ -16,42 +16,25 @@ jobs:
runs-on: ubuntu-latest-8-cores
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
# gh api repos/actions/checkout/commits/v6 --jq '.sha'
with:
submodules: true
lfs: true
- name: set up JDK 21
uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5
- name: set up JDK 17
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5
# gh api repos/actions/setup-java/commits/v5 --jq '.sha'
with:
distribution: temurin
java-version: 21
java-version: 17
cache: gradle
- name: Validate Gradle Wrapper
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # v6
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@3f131e8634966bd73d06cc69884922b02e6faf92 # 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 (ciRemote); pushes to main / 8.x branches run the
# full Android lint (qaRemote). Both include screenshot validation, which is deliberately kept
# out of the local qa/ci tasks because screenshot rendering is host-OS dependent.
- name: Build with Gradle
env:
SIGNAL_BUILD_CACHE_URL: ${{ secrets.SIGNAL_BUILD_CACHE_URL }}
SIGNAL_BUILD_CACHE_USER: ${{ secrets.SIGNAL_BUILD_CACHE_USER }}
SIGNAL_BUILD_CACHE_PASSWORD: ${{ secrets.SIGNAL_BUILD_CACHE_PASSWORD }}
SIGNAL_BUILD_CACHE_PUSH: ${{ startsWith(github.ref, 'refs/heads/8.') }}
run: ./gradlew ${{ github.event_name == 'pull_request' && 'ciRemote' || 'qaRemote' }}
run: ./gradlew qa
- name: Archive reports for failed build
if: ${{ failure() }}
@@ -59,7 +42,4 @@ jobs:
# gh api repos/actions/upload-artifact/commits/v7 --jq '.sha'
with:
name: reports
path: |
**/build/reports
**/build/test-results
if-no-files-found: ignore
path: '*/build/reports'
+8 -16
View File
@@ -16,38 +16,30 @@ jobs:
runs-on: ubuntu-latest-8-cores
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
# gh api repos/actions/checkout/commits/v6 --jq '.sha'
with:
submodules: true
ref: ${{ github.event.pull_request.base.sha }}
- name: set up JDK 21
uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5
- name: set up JDK 17
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5
# gh api repos/actions/setup-java/commits/v5 --jq '.sha'
with:
distribution: temurin
java-version: 21
- name: Set up Gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # 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 }}
java-version: 17
cache: gradle
- name: Install NDK
run: echo "y" | ${ANDROID_SDK_ROOT}/cmdline-tools/latest/bin/sdkmanager --install "ndk;${{ env.NDK_VERSION }}"
- name: Validate Gradle Wrapper
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # v6
uses: gradle/actions/wrapper-validation@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6
# gh api repos/gradle/actions/commits/v6 --jq '.sha'
- name: Cache base apk
id: cache-base
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v5
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
# gh api repos/actions/cache/commits/v5 --jq '.sha'
with:
path: diffuse-base.apk
@@ -61,7 +53,7 @@ jobs:
if: steps.cache-base.outputs.cache-hit != 'true'
run: mv app/build/outputs/apk/playProd/release/*arm64*.apk diffuse-base.apk
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
# gh api repos/actions/checkout/commits/v6 --jq '.sha'
with:
submodules: true
+4 -10
View File
@@ -11,17 +11,11 @@ jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
# gh api repos/actions/checkout/commits/v6 --jq '.sha'
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: Build image
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: reproducible-builds
tags: signal-android
load: true
cache-from: type=gha
cache-to: type=gha,mode=max
run: |
cd reproducible-builds
docker build -t signal-android .
- name: Test build
run: docker run --memory=12g --rm -v "$(pwd)":/project -w /project signal-android ./gradlew --no-daemon --max-workers=1 -Dorg.gradle.jvmargs="-Xmx4g -XX:MaxMetaspaceSize=512m" -Dkotlin.compiler.execution.strategy=in-process bundlePlayProdRelease
+1 -1
View File
@@ -14,7 +14,7 @@ jobs:
actions: write
steps:
- uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10
# gh api repos/actions/stale/commits/v10 --jq '.sha'
with:
days-before-stale: 60
+1 -1
View File
@@ -1,2 +1,2 @@
java openjdk-21.0.2
java openjdk-17.0.2
uv latest
+26 -202
View File
@@ -2,10 +2,7 @@
import com.android.build.api.artifact.ArtifactTransformationRequest
import com.android.build.api.artifact.SingleArtifact
import com.android.build.api.variant.HasAndroidTest
import org.gradle.api.tasks.testing.logging.TestExceptionFormat
import org.gradle.process.ExecOperations
import org.gradle.work.DisableCachingByDefault
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import org.jetbrains.kotlin.gradle.dsl.KotlinAndroidProjectExtension
import java.time.Instant
@@ -13,14 +10,12 @@ import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
import java.util.Locale
import java.util.Properties
import javax.inject.Inject
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.ktlint)
alias(libs.plugins.compose.compiler)
alias(libs.plugins.kotlinx.serialization)
alias(testLibs.plugins.compose.screenshot)
alias(benchmarkLibs.plugins.baselineprofile)
id("androidx.navigation.safeargs")
id("kotlin-parcelize")
@@ -32,9 +27,9 @@ plugins {
val staticIps = Properties().apply { file("static-ips.properties").reader().use { load(it) } }
staticIps.stringPropertyNames().forEach { rootProject.extra[it] = staticIps.getProperty(it) }
val canonicalVersionCode = 1719
val canonicalVersionName = "8.19.2"
val currentHotfixVersion = 0
val canonicalVersionCode = 1687
val canonicalVersionName = "8.10.3"
val currentHotfixVersion = 1
val maxHotfixVersions = 100
// We don't want versions to ever end in 0 so that they don't conflict with nightly versions
@@ -61,11 +56,6 @@ 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",
@@ -78,11 +68,13 @@ val selectableVariants = listOf(
"playProdMocked",
"playProdNonMinifiedMocked",
"playProdBenchmark",
"playProdInstrumentation",
"playProdRelease",
"playStagingDebug",
"playStagingCanary",
"playStagingSpinner",
"playStagingPerf",
"playStagingInstrumentation",
"playStagingRelease",
"playProdQuickstart",
"playStagingQuickstart",
@@ -108,9 +100,6 @@ tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile::class.java).conf
if (!isTestTask && (name.contains("Mocked") || name.contains("Benchmark"))) {
source("$projectDir/src/benchmarkShared/java")
}
if (isTestTask && name.contains("AndroidTest")) {
source("$projectDir/src/benchmarkShared/java")
}
}
wire {
@@ -132,21 +121,15 @@ ktlint {
version.set("1.5.0")
}
screenshotTests {
// Fraction of differing pixels tolerated before a screenshot test fails (0.0001 = 0.01%).
imageDifferenceThreshold = 0.0001f
}
android {
namespace = "org.thoughtcrime.securesms"
experimentalProperties["android.experimental.enableScreenshotTest"] = true
buildToolsVersion = libs.versions.buildTools.get()
compileSdkVersion(libs.versions.compileSdk.get())
ndkVersion = libs.versions.ndk.get()
flavorDimensions += listOf("distribution", "environment")
testBuildType = "instrumentation"
android.bundle.language.enableSplit = false
@@ -185,7 +168,6 @@ android {
getByName("androidTest") {
java.srcDir("$projectDir/src/testShared")
java.srcDir("$projectDir/src/benchmarkShared/java")
}
}
@@ -233,10 +215,6 @@ 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()
@@ -271,8 +249,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", "\"1240acbd4aa26974184844c8a46b1022d3957ac8a76c1fd8f5b1a15141ee0708\"")
buildConfigField("String", "SVR2_MRENCLAVE", "\"ced8217b26228e4b210c985786999d095c4958a94faf37b14acaf25c4cbb02a4\"")
buildConfigField("String", "SVR2_MRENCLAVE_LEGACY", "\"29cd63c87bea751e3bfd0fbd401279192e2e5c99948b4ee9437eafc4968355fb\"")
buildConfigField("String", "SVR2_MRENCLAVE", "\"1240acbd4aa26974184844c8a46b1022d3957ac8a76c1fd8f5b1a15141ee0708\"")
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\"")
@@ -294,6 +272,7 @@ android {
buildConfigField("String", "STRIPE_PUBLISHABLE_KEY", "\"pk_live_6cmGZopuTsV8novGgJJW9JpC00vLIgtQ1D\"")
buildConfigField("boolean", "TRACING_ENABLED", "false")
buildConfigField("boolean", "LINK_DEVICE_UX_ENABLED", "false")
buildConfigField("boolean", "USE_STRING_ID", "false")
ndk {
abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64")
@@ -309,11 +288,7 @@ android {
}
}
testInstrumentationRunner = if (project.hasProperty("imoTests")) {
"org.thoughtcrime.securesms.testing.incomingmessageobserver.IncomingMessageObserverTestRunner"
} else {
"org.thoughtcrime.securesms.testing.SignalTestRunner"
}
testInstrumentationRunner = "org.thoughtcrime.securesms.testing.SignalTestRunner"
testInstrumentationRunnerArguments["clearPackageData"] = "true"
}
@@ -362,6 +337,17 @@ android {
buildConfigField("String", "BUILD_VARIANT_TYPE", "\"Release\"")
}
create("instrumentation") {
initWith(getByName("debug"))
isDefault = false
isMinifyEnabled = false
matchingFallbacks += "debug"
applicationIdSuffix = ".instrumentation"
buildConfigField("String", "BUILD_VARIANT_TYPE", "\"Instrumentation\"")
buildConfigField("String", "STRIPE_BASE_URL", "\"http://127.0.0.1:8080/stripe\"")
}
create("spinner") {
initWith(getByName("debug"))
isDefault = false
@@ -477,8 +463,8 @@ android {
buildConfigField("String", "SIGNAL_CDN3_URL", "\"https://cdn3-staging.signal.org\"")
buildConfigField("String", "SIGNAL_CDSI_URL", "\"https://cdsi.staging.signal.org\"")
buildConfigField("String", "SIGNAL_SVR2_URL", "\"https://svr2.staging.signal.org\"")
buildConfigField("String", "SVR2_MRENCLAVE_LEGACY", "\"97f151f6ed078edbbfd72fa9cae694dcc08353f1f5e8d9ccd79a971b10ffc535\"")
buildConfigField("String", "SVR2_MRENCLAVE", "\"3c699f4975aaa3d172c0aad042f94f031b2b03e10b9c19a45116a01693d83302\"")
buildConfigField("String", "SVR2_MRENCLAVE_LEGACY", "\"a75542d82da9f6914a1e31f8a7407053b99cc99a0e7291d8fbd394253e19b036\"")
buildConfigField("String", "SVR2_MRENCLAVE", "\"97f151f6ed078edbbfd72fa9cae694dcc08353f1f5e8d9ccd79a971b10ffc535\"")
buildConfigField("String[]", "UNIDENTIFIED_SENDER_TRUST_ROOTS", "new String[]{\"BbqY1DzohE4NUZoVF+L18oUPrK3kILllLEJh2UnPSsEx\", \"BYhU6tPjqP46KGZEzRs1OL4U39V5dlPJ/X09ha4rErkm\"}")
buildConfigField("String", "ZKGROUP_SERVER_PUBLIC_PARAMS", "\"ABSY21VckQcbSXVNCGRYJcfWHiAMZmpTtTELcDmxgdFbtp/bWsSxZdMKzfCp8rvIs8ocCU3B37fT3r4Mi5qAemeGeR2X+/YmOGR5ofui7tD5mDQfstAI9i+4WpMtIe8KC3wU5w3Inq3uNWVmoGtpKndsNfwJrCg0Hd9zmObhypUnSkfYn2ooMOOnBpfdanRtrvetZUayDMSC5iSRcXKpdlukrpzzsCIvEwjwQlJYVPOQPj4V0F4UXXBdHSLK05uoPBCQG8G9rYIGedYsClJXnbrgGYG3eMTG5hnx4X4ntARBgELuMWWUEEfSK0mjXg+/2lPmWcTZWR9nkqgQQP0tbzuiPm74H2wMO4u1Wafe+UwyIlIT9L7KLS19Aw8r4sPrXZSSsOZ6s7M1+rTJN0bI5CKY2PX29y5Ok3jSWufIKcgKOnWoP67d5b2du2ZVJjpjfibNIHbT/cegy/sBLoFwtHogVYUewANUAXIaMPyCLRArsKhfJ5wBtTminG/PAvuBdJ70Z/bXVPf8TVsR292zQ65xwvWTejROW6AZX6aqucUjlENAErBme1YHmOSpU6tr6doJ66dPzVAWIanmO/5mgjNEDeK7DDqQdB1xd03HT2Qs2TxY3kCK8aAb/0iM0HQiXjxZ9HIgYhbtvGEnDKW5ILSUydqH/KBhW4Pb0jZWnqN/YgbWDKeJxnDbYcUob5ZY5Lt5ZCMKuaGUvCJRrCtuugSMaqjowCGRempsDdJEt+cMaalhZ6gczklJB/IbdwENW9KeVFPoFNFzhxWUIS5ML9riVYhAtE6JE5jX0xiHNVIIPthb458cfA8daR0nYfYAUKogQArm0iBezOO+mPk5vCNWI+wwkyFCqNDXz/qxl1gAntuCJtSfq9OC3NkdhQlgYQ==\"")
buildConfigField("String", "GENERIC_SERVER_PUBLIC_PARAMS", "\"AHILOIrFPXX9laLbalbA9+L1CXpSbM/bTJXZGZiuyK1JaI6dK5FHHWL6tWxmHKYAZTSYmElmJ5z2A5YcirjO/yfoemE03FItyaf8W1fE4p14hzb5qnrmfXUSiAIVrhaXVwIwSzH6RL/+EO8jFIjJ/YfExfJ8aBl48CKHgu1+A6kWynhttonvWWx6h7924mIzW0Czj2ROuh4LwQyZypex4GuOPW8sgIT21KNZaafgg+KbV7XM1x1tF3XA17B4uGUaDbDw2O+nR1+U5p6qHPzmJ7ggFjSN6Utu+35dS1sS0P9N\"")
@@ -530,9 +516,6 @@ android {
androidComponents {
beforeVariants { variant ->
variant.enable = variant.name in selectableVariants
if (variant.enable) {
(variant as? com.android.build.api.variant.HasUnitTestBuilder)?.enableUnitTest = true
}
}
onVariants(selector().all()) { variant: com.android.build.api.variant.ApplicationVariant ->
// Rename APK to include version name
@@ -544,10 +527,9 @@ androidComponents {
transformationRequest.set(renameRequest)
}
// Include the test-only library on non-release builds.
if (variant.buildType == "release") {
// Include the test-only library on debug builds.
if (variant.buildType != "instrumentation") {
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.
@@ -602,43 +584,6 @@ androidComponents {
}
variant.sources.assets?.addGeneratedSourceDirectory(taskProvider) { it.outputDir }
}
onVariants(selector().withName("playProdDebug")) { variant ->
val androidTest = (variant as? HasAndroidTest)?.androidTest ?: return@onVariants
tasks.register<FirebaseTestLabTask>("firebaseTestLab") {
group = "Verification"
description = "Runs the ${variant.name} instrumentation tests on Firebase Test Lab via the gcloud CLI. Run a single class with -Pftl.class=<fqcn>[#method]; override other defaults with -Pftl.* properties."
appApkDirectory.set(variant.artifacts.get(SingleArtifact.APK))
testApkDirectory.set(androidTest.artifacts.get(SingleArtifact.APK))
val deviceOverride = project.providers.gradleProperty("ftl.devices").orNull
devices.set(
deviceOverride?.split(";")?.map { it.trim() }?.filter { it.isNotEmpty() }
?: listOf("model=Pixel2.arm,version=31,locale=en,orientation=portrait")
)
useOrchestrator.set(true)
environmentVariables.set(mapOf("clearPackageData" to "true"))
testTimeout.set(project.providers.gradleProperty("ftl.timeout").getOrElse("30m"))
numFlakyTestAttempts.set(project.providers.gradleProperty("ftl.numFlakyTestAttempts").map { it.toInt() }.getOrElse(1))
gcloudProject.set(project.providers.gradleProperty("ftl.project"))
resultsBucket.set(project.providers.gradleProperty("ftl.resultsBucket"))
resultsDir.set(project.providers.gradleProperty("ftl.resultsDir"))
val testClass = project.providers.gradleProperty("ftl.class").orNull?.takeIf { it.isNotBlank() }
testTargets.set(
if (testClass != null) "class $testClass" else project.providers.gradleProperty("ftl.testTargets").orNull
)
gcloudExecutable.set(project.providers.gradleProperty("ftl.gcloud").getOrElse("gcloud"))
extraArgs.set(
project.providers.gradleProperty("ftl.extraArgs").orNull
?.split(" ")?.map { it.trim() }?.filter { it.isNotEmpty() }
?: emptyList()
)
}
}
}
baselineProfile {
@@ -711,7 +656,6 @@ dependencies {
implementation(libs.androidx.navigation.compose)
implementation(libs.androidx.navigation3.runtime)
implementation(libs.androidx.navigation3.ui)
implementation(libs.androidx.lifecycle.viewmodel.navigation3)
implementation(libs.androidx.lifecycle.viewmodel.ktx)
implementation(libs.androidx.lifecycle.livedata.ktx)
implementation(libs.androidx.lifecycle.process)
@@ -742,7 +686,6 @@ dependencies {
implementation(libs.google.play.services.maps)
implementation(libs.google.play.services.auth)
implementation(libs.google.signin)
implementation(libs.androidx.media)
implementation(libs.bundles.media3)
implementation(libs.conscrypt.android)
implementation(libs.signal.aesgcmprovider)
@@ -762,11 +705,6 @@ dependencies {
}
implementation(libs.lottie)
implementation(libs.lottie.compose)
// Compose screenshot testing
screenshotTestImplementation(testLibs.compose.screenshot.validation.api)
screenshotTestImplementation(libs.androidx.compose.ui.tooling.core)
screenshotTestImplementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.signal.android.database.sqlcipher)
implementation(libs.androidx.sqlite)
testImplementation(libs.androidx.sqlite.framework)
@@ -776,7 +714,6 @@ 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)
@@ -798,7 +735,7 @@ dependencies {
"canaryImplementation"(libs.square.leakcanary)
androidTestImplementation(libs.androidx.fragment.testing) {
"instrumentationImplementation"(libs.androidx.fragment.testing) {
exclude(group = "androidx.test", module = "core")
}
@@ -833,15 +770,6 @@ dependencies {
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
androidTestImplementation(testLibs.androidx.test.ext.junit)
androidTestImplementation(testLibs.espresso.core)
androidTestImplementation(testLibs.espresso.contrib) {
// espresso-contrib transitively pulls the full checkerframework jar (only its annotations are needed),
// whose MANIFEST.MF collides with other test dependencies during androidTest resource merging.
exclude(group = "org.checkerframework", module = "checker")
// accessibility-test-framework drags in an ancient com.google.protobuf:protobuf-lite:3.0.1 whose
// GeneratedMessageLite wins the merged dex and lacks registerDefaultInstance(Class, GeneratedMessageLite),
// crashing tests at runtime. We only use RecyclerViewActions from contrib, not the accessibility checks.
exclude(group = "com.google.android.apps.common.testing.accessibility.framework")
}
androidTestImplementation(testLibs.androidx.test.core)
androidTestImplementation(testLibs.androidx.test.core.ktx)
androidTestImplementation(testLibs.androidx.test.ext.junit.ktx)
@@ -1025,110 +953,6 @@ abstract class CopyBenchmarkBackupTask : DefaultTask() {
}
}
/**
* Runs an instrumentation test suite on Firebase Test Lab by shelling out to `gcloud firebase test android run`.
*
* The `gcloud` CLI must be installed and authenticated (`gcloud auth login` and a configured project, or an
* activated service account) before invoking this task.
*/
@DisableCachingByDefault(because = "Executes tests on remote devices; results must never be served from the build cache")
abstract class FirebaseTestLabTask
@Inject
constructor(
private val execOperations: ExecOperations
) : DefaultTask() {
@get:InputFiles
@get:PathSensitive(PathSensitivity.RELATIVE)
abstract val appApkDirectory: DirectoryProperty
@get:InputFiles
@get:PathSensitive(PathSensitivity.RELATIVE)
abstract val testApkDirectory: DirectoryProperty
@get:Input
abstract val devices: ListProperty<String>
@get:Input
abstract val useOrchestrator: Property<Boolean>
@get:Input
abstract val environmentVariables: MapProperty<String, String>
@get:Input
abstract val testTimeout: Property<String>
@get:Input
abstract val numFlakyTestAttempts: Property<Int>
@get:Input
@get:Optional
abstract val gcloudProject: Property<String>
@get:Input
@get:Optional
abstract val resultsBucket: Property<String>
@get:Input
@get:Optional
abstract val resultsDir: Property<String>
@get:Input
@get:Optional
abstract val testTargets: Property<String>
@get:Input
abstract val gcloudExecutable: Property<String>
@get:Input
abstract val extraArgs: ListProperty<String>
@TaskAction
fun run() {
val appApk = findApk(appApkDirectory.get().asFile, "app")
val testApk = findApk(testApkDirectory.get().asFile, "instrumentation test")
val arguments = mutableListOf(
gcloudExecutable.get(),
"firebase", "test", "android", "run",
"--type", "instrumentation",
"--app", appApk.absolutePath,
"--test", testApk.absolutePath,
"--timeout", testTimeout.get(),
"--num-flaky-test-attempts", numFlakyTestAttempts.get().toString()
)
devices.get().forEach { device ->
arguments += listOf("--device", device)
}
if (useOrchestrator.get()) {
arguments += "--use-orchestrator"
}
val environment = environmentVariables.get()
if (environment.isNotEmpty()) {
arguments += "--environment-variables"
arguments += environment.entries.joinToString(",") { "${it.key}=${it.value}" }
}
gcloudProject.orNull?.takeIf { it.isNotBlank() }?.let { arguments += listOf("--project", it) }
resultsBucket.orNull?.takeIf { it.isNotBlank() }?.let { arguments += listOf("--results-bucket", it) }
resultsDir.orNull?.takeIf { it.isNotBlank() }?.let { arguments += listOf("--results-dir", it) }
testTargets.orNull?.takeIf { it.isNotBlank() }?.let { arguments += listOf("--test-targets", it) }
arguments += extraArgs.get()
logger.lifecycle("Running Firebase Test Lab:\n ${arguments.joinToString(" ")}")
execOperations.exec {
commandLine(arguments)
}
}
private fun findApk(directory: File, label: String): File {
return directory.walkTopDown().firstOrNull { it.isFile && it.extension == "apk" }
?: throw GradleException("No $label APK found under ${directory.absolutePath}. Was the assemble task run?")
}
}
abstract class RenameApkTask : DefaultTask() {
@get:InputFiles
abstract val apkFolder: DirectoryProperty
+37826 -413
View File
File diff suppressed because one or more lines are too long
-14
View File
@@ -7,12 +7,6 @@
-keep class org.signal.libsignal.usernames.** { *; }
-keep class org.thoughtcrime.securesms.** { *; }
-keep class org.signal.donations.json.** { *; }
-keep class org.signal.network.** { *; }
-keep class org.signal.core.util.crypto.AttachmentSecret { *; }
-keep class org.signal.core.util.crypto.AttachmentSecret$* { *; }
-keep class org.signal.core.util.crypto.KeyStoreHelper$SealedData { *; }
-keep class org.signal.core.util.crypto.KeyStoreHelper$SealedData$* { *; }
-keepclassmembers class ** {
public void onEvent*(**);
}
@@ -22,14 +16,6 @@
-keep class androidx.window.** { *; }
# Workaround for R8 non-determinism in AGP 9.x. R8 inconsistently keeps or strips
# the Signature attribute on this Kotlin lambda subclass of the generic
# LottieValueCallback, causing intermittent dex byte differences. Explicitly
# keeping the class stabilizes R8's attribute decisions.
-keep class com.airbnb.lottie.compose.LottieDynamicPropertiesKt$toValueCallback$1 {
*;
}
-keepclassmembers class * extends androidx.constraintlayout.motion.widget.Key {
public <init>();
}
@@ -1,36 +0,0 @@
package org.thoughtcrime.securesms
import org.signal.core.util.logging.AndroidLogger
import org.signal.core.util.logging.Log
import org.signal.libsignal.protocol.logging.SignalProtocolLoggerProvider
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.dependencies.ApplicationDependencyProvider
import org.thoughtcrime.securesms.logging.CustomSignalProtocolLogger
import org.thoughtcrime.securesms.testing.incomingmessageobserver.IncomingMessageObserverDependencyProvider
import org.thoughtcrime.securesms.testing.incomingmessageobserver.IncomingMessageObserverTestRunner
/**
* Application used when running `IncomingMessageObserver` instrumentation tests. Installs
* [IncomingMessageObserverDependencyProvider] so the websocket and job manager are replaced
* with test-friendly implementations. Selected by [IncomingMessageObserverTestRunner] when
* gradle is invoked with `-PimoTests`.
*/
class IncomingMessageObserverInstrumentationApplicationContext : ApplicationContext() {
override fun initializeAppDependencies() {
val default = ApplicationDependencyProvider(this)
AppDependencies.init(this, IncomingMessageObserverDependencyProvider(this, default))
AppDependencies.deadlockDetector.start()
}
override fun initializeLogging() {
Log.initialize({ true }, AndroidLogger)
SignalProtocolLoggerProvider.setProvider(CustomSignalProtocolLogger())
}
override fun beginJobLoop() = Unit
fun beginJobLoopForTests() {
super.beginJobLoop()
}
}
@@ -1,6 +1,5 @@
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
@@ -9,12 +8,9 @@ import org.thoughtcrime.securesms.database.LogDatabase
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.dependencies.ApplicationDependencyProvider
import org.thoughtcrime.securesms.dependencies.InstrumentationApplicationDependencyProvider
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.logging.CustomSignalProtocolLogger
import org.thoughtcrime.securesms.logging.PersistentLogger
import org.thoughtcrime.securesms.testing.InMemoryLogger
import org.thoughtcrime.securesms.testing.TestRemoteConfig
import org.thoughtcrime.securesms.util.Environment
/**
* Application context for running instrumentation tests (aka androidTests).
@@ -23,22 +19,10 @@ 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))
AppDependencies.deadlockDetector.start()
// Stage any test-declared remote config into the store to be read in RemoteConfig.init().
if (TestRemoteConfig.pending.isNotEmpty()) {
val json = TestRemoteConfig.json
SignalStore.remoteConfig.currentConfig = json
SignalStore.remoteConfig.pendingConfig = json
}
}
override fun initializeLogging() {
@@ -1,82 +0,0 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.subscription.donate
import androidx.compose.ui.test.junit4.v2.createEmptyComposeRule
import androidx.test.core.app.ActivityScenario
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.ViewActions
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.matcher.RootMatchers.isDialog
import androidx.test.espresso.matcher.ViewMatchers.isClickable
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.espresso.matcher.ViewMatchers.withText
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.hamcrest.Matchers.allOf
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.signal.core.util.deleteAll
import org.signal.donations.InAppPaymentType
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.database.InAppPaymentTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.testing.GooglePayTestRule
import org.thoughtcrime.securesms.testing.InAppPaymentsRule
import org.thoughtcrime.securesms.testing.RxTestSchedulerRule
import org.thoughtcrime.securesms.testing.SignalActivityRule
import org.thoughtcrime.securesms.testing.actions.scrollToDescendant
@Suppress("ClassName")
@RunWith(AndroidJUnit4::class)
class CheckoutFlowActivityTest__OneTimeDonations {
@get:Rule
val harness = SignalActivityRule(othersCount = 10)
@get:Rule
val iapRule = InAppPaymentsRule()
@get:Rule
val rxRule = RxTestSchedulerRule()
@get:Rule
val googlePayRule = GooglePayTestRule()
@get:Rule
val composeRule = createEmptyComposeRule()
private val intent = CheckoutFlowActivity.createIntent(InstrumentationRegistry.getInstrumentation().targetContext, InAppPaymentType.ONE_TIME_DONATION)
@Before
fun setUp() {
SignalDatabase.inAppPayments.writableDatabase.deleteAll(InAppPaymentTable.TABLE_NAME)
startJobLoopForTests()
}
@Test
fun givenPermitAcquisitionFails_whenIDonateOnce_thenIExpectPaymentSetupErrorDialog() {
failDonationPermitAcquisition()
val scenario = ActivityScenario.launch<CheckoutFlowActivity>(intent)
rxRule.defaultTestScheduler.triggerActions()
scrollToDescendant(R.id.recycler, withId(R.id.boost_1), rxRule.defaultTestScheduler)
onView(allOf(withId(R.id.boost_1), isClickable())).perform(ViewActions.click())
rxRule.defaultTestScheduler.triggerActions()
scrollToDescendant(R.id.recycler, withText(R.string.DonateToSignalFragment__continue), rxRule.defaultTestScheduler)
onView(withText(R.string.DonateToSignalFragment__continue)).perform(ViewActions.click())
rxRule.defaultTestScheduler.triggerActions()
scenario.selectGooglePay(composeRule, rxRule.defaultTestScheduler, InAppPaymentType.ONE_TIME_DONATION)
awaitDialog(rxRule.defaultTestScheduler, R.string.DonationsErrors__error_processing_payment)
onView(withText(R.string.DonationsErrors__your_payment)).inRoot(isDialog()).check(matches(isDisplayed()))
}
}
@@ -1,11 +1,9 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.donate
import androidx.compose.ui.test.junit4.v2.createEmptyComposeRule
import androidx.test.core.app.ActivityScenario
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.ViewActions
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.matcher.RootMatchers.isDialog
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import androidx.test.espresso.matcher.ViewMatchers.isNotEnabled
import androidx.test.espresso.matcher.ViewMatchers.isSelected
@@ -14,32 +12,21 @@ import androidx.test.espresso.matcher.ViewMatchers.withText
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import io.mockk.every
import io.mockk.mockkObject
import io.mockk.unmockkObject
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.signal.core.util.deleteAll
import org.signal.donations.InAppPaymentType
import org.signal.libsignal.net.RequestResult
import org.signal.network.NetworkResult
import org.signal.network.exceptions.NonSuccessfulResponseCodeException
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.permits.DonationPermits
import org.thoughtcrime.securesms.database.InAppPaymentTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.testing.GooglePayTestRule
import org.thoughtcrime.securesms.testing.InAppPaymentsRule
import org.thoughtcrime.securesms.testing.RxTestSchedulerRule
import org.thoughtcrime.securesms.testing.SignalActivityRule
import org.thoughtcrime.securesms.testing.actions.scrollToDescendant
import org.thoughtcrime.securesms.testing.actions.RecyclerViewScrollToBottomAction
import org.whispersystems.signalservice.api.NetworkResult
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription
import org.whispersystems.signalservice.api.subscriptions.SubscriberId
import java.math.BigDecimal
@@ -59,37 +46,19 @@ class CheckoutFlowActivityTest__RecurringDonations {
@get:Rule
val rxRule = RxTestSchedulerRule()
@get:Rule
val googlePayRule = GooglePayTestRule()
@get:Rule
val composeRule = createEmptyComposeRule()
private val intent = CheckoutFlowActivity.createIntent(InstrumentationRegistry.getInstrumentation().targetContext, InAppPaymentType.RECURRING_DONATION)
@Before
fun setUp() {
SignalDatabase.inAppPayments.writableDatabase.deleteAll(InAppPaymentTable.TABLE_NAME)
startJobLoopForTests()
}
@After
fun tearDown() {
unmockkObject(DonationPermits)
}
@Test
fun givenRecurringDonations_whenILoadScreen_thenIExpectMonthlySelected() {
ActivityScenario.launch<CheckoutFlowActivity>(intent)
scrollToDescendant(R.id.recycler, withId(R.id.monthly), rxRule.defaultTestScheduler)
onView(withId(R.id.monthly)).check(matches(isSelected()))
}
@Test
fun givenNoCurrentDonation_whenILoadScreen_thenIExpectContinueButton() {
ActivityScenario.launch<CheckoutFlowActivity>(intent)
scrollToDescendant(R.id.recycler, withText(R.string.DonateToSignalFragment__continue), rxRule.defaultTestScheduler)
onView(withText(R.string.DonateToSignalFragment__continue)).check(matches(isDisplayed()))
onView(withId(R.id.recycler)).perform(RecyclerViewScrollToBottomAction)
onView(withText("Continue")).check(matches(isDisplayed()))
}
@Test
@@ -100,9 +69,8 @@ class CheckoutFlowActivityTest__RecurringDonations {
rxRule.defaultTestScheduler.triggerActions()
scrollToDescendant(R.id.recycler, withText(R.string.SubscribeFragment__update_subscription), rxRule.defaultTestScheduler)
onView(withId(R.id.recycler)).perform(RecyclerViewScrollToBottomAction)
onView(withText(R.string.SubscribeFragment__update_subscription)).check(matches(isDisplayed()))
scrollToDescendant(R.id.recycler, withText(R.string.SubscribeFragment__cancel_subscription), rxRule.defaultTestScheduler)
onView(withText(R.string.SubscribeFragment__cancel_subscription)).check(matches(isDisplayed()))
}
@@ -114,7 +82,7 @@ class CheckoutFlowActivityTest__RecurringDonations {
rxRule.defaultTestScheduler.triggerActions()
scrollToDescendant(R.id.recycler, withText(R.string.SubscribeFragment__cancel_subscription), rxRule.defaultTestScheduler)
onView(withId(R.id.recycler)).perform(RecyclerViewScrollToBottomAction)
onView(withText(R.string.SubscribeFragment__cancel_subscription)).check(matches(isDisplayed()))
onView(withText(R.string.SubscribeFragment__cancel_subscription)).perform(ViewActions.click())
onView(withText(R.string.SubscribeFragment__confirm_cancellation)).check(matches(isDisplayed()))
@@ -129,48 +97,11 @@ class CheckoutFlowActivityTest__RecurringDonations {
rxRule.defaultTestScheduler.triggerActions()
scrollToDescendant(R.id.recycler, withText(R.string.SubscribeFragment__update_subscription), rxRule.defaultTestScheduler)
onView(withId(R.id.recycler)).perform(RecyclerViewScrollToBottomAction)
onView(withText(R.string.SubscribeFragment__update_subscription)).check(matches(isDisplayed()))
onView(withText(R.string.SubscribeFragment__update_subscription)).check(matches(isNotEnabled()))
}
@Test
fun givenSubscriberPermitAcquisitionFails_whenISubscribe_thenIExpectPaymentSetupErrorDialog() {
failDonationPermitAcquisition()
val scenario = ActivityScenario.launch<CheckoutFlowActivity>(intent)
rxRule.defaultTestScheduler.triggerActions()
scrollToDescendant(R.id.recycler, withText(R.string.DonateToSignalFragment__continue), rxRule.defaultTestScheduler)
onView(withText(R.string.DonateToSignalFragment__continue)).perform(ViewActions.click())
rxRule.defaultTestScheduler.triggerActions()
scenario.selectGooglePay(composeRule, rxRule.defaultTestScheduler, InAppPaymentType.RECURRING_DONATION)
awaitDialog(rxRule.defaultTestScheduler, R.string.DonationsErrors__error_processing_payment)
onView(withText(R.string.DonationsErrors__your_payment)).inRoot(isDialog()).check(matches(isDisplayed()))
}
@Test
fun givenSubscriptionPaymentMethodPermitRejected_whenISubscribe_thenIExpectPaymentSetupErrorDialog() {
AppDependencies.donationPermitsRepository.clearPermits()
mockkObject(DonationPermits)
every { DonationPermits.getDonationPermit() } returns RequestResult.Success("permit")
every { AppDependencies.donationsApi.createStripeSubscriptionPaymentMethod(any(), any(), any()) } returns NetworkResult.StatusCodeError(NonSuccessfulResponseCodeException(402))
val scenario = ActivityScenario.launch<CheckoutFlowActivity>(intent)
rxRule.defaultTestScheduler.triggerActions()
scrollToDescendant(R.id.recycler, withText(R.string.DonateToSignalFragment__continue), rxRule.defaultTestScheduler)
onView(withText(R.string.DonateToSignalFragment__continue)).perform(ViewActions.click())
rxRule.defaultTestScheduler.triggerActions()
scenario.selectGooglePay(composeRule, rxRule.defaultTestScheduler, InAppPaymentType.RECURRING_DONATION)
awaitDialog(rxRule.defaultTestScheduler, R.string.DonationsErrors__error_processing_payment)
onView(withText(R.string.DonationsErrors__your_payment)).inRoot(isDialog()).check(matches(isDisplayed()))
}
private fun initialiseActiveSubscription() {
val currency = Currency.getInstance("USD")
val subscriber = InAppPaymentSubscriberRecord(
@@ -1,109 +0,0 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.subscription.donate
import android.app.Activity
import android.content.Intent
import androidx.compose.ui.test.junit4.ComposeTestRule
import androidx.compose.ui.test.onAllNodesWithTag
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick
import androidx.test.core.app.ActivityScenario
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.matcher.RootMatchers.isDialog
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import androidx.test.espresso.matcher.ViewMatchers.withText
import io.mockk.every
import io.reactivex.rxjava3.schedulers.TestScheduler
import org.signal.donations.InAppPaymentType
import org.signal.libsignal.net.RequestResult
import org.signal.network.rest.RestStatusCodeError
import org.thoughtcrime.securesms.SignalInstrumentationApplicationContext
import org.thoughtcrime.securesms.components.settings.app.subscription.GooglePayComponent
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewaySelectorTestTags
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.testing.flushUntil
/**
* Forces real donation-permit acquisition to fail at the issuer, exercising the permit code path through
* [org.thoughtcrime.securesms.components.settings.app.subscription.permits.DonationPermits].
*/
fun failDonationPermitAcquisition(statusCode: Int = 500) {
AppDependencies.donationPermitsRepository.clearPermits()
every { AppDependencies.donationsApi.createDonationPermits(any()) } returns RequestResult.NonSuccess(RestStatusCodeError(statusCode, emptyMap(), null))
}
/**
* The instrumentation app stubs [org.thoughtcrime.securesms.ApplicationContext.beginJobLoop] to a no-op, so enqueued
* jobs never run. The checkout pipeline drives a real setup job through the JobManager, so the loop must be started.
*/
fun startJobLoopForTests() {
(AppDependencies.application as SignalInstrumentationApplicationContext).beginJobLoopForTests()
}
/**
* Selects Google Pay in the Compose gateway selector and feeds the stubbed Google Pay result back into the
* checkout, navigating to the payment-in-progress screen where the pipeline runs.
*
* The gateway selector is populated by Rx work on [scheduler], so we [flushUntil] the button is present
* rather than sleeping. Selecting Google Pay dismisses the gateway sheet and hands a fragment result back to
* the checkout, which runs `launchGooglePay` -> `provideGatewayRequestForGooglePay` on [scheduler]; only then
* does the checkout's subscriber consume a [GooglePayComponent.googlePayResultPublisher] emission
* (`consumeGatewayRequestForGooglePay` returns null until then, and the publisher is a hot PublishSubject that
* drops earlier emissions). So we [flushUntil] the sheet has dismissed — a real signal that the click was
* fully processed — before dispatching the result, rather than pumping a fixed number of times and racing.
*/
fun ActivityScenario<CheckoutFlowActivity>.selectGooglePay(
composeRule: ComposeTestRule,
scheduler: TestScheduler,
inAppPaymentType: InAppPaymentType
) {
scheduler.flushUntil {
composeRule.onAllNodesWithTag(GatewaySelectorTestTags.GOOGLE_PAY_BUTTON).fetchSemanticsNodes().isNotEmpty()
}
composeRule.onNodeWithTag(GatewaySelectorTestTags.GOOGLE_PAY_BUTTON).performClick()
scheduler.flushUntil {
// Once the gateway sheet dismisses there is no Compose hierarchy left, so fetchSemanticsNodes throws
// rather than returning empty; treat both the empty list and the missing hierarchy as "sheet gone".
runCatching {
composeRule.onAllNodesWithTag(GatewaySelectorTestTags.GOOGLE_PAY_BUTTON).fetchSemanticsNodes().isEmpty()
}.getOrDefault(true)
}
onActivity { activity ->
(activity as GooglePayComponent).googlePayResultPublisher.onNext(
GooglePayComponent.GooglePayResult(
requestCode = InAppPaymentsRepository.getGooglePayRequestCode(inAppPaymentType),
resultCode = Activity.RESULT_OK,
data = Intent()
)
)
}
}
/**
* Waits for a dialog whose title is [titleResId] to be displayed. Matches any dialog by title (error,
* confirmation, thanks, etc.) — it is not specific to error dialogs.
*
* [flushUntil] advances the Rx pipeline (which may create the payment, enqueue the real setup job, and react
* to its committed state) while yielding real time for any job to run, until the dialog renders. A single
* condition-driven pump rather than a fixed-duration wall-clock poll. Letting the Espresso check throw (rather
* than collapsing it to a boolean) lets [flushUntil] chain the last matcher failure as the timeout cause.
*/
fun awaitDialog(
scheduler: TestScheduler,
titleResId: Int,
timeoutMillis: Long = 15_000
) {
scheduler.flushUntil(timeoutMillis) {
onView(withText(titleResId)).inRoot(isDialog()).check(matches(isDisplayed()))
true
}
}
@@ -138,7 +138,7 @@ class ConversationItemPreviewer {
private fun attachment(): SignalServiceAttachmentPointer {
return SignalServiceAttachmentPointer(
Cdn.CDN_3.cdnNumber,
SignalServiceAttachmentRemoteId.from("", Cdn.CDN_3.cdnNumber),
SignalServiceAttachmentRemoteId.from(""),
"image/webp",
null,
Optional.empty(),
@@ -1,393 +0,0 @@
/*
* 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)
}
}
}
@@ -11,7 +11,6 @@ import assertk.assertions.isEmpty
import assertk.assertions.isEqualTo
import assertk.assertions.isNotEmpty
import assertk.assertions.isNotEqualTo
import assertk.assertions.isNull
import assertk.assertions.isTrue
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
@@ -21,22 +20,22 @@ import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.signal.core.models.backup.MediaName
import org.signal.core.models.database.AttachmentId
import org.signal.core.models.media.TransformProperties
import org.signal.core.util.Base64
import org.signal.core.util.Base64.decodeBase64OrThrow
import org.signal.core.util.copyTo
import org.signal.core.util.stream.NullOutputStream
import org.signal.mediasend.SentMediaQuality
import org.thoughtcrime.securesms.attachments.ArchivedAttachment
import org.thoughtcrime.securesms.attachments.Attachment
import org.thoughtcrime.securesms.attachments.AttachmentId
import org.thoughtcrime.securesms.attachments.PointerAttachment
import org.thoughtcrime.securesms.attachments.UriAttachment
import org.thoughtcrime.securesms.backup.v2.ArchivedMediaObject
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.mms.IncomingMessage
import org.thoughtcrime.securesms.mms.MediaStream
import org.thoughtcrime.securesms.mms.SentMediaQuality
import org.thoughtcrime.securesms.providers.BlobProvider
import org.thoughtcrime.securesms.testing.SignalActivityRule
import org.thoughtcrime.securesms.util.MediaUtil
import org.whispersystems.signalservice.api.crypto.AttachmentCipherOutputStream
@@ -68,7 +67,7 @@ class AttachmentTableTest {
@Test
fun givenABlob_whenIInsert2AttachmentsForPreUpload_thenIExpectDistinctIdsButSameFileName() {
val blob = AppDependencies.blobs.forData(byteArrayOf(1, 2, 3, 4, 5)).createForSingleSessionInMemory()
val blob = BlobProvider.getInstance().forData(byteArrayOf(1, 2, 3, 4, 5)).createForSingleSessionInMemory()
val highQualityProperties = createHighQualityTransformProperties()
val highQualityImage = createAttachment(1, blob, highQualityProperties)
val attachment = SignalDatabase.attachments.insertAttachmentForPreUpload(highQualityImage)
@@ -81,7 +80,7 @@ class AttachmentTableTest {
@FlakyTest
@Test
fun givenABlobAndDifferentTransformQuality_whenIInsert2AttachmentsForPreUpload_thenIExpectDifferentFileInfos() {
val blob = AppDependencies.blobs.forData(byteArrayOf(1, 2, 3, 4, 5)).createForSingleSessionInMemory()
val blob = BlobProvider.getInstance().forData(byteArrayOf(1, 2, 3, 4, 5)).createForSingleSessionInMemory()
val highQualityProperties = createHighQualityTransformProperties()
val highQualityImage = createAttachment(1, blob, highQualityProperties)
val lowQualityImage = createAttachment(1, blob, TransformProperties.empty())
@@ -108,7 +107,7 @@ class AttachmentTableTest {
@Ignore("test is flaky")
@Test
fun givenIdenticalAttachmentsInsertedForPreUpload_whenIUpdateAttachmentDataAndSpecifyOnlyModifyThisAttachment_thenIExpectDifferentFileInfos() {
val blob = AppDependencies.blobs.forData(byteArrayOf(1, 2, 3, 4, 5)).createForSingleSessionInMemory()
val blob = BlobProvider.getInstance().forData(byteArrayOf(1, 2, 3, 4, 5)).createForSingleSessionInMemory()
val highQualityProperties = createHighQualityTransformProperties()
val highQualityImage = createAttachment(1, blob, highQualityProperties)
val attachment = SignalDatabase.attachments.insertAttachmentForPreUpload(highQualityImage)
@@ -144,9 +143,9 @@ class AttachmentTableTest {
val uncompressData = byteArrayOf(1, 2, 3, 4, 5)
val compressedData = byteArrayOf(1, 2, 3)
val blobUncompressed = AppDependencies.blobs.forData(uncompressData).createForSingleSessionInMemory()
val blobUncompressed = BlobProvider.getInstance().forData(uncompressData).createForSingleSessionInMemory()
val previousAttachment = createAttachment(1, AppDependencies.blobs.forData(compressedData).createForSingleSessionInMemory(), TransformProperties.empty())
val previousAttachment = createAttachment(1, BlobProvider.getInstance().forData(compressedData).createForSingleSessionInMemory(), TransformProperties.empty())
val previousDatabaseAttachmentId: AttachmentId = SignalDatabase.attachments.insertAttachmentsForMessage(1, listOf(previousAttachment), emptyList()).values.first()
val standardQualityPreUpload = createAttachment(1, blobUncompressed, TransformProperties.empty())
@@ -179,7 +178,7 @@ class AttachmentTableTest {
fun doNotDedupedFileIfUsedByAnotherAttachmentWithADifferentTransformProperties() {
// GIVEN
val uncompressData = byteArrayOf(1, 2, 3, 4, 5)
val blobUncompressed = AppDependencies.blobs.forData(uncompressData).createForSingleSessionInMemory()
val blobUncompressed = BlobProvider.getInstance().forData(uncompressData).createForSingleSessionInMemory()
val standardQualityPreUpload = createAttachment(1, blobUncompressed, TransformProperties.empty())
val standardDatabaseAttachment = SignalDatabase.attachments.insertAttachmentForPreUpload(standardQualityPreUpload)
@@ -205,7 +204,7 @@ class AttachmentTableTest {
@Test
fun resetArchiveTransferStateByPlaintextHashAndRemoteKey_singleMatch() {
// Given an attachment with some plaintextHash+remoteKey
val blob = AppDependencies.blobs.forData(byteArrayOf(1, 2, 3, 4, 5)).createForSingleSessionInMemory()
val blob = BlobProvider.getInstance().forData(byteArrayOf(1, 2, 3, 4, 5)).createForSingleSessionInMemory()
val attachment = createAttachment(1, blob, TransformProperties.empty())
val attachmentId = SignalDatabase.attachments.insertAttachmentsForMessage(-1L, listOf(attachment), emptyList()).values.first()
SignalDatabase.attachments.finalizeAttachmentAfterUpload(attachmentId, AttachmentTableTestUtil.createUploadResult(attachmentId))
@@ -220,26 +219,6 @@ class AttachmentTableTest {
assertThat(SignalDatabase.attachments.getAttachment(attachmentId)!!.archiveTransferState).isEqualTo(AttachmentTable.ArchiveTransferState.NONE)
}
@Test
fun resetArchiveTransferStateForLocalBackupMedia_onlyResetsLocalBackupMedia() {
// Given one archive-finished attachment restored from a local backup, and one that wasn't
val localBackupMessageId = SignalDatabase.messages.insertMessageInbox(createIncomingMessage(serverTime = 0.days, attachment = createArchivedAttachment(localBackupKey = Random.nextBytes(32)))).map { it.messageId }.get()
val localBackupAttachmentId = SignalDatabase.attachments.getAttachmentsForMessage(localBackupMessageId).first().attachmentId
val nonLocalBackupMessageId = SignalDatabase.messages.insertMessageInbox(createIncomingMessage(serverTime = 1.days, attachment = createArchivedAttachment())).map { it.messageId }.get()
val nonLocalBackupAttachmentId = SignalDatabase.attachments.getAttachmentsForMessage(nonLocalBackupMessageId).first().attachmentId
SignalDatabase.attachments.setArchiveTransferState(localBackupAttachmentId, AttachmentTable.ArchiveTransferState.FINISHED)
SignalDatabase.attachments.setArchiveTransferState(nonLocalBackupAttachmentId, AttachmentTable.ArchiveTransferState.FINISHED)
val resetCount = SignalDatabase.attachments.resetArchiveTransferStateForLocalBackupMedia()
// Only the local-backup attachment is reset
assertThat(resetCount).isEqualTo(1)
assertThat(SignalDatabase.attachments.getAttachment(localBackupAttachmentId)!!.archiveTransferState).isEqualTo(AttachmentTable.ArchiveTransferState.NONE)
assertThat(SignalDatabase.attachments.getAttachment(nonLocalBackupAttachmentId)!!.archiveTransferState).isEqualTo(AttachmentTable.ArchiveTransferState.FINISHED)
}
@Test
fun given10NewerAnd10OlderAttachments_whenIGetEachBatch_thenIExpectProperBucketing() {
val now = System.currentTimeMillis().milliseconds
@@ -280,7 +259,7 @@ class AttachmentTableTest {
fun givenAnAttachmentWithAMessageThatExpiresIn5Minutes_whenIGetAttachmentsThatNeedArchiveUpload_thenIDoNotExpectThatAttachment() {
// GIVEN
val uncompressData = byteArrayOf(1, 2, 3, 4, 5)
val blobUncompressed = AppDependencies.blobs.forData(uncompressData).createForSingleSessionInMemory()
val blobUncompressed = BlobProvider.getInstance().forData(uncompressData).createForSingleSessionInMemory()
val attachment = createAttachment(1, blobUncompressed, TransformProperties.empty())
val message = createIncomingMessage(serverTime = 0.days, attachment = attachment, expiresIn = 5.minutes)
val messageId = SignalDatabase.messages.insertMessageInbox(message).map { it.messageId }.get()
@@ -299,7 +278,7 @@ class AttachmentTableTest {
fun givenAnAttachmentWithAMessageThatExpiresIn5Days_whenIGetAttachmentsThatNeedArchiveUpload_thenIDoExpectThatAttachment() {
// GIVEN
val uncompressData = byteArrayOf(1, 2, 3, 4, 5)
val blobUncompressed = AppDependencies.blobs.forData(uncompressData).createForSingleSessionInMemory()
val blobUncompressed = BlobProvider.getInstance().forData(uncompressData).createForSingleSessionInMemory()
val attachment = createAttachment(1, blobUncompressed, TransformProperties.empty())
val message = createIncomingMessage(serverTime = 0.days, attachment = attachment, expiresIn = 5.days)
val messageId = SignalDatabase.messages.insertMessageInbox(message).map { it.messageId }.get()
@@ -318,7 +297,7 @@ class AttachmentTableTest {
fun givenAnAttachmentWithAMessageWithExpirationStartedThatExpiresIn5Days_whenIGetAttachmentsThatNeedArchiveUpload_thenIDoExpectThatAttachment() {
// GIVEN
val uncompressData = byteArrayOf(1, 2, 3, 4, 5)
val blobUncompressed = AppDependencies.blobs.forData(uncompressData).createForSingleSessionInMemory()
val blobUncompressed = BlobProvider.getInstance().forData(uncompressData).createForSingleSessionInMemory()
val attachment = createAttachment(1, blobUncompressed, TransformProperties.empty())
val message = createIncomingMessage(serverTime = 0.days, attachment = attachment, expiresIn = 5.days)
val messageId = SignalDatabase.messages.insertMessageInbox(message).map { it.messageId }.get()
@@ -338,7 +317,7 @@ class AttachmentTableTest {
fun givenAnAttachmentWithALongTextAttachment_whenIGetAttachmentsThatNeedArchiveUpload_thenIDoNotExpectThatAttachment() {
// GIVEN
val uncompressData = byteArrayOf(1, 2, 3, 4, 5)
val blobUncompressed = AppDependencies.blobs.forData(uncompressData).createForSingleSessionInMemory()
val blobUncompressed = BlobProvider.getInstance().forData(uncompressData).createForSingleSessionInMemory()
val attachment = createAttachment(1, blobUncompressed, TransformProperties.empty(), contentType = MediaUtil.LONG_TEXT)
val message = createIncomingMessage(serverTime = 0.days, attachment = attachment)
val messageId = SignalDatabase.messages.insertMessageInbox(message).map { it.messageId }.get()
@@ -439,39 +418,6 @@ class AttachmentTableTest {
assertThat(dbAttachment2.archiveTransferState).isEqualTo(AttachmentTable.ArchiveTransferState.FINISHED)
}
@Test
fun givenLocalBackupRestore_whenIFinalizeAttachment_thenIExpectArchiveStateNoneSoItGetsUploaded() {
val data = byteArrayOf(1, 2, 3, 4, 5)
val attachment = createAttachmentPointer("remote-key-1".toByteArray(), data.size)
val messageResult = SignalDatabase.messages.insertMessageInbox(createIncomingMessage(serverTime = 0.days, attachment = attachment)).get()
val attachmentId = messageResult.insertedAttachments!![attachment]!!
SignalDatabase.attachments.setTransferState(messageResult.messageId, attachmentId, AttachmentTable.TRANSFER_PROGRESS_STARTED)
// Data is restored from a local backup file, not the archive CDN
SignalDatabase.attachments.finalizeAttachmentAfterDownload(messageResult.messageId, attachmentId, ByteArrayInputStream(data), archiveRestore = true, restoredFromArchiveCdn = false)
val result = SignalDatabase.attachments.getAttachment(attachmentId)!!
assertThat(result.archiveTransferState).isEqualTo(AttachmentTable.ArchiveTransferState.NONE)
assertThat(result.archiveCdn).isNull()
}
@Test
fun givenArchiveCdnRestore_whenIFinalizeAttachment_thenIExpectArchiveStateFinished() {
val data = byteArrayOf(1, 2, 3, 4, 5)
val attachment = createAttachmentPointer("remote-key-1".toByteArray(), data.size)
val messageResult = SignalDatabase.messages.insertMessageInbox(createIncomingMessage(serverTime = 0.days, attachment = attachment)).get()
val attachmentId = messageResult.insertedAttachments!![attachment]!!
SignalDatabase.attachments.setTransferState(messageResult.messageId, attachmentId, AttachmentTable.TRANSFER_PROGRESS_STARTED)
// Data is restored directly from the archive CDN
SignalDatabase.attachments.finalizeAttachmentAfterDownload(messageResult.messageId, attachmentId, ByteArrayInputStream(data), archiveRestore = true, restoredFromArchiveCdn = true)
val result = SignalDatabase.attachments.getAttachment(attachmentId)!!
assertThat(result.archiveTransferState).isEqualTo(AttachmentTable.ArchiveTransferState.FINISHED)
}
@Test
fun givenAttachmentsWithMatchingMediaId_whenISetArchiveFinishedForMatchingMediaObjects_thenIExpectThoseAttachmentsToBeMarkedFinished() {
// GIVEN
@@ -639,7 +585,7 @@ class AttachmentTableTest {
).get()
}
private fun createArchivedAttachment(localBackupKey: ByteArray? = null): Attachment {
private fun createArchivedAttachment(): Attachment {
return ArchivedAttachment(
contentType = "image/jpeg",
size = 1024,
@@ -663,7 +609,7 @@ class AttachmentTableTest {
quoteTargetContentType = null,
uuid = UUID.randomUUID(),
fileName = null,
localBackupKey = localBackupKey
localBackupKey = null
)
}
@@ -5,11 +5,11 @@
package org.thoughtcrime.securesms.database
import org.signal.core.models.database.AttachmentId
import org.signal.core.util.Base64
import org.signal.core.util.Util
import org.signal.network.api.AttachmentUploadResult
import org.thoughtcrime.securesms.attachments.AttachmentId
import org.thoughtcrime.securesms.attachments.Cdn
import org.whispersystems.signalservice.api.attachment.AttachmentUploadResult
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentRemoteId
import kotlin.random.Random
@@ -12,21 +12,21 @@ import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.signal.core.models.ServiceId
import org.signal.core.models.database.AttachmentId
import org.signal.core.models.media.TransformProperties
import org.signal.core.util.Base64
import org.signal.core.util.Util
import org.signal.core.util.readFully
import org.signal.core.util.stream.LimitedInputStream
import org.signal.core.util.update
import org.signal.mediasend.SentMediaQuality
import org.thoughtcrime.securesms.attachments.AttachmentId
import org.thoughtcrime.securesms.attachments.Cdn
import org.thoughtcrime.securesms.attachments.PointerAttachment
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.mms.MediaStream
import org.thoughtcrime.securesms.mms.OutgoingMessage
import org.thoughtcrime.securesms.mms.QuoteModel
import org.thoughtcrime.securesms.mms.SentMediaQuality
import org.thoughtcrime.securesms.providers.BlobProvider
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.MediaUtil
import org.whispersystems.signalservice.internal.crypto.PaddingInputStream
@@ -671,7 +671,7 @@ class AttachmentTableTest_deduping {
}
fun insertWithData(data: ByteArray, transformProperties: TransformProperties = TransformProperties.empty()): AttachmentId {
val uri = AppDependencies.blobs.forData(data).createForSingleSessionInMemory()
val uri = BlobProvider.getInstance().forData(data).createForSingleSessionInMemory()
val attachment = UriAttachmentBuilder.build(
id = Random.nextLong(),
@@ -1,29 +1,23 @@
package org.thoughtcrime.securesms.database
import android.app.Application
import androidx.media3.common.util.Util
import androidx.test.ext.junit.runners.AndroidJUnit4
import assertk.assertThat
import assertk.assertions.isEqualTo
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.signal.core.util.count
import org.signal.core.util.readToSingleInt
import org.thoughtcrime.securesms.backup.v2.ArchivedMediaObject
import org.thoughtcrime.securesms.database.BackupMediaSnapshotTable.MediaEntry
import org.thoughtcrime.securesms.testutil.MockAppDependenciesRule
import org.thoughtcrime.securesms.testutil.SignalDatabaseRule
import org.thoughtcrime.securesms.testing.SignalActivityRule
@RunWith(RobolectricTestRunner::class)
@Config(manifest = Config.NONE, application = Application::class)
@RunWith(AndroidJUnit4::class)
class BackupMediaSnapshotTableTest {
@get:Rule
val appDependencies = MockAppDependenciesRule()
@get:Rule
val signalDatabaseRule = SignalDatabaseRule()
val harness = SignalActivityRule()
@Test
fun givenAnEmptyTable_whenIWriteToTable_thenIExpectEmptyTable() {
@@ -308,21 +302,12 @@ class BackupMediaSnapshotTableTest {
return MediaEntry(
mediaId = mediaId(seed, thumbnail),
cdn = cdn,
plaintextHash = intToByteArray(seed),
remoteKey = intToByteArray(seed),
plaintextHash = Util.toByteArray(seed),
remoteKey = Util.toByteArray(seed),
isThumbnail = thumbnail
)
}
private fun intToByteArray(value: Int): ByteArray {
return byteArrayOf(
(value shr 24).toByte(),
(value shr 16).toByte(),
(value shr 8).toByte(),
value.toByte()
)
}
private fun createArchiveMediaObject(seed: Int, thumbnail: Boolean = false, cdn: Int = 0): ArchivedMediaObject {
return ArchivedMediaObject(
mediaId = mediaId(seed, thumbnail),
@@ -10,14 +10,9 @@ import org.junit.Test
import org.junit.runner.RunWith
import org.signal.ringrtc.CallId
import org.signal.ringrtc.CallManager
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.testing.Flag
import org.thoughtcrime.securesms.testing.RemoteConfigForTest
import org.thoughtcrime.securesms.testing.SignalActivityRule
import org.thoughtcrime.securesms.testing.TestRemoteConfigFlag
@RemoteConfigForTest(flags = [Flag(TestRemoteConfigFlag.DISAPPEAR_MORE, "true")])
@RunWith(AndroidJUnit4::class)
class CallTableTest {
@@ -436,7 +431,7 @@ class CallTableTest {
val call = SignalDatabase.calls.getCallById(callId, groupRecipientId)
assertNotNull(call)
assertEquals(CallTable.Event.MISSED, call?.event)
assertEquals(CallTable.Event.GENERIC_GROUP_CALL, call?.event)
assertEquals(1L, call?.timestamp)
}
@@ -982,80 +977,6 @@ class CallTableTest {
// assertEquals(0, allCallEvents.size)
}
@Test
fun givenAMissedOneToOneCall_whenIMarkAllCallEventsRead_thenTimerShouldStart() {
val callId = 1L
val peer = harness.others[0]
insertExpiringThread(peer)
SignalDatabase.calls.insertOneToOneCall(callId, System.currentTimeMillis(), peer, CallTable.Type.AUDIO_CALL, CallTable.Direction.INCOMING, CallTable.Event.MISSED)
val readAt = System.currentTimeMillis()
SignalDatabase.calls.markAllCallEventsRead(readAt = readAt)
val call = SignalDatabase.calls.getCallById(callId, peer)
assertEquals(readAt, SignalDatabase.messages.getMessageRecord(call!!.messageId!!).expireStarted)
}
@Test
fun givenAMissedNotificationProfileOneToOneCall_whenIMarkAllCallEventsRead_thenTimerShouldStart() {
val callId = 1L
val peer = harness.others[0]
insertExpiringThread(peer)
SignalDatabase.calls.insertOneToOneCall(callId, System.currentTimeMillis(), peer, CallTable.Type.AUDIO_CALL, CallTable.Direction.INCOMING, CallTable.Event.MISSED_NOTIFICATION_PROFILE)
val readAt = System.currentTimeMillis()
SignalDatabase.calls.markAllCallEventsRead(readAt = readAt)
val call = SignalDatabase.calls.getCallById(callId, peer)
assertEquals(readAt, SignalDatabase.messages.getMessageRecord(call!!.messageId!!).expireStarted)
}
@Test
fun givenAMissedGroupCall_whenIMarkAllCallEventsRead_thenTimerShouldStart() {
val callId = 1L
SignalDatabase.recipients.setExpireMessagesForGroup(groupRecipientId, 60)
SignalDatabase.calls.insertOrUpdateGroupCallFromRingState(callId, groupRecipientId, harness.others[1], System.currentTimeMillis(), CallManager.RingUpdate.EXPIRED_REQUEST)
val readAt = System.currentTimeMillis()
SignalDatabase.calls.markAllCallEventsRead(readAt = readAt)
val call = SignalDatabase.calls.getCallById(callId, groupRecipientId)
assertEquals(readAt, SignalDatabase.messages.getMessageRecord(call!!.messageId!!).expireStarted)
}
@Test
fun givenAMissedNotificationProfileGroupCall_whenIMarkAllCallEventsRead_thenTimerShouldStart() {
val callId = 1L
val ringerAci = Recipient.resolved(harness.others[1]).requireAci()
SignalDatabase.recipients.setExpireMessagesForGroup(groupRecipientId, 60)
SignalDatabase.calls.insertOrUpdateGroupCallFromRingState(callId, groupRecipientId, ringerAci, System.currentTimeMillis(), CallManager.RingUpdate.EXPIRED_REQUEST, true)
val readAt = System.currentTimeMillis()
SignalDatabase.calls.markAllCallEventsRead(readAt = readAt)
val call = SignalDatabase.calls.getCallById(callId, groupRecipientId)
assertEquals(readAt, SignalDatabase.messages.getMessageRecord(call!!.messageId!!).expireStarted)
}
@Test
fun givenAnOutgoingOneToOneCallFromSync_whenInserted_thenTimerIsStarted() {
val callId = 1L
val peer = harness.others[0]
val timestamp = System.currentTimeMillis()
insertExpiringThread(peer)
SignalDatabase.calls.insertOneToOneCall(callId, timestamp, peer, CallTable.Type.AUDIO_CALL, CallTable.Direction.OUTGOING, CallTable.Event.OUTGOING_RING, true)
val call = SignalDatabase.calls.getCallById(callId, peer)
val message = SignalDatabase.messages.getMessageRecord(call!!.messageId!!)
assertNotEquals(0L, message.expireStarted)
}
private fun insertTwoCallEvents() {
SignalDatabase.calls.insertAcceptedGroupCall(
1,
@@ -1071,10 +992,4 @@ class CallTableTest {
2000
)
}
private fun insertExpiringThread(recipientId: RecipientId) {
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(Recipient.resolved(recipientId))
MmsHelper.insert(recipient = Recipient.resolved(recipientId), expiresIn = 30_000L, threadId = threadId)
SignalDatabase.threads.update(threadId, false)
}
}
@@ -1,28 +1,17 @@
package org.thoughtcrime.securesms.database
import android.app.Application
import org.junit.Assert
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.signal.core.models.ServiceId.ACI
import org.thoughtcrime.securesms.database.model.DistributionListId
import org.thoughtcrime.securesms.database.model.DistributionListRecord
import org.thoughtcrime.securesms.database.model.StoryType
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.testutil.RecipientTestRule
import java.util.UUID
@RunWith(RobolectricTestRunner::class)
@Config(manifest = Config.NONE, application = Application::class)
class DistributionListTablesTest {
@get:Rule
val recipients = RecipientTestRule()
private lateinit var distributionDatabase: DistributionListTables
@Before
@@ -38,7 +27,8 @@ class DistributionListTablesTest {
@Test
fun getList_returnCorrectList() {
val members: List<RecipientId> = createRecipients(3)
createRecipients(3)
val members: List<RecipientId> = recipientList(1, 2, 3)
val id: DistributionListId? = distributionDatabase.createList("test", members)
Assert.assertNotNull(id)
@@ -52,7 +42,8 @@ class DistributionListTablesTest {
@Test
fun getMembers_returnsCorrectMembers() {
val members: List<RecipientId> = createRecipients(3)
createRecipients(3)
val members: List<RecipientId> = recipientList(1, 2, 3)
val id: DistributionListId? = distributionDatabase.createList("test", members)
Assert.assertNotNull(id)
@@ -86,8 +77,8 @@ class DistributionListTablesTest {
Assert.fail("Expected an assertion error.")
}
private fun createRecipients(count: Int): List<RecipientId> {
return (0 until count).map {
private fun createRecipients(count: Int) {
for (i in 0 until count) {
SignalDatabase.recipients.getOrInsertFromServiceId(ACI.from(UUID.randomUUID()))
}
}
@@ -1,6 +1,5 @@
package org.thoughtcrime.securesms.database
import android.app.Application
import android.database.sqlite.SQLiteConstraintException
import assertk.assertThat
import assertk.assertions.isEqualTo
@@ -9,34 +8,20 @@ import org.junit.Assert.fail
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.signal.core.util.count
import org.signal.core.util.deleteAll
import org.signal.core.util.readToSingleInt
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository
import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
import org.thoughtcrime.securesms.testutil.MockAppDependenciesRule
import org.thoughtcrime.securesms.testutil.MockSignalStoreRule
import org.thoughtcrime.securesms.testutil.SignalDatabaseRule
import org.thoughtcrime.securesms.testing.SignalActivityRule
import org.whispersystems.signalservice.api.storage.IAPSubscriptionId
import org.whispersystems.signalservice.api.subscriptions.SubscriberId
import java.util.Currency
@RunWith(RobolectricTestRunner::class)
@Config(manifest = Config.NONE, application = Application::class)
class InAppPaymentSubscriberTableTest {
@get:Rule
val signalStore = MockSignalStoreRule()
@get:Rule
val appDependencies = MockAppDependenciesRule()
@get:Rule
val signalDatabaseRule = SignalDatabaseRule()
val harness = SignalActivityRule()
@Before
fun setUp() {
@@ -0,0 +1,50 @@
/*
* 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)
}
}
@@ -5,43 +5,20 @@
package org.thoughtcrime.securesms.database
import android.app.Application
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.signal.core.models.ServiceId
import org.signal.core.models.ServiceId.ACI
import org.signal.core.models.ServiceId.PNI
import org.signal.core.util.readToSingleObject
import org.signal.core.util.requireLongOrNull
import org.signal.core.util.select
import org.signal.core.util.update
import org.signal.libsignal.protocol.ReusedBaseKeyException
import org.signal.libsignal.protocol.ecc.ECKeyPair
import org.signal.libsignal.protocol.ecc.ECPublicKey
import org.signal.libsignal.protocol.kem.KEMKeyPair
import org.signal.libsignal.protocol.kem.KEMKeyType
import org.signal.libsignal.protocol.state.KyberPreKeyRecord
import org.thoughtcrime.securesms.testutil.MockAppDependenciesRule
import org.thoughtcrime.securesms.testutil.SignalDatabaseRule
import java.security.SecureRandom
import org.thoughtcrime.securesms.util.KyberPreKeysTestUtil.generateECPublicKey
import org.thoughtcrime.securesms.util.KyberPreKeysTestUtil.getStaleTime
import org.thoughtcrime.securesms.util.KyberPreKeysTestUtil.insertTestRecord
import java.util.UUID
@RunWith(RobolectricTestRunner::class)
@Config(manifest = Config.NONE, application = Application::class)
class KyberPreKeyTableTest {
@get:Rule
val appDependencies = MockAppDependenciesRule()
@get:Rule
val signalDatabaseRule = SignalDatabaseRule()
private val aci: ACI = ACI.from(UUID.randomUUID())
private val pni: PNI = PNI.from(UUID.randomUUID())
@@ -153,7 +130,7 @@ class KyberPreKeyTableTest {
insertTestRecord(aci, id = 2, staleTime = 10, lastResort = true)
insertTestRecord(aci, id = 3, staleTime = 10, lastResort = true)
SignalDatabase.kyberPreKeys.deleteAllStaleBefore(aci, threshold = 11, minCount = 0)
SignalDatabase.oneTimePreKeys.deleteAllStaleBefore(aci, threshold = 11, minCount = 0)
assertNotNull(getStaleTime(aci, 1))
assertNotNull(getStaleTime(aci, 2))
@@ -199,50 +176,4 @@ class KyberPreKeyTableTest {
baseKey = publicKey
)
}
private fun insertTestRecord(account: ServiceId, id: Int, staleTime: Long = 0, lastResort: Boolean = false) {
val kemKeyPair = KEMKeyPair.generate(KEMKeyType.KYBER_1024)
SignalDatabase.kyberPreKeys.insert(
serviceId = account,
keyId = id,
record = KyberPreKeyRecord(
id,
System.currentTimeMillis(),
kemKeyPair,
ECKeyPair.generate().privateKey.calculateSignature(kemKeyPair.publicKey.serialize())
),
lastResort = lastResort
)
val count = SignalDatabase.writableDatabase
.update(KyberPreKeyTable.TABLE_NAME)
.values(KyberPreKeyTable.STALE_TIMESTAMP to staleTime)
.where("${KyberPreKeyTable.ACCOUNT_ID} = ? AND ${KyberPreKeyTable.KEY_ID} = $id", account.toAccountId())
.run()
assertEquals(1, count)
}
private fun getStaleTime(account: ServiceId, id: Int): Long? {
return SignalDatabase.writableDatabase
.select(KyberPreKeyTable.STALE_TIMESTAMP)
.from(KyberPreKeyTable.TABLE_NAME)
.where("${KyberPreKeyTable.ACCOUNT_ID} = ? AND ${KyberPreKeyTable.KEY_ID} = $id", account.toAccountId())
.run()
.readToSingleObject { it.requireLongOrNull(KyberPreKeyTable.STALE_TIMESTAMP) }
}
private fun generateECPublicKey(): ECPublicKey {
val byteArray = ByteArray(ECPublicKey.KEY_SIZE - 1)
SecureRandom().nextBytes(byteArray)
return ECPublicKey.fromPublicKeyBytes(byteArray)
}
private fun ServiceId.toAccountId(): String {
return when (this) {
is ACI -> this.toString()
is PNI -> KyberPreKeyTable.PNI_ACCOUNT_ID
}
}
}
@@ -1,37 +1,38 @@
package org.thoughtcrime.securesms.database
import android.app.Application
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.signal.core.models.ServiceId.ACI
import org.signal.core.models.ServiceId.PNI
import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.testutil.RecipientTestRule
import java.util.UUID
@Suppress("ClassName")
@RunWith(RobolectricTestRunner::class)
@Config(manifest = Config.NONE, application = Application::class)
@RunWith(AndroidJUnit4::class)
class MessageTableTest_gifts {
@get:Rule
val recipientTestRule = RecipientTestRule()
private lateinit var mms: MessageTable
private val localAci = ACI.from(UUID.randomUUID())
private val localPni = PNI.from(UUID.randomUUID())
private lateinit var recipients: List<RecipientId>
@Before
fun setUp() {
mms = SignalDatabase.messages
mms.deleteAllThreads()
SignalStore.account.setAci(localAci)
SignalStore.account.setPni(localPni)
recipients = (0 until 5).map { SignalDatabase.recipients.getOrInsertFromServiceId(ACI.from(UUID.randomUUID())) }
}
@@ -0,0 +1,174 @@
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
}
@@ -5,15 +5,10 @@
package org.thoughtcrime.securesms.database
import android.app.Application
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.signal.core.models.ServiceId
import org.signal.core.models.ServiceId.ACI
import org.signal.core.models.ServiceId.PNI
@@ -23,20 +18,10 @@ import org.signal.core.util.select
import org.signal.core.util.update
import org.signal.libsignal.protocol.ecc.ECKeyPair
import org.signal.libsignal.protocol.state.PreKeyRecord
import org.thoughtcrime.securesms.testutil.MockAppDependenciesRule
import org.thoughtcrime.securesms.testutil.SignalDatabaseRule
import java.util.UUID
@RunWith(RobolectricTestRunner::class)
@Config(manifest = Config.NONE, application = Application::class)
class OneTimePreKeyTableTest {
@get:Rule
val appDependencies = MockAppDependenciesRule()
@get:Rule
val signalDatabaseRule = SignalDatabaseRule()
private val aci: ACI = ACI.from(UUID.randomUUID())
private val pni: PNI = PNI.from(UUID.randomUUID())
@@ -132,7 +117,7 @@ class OneTimePreKeyTableTest {
record = PreKeyRecord(id, ECKeyPair.generate())
)
val count = SignalDatabase.writableDatabase
val count = SignalDatabase.rawDatabase
.update(OneTimePreKeyTable.TABLE_NAME)
.values(OneTimePreKeyTable.STALE_TIMESTAMP to staleTime)
.where("${OneTimePreKeyTable.ACCOUNT_ID} = ? AND ${OneTimePreKeyTable.KEY_ID} = $id", account.toAccountId())
@@ -142,7 +127,7 @@ class OneTimePreKeyTableTest {
}
private fun getStaleTime(account: ServiceId, id: Int): Long? {
return SignalDatabase.writableDatabase
return SignalDatabase.rawDatabase
.select(OneTimePreKeyTable.STALE_TIMESTAMP)
.from(OneTimePreKeyTable.TABLE_NAME)
.where("${OneTimePreKeyTable.ACCOUNT_ID} = ? AND ${OneTimePreKeyTable.KEY_ID} = $id", account.toAccountId())
@@ -1,14 +1,12 @@
package org.thoughtcrime.securesms.database
import android.app.Application
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.signal.core.util.deleteAll
import org.thoughtcrime.securesms.database.model.MessageId
import org.thoughtcrime.securesms.mms.IncomingMessage
@@ -16,18 +14,15 @@ import org.thoughtcrime.securesms.polls.PollOption
import org.thoughtcrime.securesms.polls.PollRecord
import org.thoughtcrime.securesms.polls.Voter
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.testutil.RecipientTestRule
import org.thoughtcrime.securesms.testing.SignalActivityRule
@RunWith(RobolectricTestRunner::class)
@Config(manifest = Config.NONE, application = Application::class)
@RunWith(AndroidJUnit4::class)
class PollTablesTest {
@get:Rule
val recipients = RecipientTestRule()
val harness = SignalActivityRule()
private lateinit var poll1: PollRecord
private lateinit var other0: RecipientId
@Before
fun setUp() {
@@ -49,9 +44,8 @@ class PollTablesTest {
SignalDatabase.polls.writableDatabase.deleteAll(PollTables.PollOptionTable.TABLE_NAME)
SignalDatabase.polls.writableDatabase.deleteAll(PollTables.PollVoteTable.TABLE_NAME)
other0 = recipients.createRecipient("Buddy #0")
val message = IncomingMessage(type = MessageType.NORMAL, from = other0, sentTimeMillis = 100, serverTimeMillis = 100, receivedTimeMillis = 100)
SignalDatabase.messages.insertMessageInbox(message, SignalDatabase.threads.getOrCreateThreadIdFor(other0, isGroup = false))
val message = IncomingMessage(type = MessageType.NORMAL, from = harness.others[0], sentTimeMillis = 100, serverTimeMillis = 100, receivedTimeMillis = 100)
SignalDatabase.messages.insertMessageInbox(message, SignalDatabase.threads.getOrCreateThreadIdFor(harness.others[0], isGroup = false))
}
@Test
@@ -8,17 +8,10 @@ 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
@@ -26,11 +19,8 @@ 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)
@@ -70,46 +60,4 @@ 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)
}
}
@@ -1,17 +1,13 @@
package org.thoughtcrime.securesms.database
import android.app.Application
import androidx.test.ext.junit.runners.AndroidJUnit4
import assertk.assertThat
import assertk.assertions.isEqualTo
import assertk.assertions.isNull
import assertk.assertions.isPresent
import io.mockk.every
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.signal.core.models.ServiceId.ACI
import org.signal.core.models.ServiceId.PNI
import org.signal.core.util.Hex
@@ -30,18 +26,12 @@ import org.thoughtcrime.securesms.isAbsent
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.mms.IncomingMessage
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.testutil.RecipientTestRule
import org.whispersystems.signalservice.api.push.ServiceIds
import java.util.UUID
@Suppress("ClassName", "TestFunctionName")
@RunWith(RobolectricTestRunner::class)
@Config(manifest = Config.NONE, application = Application::class)
@RunWith(AndroidJUnit4::class)
class SmsDatabaseTest_collapseJoinRequestEventsIfPossible {
@get:Rule
val recipientRule = RecipientTestRule()
private lateinit var recipients: RecipientTable
private lateinit var sms: MessageTable
@@ -58,7 +48,8 @@ class SmsDatabaseTest_collapseJoinRequestEventsIfPossible {
recipients = SignalDatabase.recipients
sms = SignalDatabase.messages
every { recipientRule.signalStore.account.getServiceIds() } returns ServiceIds(localAci, localPni)
SignalStore.account.setAci(localAci)
SignalStore.account.setPni(localPni)
alice = recipients.getOrInsertFromServiceId(aliceServiceId)
bob = recipients.getOrInsertFromServiceId(bobServiceId)
@@ -1,6 +1,6 @@
package org.thoughtcrime.securesms.database
import android.app.Application
import androidx.test.ext.junit.runners.AndroidJUnit4
import assertk.assertThat
import assertk.assertions.containsExactlyInAnyOrder
import assertk.assertions.hasSize
@@ -16,23 +16,20 @@ import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.signal.core.models.ServiceId.ACI
import org.thoughtcrime.securesms.database.model.DistributionListId
import org.thoughtcrime.securesms.database.model.StoryType
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.testutil.RecipientTestRule
import org.thoughtcrime.securesms.testing.SignalActivityRule
import org.whispersystems.signalservice.api.push.DistributionId
import java.util.UUID
@RunWith(RobolectricTestRunner::class)
@Config(manifest = Config.NONE, application = Application::class)
@RunWith(AndroidJUnit4::class)
class StorySendTableTest {
@get:Rule
val recipients = RecipientTestRule()
val harness = SignalActivityRule(othersCount = 0, createGroup = false)
private val distributionId1 = DistributionId.from(UUID.randomUUID())
private val distributionId2 = DistributionId.from(UUID.randomUUID())
@@ -0,0 +1,120 @@
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
)
}
}
@@ -10,11 +10,11 @@ import org.thoughtcrime.securesms.recipients.LiveRecipientCache
import org.whispersystems.signalservice.api.SignalServiceDataStore
import org.whispersystems.signalservice.api.SignalServiceMessageSender
import org.whispersystems.signalservice.api.account.AccountApi
import org.whispersystems.signalservice.api.attachment.AttachmentApi
import org.whispersystems.signalservice.api.donations.DonationsApi
import org.whispersystems.signalservice.api.keys.KeysApi
import org.whispersystems.signalservice.api.message.MessageApi
import org.whispersystems.signalservice.api.websocket.SignalWebSocket
import org.whispersystems.signalservice.internal.configuration.SignalServiceConfiguration
import org.whispersystems.signalservice.internal.push.PushServiceSocket
/**
@@ -41,7 +41,7 @@ class InstrumentationApplicationDependencyProvider(val application: Application,
return recipientCache
}
override fun provideArchiveApi(authWebSocket: SignalWebSocket.AuthenticatedWebSocket, unauthWebSocket: SignalWebSocket.UnauthenticatedWebSocket, pushServiceSocket: PushServiceSocket, signalServiceConfiguration: SignalServiceConfiguration): ArchiveApi {
override fun provideArchiveApi(authWebSocket: SignalWebSocket.AuthenticatedWebSocket, unauthWebSocket: SignalWebSocket.UnauthenticatedWebSocket, pushServiceSocket: PushServiceSocket): ArchiveApi {
return mockk()
}
@@ -52,11 +52,12 @@ class InstrumentationApplicationDependencyProvider(val application: Application,
override fun provideSignalServiceMessageSender(
protocolStore: SignalServiceDataStore,
pushServiceSocket: PushServiceSocket,
attachmentApi: AttachmentApi,
messageApi: MessageApi,
keysApi: KeysApi
): SignalServiceMessageSender {
if (signalServiceMessageSender == null) {
signalServiceMessageSender = spyk(objToCopy = default.provideSignalServiceMessageSender(protocolStore, pushServiceSocket, messageApi, keysApi))
signalServiceMessageSender = spyk(objToCopy = default.provideSignalServiceMessageSender(protocolStore, pushServiceSocket, attachmentApi, messageApi, keysApi))
}
return signalServiceMessageSender!!
}
@@ -1,279 +0,0 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.jobs
import androidx.test.ext.junit.runners.AndroidJUnit4
import assertk.assertThat
import assertk.assertions.isEqualTo
import assertk.assertions.isFalse
import assertk.assertions.isNull
import io.mockk.Runs
import io.mockk.every
import io.mockk.just
import io.mockk.mockkObject
import io.mockk.unmockkAll
import io.mockk.verify
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.signal.core.models.backup.MediaName
import org.signal.core.models.database.AttachmentId
import org.signal.core.util.Base64.decodeBase64OrThrow
import org.signal.network.NetworkResult
import org.thoughtcrime.securesms.attachments.Attachment
import org.thoughtcrime.securesms.attachments.PointerAttachment
import org.thoughtcrime.securesms.backup.v2.BackupRepository
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
import org.thoughtcrime.securesms.database.AttachmentTable
import org.thoughtcrime.securesms.database.BackupMediaSnapshotTable.MediaEntry
import org.thoughtcrime.securesms.database.MessageType
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.mms.IncomingMessage
import org.thoughtcrime.securesms.testing.SignalActivityRule
import org.thoughtcrime.securesms.util.MediaUtil
import org.whispersystems.signalservice.api.archive.ArchiveGetMediaItemsResponse
import org.whispersystems.signalservice.api.archive.ArchiveGetMediaItemsResponse.StoredMediaObject
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentPointer
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentRemoteId
import java.io.ByteArrayInputStream
import java.util.Optional
import kotlin.time.Duration
import kotlin.time.Duration.Companion.days
@RunWith(AndroidJUnit4::class)
class ArchiveAttachmentReconciliationJobTest {
@get:Rule
val harness = SignalActivityRule()
@Before
fun setUp() {
SignalStore.backup.backupTier = MessageBackupTier.PAID
SignalStore.backup.hasBackupBeenUploaded = true
SignalStore.backup.lastAttachmentReconciliationTime = System.currentTimeMillis()
SignalStore.backup.localRestoreReconcilePending = false
mockkObject(BackupRepository)
mockkObject(ArchiveCommitAttachmentDeletesJob)
every { ArchiveCommitAttachmentDeletesJob.deleteMediaObjectsFromCdn(any(), any(), any(), any()) } returns null
}
@After
fun tearDown() {
unmockkAll()
}
/**
* The core of the reconcile-first restore flow: a local restore resets everything to [AttachmentTable.ArchiveTransferState.NONE], so media that genuinely is
* on the CDN must be promoted back to FINISHED during reconciliation -- otherwise the backfill would needlessly re-upload it. This only happens while
* [localRestoreReconcilePending] is set, so it never runs in the common periodic reconciliation.
*/
@Test
fun givenLocalRestorePendingAndAttachmentOnCdn_whenIReconcile_thenIExpectItMarkedFinished() {
SignalStore.backup.localRestoreReconcilePending = true
val attachmentId = seedFinalizedAttachment("remote-key-1".toByteArray(), byteArrayOf(1, 2, 3, 4, 5))
commitSnapshotFor(attachmentId, cdn = 3)
fakeCdnContains(attachmentId, cdn = 3)
ArchiveAttachmentReconciliationJob(forced = true).run()
assertThat(SignalDatabase.attachments.getAttachment(attachmentId)!!.archiveTransferState).isEqualTo(AttachmentTable.ArchiveTransferState.FINISHED)
}
/**
* Guards the reconcile-first promotion above: outside the local-restore flow (the common periodic reconciliation), NONE media that happens to be on the CDN is
* left alone, so we don't do the expensive mark-finished scan in the common case.
*/
@Test
fun givenNoLocalRestorePendingAndNoneAttachmentOnCdn_whenIReconcile_thenItStaysNone() {
val attachmentId = seedFinalizedAttachment("remote-key-common".toByteArray(), byteArrayOf(2, 3, 4, 5, 6))
commitSnapshotFor(attachmentId, cdn = 3)
fakeCdnContains(attachmentId, cdn = 3)
ArchiveAttachmentReconciliationJob(forced = true).run()
assertThat(SignalDatabase.attachments.getAttachment(attachmentId)!!.archiveTransferState).isEqualTo(AttachmentTable.ArchiveTransferState.NONE)
}
@Test
fun givenFinishedAttachmentMissingFromCdn_whenIReconcile_thenIExpectItResetToNone() {
val attachmentId = seedFinalizedAttachment("remote-key-2".toByteArray(), byteArrayOf(6, 7, 8, 9, 10))
SignalDatabase.attachments.setArchiveTransferState(attachmentId, AttachmentTable.ArchiveTransferState.FINISHED)
commitSnapshotFor(attachmentId, cdn = 3)
fakeCdnEmpty()
ArchiveAttachmentReconciliationJob(forced = true).run()
assertThat(SignalDatabase.attachments.getAttachment(attachmentId)!!.archiveTransferState).isEqualTo(AttachmentTable.ArchiveTransferState.NONE)
}
/**
* The eventual safety net: an ordinary (non-forced) periodic reconciliation, run after the sync interval has elapsed, heals the bad state on its own -- media
* in the snapshot but missing from the CDN is reset to [AttachmentTable.ArchiveTransferState.NONE] and a re-upload is enqueued -- with no help from the
* migration or the reconcile-first flow.
*/
@Test
fun givenFinishedMediaMissingFromCdn_whenAnOrdinaryPeriodicReconciliationRuns_thenItHealsToNoneAndReUploads() {
SignalStore.backup.lastAttachmentReconciliationTime = System.currentTimeMillis() - 60.days.inWholeMilliseconds
mockkObject(BackupMessagesJob)
every { BackupMessagesJob.enqueue() } just Runs
val attachmentId = seedFinalizedAttachment("remote-key-periodic".toByteArray(), byteArrayOf(1, 2, 3, 4, 5))
SignalDatabase.attachments.setArchiveTransferState(attachmentId, AttachmentTable.ArchiveTransferState.FINISHED)
commitSnapshotFor(attachmentId, cdn = 3)
fakeCdnEmpty()
ArchiveAttachmentReconciliationJob(forced = false).run()
val healed = SignalDatabase.attachments.getAttachment(attachmentId)!!
assertThat(healed.archiveTransferState).isEqualTo(AttachmentTable.ArchiveTransferState.NONE)
assertThat(healed.archiveCdn).isNull()
verify(exactly = 1) { BackupMessagesJob.enqueue() }
}
/**
* Reconciliation must only repair genuinely-broken state. Media that is actually present on the CDN stays [AttachmentTable.ArchiveTransferState.FINISHED], so
* we never needlessly reset (and therefore re-upload) media that was correctly archived.
*/
@Test
fun givenFinishedMediaStillOnCdn_whenIReconcile_thenItStaysFinished() {
val attachmentId = seedFinalizedAttachment("remote-key-on-cdn".toByteArray(), byteArrayOf(6, 7, 8, 9, 10))
SignalDatabase.attachments.setArchiveTransferState(attachmentId, AttachmentTable.ArchiveTransferState.FINISHED)
commitSnapshotFor(attachmentId, cdn = 3)
fakeCdnContains(attachmentId, cdn = 3)
ArchiveAttachmentReconciliationJob(forced = true).run()
assertThat(SignalDatabase.attachments.getAttachment(attachmentId)!!.archiveTransferState).isEqualTo(AttachmentTable.ArchiveTransferState.FINISHED)
}
/**
* The other healing direction: media that is on the CDN but locally marked [AttachmentTable.ArchiveTransferState.NONE] and absent from the current snapshot is
* treated as a delete-candidate. Before deleting, reconciliation confirms it's still referenced locally and recovers it to
* [AttachmentTable.ArchiveTransferState.FINISHED] rather than deleting it from the CDN.
*/
@Test
fun givenNoneMediaOnCdnButNotInSnapshot_whenIReconcile_thenItIsRecoveredToFinished() {
val attachmentId = seedFinalizedAttachment("remote-key-flow2".toByteArray(), byteArrayOf(11, 12, 13, 14, 15))
SignalDatabase.attachments.setArchiveTransferState(attachmentId, AttachmentTable.ArchiveTransferState.NONE)
fakeCdnContains(attachmentId, cdn = 3)
ArchiveAttachmentReconciliationJob(forced = true).run()
assertThat(SignalDatabase.attachments.getAttachment(attachmentId)!!.archiveTransferState).isEqualTo(AttachmentTable.ArchiveTransferState.FINISHED)
}
@Test
fun givenFirstEverReconciliation_whenIForceIt_thenItStillRunsAndRepairs() {
SignalStore.backup.lastAttachmentReconciliationTime = -1
val attachmentId = seedFinalizedAttachment("remote-key-3".toByteArray(), byteArrayOf(11, 12, 13, 14, 15))
SignalDatabase.attachments.setArchiveTransferState(attachmentId, AttachmentTable.ArchiveTransferState.FINISHED)
commitSnapshotFor(attachmentId, cdn = 3)
fakeCdnEmpty()
ArchiveAttachmentReconciliationJob(forced = true).run()
assertThat(SignalDatabase.attachments.getAttachment(attachmentId)!!.archiveTransferState).isEqualTo(AttachmentTable.ArchiveTransferState.NONE)
}
@Test
fun givenLocalRestoreReconcilePending_whenReconcileCompletes_thenIExpectFlagCleared() {
SignalStore.backup.localRestoreReconcilePending = true
fakeCdnEmpty()
ArchiveAttachmentReconciliationJob(forced = true).run()
assertThat(SignalStore.backup.localRestoreReconcilePending).isFalse()
}
private fun seedFinalizedAttachment(remoteKey: ByteArray, data: ByteArray): AttachmentId {
val attachment = createAttachmentPointer(remoteKey, data.size)
val messageResult = SignalDatabase.messages.insertMessageInbox(createIncomingMessage(serverTime = 0.days, attachment = attachment)).get()
val attachmentId = messageResult.insertedAttachments!![attachment]!!
SignalDatabase.attachments.setTransferState(messageResult.messageId, attachmentId, AttachmentTable.TRANSFER_PROGRESS_STARTED)
SignalDatabase.attachments.finalizeAttachmentAfterDownload(messageResult.messageId, attachmentId, ByteArrayInputStream(data))
return attachmentId
}
private fun commitSnapshotFor(attachmentId: AttachmentId, cdn: Int) {
val attachment = SignalDatabase.attachments.getAttachment(attachmentId)!!
val plaintextHash = attachment.dataHash!!.decodeBase64OrThrow()
val remoteKey = attachment.remoteKey!!.decodeBase64OrThrow()
val mediaId = MediaName.fromPlaintextHashAndRemoteKey(plaintextHash, remoteKey).toMediaId(SignalStore.backup.mediaRootBackupKey).encode()
SignalDatabase.backupMediaSnapshots.writePendingMediaEntries(
listOf(MediaEntry(mediaId = mediaId, cdn = cdn, plaintextHash = plaintextHash, remoteKey = remoteKey, isThumbnail = false))
)
SignalDatabase.backupMediaSnapshots.commitPendingRows()
}
private fun fakeCdnContains(attachmentId: AttachmentId, cdn: Int) {
val attachment = SignalDatabase.attachments.getAttachment(attachmentId)!!
val plaintextHash = attachment.dataHash!!.decodeBase64OrThrow()
val remoteKey = attachment.remoteKey!!.decodeBase64OrThrow()
val mediaId = MediaName.fromPlaintextHashAndRemoteKey(plaintextHash, remoteKey).toMediaId(SignalStore.backup.mediaRootBackupKey).encode()
every { BackupRepository.listRemoteMediaObjects(any(), any()) } returns NetworkResult.Success(
ArchiveGetMediaItemsResponse(
storedMediaObjects = listOf(StoredMediaObject(cdn = cdn, mediaId = mediaId, objectLength = attachment.size)),
backupDir = null,
mediaDir = null,
cursor = null
)
)
}
private fun fakeCdnEmpty() {
every { BackupRepository.listRemoteMediaObjects(any(), any()) } returns NetworkResult.Success(
ArchiveGetMediaItemsResponse(storedMediaObjects = emptyList(), backupDir = null, mediaDir = null, cursor = null)
)
}
private fun createIncomingMessage(serverTime: Duration, attachment: Attachment): IncomingMessage {
return IncomingMessage(
type = MessageType.NORMAL,
from = harness.others[0],
body = null,
sentTimeMillis = serverTime.inWholeMilliseconds,
serverTimeMillis = serverTime.inWholeMilliseconds,
receivedTimeMillis = serverTime.inWholeMilliseconds,
attachments = listOf(attachment)
)
}
private fun createAttachmentPointer(key: ByteArray, size: Int): Attachment {
return PointerAttachment.forPointer(
pointer = Optional.of(
SignalServiceAttachmentPointer(
cdnNumber = 3,
remoteId = SignalServiceAttachmentRemoteId.V4("asdf"),
contentType = MediaUtil.IMAGE_JPEG,
key = key,
size = Optional.of(size),
preview = Optional.empty(),
width = 2,
height = 2,
digest = Optional.of(byteArrayOf()),
incrementalDigest = Optional.empty(),
incrementalMacChunkSize = 0,
fileName = Optional.of("file.jpg"),
voiceNote = false,
isBorderless = false,
isGif = false,
caption = Optional.empty(),
blurHash = Optional.empty(),
uploadTimestamp = 0,
uuid = null
)
)
).get()
}
}
@@ -15,13 +15,14 @@ import org.junit.Test
import org.junit.runner.RunWith
import org.signal.core.models.media.TransformProperties
import org.signal.core.util.StreamUtil
import org.signal.mediasend.SentMediaQuality
import org.thoughtcrime.securesms.attachments.UriAttachment
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.UriAttachmentBuilder
import org.thoughtcrime.securesms.database.transformPropertiesForSentMediaQuality
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobmanager.Job
import org.thoughtcrime.securesms.mms.SentMediaQuality
import org.thoughtcrime.securesms.providers.BlobProvider
import org.thoughtcrime.securesms.testing.SignalActivityRule
import org.thoughtcrime.securesms.util.MediaUtil
import java.util.Optional
@@ -39,7 +40,7 @@ class AttachmentCompressionJobTest {
StreamUtil.readFully(it)
}
val blob = AppDependencies.blobs.forData(imageBytes).createForSingleSessionOnDisk(AppDependencies.application)
val blob = BlobProvider.getInstance().forData(imageBytes).createForSingleSessionOnDisk(AppDependencies.application)
val firstPreUpload = createAttachment(1, blob, TransformProperties.empty())
val firstDatabaseAttachment = SignalDatabase.attachments.insertAttachmentForPreUpload(firstPreUpload)
@@ -19,31 +19,17 @@ import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.signal.core.util.Base64
import org.signal.core.util.Util
import org.signal.network.NetworkResult
import org.signal.network.exceptions.NonSuccessfulResponseCodeException
import org.thoughtcrime.securesms.attachments.Cdn
import org.thoughtcrime.securesms.attachments.PointerAttachment
import org.thoughtcrime.securesms.backup.DeletionState
import org.thoughtcrime.securesms.backup.v2.BackupRepository
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
import org.thoughtcrime.securesms.database.AttachmentTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.jobs.protos.BackupDeleteJobData
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.testing.Flag
import org.thoughtcrime.securesms.testing.RemoteConfigForTest
import org.thoughtcrime.securesms.testing.SignalActivityRule
import org.thoughtcrime.securesms.testing.TestRemoteConfigFlag
import java.util.UUID
import org.thoughtcrime.securesms.util.RemoteConfig
import org.whispersystems.signalservice.api.NetworkResult
import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException
@RemoteConfigForTest(
flags = [
Flag(TestRemoteConfigFlag.INTERNAL_USER, "true"),
Flag(TestRemoteConfigFlag.DEFAULT_MAX_BACKOFF, "1")
]
)
class BackupDeleteJobTest {
@get:Rule
@@ -51,6 +37,10 @@ class BackupDeleteJobTest {
@Before
fun setUp() {
mockkObject(RemoteConfig)
every { RemoteConfig.internalUser } returns true
every { RemoteConfig.defaultMaxBackoff } returns 1000L
mockkObject(BackupRepository)
every { BackupRepository.getBackupTier() } returns NetworkResult.Success(MessageBackupTier.PAID)
every { BackupRepository.deleteBackup() } returns NetworkResult.Success(Unit)
@@ -64,24 +54,29 @@ class BackupDeleteJobTest {
@Test
fun givenUserNotRegistered_whenIRun_thenIExpectFailure() {
SignalStore.account.setRegistered(false)
mockkObject(SignalStore) {
every { SignalStore.account.isRegistered } returns false
val job = BackupDeleteJob()
val job = BackupDeleteJob()
val result = job.run()
val result = job.run()
assertThat(result.isFailure).isTrue()
assertThat(result.isFailure).isTrue()
}
}
@Test
fun givenLinkedDevice_whenIRun_thenIExpectFailure() {
SignalStore.account.deviceId = 2
mockkObject(SignalStore) {
every { SignalStore.account.isRegistered } returns true
every { SignalStore.account.isLinkedDevice } returns true
val job = BackupDeleteJob()
val job = BackupDeleteJob()
val result = job.run()
val result = job.run()
assertThat(result.isFailure).isTrue()
assertThat(result.isFailure).isTrue()
}
}
@Test
@@ -160,7 +155,10 @@ class BackupDeleteJobTest {
@Test
fun givenMediaOffloaded_whenIRun_thenIExpectAwaitingMediaDownload() {
insertOffloadedAttachment()
mockkObject(SignalDatabase)
every { SignalDatabase.attachments.getRemainingRestorableAttachmentSize() } returns 1
every { SignalDatabase.attachments.getOptimizedMediaAttachmentSize() } returns 1
every { SignalDatabase.attachments.clearAllArchiveData() } returns Unit
SignalStore.backup.deletionState = DeletionState.CLEAR_LOCAL_STATE
@@ -254,39 +252,4 @@ class BackupDeleteJobTest {
assertThat(result.isRetry).isTrue()
}
private fun insertOffloadedAttachment(size: Long = 100) {
SignalDatabase.attachments.insertAttachmentsForMessage(
mmsId = 1,
attachments = listOf(
PointerAttachment(
contentType = "image/jpeg",
transferState = AttachmentTable.TRANSFER_RESTORE_OFFLOADED,
size = size,
fileName = null,
cdn = Cdn.CDN_3,
location = "somelocation",
key = Base64.encodeWithPadding(Util.getSecretBytes(64)),
iv = null,
digest = Util.getSecretBytes(64),
incrementalDigest = null,
incrementalMacChunkSize = 0,
fastPreflightId = null,
voiceNote = false,
borderless = false,
videoGif = false,
width = 100,
height = 100,
uploadTimestamp = System.currentTimeMillis(),
caption = null,
stickerLocator = null,
blurHash = null,
uuid = UUID.randomUUID(),
quote = false,
quoteTargetContentType = null
)
),
quoteAttachment = emptyList()
)
}
}
@@ -27,8 +27,6 @@ import org.signal.core.util.billing.BillingPurchaseState
import org.signal.core.util.billing.BillingResponseCode
import org.signal.core.util.money.FiatMoney
import org.signal.donations.InAppPaymentType
import org.signal.network.NetworkResult
import org.signal.network.exceptions.NonSuccessfulResponseCodeException
import org.thoughtcrime.securesms.backup.DeletionState
import org.thoughtcrime.securesms.backup.v2.BackupRepository
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
@@ -42,10 +40,10 @@ import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobmanager.Job
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.net.SignalNetwork
import org.thoughtcrime.securesms.testing.Flag
import org.thoughtcrime.securesms.testing.RemoteConfigForTest
import org.thoughtcrime.securesms.testing.SignalActivityRule
import org.thoughtcrime.securesms.testing.TestRemoteConfigFlag
import org.thoughtcrime.securesms.util.RemoteConfig
import org.whispersystems.signalservice.api.NetworkResult
import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException
import org.whispersystems.signalservice.api.storage.IAPSubscriptionId
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription.ChargeFailure
@@ -57,7 +55,6 @@ import java.util.Currency
import kotlin.time.Duration.Companion.days
import kotlin.time.Duration.Companion.milliseconds
@RemoteConfigForTest(flags = [Flag(TestRemoteConfigFlag.INTERNAL_USER, "true") ])
@RunWith(AndroidJUnit4::class)
class BackupSubscriptionCheckJobTest {
@@ -70,6 +67,9 @@ class BackupSubscriptionCheckJobTest {
@Before
fun setUp() {
mockkObject(RemoteConfig)
every { RemoteConfig.internalUser } returns true
coEvery { AppDependencies.billingApi.getApiAvailability() } returns BillingResponseCode.OK
coEvery { AppDependencies.billingApi.queryPurchases() } returns BillingPurchaseResult.Success(
@@ -88,7 +88,6 @@ class BackupSubscriptionCheckJobTest {
every { RecurringInAppPaymentRepository.getActiveSubscriptionSync(InAppPaymentSubscriberRecord.Type.BACKUP) } returns NetworkResult.Success(
createActiveSubscription()
)
every { RecurringInAppPaymentRepository.ensureSubscriberIdSync(any(), any(), any()) } returns Unit
mockkObject(BackupRepository)
every { BackupRepository.getBackupTier() } answers {
@@ -143,22 +142,26 @@ class BackupSubscriptionCheckJobTest {
@Test
fun givenUserIsNotRegistered_whenIRun_thenIExpectSuccessAndEarlyExit() {
SignalStore.account.setRegistered(false)
mockkObject(SignalStore.account) {
every { SignalStore.account.isRegistered } returns false
val job = BackupSubscriptionCheckJob.create()
val result = job.run()
val job = BackupSubscriptionCheckJob.create()
val result = job.run()
assertEarlyExit(result)
assertEarlyExit(result)
}
}
@Test
fun givenIsLinkedDevice_whenIRun_thenIExpectSuccessAndEarlyExit() {
SignalStore.account.deviceId = 2
mockkObject(SignalStore.account) {
every { SignalStore.account.isLinkedDevice } returns true
val job = BackupSubscriptionCheckJob.create()
val result = job.run()
val job = BackupSubscriptionCheckJob.create()
val result = job.run()
assertEarlyExit(result)
assertEarlyExit(result)
}
}
@Test
@@ -1,847 +0,0 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.main
import android.app.Activity
import android.app.Application
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.net.Uri
import android.os.Bundle
import android.view.View
import android.widget.TextView
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity
import androidx.fragment.app.FragmentManager
import androidx.lifecycle.ViewModelProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.signal.core.models.media.Media
import org.thoughtcrime.securesms.MainActivity
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.calls.log.CallLogFragment
import org.thoughtcrime.securesms.conversation.ConversationArgs
import org.thoughtcrime.securesms.conversation.ConversationIntents
import org.thoughtcrime.securesms.conversation.v2.ConversationFragment
import org.thoughtcrime.securesms.conversationlist.ConversationListArchiveFragment
import org.thoughtcrime.securesms.conversationlist.ConversationListFragment
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionActivity
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.stories.landing.StoriesLandingFragment
import org.thoughtcrime.securesms.testing.SignalActivityRule
import java.io.ByteArrayOutputStream
import java.util.Collections
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
/**
* End-to-end launch tests for [MainActivity], covering cold-launch and onNewIntent paths
* through [MainNavigationViewModel].
*/
@RunWith(AndroidJUnit4::class)
class MainNavigationLaunchTest {
@get:Rule
val harness = SignalActivityRule(othersCount = 2)
private val context: Context get() = harness.context
private val recipient: RecipientId get() = harness.others.first()
/**
* Share-target cold-launch regression test. Pre-fix, wrapNavigator() re-routed the
* early-staged Conversation through goTo(), whose async wallpaper-prefetch path emitted
* a SECOND internalDetailLocation with a fresh ConversationArgs — recreating the
* fragment and dropping share data.
*/
@Test
fun coldLaunch_shareIntent_createsFragmentExactlyOnceWithShareData() {
val timestamp = System.currentTimeMillis()
val mimeType = "image/jpeg"
val blob = realBlob(byteArrayOf(0x01, 0x02, 0x03), mimeType)
val intent = shareToConversationIntent(
recipient = recipient,
blob = blob,
mimeType = mimeType,
shareDataTimestamp = timestamp
)
launchSync(intent).use { launched ->
val recorder = launched.recorder
try {
await(timeoutMs = 10_000, description = "ConversationFragment to be added") {
recorder.createdArgs.isNotEmpty()
}
} catch (e: IllegalStateException) {
val vm = runOnMainSync { launched.activity.mainNavigationViewModel() }
val state = runOnMainSync {
buildString {
appendLine("--- diagnostic dump ---")
appendLine("fragments observed: ${recorder.allCreated}")
appendLine("activity fragments: ${launched.activity.supportFragmentManager.fragments.map { it::class.simpleName }}")
appendLine("vm.currentListLocation: ${vm.mainNavigationState.value.currentListLocation}")
appendLine("vm.detailLocation: ${vm.detailLocation.value}")
appendLine("vm.chatsBackStackEntries: ${vm.chatsBackStackEntries.toList()}")
}
}
throw IllegalStateException("${e.message}\n$state", e)
}
val expectedName = runOnMainSync { Recipient.resolved(recipient).getDisplayName(context) }
awaitConversationTitle(launched, expectedName)
// Give the post-navigator wallpaper-prefetch path a chance to emit a (pre-fix)
// duplicate second nav before we count fragments.
Thread.sleep(750)
check(recorder.createdArgs.size == 1) {
"Expected exactly one ConversationFragment, got ${recorder.createdArgs.size}"
}
val args = recorder.createdArgs.single()
check(args.shareDataTimestamp == timestamp) {
"Expected shareDataTimestamp=$timestamp, got ${args.shareDataTimestamp}"
}
check(args.recipientId == recipient) {
"Expected recipient=$recipient, got ${args.recipientId}"
}
check(args.draftMedia == blob) {
"Expected draftMedia=$blob, got ${args.draftMedia}"
}
}
}
/**
* Image-share cold-launch: the dispatch path through `ShareOrDraftData.StartSendMedia`
* that hops the user from the conversation into the media-send screen
* ([MediaSelectionActivity]). Asserts that the secondary activity actually launches and
* that its [MediaReviewFragment] surfaces the recipient's display name in the top
* corner — i.e. it knows who the share is targeted at.
*/
@Test
fun coldLaunch_shareImageIntent_opensMediaSendForRecipient() {
val media = realJpegMedia()
val intent = shareImageIntent(recipient = recipient, media = media)
launchSync(intent).use { launched ->
val mediaSend = launched.awaitActivity(MediaSelectionActivity::class.java, timeoutMs = 20_000)
val expectedName = runOnMainSync { Recipient.resolved(recipient).getDisplayName(context) }
await(timeoutMs = 15_000, description = "recipient label populated in MediaReviewFragment") {
// await() already runs the predicate on the main thread; nesting another
// runOnMainSync here would throw "can not be called from the main application thread".
mediaSend.findViewById<TextView>(R.id.recipient)?.text?.toString() == expectedName
}
// Exactly one ConversationFragment should have been created — the share dispatch
// happens from inside it, then it stays put while the media editor sits on top.
check(launched.recorder.createdArgs.size == 1) {
"Expected exactly one ConversationFragment for image share, got ${launched.recorder.createdArgs.size}"
}
}
}
/**
* Text-share cold-launch: the dispatch path through `ShareOrDraftData.SetText`. Asserts
* the navigation boundary — one ConversationFragment, no secondary activity pushed on
* top — *and* that the draft text actually shows up in the composer the user sees.
*/
@Test
fun coldLaunch_shareTextIntent_opensConversationWithDraftText() {
val draftText = "hello from share"
val intent = shareTextIntent(recipient = recipient, text = draftText)
launchSync(intent).use { launched ->
val recorder = launched.recorder
await(timeoutMs = 10_000, description = "ConversationFragment to be added") {
recorder.createdArgs.isNotEmpty()
}
awaitComposerText(launched, draftText)
// Give a beat for any spurious second navigation to surface.
Thread.sleep(750)
check(recorder.createdArgs.size == 1) {
"Expected exactly one ConversationFragment, got ${recorder.createdArgs.size}"
}
val args = recorder.createdArgs.single()
check(args.recipientId == recipient) {
"Expected recipient=$recipient, got ${args.recipientId}"
}
check(args.draftMedia == null) {
"Expected no draftMedia, got ${args.draftMedia}"
}
check(launched.nonMainActivities().isEmpty()) {
"Text share should not launch a secondary activity, got ${launched.nonMainActivities().map { it::class.simpleName }}"
}
}
}
@Test
fun coldLaunch_notificationIntent_opensConversation() {
val intent = notificationToConversationIntent(recipient)
launchSync(intent).use { launched ->
val recorder = launched.recorder
await(timeoutMs = 10_000, description = "ConversationFragment to be added") {
recorder.createdArgs.isNotEmpty()
}
val expectedName = runOnMainSync { Recipient.resolved(recipient).getDisplayName(context) }
awaitConversationTitle(launched, expectedName)
check(recorder.createdArgs.size == 1) {
"Expected exactly one ConversationFragment, got ${recorder.createdArgs.size}"
}
val args = recorder.createdArgs.single()
check(args.recipientId == recipient) {
"Expected recipient=$recipient, got ${args.recipientId}"
}
check(args.threadId > 0) {
"Expected threadId > 0, got ${args.threadId}"
}
check(args.draftMedia == null) {
"Expected no draftMedia, got ${args.draftMedia}"
}
check(args.shareDataTimestamp == -1L) {
"Expected shareDataTimestamp=-1 for notification path, got ${args.shareDataTimestamp}"
}
val vm = runOnMainSync { launched.activity.mainNavigationViewModel() }
check(vm.mainNavigationState.value.currentListLocation == MainNavigationListLocation.CHATS) {
"Expected currentListLocation=CHATS, got ${vm.mainNavigationState.value.currentListLocation}"
}
}
}
@Test
fun coldLaunch_tabIntent_setsListLocation() {
val intent = tabIntent(MainNavigationListLocation.CALLS)
launchSync(intent).use { launched ->
val recorder = launched.recorder
awaitListFragment(launched, MainNavigationListLocation.CALLS)
val vm = runOnMainSync { launched.activity.mainNavigationViewModel() }
check(vm.mainNavigationState.value.currentListLocation == MainNavigationListLocation.CALLS) {
"Expected VM CALLS, got ${vm.mainNavigationState.value.currentListLocation}"
}
Thread.sleep(750)
check(recorder.createdArgs.isEmpty()) {
"Expected no ConversationFragment for tab launch, got ${recorder.createdArgs.size}"
}
}
}
/**
* Locks down present cold-launch behaviour for KEY_DETAIL_LOCATION: today it is only
* consumed by onNewIntent. If a future change starts handling it on cold launch, this
* test should fail and force a deliberate decision.
*/
@Test
fun coldLaunch_detailLocationIntent_isNoOpToday() {
val intent = detailLocationIntent(MainNavigationDetailLocation.Chats.ConversationSettings(recipient))
launchSync(intent).use { launched ->
val recorder = launched.recorder
Thread.sleep(1500)
check(recorder.createdArgs.isEmpty()) {
"KEY_DETAIL_LOCATION is currently only handled by onNewIntent. If a future change " +
"starts handling it on cold launch, update or delete this test. Got: ${recorder.allCreated}"
}
val vm = runOnMainSync { launched.activity.mainNavigationViewModel() }
val staged = runOnMainSync { vm.chatsBackStackEntries.filterNot { it is MainNavigationDetailLocation.Empty } }
check(staged.isEmpty()) {
"Expected no detail to be staged on the chats back stack, got $staged"
}
}
}
@Test
fun coldLaunch_deepLinkIntent_reachesChatsList() {
val intent = deepLinkIntent(Uri.parse("https://signal.org/test-not-a-real-deeplink"))
launchSync(intent).use { launched ->
val recorder = launched.recorder
awaitListFragment(launched, MainNavigationListLocation.CHATS)
val vm = runOnMainSync { launched.activity.mainNavigationViewModel() }
check(vm.mainNavigationState.value.currentListLocation == MainNavigationListLocation.CHATS) {
"Expected CHATS for deep-link launch, got ${vm.mainNavigationState.value.currentListLocation}"
}
check(recorder.createdArgs.isEmpty()) {
"Expected no ConversationFragment for deep-link launch, got ${recorder.createdArgs.size}"
}
}
}
@Test
fun coldLaunch_noExtras_defaultsToChats() {
val intent = Intent(context, MainActivity::class.java)
launchSync(intent).use { launched ->
val recorder = launched.recorder
awaitListFragment(launched, MainNavigationListLocation.CHATS)
val vm = runOnMainSync { launched.activity.mainNavigationViewModel() }
check(vm.mainNavigationState.value.currentListLocation == MainNavigationListLocation.CHATS) {
"Expected default CHATS, got ${vm.mainNavigationState.value.currentListLocation}"
}
Thread.sleep(750)
val detailLocation = runOnMainSync { vm.detailLocation.value }
check(detailLocation == MainNavigationDetailLocation.Empty) {
"Expected Empty detail location, got $detailLocation"
}
check(recorder.createdArgs.isEmpty()) {
"Expected no ConversationFragment for bare launch, got ${recorder.createdArgs.size}"
}
}
}
@Test
fun warmStart_onNewIntent_conversationIntent_opensConversation() {
launchSync(Intent(context, MainActivity::class.java)).use { launched ->
val recorder = launched.recorder
// Let the bare list settle so we know any further fragment adds came from onNewIntent.
Thread.sleep(1000)
val baseline = recorder.createdArgs.size
val warmIntent = notificationToConversationIntent(recipient)
runOnMainSync {
InstrumentationRegistry.getInstrumentation().callActivityOnNewIntent(launched.activity, warmIntent)
}
await(timeoutMs = 10_000, description = "ConversationFragment after onNewIntent") {
recorder.createdArgs.size > baseline
}
val expectedName = runOnMainSync { Recipient.resolved(recipient).getDisplayName(context) }
awaitConversationTitle(launched, expectedName)
val newArgs = recorder.createdArgs.drop(baseline)
check(newArgs.size == 1) { "Expected one new ConversationFragment, got ${newArgs.size}" }
check(newArgs.single().recipientId == recipient) {
"Expected recipient=$recipient, got ${newArgs.single().recipientId}"
}
}
}
/**
* Mid-conversation onNewIntent with `KEY_DETAIL_LOCATION = Empty` — the contract used
* by [ConversationSettingsFragment.goToConversationList] to drop back to the chat list
* on phones. No new ConversationFragment should be added.
*/
@Test
fun warmStart_onNewIntent_emptyDetailIntent_returnsToList() {
launchSync(notificationToConversationIntent(recipient)).use { launched ->
val recorder = launched.recorder
await(timeoutMs = 10_000, description = "initial ConversationFragment") {
recorder.createdArgs.isNotEmpty()
}
val baseline = recorder.createdArgs.size
val warmIntent = detailLocationIntent(MainNavigationDetailLocation.Empty)
runOnMainSync {
InstrumentationRegistry.getInstrumentation().callActivityOnNewIntent(launched.activity, warmIntent)
}
await(description = "no new ConversationFragment after Empty detail intent") {
recorder.createdArgs.size == baseline
}
val vm = runOnMainSync { launched.activity.mainNavigationViewModel() }
await(description = "conversation cleared from chats back stack after Empty detail intent") {
vm.chatsBackStackEntries.none { it is MainNavigationDetailLocation.Conversation }
}
check(vm.mainNavigationState.value.currentListLocation == MainNavigationListLocation.CHATS) {
"Expected CHATS, got ${vm.mainNavigationState.value.currentListLocation}"
}
}
}
@Test
fun warmStart_onNewIntent_tabIntent_switchesList() {
launchSync(Intent(context, MainActivity::class.java)).use { launched ->
awaitListFragment(launched, MainNavigationListLocation.CHATS)
val warmIntent = tabIntent(MainNavigationListLocation.CALLS)
runOnMainSync {
InstrumentationRegistry.getInstrumentation().callActivityOnNewIntent(launched.activity, warmIntent)
}
awaitListFragment(launched, MainNavigationListLocation.CALLS)
val vm = runOnMainSync { launched.activity.mainNavigationViewModel() }
check(vm.mainNavigationState.value.currentListLocation == MainNavigationListLocation.CALLS) {
"Expected VM CALLS, got ${vm.mainNavigationState.value.currentListLocation}"
}
check(launched.recorder.createdArgs.isEmpty()) {
"Expected no ConversationFragment for tab switch, got ${launched.recorder.createdArgs.size}"
}
}
}
@Test
fun recreate_midConversation_restoresState() {
launchSync(notificationToConversationIntent(recipient)).use { launched ->
val recorder = launched.recorder
await(timeoutMs = 10_000, description = "initial ConversationFragment") {
recorder.createdArgs.isNotEmpty()
}
val expectedName = runOnMainSync { Recipient.resolved(recipient).getDisplayName(context) }
awaitConversationTitle(launched, expectedName)
val initial = recorder.createdArgs.first()
runOnMainSync { launched.activity.recreate() }
await(timeoutMs = 15_000, description = "ConversationFragment after recreate") {
recorder.createdArgs.size >= 2
}
// Verify the user-visible title rebinds after recreate, not just the args.
awaitConversationTitle(launched, expectedName)
val recreated = recorder.createdArgs[1]
check(recreated.recipientId == initial.recipientId) {
"Recipient changed across recreate: ${initial.recipientId} -> ${recreated.recipientId}"
}
check(recreated.threadId == initial.threadId) {
"Thread changed across recreate: ${initial.threadId} -> ${recreated.threadId}"
}
}
}
@Test
fun recreate_midTab_restoresTab() {
launchSync(tabIntent(MainNavigationListLocation.CALLS)).use { launched ->
awaitListFragment(launched, MainNavigationListLocation.CALLS)
runOnMainSync { launched.activity.recreate() }
// Verify the user-visible tab content rebinds after recreate, not just the VM. The
// recorder removes destroyed fragments, so this only passes once the post-recreate
// CallLogFragment instance is attached.
awaitListFragment(launched, MainNavigationListLocation.CALLS)
// launched.activity returns the *latest* MainActivity (the holder updates in
// onActivityCreated), so this reads the post-recreate VM instance.
val location = runOnMainSync {
launched.activity.mainNavigationViewModel().mainNavigationState.value.currentListLocation
}
check(location == MainNavigationListLocation.CALLS) {
"Expected VM CALLS post-recreate, got $location"
}
check(launched.recorder.createdArgs.isEmpty()) {
"Expected no ConversationFragment across tab recreate, got ${launched.recorder.createdArgs.size}"
}
}
}
@Test
fun recreate_midShareConversation_preservesShareData() {
val timestamp = System.currentTimeMillis()
val mimeType = "image/jpeg"
val blob = realBlob(byteArrayOf(0x01, 0x02, 0x03), mimeType)
val intent = shareToConversationIntent(
recipient = recipient,
blob = blob,
mimeType = mimeType,
shareDataTimestamp = timestamp
)
launchSync(intent).use { launched ->
val recorder = launched.recorder
await(timeoutMs = 10_000, description = "initial ConversationFragment") {
recorder.createdArgs.isNotEmpty()
}
val expectedName = runOnMainSync { Recipient.resolved(recipient).getDisplayName(context) }
awaitConversationTitle(launched, expectedName)
val initialCount = recorder.createdArgs.size
runOnMainSync { launched.activity.recreate() }
await(timeoutMs = 15_000, description = "ConversationFragment after recreate") {
recorder.createdArgs.size > initialCount
}
awaitConversationTitle(launched, expectedName)
val recreated = recorder.createdArgs.last()
check(recreated.shareDataTimestamp == timestamp) {
"shareDataTimestamp not preserved across recreate: $timestamp -> ${recreated.shareDataTimestamp}"
}
check(recreated.draftMedia == blob) {
"draftMedia not preserved across recreate: $blob -> ${recreated.draftMedia}"
}
}
}
// region Helpers
/**
* Mirrors [org.thoughtcrime.securesms.sharing.v2.ShareActivity.openConversation]. We
* deliberately drop the producer's `clearTop` flags (NEW_TASK | CLEAR_TOP | SINGLE_TOP)
* — they are launch-routing concerns that are incompatible with our lifecycle monitor.
*/
private fun shareToConversationIntent(
recipient: RecipientId,
blob: Uri,
mimeType: String,
draftText: String? = null,
shareDataTimestamp: Long = System.currentTimeMillis()
): Intent {
val builder = ConversationIntents.createBuilder(context, recipient, -1L).blockingGet()
val conversationIntent = builder
.withDataUri(blob)
.withDataType(mimeType)
.withMedia(emptyList())
.withDraftText(draftText)
.withStickerLocator(null)
.asBorderless(false)
.withShareDataTimestamp(shareDataTimestamp)
.build()
return Intent(context, MainActivity::class.java).apply {
action = ConversationIntents.ACTION
putExtras(conversationIntent)
}
}
/**
* Mirrors the image-share path through [org.thoughtcrime.securesms.sharing.v2.ShareActivity.openConversation]:
* a non-empty `media` list is what flips dispatch to `ShareOrDraftData.StartSendMedia`,
* which is what triggers the hop to the media-send screen.
*/
private fun shareImageIntent(recipient: RecipientId, media: Media): Intent {
val builder = ConversationIntents.createBuilder(context, recipient, -1L).blockingGet()
val conversationIntent = builder
.withDataUri(media.uri)
.withDataType(media.contentType)
.withMedia(listOf(media))
.withStickerLocator(null)
.asBorderless(false)
.withShareDataTimestamp(System.currentTimeMillis())
.build()
return Intent(context, MainActivity::class.java).apply {
action = ConversationIntents.ACTION
putExtras(conversationIntent)
}
}
/**
* Mirrors a text-only share. Empty media list + non-null draft text routes dispatch to
* `ShareOrDraftData.SetText`.
*/
private fun shareTextIntent(recipient: RecipientId, text: String): Intent {
val builder = ConversationIntents.createBuilder(context, recipient, -1L).blockingGet()
val conversationIntent = builder
.withMedia(emptyList())
.withDraftText(text)
.withStickerLocator(null)
.asBorderless(false)
.withShareDataTimestamp(System.currentTimeMillis())
.build()
return Intent(context, MainActivity::class.java).apply {
action = ConversationIntents.ACTION
putExtras(conversationIntent)
}
}
private fun notificationToConversationIntent(recipient: RecipientId): Intent {
val conversationIntent = ConversationIntents.createBuilder(context, recipient, -1L)
.blockingGet()
.build()
return Intent(context, MainActivity::class.java).apply {
action = ConversationIntents.ACTION
putExtras(conversationIntent)
}
}
private fun tabIntent(tab: MainNavigationListLocation): Intent {
return Intent(context, MainActivity::class.java)
.putExtra("STARTING_TAB", tab)
}
private fun detailLocationIntent(location: MainNavigationDetailLocation): Intent {
return Intent(context, MainActivity::class.java)
.putExtra("DETAIL_LOCATION", location)
}
private fun realBlob(bytes: ByteArray, mimeType: String): Uri {
return AppDependencies.blobs
.forData(bytes)
.withMimeType(mimeType)
.createForSingleSessionInMemory()
}
/**
* Build a [Media] backed by a real 1×1 JPEG. The media-send screen attempts to decode
* the image during MediaReviewFragment setup, so a fake byte array won't survive — we
* need genuine JPEG bytes for the fragment to reach the state where `R.id.recipient`
* is populated.
*/
private fun realJpegMedia(): Media {
val bitmap = Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888)
val bytes = ByteArrayOutputStream().use { stream ->
bitmap.compress(Bitmap.CompressFormat.JPEG, 90, stream)
stream.toByteArray()
}
bitmap.recycle()
val uri = realBlob(bytes, "image/jpeg")
return Media(
uri = uri,
contentType = "image/jpeg",
date = 0L,
width = 1,
height = 1,
size = bytes.size.toLong(),
duration = 0L,
isBorderless = false,
isVideoGif = false,
bucketId = null,
caption = null,
transformProperties = null,
fileName = null
)
}
/**
* Mirrors [org.thoughtcrime.securesms.deeplinks.DeepLinkEntryActivity]: bare clearTop
* plus a [Uri] in the data field.
*/
private fun deepLinkIntent(data: Uri): Intent {
return Intent(context, MainActivity::class.java).setData(data)
}
/**
* Synchronously launch [MainActivity] and return the running instance plus a fragment
* recorder wired up *before* the activity is created.
*
* We bypass [androidx.test.core.app.ActivityScenario] and
* [android.app.Instrumentation.startActivitySync] because both fail for our case:
* ActivityScenario's lifecycle tracker misses CREATED/STARTED/RESUMED for activities
* launched with a custom-action intent, and `startActivitySync` waits for main-thread
* idle which never arrives while MainActivity's composition + ConversationFragment
* setup keeps the looper busy.
*/
private fun launchSync(intent: Intent): LaunchedActivity {
val recorder = ConversationFragmentRecorder()
val app = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as Application
val resumed = CountDownLatch(1)
val activityHolder = arrayOfNulls<MainActivity>(1)
val allActivities: MutableList<Activity> = Collections.synchronizedList(mutableListOf())
val callbacks = object : Application.ActivityLifecycleCallbacks {
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {
allActivities += activity
if (activity is MainActivity) {
activityHolder[0] = activity
activity.supportFragmentManager.registerFragmentLifecycleCallbacks(recorder, true)
}
}
override fun onActivityStarted(activity: Activity) = Unit
override fun onActivityResumed(activity: Activity) {
if (activity is MainActivity) resumed.countDown()
}
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(callbacks)
// Application.startActivity from a non-Activity context requires FLAG_ACTIVITY_NEW_TASK.
val launchIntent = Intent(intent).apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) }
try {
app.startActivity(launchIntent)
} catch (t: Throwable) {
app.unregisterActivityLifecycleCallbacks(callbacks)
throw t
}
if (!resumed.await(15, TimeUnit.SECONDS)) {
app.unregisterActivityLifecycleCallbacks(callbacks)
error("MainActivity did not reach RESUMED within 15s")
}
return LaunchedActivity(activityHolder, recorder, app, callbacks, allActivities)
}
private fun <T> runOnMainSync(block: () -> T): T {
var result: Result<T> = Result.failure(IllegalStateException("runOnMainSync did not produce a result"))
InstrumentationRegistry.getInstrumentation().runOnMainSync {
result = runCatching(block)
}
return result.getOrThrow()
}
private fun await(
timeoutMs: Long = 5_000,
pollMs: Long = 50,
description: String = "condition",
predicate: () -> Boolean
) {
val deadline = System.currentTimeMillis() + timeoutMs
while (System.currentTimeMillis() < deadline) {
if (runOnMainSync(predicate)) return
Thread.sleep(pollMs)
}
error("Timed out after ${timeoutMs}ms waiting for $description")
}
private fun MainActivity.mainNavigationViewModel(): MainNavigationViewModel {
return ViewModelProvider(this as FragmentActivity, MainNavigationViewModel.Factory())[MainNavigationViewModel::class.java]
}
/**
* Wait until the latest [ConversationFragment]'s composer EditText shows [expected].
* setDraftText is invoked off the InputReadyState/ShareOrDraftData reactive chain, so the
* text won't be present at fragment-create time — we have to poll the rendered view.
*/
private fun awaitComposerText(launched: LaunchedActivity, expected: String) {
await(timeoutMs = 15_000, description = "composer shows \"$expected\"") {
val frag = launched.recorder.latestActive() ?: return@await false
val view = frag.view ?: return@await false
view.findViewById<TextView>(R.id.embedded_text_editor)?.text?.toString() == expected
}
}
/**
* Wait until the latest [ConversationFragment]'s toolbar shows [expected]. Scoped through
* R.id.conversation_title_view to avoid colliding with other R.id.title uses.
*/
private fun awaitConversationTitle(launched: LaunchedActivity, expected: String) {
await(timeoutMs = 15_000, description = "conversation title shows \"$expected\"") {
val frag = launched.recorder.latestActive() ?: return@await false
val view = frag.view ?: return@await false
val titleHost = view.findViewById<View>(R.id.conversation_title_view) ?: return@await false
titleHost.findViewById<TextView>(R.id.title)?.text?.toString() == expected
}
}
/**
* MainActivity hosts each tab as a different [Fragment] via Compose's `AndroidFragment`
* (see MainActivity.kt:662-698). The user sees the content of whichever one is currently
* attached, so a tab assertion that reads the FragmentManager is a real user-visible
* signal — strictly stronger than reading the VM's `currentListLocation`.
*/
private fun listFragmentClass(location: MainNavigationListLocation): Class<out Fragment> = when (location) {
MainNavigationListLocation.CHATS -> ConversationListFragment::class.java
MainNavigationListLocation.ARCHIVE -> ConversationListArchiveFragment::class.java
MainNavigationListLocation.CALLS -> CallLogFragment::class.java
MainNavigationListLocation.STORIES -> StoriesLandingFragment::class.java
}
private fun awaitListFragment(launched: LaunchedActivity, location: MainNavigationListLocation) {
val expected = listFragmentClass(location)
try {
await(timeoutMs = 10_000, description = "${expected.simpleName} attached for $location") {
launched.recorder.isAttached(expected)
}
} catch (e: IllegalStateException) {
throw IllegalStateException("${e.message}; currently attached: ${launched.recorder.attachedNames()}", e)
}
}
// endregion
// region Types
/**
* Records every [ConversationFragment] added under an activity's fragment manager,
* capturing each fragment's arguments at create-time.
*/
private class ConversationFragmentRecorder : FragmentManager.FragmentLifecycleCallbacks() {
val createdArgs: MutableList<ConversationArgs> = mutableListOf()
val allCreated: MutableList<String> = mutableListOf()
private val active: MutableList<ConversationFragment> = mutableListOf()
private val attached: MutableList<Fragment> = mutableListOf()
var destroyedCount: Int = 0
private set
/** Most-recently-added still-attached ConversationFragment, or null. Main-thread read. */
fun latestActive(): ConversationFragment? = active.lastOrNull()
/**
* Exact class match (not [Class.isInstance]) — `ConversationListArchiveFragment`
* extends `ConversationListFragment`, so an `isInstance` check for CHATS would falsely
* pass when the archive list is attached.
*/
fun isAttached(clazz: Class<out Fragment>): Boolean = attached.any { it::class.java == clazz }
fun attachedNames(): List<String> = attached.map { it::class.simpleName ?: it::class.java.name }
override fun onFragmentCreated(fm: FragmentManager, f: Fragment, savedInstanceState: android.os.Bundle?) {
allCreated += f::class.simpleName ?: f::class.java.name
attached += f
if (f is ConversationFragment) {
createdArgs += ConversationIntents.readArgsFromBundle(f.requireArguments())
active += f
}
}
override fun onFragmentDestroyed(fm: FragmentManager, f: Fragment) {
attached.remove(f)
if (f is ConversationFragment) {
active.remove(f)
destroyedCount++
}
}
}
private class LaunchedActivity(
private val activityHolder: Array<MainActivity?>,
val recorder: ConversationFragmentRecorder,
private val app: Application,
private val callbacks: Application.ActivityLifecycleCallbacks,
private val allActivities: MutableList<Activity>
) : AutoCloseable {
/**
* Always returns the *latest* MainActivity instance so reads follow `recreate()`.
*/
val activity: MainActivity get() = checkNotNull(activityHolder[0]) { "No active MainActivity" }
/**
* Poll until an activity of [clazz] has been created, then return it. Used to assert
* the share-image flow's hop into MediaSelectionActivity.
*/
fun <T : Activity> awaitActivity(clazz: Class<T>, timeoutMs: Long = 10_000): T {
val deadline = System.currentTimeMillis() + timeoutMs
while (System.currentTimeMillis() < deadline) {
val match = synchronized(allActivities) {
allActivities.firstOrNull { clazz.isInstance(it) }
}
if (match != null) return clazz.cast(match)!!
Thread.sleep(50)
}
val seen = synchronized(allActivities) { allActivities.map { it::class.simpleName } }
error("Timed out after ${timeoutMs}ms waiting for ${clazz.simpleName}; saw $seen")
}
fun nonMainActivities(): List<Activity> = synchronized(allActivities) {
allActivities.filter { it !is MainActivity }.toList()
}
override fun close() {
// Don't wait for looper idle — secondary activities (e.g. MediaSelectionActivity
// opened by share processing) can keep it busy indefinitely. Finish every tracked
// activity so subsequent tests start from a clean slate.
val toFinish = synchronized(allActivities) { allActivities.toList() }
if (toFinish.isNotEmpty()) {
InstrumentationRegistry.getInstrumentation().runOnMainSync {
toFinish.forEach { it.finish() }
}
}
app.unregisterActivityLifecycleCallbacks(callbacks)
}
}
// endregion
}
@@ -75,14 +75,6 @@ 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()
@@ -108,14 +100,6 @@ class EditMessageSyncProcessorTest {
.targetSentTimestamp(originalTimestamp)
.build()
)
.unidentifiedStatus(
listOf(
SyncMessage.Sent.UnidentifiedDeliveryStatus.Builder()
.destinationServiceIdBinary(toRecipient.requireServiceId().toByteString())
.unidentified(true)
.build()
)
)
.build()
).build()
).build()
@@ -18,10 +18,10 @@ import org.thoughtcrime.securesms.database.UriAttachmentBuilder
import org.thoughtcrime.securesms.database.model.GroupsV2UpdateMessageConverter
import org.thoughtcrime.securesms.database.model.databaseprotos.DecryptedGroupV2Context
import org.thoughtcrime.securesms.database.model.databaseprotos.GV2UpdateDescription
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobs.ThreadUpdateJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.mms.OutgoingMessage
import org.thoughtcrime.securesms.providers.BlobProvider
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.testing.GroupTestingUtils
@@ -120,7 +120,7 @@ class MessageHelper(private val harness: SignalActivityRule, var startTime: Long
}
fun outgoingAttachment(data: ByteArray, uuid: UUID? = UUID.randomUUID()): Attachment {
val uri: Uri = AppDependencies.blobs.forData(data).createForSingleSessionInMemory()
val uri: Uri = BlobProvider.getInstance().forData(data).createForSingleSessionInMemory()
val attachment: UriAttachment = UriAttachmentBuilder.build(
id = Random.nextLong(),
@@ -1,338 +0,0 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.messages
import androidx.test.ext.junit.runners.AndroidJUnit4
import assertk.assertThat
import assertk.assertions.isEqualTo
import assertk.assertions.isNotNull
import okio.ByteString.Companion.toByteString
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.signal.core.models.database.AttachmentId
import org.signal.core.util.Base64
import org.thoughtcrime.securesms.database.AttachmentTable
import org.thoughtcrime.securesms.database.MessageTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.testing.MessageContentFuzzer
import org.thoughtcrime.securesms.testing.SignalActivityRule
import org.thoughtcrime.securesms.util.MediaUtil
import org.whispersystems.signalservice.api.push.SignalServiceAddress
import org.whispersystems.signalservice.internal.push.AddressableMessage
import org.whispersystems.signalservice.internal.push.AttachmentPointer
import org.whispersystems.signalservice.internal.push.Content
import org.whispersystems.signalservice.internal.push.ConversationIdentifier
import org.whispersystems.signalservice.internal.push.DataMessage
import org.whispersystems.signalservice.internal.push.SyncMessage
import java.util.UUID
@Suppress("ClassName")
@RunWith(AndroidJUnit4::class)
class SyncMessageProcessorTest_attachmentBackfill {
@get:Rule
val harness = SignalActivityRule(createGroup = true)
private lateinit var messageHelper: MessageHelper
private var originalDeviceId: Int = SignalServiceAddress.DEFAULT_DEVICE_ID
@Before
fun setUp() {
messageHelper = MessageHelper(harness)
originalDeviceId = SignalStore.account.deviceId
// Make this device a linked device so backfill response handling activates.
SignalStore.account.deviceId = 2
}
@After
fun tearDown() {
SignalStore.account.deviceId = originalDeviceId
messageHelper.tearDown()
}
@Test
fun fresh_pointer_updates_row_and_resets_transfer_state() {
val (messageId, attachmentId) = insertIncomingMediaMessage(messageHelper.alice)
SignalDatabase.attachments.setTransferProgressFailed(attachmentId, messageId)
val pointer = freshPointer(cdnNumber = 3, cdnKey = "fresh-key", size = 1234, uploadTimestamp = 9_999_000L)
deliverBackfillResponse(
sender = messageHelper.alice,
sentTimestamp = sentTimestampFor(messageId),
conversationId = messageHelper.alice,
attachmentData = listOf(SyncMessage.AttachmentBackfillResponse.AttachmentData(attachment = pointer))
)
// transferState is not asserted: the forced download job's onAdded() races it PENDING -> STARTED. The pointer fields
// are written synchronously and are stable.
val refreshed = SignalDatabase.attachments.getAttachmentsForMessage(messageId).single()
assertThat(refreshed.remoteLocation).isEqualTo("fresh-key")
assertThat(refreshed.cdn.cdnNumber).isEqualTo(3)
assertThat(refreshed.size).isEqualTo(1234L)
assertThat(refreshed.uploadTimestamp).isEqualTo(9_999_000L)
}
@Test
fun terminal_error_marks_permanent_failure() {
val (messageId, attachmentId) = insertIncomingMediaMessage(messageHelper.alice)
SignalDatabase.attachments.setTransferProgressFailed(attachmentId, messageId)
deliverBackfillResponse(
sender = messageHelper.alice,
sentTimestamp = sentTimestampFor(messageId),
conversationId = messageHelper.alice,
attachmentData = listOf(
SyncMessage.AttachmentBackfillResponse.AttachmentData(
status = SyncMessage.AttachmentBackfillResponse.AttachmentData.Status.TERMINAL_ERROR
)
)
)
val refreshed = SignalDatabase.attachments.getAttachmentsForMessage(messageId).single()
assertThat(refreshed.transferState).isEqualTo(AttachmentTable.TRANSFER_PROGRESS_PERMANENT_FAILURE)
}
@Test
fun pending_status_leaves_row_unchanged() {
val (messageId, attachmentId) = insertIncomingMediaMessage(messageHelper.alice)
SignalDatabase.attachments.setTransferProgressFailed(attachmentId, messageId)
deliverBackfillResponse(
sender = messageHelper.alice,
sentTimestamp = sentTimestampFor(messageId),
conversationId = messageHelper.alice,
attachmentData = listOf(
SyncMessage.AttachmentBackfillResponse.AttachmentData(
status = SyncMessage.AttachmentBackfillResponse.AttachmentData.Status.PENDING
)
)
)
val refreshed = SignalDatabase.attachments.getAttachmentsForMessage(messageId).single()
assertThat(refreshed.transferState).isEqualTo(AttachmentTable.TRANSFER_PROGRESS_FAILED)
}
@Test
fun message_not_found_error_marks_attachments_retryable_failed() {
val (messageId, attachmentId) = insertIncomingMediaMessage(messageHelper.alice)
SignalDatabase.attachments.setTransferProgressFailed(attachmentId, messageId)
deliverBackfillResponse(
sender = messageHelper.alice,
sentTimestamp = sentTimestampFor(messageId),
conversationId = messageHelper.alice,
error = SyncMessage.AttachmentBackfillResponse.Error.MESSAGE_NOT_FOUND
)
val refreshed = SignalDatabase.attachments.getAttachmentsForMessage(messageId).single()
assertThat(refreshed.transferState).isEqualTo(AttachmentTable.TRANSFER_PROGRESS_FAILED)
}
@Test
fun primary_device_ignores_backfill_response() {
SignalStore.account.deviceId = SignalServiceAddress.DEFAULT_DEVICE_ID
val (messageId, attachmentId) = insertIncomingMediaMessage(messageHelper.alice)
SignalDatabase.attachments.setTransferProgressFailed(attachmentId, messageId)
deliverBackfillResponse(
sender = messageHelper.alice,
sentTimestamp = sentTimestampFor(messageId),
conversationId = messageHelper.alice,
attachmentData = listOf(
SyncMessage.AttachmentBackfillResponse.AttachmentData(
status = SyncMessage.AttachmentBackfillResponse.AttachmentData.Status.TERMINAL_ERROR
)
)
)
val refreshed = SignalDatabase.attachments.getAttachmentsForMessage(messageId).single()
assertThat(refreshed.transferState).isEqualTo(AttachmentTable.TRANSFER_PROGRESS_FAILED)
}
@Test
fun multi_attachment_response_matches_positionally_with_mixed_status() {
val messageId = insertIncomingMessageWith(messageHelper.alice, listOf(incomingImagePointer(), incomingImagePointer()))
val body = SignalDatabase.attachments.getAttachmentsForMessage(messageId).sortedBy { it.displayOrder }
assertThat(body.size).isEqualTo(2)
body.forEach { SignalDatabase.attachments.setTransferProgressFailed(it.attachmentId, messageId) }
// Response is a positional array: index 0 -> body[0] (fresh pointer), index 1 -> body[1] (terminal).
deliverBackfillResponse(
sender = messageHelper.alice,
sentTimestamp = sentTimestampFor(messageId),
conversationId = messageHelper.alice,
attachmentData = listOf(
SyncMessage.AttachmentBackfillResponse.AttachmentData(attachment = freshPointer(cdnNumber = 3, cdnKey = "first-key", size = 11, uploadTimestamp = 111L)),
SyncMessage.AttachmentBackfillResponse.AttachmentData(status = SyncMessage.AttachmentBackfillResponse.AttachmentData.Status.TERMINAL_ERROR)
)
)
val refreshed = SignalDatabase.attachments.getAttachmentsForMessage(messageId).sortedBy { it.displayOrder }
// remoteLocation proves index 0 routed to body[0]. transferState is not asserted: it races the download job's onAdded().
assertThat(refreshed[0].remoteLocation).isEqualTo("first-key")
assertThat(refreshed[0].cdn.cdnNumber).isEqualTo(3)
assertThat(refreshed[1].transferState).isEqualTo(AttachmentTable.TRANSFER_PROGRESS_PERMANENT_FAILURE)
}
@Test
fun long_text_slot_is_applied_independently_of_the_body() {
val messageId = insertIncomingMessageWith(messageHelper.alice, listOf(incomingImagePointer(), incomingLongTextPointer()))
val all = SignalDatabase.attachments.getAttachmentsForMessage(messageId)
all.forEach { SignalDatabase.attachments.setTransferProgressFailed(it.attachmentId, messageId) }
deliverBackfillResponse(
sender = messageHelper.alice,
sentTimestamp = sentTimestampFor(messageId),
conversationId = messageHelper.alice,
attachmentData = listOf(SyncMessage.AttachmentBackfillResponse.AttachmentData(attachment = freshPointer(cdnNumber = 3, cdnKey = "body-key", size = 22, uploadTimestamp = 222L))),
longText = SyncMessage.AttachmentBackfillResponse.AttachmentData(attachment = freshPointer(cdnNumber = 3, cdnKey = "long-text-key", size = 33, uploadTimestamp = 333L))
)
val refreshed = SignalDatabase.attachments.getAttachmentsForMessage(messageId)
val bodyRow = refreshed.single { it.contentType != MediaUtil.LONG_TEXT }
val longTextRow = refreshed.single { it.contentType == MediaUtil.LONG_TEXT }
// The positional `attachments` array fills the body row and the separate `longText` slot fills the long-text row,
// with no cross-contamination. transferState is not asserted: it races the download job's onAdded().
assertThat(bodyRow.remoteLocation).isEqualTo("body-key")
assertThat(longTextRow.remoteLocation).isEqualTo("long-text-key")
}
@Test
fun remote_attachment_list_longer_than_local_skips_extras() {
val messageId = insertIncomingMessageWith(messageHelper.alice, listOf(incomingImagePointer()))
val attachmentId = SignalDatabase.attachments.getAttachmentsForMessage(messageId).single().attachmentId
SignalDatabase.attachments.setTransferProgressFailed(attachmentId, messageId)
deliverBackfillResponse(
sender = messageHelper.alice,
sentTimestamp = sentTimestampFor(messageId),
conversationId = messageHelper.alice,
attachmentData = listOf(
SyncMessage.AttachmentBackfillResponse.AttachmentData(attachment = freshPointer(cdnNumber = 3, cdnKey = "only-key", size = 44, uploadTimestamp = 444L)),
SyncMessage.AttachmentBackfillResponse.AttachmentData(attachment = freshPointer(cdnNumber = 3, cdnKey = "extra-key", size = 55, uploadTimestamp = 555L))
)
)
// The single local row is routed from index 0; the extra index-1 entry has no body[1] and must be skipped, not throw.
val refreshed = SignalDatabase.attachments.getAttachmentsForMessage(messageId).single()
assertThat(refreshed.remoteLocation).isEqualTo("only-key")
}
private fun insertIncomingMediaMessage(sender: RecipientId): Pair<Long, AttachmentId> {
messageHelper.startTime = messageHelper.nextStartTime()
val sentTimestamp = messageHelper.startTime
val content = Content.Builder()
.dataMessage(
DataMessage.Builder()
.timestamp(sentTimestamp)
.attachments(listOf(MessageContentFuzzer.attachmentPointer()))
.build()
)
.build()
messageHelper.processor.process(
envelope = MessageContentFuzzer.envelope(sentTimestamp),
content = content,
metadata = MessageContentFuzzer.envelopeMetadata(source = sender, destination = harness.self.id),
serverDeliveredTimestamp = sentTimestamp + 10
)
val syncMessageId = MessageTable.SyncMessageId(sender, sentTimestamp)
val messageId = SignalDatabase.messages.getMessageIdOrNull(syncMessageId)
assertThat(messageId, name = "messageId").isNotNull()
val attachment = SignalDatabase.attachments.getAttachmentsForMessage(messageId!!).single()
return messageId to attachment.attachmentId
}
private fun insertIncomingMessageWith(sender: RecipientId, pointers: List<AttachmentPointer>): Long {
messageHelper.startTime = messageHelper.nextStartTime()
val sentTimestamp = messageHelper.startTime
val content = Content.Builder()
.dataMessage(
DataMessage.Builder()
.timestamp(sentTimestamp)
.attachments(pointers)
.build()
)
.build()
messageHelper.processor.process(
envelope = MessageContentFuzzer.envelope(sentTimestamp),
content = content,
metadata = MessageContentFuzzer.envelopeMetadata(source = sender, destination = harness.self.id),
serverDeliveredTimestamp = sentTimestamp + 10
)
val messageId = SignalDatabase.messages.getMessageIdOrNull(MessageTable.SyncMessageId(sender, sentTimestamp))
assertThat(messageId, name = "messageId").isNotNull()
return messageId!!
}
private fun incomingImagePointer(): AttachmentPointer = MessageContentFuzzer.attachmentPointer().newBuilder().contentType("image/jpeg").build()
private fun incomingLongTextPointer(): AttachmentPointer = MessageContentFuzzer.attachmentPointer().newBuilder().contentType(MediaUtil.LONG_TEXT).build()
private fun sentTimestampFor(messageId: Long): Long {
return SignalDatabase.messages.getMessageRecord(messageId).dateSent
}
private fun deliverBackfillResponse(
sender: RecipientId,
sentTimestamp: Long,
conversationId: RecipientId,
attachmentData: List<SyncMessage.AttachmentBackfillResponse.AttachmentData> = emptyList(),
longText: SyncMessage.AttachmentBackfillResponse.AttachmentData? = null,
error: SyncMessage.AttachmentBackfillResponse.Error? = null
) {
messageHelper.startTime = messageHelper.nextStartTime()
val envelopeTimestamp = messageHelper.startTime
val response = SyncMessage.AttachmentBackfillResponse(
targetMessage = AddressableMessage(
authorServiceIdBinary = Recipient.resolved(sender).requireAci().toByteString(),
sentTimestamp = sentTimestamp
),
targetConversation = ConversationIdentifier(
threadServiceIdBinary = Recipient.resolved(conversationId).requireAci().toByteString()
),
attachments = if (error == null) SyncMessage.AttachmentBackfillResponse.AttachmentDataList(attachments = attachmentData, longText = longText) else null,
error = error
)
val content = Content.Builder()
.syncMessage(SyncMessage.Builder().attachmentBackfillResponse(response).build())
.build()
messageHelper.processor.process(
envelope = MessageContentFuzzer.envelope(envelopeTimestamp, serverGuid = UUID.randomUUID()),
content = content,
metadata = MessageContentFuzzer.envelopeMetadata(source = harness.self.id, destination = harness.self.id, sourceDeviceId = 1),
serverDeliveredTimestamp = envelopeTimestamp + 10
)
}
private fun freshPointer(cdnNumber: Int, cdnKey: String, size: Int, uploadTimestamp: Long): AttachmentPointer {
return AttachmentPointer.Builder()
.cdnKey(cdnKey)
.cdnNumber(cdnNumber)
.key(Base64.decode("AAAAAAAA").toByteString())
.digest(ByteArray(32) { it.toByte() }.toByteString())
.size(size)
.uploadTimestamp(uploadTimestamp)
.contentType("image/jpeg")
.build()
}
}
@@ -13,6 +13,7 @@ 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
@@ -24,7 +25,6 @@ import org.signal.core.util.Util
import org.signal.core.util.logging.Log
import org.signal.core.util.update
import org.signal.core.util.withinTransaction
import org.signal.network.api.AttachmentUploadResult
import org.thoughtcrime.securesms.attachments.Attachment
import org.thoughtcrime.securesms.attachments.DatabaseAttachment
import org.thoughtcrime.securesms.database.AttachmentTable
@@ -37,6 +37,7 @@ import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.testing.MessageContentFuzzer.DeleteForMeSync
import org.thoughtcrime.securesms.testing.SignalActivityRule
import org.thoughtcrime.securesms.util.IdentityUtil
import org.whispersystems.signalservice.api.attachment.AttachmentUploadResult
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentRemoteId
import java.util.UUID
@@ -165,7 +166,7 @@ class SyncMessageProcessorTest_synchronizeDeleteForMe {
assertThat(messageCount).isEqualTo(0)
val threadRecord = SignalDatabase.threads.getThreadRecord(threadId)
assertThat(threadRecord?.active).isEqualTo(false)
assertThat(threadRecord).isNull()
}
@Test
@@ -244,7 +245,7 @@ class SyncMessageProcessorTest_synchronizeDeleteForMe {
// THEN
assertThat(SignalDatabase.messages.getMessageCountForThread(threadId)).isEqualTo(0)
assertThat(SignalDatabase.threads.getThreadRecord(threadId)?.active).isEqualTo(false)
assertThat(SignalDatabase.threads.getThreadRecord(threadId)).isNull()
}
@Test
@@ -303,7 +304,7 @@ class SyncMessageProcessorTest_synchronizeDeleteForMe {
// THEN
assertThat(SignalDatabase.messages.getMessageCountForThread(threadId)).isEqualTo(0)
assertThat(SignalDatabase.threads.getThreadRecord(threadId)?.active).isEqualTo(false)
assertThat(SignalDatabase.threads.getThreadRecord(threadId)).isNull()
harness.inMemoryLogger.flush()
assertThat(harness.inMemoryLogger.entries().filter { it.message?.contains("Using backup non-expiring messages") == true }).hasSize(1)
@@ -343,7 +344,7 @@ class SyncMessageProcessorTest_synchronizeDeleteForMe {
// THEN
assertThat(SignalDatabase.messages.getMessageCountForThread(threadId)).isEqualTo(0)
assertThat(SignalDatabase.threads.getThreadRecord(threadId)?.active).isEqualTo(false)
assertThat(SignalDatabase.threads.getThreadRecord(threadId)).isNull()
}
@Test
@@ -375,7 +376,7 @@ class SyncMessageProcessorTest_synchronizeDeleteForMe {
// THEN
assertThat(SignalDatabase.messages.getMessageCountForThread(threadId)).isEqualTo(3)
assertThat(SignalDatabase.threads.getThreadRecord(threadId)?.active).isEqualTo(true)
assertThat(SignalDatabase.threads.getThreadRecord(threadId)).isNotNull()
}
@Test
@@ -404,7 +405,7 @@ class SyncMessageProcessorTest_synchronizeDeleteForMe {
// THEN
assertThat(SignalDatabase.messages.getMessageCountForThread(threadId)).isEqualTo(0)
assertThat(SignalDatabase.threads.getThreadRecord(threadId)?.active).isEqualTo(false)
assertThat(SignalDatabase.threads.getThreadRecord(threadId)).isNull()
}
@Test
@@ -434,7 +435,7 @@ class SyncMessageProcessorTest_synchronizeDeleteForMe {
// THEN
threadIds.forEach {
assertThat(SignalDatabase.messages.getMessageCountForThread(it)).isEqualTo(0)
assertThat(SignalDatabase.threads.getThreadRecord(it)?.active).isEqualTo(false)
assertThat(SignalDatabase.threads.getThreadRecord(it)).isNull()
}
}
@@ -462,7 +463,7 @@ class SyncMessageProcessorTest_synchronizeDeleteForMe {
// THEN
assertThat(SignalDatabase.messages.getMessageCountForThread(aliceThreadId)).isEqualTo(0)
assertThat(SignalDatabase.threads.getThreadRecord(aliceThreadId)?.active).isEqualTo(false)
assertThat(SignalDatabase.threads.getThreadRecord(aliceThreadId)).isNull()
}
@Ignore("counts are consistent for some reason")
@@ -526,10 +527,10 @@ class SyncMessageProcessorTest_synchronizeDeleteForMe {
// THEN
assertThat(SignalDatabase.messages.getMessageCountForThread(aliceThreadId)).isEqualTo(0)
assertThat(SignalDatabase.threads.getThreadRecord(aliceThreadId)?.active).isEqualTo(false)
assertThat(SignalDatabase.threads.getThreadRecord(aliceThreadId)).isNull()
assertThat(SignalDatabase.messages.getMessageCountForThread(groupThreadId)).isEqualTo(0)
assertThat(SignalDatabase.threads.getThreadRecord(groupThreadId)?.active).isEqualTo(false)
assertThat(SignalDatabase.threads.getThreadRecord(groupThreadId)).isNull()
}
@Test
@@ -550,7 +551,7 @@ class SyncMessageProcessorTest_synchronizeDeleteForMe {
// THEN
assertThat(SignalDatabase.messages.getMessageCountForThread(threadId)).isEqualTo(20)
assertThat(SignalDatabase.threads.getThreadRecord(threadId)?.active).isEqualTo(true)
assertThat(SignalDatabase.threads.getThreadRecord(threadId)).isNotNull()
harness.inMemoryLogger.flush()
assertThat(harness.inMemoryLogger.entries().filter { it.message?.contains("Thread is not local only") == true }).hasSize(1)
@@ -664,7 +665,7 @@ class SyncMessageProcessorTest_synchronizeDeleteForMe {
updatedAttachments = SignalDatabase.attachments.getAttachmentsForMessage(message1.messageId)
assertThat(updatedAttachments).isEmpty()
assertThat(SignalDatabase.threads.getThreadRecord(threadId)?.active).isEqualTo(false)
assertThat(SignalDatabase.threads.getThreadRecord(threadId)).isNull()
}
private fun DatabaseAttachment.copy(
@@ -1,383 +0,0 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.messages
import androidx.test.ext.junit.runners.AndroidJUnit4
import assertk.assertThat
import assertk.assertions.isEqualTo
import assertk.assertions.isNotNull
import assertk.assertions.isTrue
import okio.ByteString
import okio.ByteString.Companion.toByteString
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.signal.core.models.ServiceId
import org.signal.core.util.UuidUtil
import org.signal.core.util.orNull
import org.signal.libsignal.protocol.IdentityKeyPair
import org.signal.libsignal.protocol.SignalProtocolAddress
import org.signal.libsignal.protocol.state.KyberPreKeyRecord
import org.signal.libsignal.protocol.state.SignedPreKeyRecord
import org.thoughtcrime.securesms.crypto.PreKeyUtil
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.testing.MessageContentFuzzer
import org.thoughtcrime.securesms.testing.SignalActivityRule
import org.whispersystems.signalservice.api.push.SignalServiceAddress
import org.whispersystems.signalservice.internal.push.Content
import org.whispersystems.signalservice.internal.push.SyncMessage
import java.util.UUID
@Suppress("ClassName")
@RunWith(AndroidJUnit4::class)
class SyncMessageProcessorTest_synchronizePniChangeNumber {
@get:Rule
val harness = SignalActivityRule(createGroup = true)
private lateinit var messageHelper: MessageHelper
private val newPniUuid: UUID = UUID.randomUUID()
private val newPni: ServiceId.PNI = ServiceId.PNI.from(newPniUuid)
// 16-byte raw UUID — matches the actual wire format the server sends (per proto comment and
// iOS/Desktop behavior). Do NOT use `newPni.toByteString()` here — that produces libsignal's
// 17-byte ServiceIdBinary form, which is a different format.
private val newPniBytes: ByteString = UuidUtil.toByteArray(newPniUuid).toByteString()
private val newE164 = "+15555550199"
private val newPniIdentity: IdentityKeyPair = IdentityKeyPair.generate()
private val newSignedPreKey: SignedPreKeyRecord = PreKeyUtil.generateSignedPreKey(1234, newPniIdentity.privateKey)
private val newLastResortKyber: KyberPreKeyRecord = PreKeyUtil.generateLastResortKyberPreKey(5678, newPniIdentity.privateKey)
private val newRegistrationId = 4242
@Before
fun setUp() {
messageHelper = MessageHelper(harness)
SignalStore.account.deviceId = 2
}
@After
fun tearDown() {
messageHelper.tearDown()
}
@Test
fun appliesAllStateOnHappyPath() {
sendPniChangeNumber()
assertThat(SignalStore.account.e164).isEqualTo(newE164)
assertThat(SignalStore.account.pni).isEqualTo(newPni)
assertThat(SignalStore.account.pniRegistrationId).isEqualTo(newRegistrationId)
assertThat(SignalStore.account.pniIdentityKey.publicKey.serialize().toByteString())
.isEqualTo(newPniIdentity.publicKey.serialize().toByteString())
assertThat(SignalStore.account.pniPreKeys.activeSignedPreKeyId).isEqualTo(newSignedPreKey.id)
assertThat(SignalStore.account.pniPreKeys.isSignedPreKeyRegistered).isTrue()
assertThat(SignalStore.account.pniPreKeys.lastResortKyberPreKeyId).isEqualTo(newLastResortKyber.id)
assertThat(SignalStore.misc.forcePniSignedPreKeyRotation).isTrue()
val self = Recipient.self().fresh()
assertThat(self.requireE164()).isEqualTo(newE164)
assertThat(self.pni.orNull()).isEqualTo(newPni)
val pniProtocolStore = AppDependencies.protocolStore.pni()
val storedSigned = pniProtocolStore.loadSignedPreKey(newSignedPreKey.id)
assertThat(storedSigned.serialize().toByteString()).isEqualTo(newSignedPreKey.serialize().toByteString())
val storedKyber = pniProtocolStore.loadLastResortKyberPreKeys().firstOrNull { it.id == newLastResortKyber.id }
assertThat(storedKyber).isNotNull()
assertThat(storedKyber!!.serialize().toByteString()).isEqualTo(newLastResortKyber.serialize().toByteString())
// The IdentityTable cache is keyed by ServiceId string, not RecipientId — for self, that's
// separate ACI and PNI rows. We want the PNI row, so look it up by the new PNI directly.
val selfPniIdentity = pniProtocolStore.getIdentity(SignalProtocolAddress(newPni.toString(), SignalServiceAddress.DEFAULT_DEVICE_ID))
assertThat(selfPniIdentity).isNotNull()
assertThat(selfPniIdentity!!.publicKey.serialize().toByteString())
.isEqualTo(newPniIdentity.publicKey.serialize().toByteString())
}
@Test
fun appliesStateWhenLastResortKyberAbsent() {
val original = captureOriginalState()
sendPniChangeNumber(lastResortKyberPreKey = null)
assertThat(SignalStore.account.e164).isEqualTo(newE164)
assertThat(SignalStore.account.pni).isEqualTo(newPni)
assertThat(SignalStore.account.pniRegistrationId).isEqualTo(newRegistrationId)
assertThat(SignalStore.account.pniPreKeys.activeSignedPreKeyId).isEqualTo(newSignedPreKey.id)
assertThat(SignalStore.account.pniPreKeys.isSignedPreKeyRegistered).isTrue()
// No kyber was supplied, so kyber metadata should be unchanged.
assertThat(SignalStore.account.pniPreKeys.lastResortKyberPreKeyId).isEqualTo(original.lastResortKyberPreKeyId)
assertThat(SignalStore.misc.forcePniSignedPreKeyRotation).isTrue()
}
@Test
fun bailsWhenPrimaryDevice() {
SignalStore.account.deviceId = SignalServiceAddress.DEFAULT_DEVICE_ID
val original = captureOriginalState()
sendPniChangeNumber()
assertOriginalStatePreserved(original)
}
@Test
fun bailsWhenSourceIsNotPrimaryDevice() {
val original = captureOriginalState()
sendPniChangeNumber(sourceDeviceId = 3)
assertOriginalStatePreserved(original)
}
@Test
fun bailsWhenEnvelopePniMissing() {
val original = captureOriginalState()
sendPniChangeNumber(envelopePniBinary = null)
assertOriginalStatePreserved(original)
}
@Test
fun bailsWhenIdentityKeyPairMissing() {
val original = captureOriginalState()
sendPniChangeNumber(identityKeyPair = null)
assertOriginalStatePreserved(original)
}
@Test
fun bailsWhenSignedPreKeyMissing() {
val original = captureOriginalState()
sendPniChangeNumber(signedPreKey = null)
assertOriginalStatePreserved(original)
}
@Test
fun bailsWhenRegistrationIdMissing() {
val original = captureOriginalState()
sendPniChangeNumber(registrationId = null)
assertOriginalStatePreserved(original)
}
@Test
fun bailsWhenRegistrationIdZero() {
val original = captureOriginalState()
sendPniChangeNumber(registrationId = 0)
assertOriginalStatePreserved(original)
}
@Test
fun bailsWhenNewE164Missing() {
val original = captureOriginalState()
sendPniChangeNumber(e164 = null)
assertOriginalStatePreserved(original)
}
@Test
fun bailsWhenNewE164Empty() {
val original = captureOriginalState()
sendPniChangeNumber(e164 = "")
assertOriginalStatePreserved(original)
}
@Test
fun bailsWhenNewE164NotValid() {
val original = captureOriginalState()
sendPniChangeNumber(e164 = "not a phone number")
assertOriginalStatePreserved(original)
}
@Test
fun bailsOnMalformedIdentityKeyPair() {
val original = captureOriginalState()
sendPniChangeNumber(identityKeyPair = malformedBytes())
assertOriginalStatePreserved(original)
}
@Test
fun bailsOnMalformedSignedPreKey() {
val original = captureOriginalState()
sendPniChangeNumber(signedPreKey = malformedBytes())
assertOriginalStatePreserved(original)
}
@Test
fun bailsOnMalformedLastResortKyber() {
val original = captureOriginalState()
sendPniChangeNumber(lastResortKyberPreKey = malformedBytes())
assertOriginalStatePreserved(original)
}
@Test
fun skipsRedeliveryWithSameServerTimestamp() {
val timestamp = messageHelper.nextStartTime()
sendPniChangeNumber(timestamp = timestamp)
val afterFirstApply = captureOriginalState()
val otherIdentity = IdentityKeyPair.generate()
val otherSignedPreKey = PreKeyUtil.generateSignedPreKey(9999, otherIdentity.privateKey)
sendPniChangeNumber(
identityKeyPair = otherIdentity.serialize().toByteString(),
signedPreKey = otherSignedPreKey.serialize().toByteString(),
e164 = "+15555550100",
timestamp = timestamp
)
assertOriginalStatePreserved(afterFirstApply)
}
@Test
fun reappliesWhenServerTimestampIsNewer() {
sendPniChangeNumber()
val secondPniUuid = UUID.randomUUID()
val secondPni = ServiceId.PNI.from(secondPniUuid)
val secondPniBytes = UuidUtil.toByteArray(secondPniUuid).toByteString()
val secondIdentity = IdentityKeyPair.generate()
val secondSignedPreKey = PreKeyUtil.generateSignedPreKey(9999, secondIdentity.privateKey)
val secondE164 = "+15555550100"
val secondRegistrationId = 7777
sendPniChangeNumber(
identityKeyPair = secondIdentity.serialize().toByteString(),
signedPreKey = secondSignedPreKey.serialize().toByteString(),
lastResortKyberPreKey = null,
registrationId = secondRegistrationId,
e164 = secondE164,
envelopePniBinary = secondPniBytes,
timestamp = messageHelper.nextStartTime() + 1000
)
assertThat(SignalStore.account.e164).isEqualTo(secondE164)
assertThat(SignalStore.account.pni).isEqualTo(secondPni)
assertThat(SignalStore.account.pniRegistrationId).isEqualTo(secondRegistrationId)
assertThat(SignalStore.account.pniIdentityKey.publicKey.serialize().toByteString())
.isEqualTo(secondIdentity.publicKey.serialize().toByteString())
}
@Test
fun bailsWhenServerTimestampStale() {
sendPniChangeNumber()
val afterFirstApply = captureOriginalState()
val otherPniUuid = UUID.randomUUID()
val otherPniBytes = UuidUtil.toByteArray(otherPniUuid).toByteString()
sendPniChangeNumber(
envelopePniBinary = otherPniBytes,
e164 = "+15555550100",
timestamp = messageHelper.nextStartTime() - 100_000L
)
assertOriginalStatePreserved(afterFirstApply)
}
private fun captureOriginalState(): OriginalState {
val self = Recipient.self().fresh()
return OriginalState(
e164 = SignalStore.account.e164,
pni = SignalStore.account.pni,
pniRegistrationId = SignalStore.account.pniRegistrationId,
isSignedPreKeyRegistered = SignalStore.account.pniPreKeys.isSignedPreKeyRegistered,
activeSignedPreKeyId = SignalStore.account.pniPreKeys.activeSignedPreKeyId,
lastResortKyberPreKeyId = SignalStore.account.pniPreKeys.lastResortKyberPreKeyId,
pniIdentityPublicKey = SignalStore.account.pniIdentityKey.publicKey.serialize().toByteString(),
selfE164 = self.e164.orNull(),
selfPni = self.pni.orNull(),
forcePniSignedPreKeyRotation = SignalStore.misc.forcePniSignedPreKeyRotation
)
}
private fun assertOriginalStatePreserved(original: OriginalState) {
assertThat(SignalStore.account.e164).isEqualTo(original.e164)
assertThat(SignalStore.account.pni).isEqualTo(original.pni)
assertThat(SignalStore.account.pniRegistrationId).isEqualTo(original.pniRegistrationId)
assertThat(SignalStore.account.pniPreKeys.isSignedPreKeyRegistered).isEqualTo(original.isSignedPreKeyRegistered)
assertThat(SignalStore.account.pniPreKeys.activeSignedPreKeyId).isEqualTo(original.activeSignedPreKeyId)
assertThat(SignalStore.account.pniPreKeys.lastResortKyberPreKeyId).isEqualTo(original.lastResortKyberPreKeyId)
assertThat(SignalStore.account.pniIdentityKey.publicKey.serialize().toByteString())
.isEqualTo(original.pniIdentityPublicKey)
assertThat(SignalStore.misc.forcePniSignedPreKeyRotation).isEqualTo(original.forcePniSignedPreKeyRotation)
val self = Recipient.self().fresh()
assertThat(self.e164.orNull()).isEqualTo(original.selfE164)
assertThat(self.pni.orNull()).isEqualTo(original.selfPni)
}
private data class OriginalState(
val e164: String?,
val pni: ServiceId.PNI?,
val pniRegistrationId: Int,
val isSignedPreKeyRegistered: Boolean,
val activeSignedPreKeyId: Int,
val lastResortKyberPreKeyId: Int,
val pniIdentityPublicKey: ByteString,
val selfE164: String?,
val selfPni: ServiceId.PNI?,
val forcePniSignedPreKeyRotation: Boolean
)
private fun malformedBytes(): ByteString = byteArrayOf(0x00, 0x01, 0x02).toByteString()
private fun sendPniChangeNumber(
identityKeyPair: ByteString? = newPniIdentity.serialize().toByteString(),
signedPreKey: ByteString? = newSignedPreKey.serialize().toByteString(),
lastResortKyberPreKey: ByteString? = newLastResortKyber.serialize().toByteString(),
registrationId: Int? = newRegistrationId,
e164: String? = newE164,
envelopePniBinary: ByteString? = newPniBytes,
sourceDeviceId: Int = SignalServiceAddress.DEFAULT_DEVICE_ID,
timestamp: Long = messageHelper.nextStartTime()
) {
val content = Content(
syncMessage = SyncMessage(
pniChangeNumber = SyncMessage.PniChangeNumber(
identityKeyPair = identityKeyPair,
signedPreKey = signedPreKey,
lastResortKyberPreKey = lastResortKyberPreKey,
registrationId = registrationId,
newE164 = e164
)
)
)
val envelope = MessageContentFuzzer.envelope(
timestamp = timestamp,
updatedPniBinary = envelopePniBinary
)
messageHelper.processor.process(
envelope = envelope,
content = content,
metadata = MessageContentFuzzer.envelopeMetadata(harness.self.id, harness.self.id, sourceDeviceId = sourceDeviceId),
serverDeliveredTimestamp = timestamp + 10
)
}
}
@@ -1,27 +0,0 @@
package org.thoughtcrime.securesms.messages.incomingmessageobserver
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.thoughtcrime.securesms.testing.incomingmessageobserver.IncomingMessageObserverAssertions.assertMessageReceived
import org.thoughtcrime.securesms.testing.incomingmessageobserver.IncomingMessageObserverAssertions.assertNoMessageReceived
import org.thoughtcrime.securesms.testing.incomingmessageobserver.IncomingMessageObserverRule
@RunWith(AndroidJUnit4::class)
class DecryptionErrorTest {
@get:Rule
val rule = IncomingMessageObserverRule(peerCount = 2)
@Test
fun malformedEnvelope_dropsMessage_butPipelineRecovers() {
val peer = rule.peers[0]
rule.deliver { malformedEnvelope() from peer }
assertNoMessageReceived(from = peer, body = "subsequent")
rule.deliver { text("subsequent") from peer }
assertMessageReceived(from = peer, body = "subsequent")
}
}
@@ -1,39 +0,0 @@
package org.thoughtcrime.securesms.messages.incomingmessageobserver
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.thoughtcrime.securesms.testing.incomingmessageobserver.IncomingMessageObserverAssertions.assertGroupMessageReceived
import org.thoughtcrime.securesms.testing.incomingmessageobserver.IncomingMessageObserverRule
@RunWith(AndroidJUnit4::class)
class IncomingGroupMessageTest {
@get:Rule
val rule = IncomingMessageObserverRule(peerCount = 5)
@Test
fun deliveredGroupText_isPersistedInGroupThread() {
val group = rule.testGroup
rule.deliver { groupText("hello group", group = group) from rule.peers[0] }
assertGroupMessageReceived(from = rule.peers[0], group = group, body = "hello group")
}
@Test
fun multipleGroupMembers_messagesPersistedFromEach() {
val group = rule.testGroup
rule.deliver {
groupText("from peer 0", group = group) from rule.peers[0]
groupText("from peer 1", group = group) from rule.peers[1]
groupText("from peer 2", group = group) from rule.peers[2]
}
assertGroupMessageReceived(from = rule.peers[0], group = group, body = "from peer 0")
assertGroupMessageReceived(from = rule.peers[1], group = group, body = "from peer 1")
assertGroupMessageReceived(from = rule.peers[2], group = group, body = "from peer 2")
}
}
@@ -1,22 +0,0 @@
package org.thoughtcrime.securesms.messages.incomingmessageobserver
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.thoughtcrime.securesms.testing.incomingmessageobserver.IncomingMessageObserverAssertions.assertMessageReceived
import org.thoughtcrime.securesms.testing.incomingmessageobserver.IncomingMessageObserverRule
@RunWith(AndroidJUnit4::class)
class IncomingTextMessageTest {
@get:Rule
val rule = IncomingMessageObserverRule(peerCount = 2)
@Test
fun deliveredOneToOneText_isPersisted() {
rule.deliver { text("hello world") from rule.peers[0] }
assertMessageReceived(from = rule.peers[0], body = "hello world")
}
}
@@ -1,49 +0,0 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.testing
import com.google.android.gms.wallet.PaymentData
import io.mockk.Runs
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.mockkConstructor
import io.mockk.unmockkConstructor
import io.reactivex.rxjava3.core.Completable
import org.junit.rules.ExternalResource
import org.signal.donations.GooglePayApi
/**
* Makes Google Pay appear available and return a fake [com.google.android.gms.wallet.PaymentData] without
* launching the real Google Pay sheet, allowing checkout to be driven to the payment pipeline in instrumentation.
*/
class GooglePayTestRule : ExternalResource() {
override fun before() {
val paymentData = mockk<PaymentData> {
every { toJson() } returns GOOGLE_PAY_PAYMENT_DATA_JSON
}
mockkConstructor(GooglePayApi::class)
every { anyConstructed<GooglePayApi>().queryIsReadyToPay() } returns Completable.complete()
every { anyConstructed<GooglePayApi>().requestPayment(any(), any(), any()) } just Runs
every { anyConstructed<GooglePayApi>().onActivityResult(any(), any(), any(), any(), any()) } answers {
arg<GooglePayApi.PaymentRequestCallback>(4).onSuccess(paymentData)
}
}
override fun after() {
unmockkConstructor(GooglePayApi::class)
}
companion object {
/**
* Minimal but well-formed Google Pay payload. [org.signal.donations.GooglePayPaymentSource] parses
* `paymentMethodData.tokenizationData.token` (itself a JSON object with an `id`) when the source is
* serialized into the setup job, so a relaxed mock that returns an empty body fails before the job runs.
*/
const val GOOGLE_PAY_PAYMENT_DATA_JSON = """{"paymentMethodData":{"tokenizationData":{"token":"{\"id\":\"tok_test\"}"}},"email":"test@signal.org"}"""
}
}
@@ -1,57 +0,0 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.testing
import android.os.SystemClock
import androidx.test.platform.app.InstrumentationRegistry
import io.reactivex.rxjava3.schedulers.TestScheduler
/**
* Timing primitives for in-app-payment instrumentation tests.
*
* The checkout flow runs against two clocks: the Rx pipeline that drives the UI (creating the payment,
* enqueuing the setup job, reacting to state changes, rendering dialogs) runs on the test's [TestScheduler]
* (virtual time), while the setup job itself executes on a live JobManager worker thread (real time). Neither
* clock can be waited on alone — the scheduler must be advanced for the pipeline to make progress, and the
* test must yield real time for the job to run and commit its result. [flushUntil] does both.
*/
/**
* Advances virtual Rx time on this [TestScheduler], drains the main looper, and yields briefly for real
* JobManager work, repeating until [condition] holds or [timeoutMs] elapses.
*
* [TestScheduler.triggerActions] runs the pipeline's scheduled work (which may enqueue a job or process a
* database update), `waitForIdleSync` renders whatever that produced, and the short sleep lets the real setup
* job run on its worker thread and commit the next state before the loop advances the scheduler again to pick
* it up. Throws once [timeoutMs] is exceeded, which means the expected UI state never materialised.
*
* [condition] may either return `false` or throw (e.g. an Espresso `check` that has not yet matched) to signal
* "not satisfied"; a thrown failure is retained and chained as the cause of the timeout [AssertionError] so a
* flake surfaces the underlying Espresso error rather than an opaque "condition not satisfied".
*/
fun TestScheduler.flushUntil(timeoutMs: Long = 15_000, condition: () -> Boolean) {
val deadline = SystemClock.uptimeMillis() + timeoutMs
var lastFailure: Throwable? = null
while (true) {
triggerActions()
InstrumentationRegistry.getInstrumentation().waitForIdleSync()
try {
if (condition()) {
return
}
} catch (t: Throwable) {
lastFailure = t
}
if (SystemClock.uptimeMillis() >= deadline) {
throw AssertionError("Condition was not satisfied within ${timeoutMs}ms of flushing the checkout pipeline.", lastFailure)
}
Thread.sleep(50)
}
}
@@ -8,13 +8,10 @@ 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.signal.network.exceptions.NonSuccessfulResponseCodeException
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription
import org.thoughtcrime.securesms.util.JsonUtils
import org.whispersystems.signalservice.api.NetworkResult
import org.whispersystems.signalservice.internal.push.SubscriptionsConfiguration
import org.whispersystems.signalservice.internal.push.WhoAmIResponse
/**
* Sets up some common infrastructure for on-device InAppPayment testing
@@ -24,8 +21,6 @@ class InAppPaymentsRule : ExternalResource() {
initialiseConfigurationResponse()
initialisePutSubscription()
initialiseSetArchiveBackupId()
initialiseSetAccountAttributes()
initialiseAccountAndSubscriptionLookups()
}
private fun initialiseConfigurationResponse() {
@@ -42,7 +37,6 @@ class InAppPaymentsRule : ExternalResource() {
private fun initialisePutSubscription() {
AppDependencies.donationsApi.apply {
every { putSubscription(any()) } returns NetworkResult.Success(Unit)
every { createSubscriber(any(), any()) } returns NetworkResult.Success(Unit)
}
}
@@ -51,31 +45,4 @@ class InAppPaymentsRule : ExternalResource() {
every { triggerBackupIdReservation(any(), any(), any()) } returns NetworkResult.Success(Unit)
}
}
private fun initialiseSetAccountAttributes() {
AppDependencies.accountApi.apply {
every { setAccountAttributes(any()) } returns NetworkResult.Success(Unit)
}
}
/**
* Starting the real job loop lets background jobs unrelated to the assertion under test run against the strict
* API mocks (e.g. [org.thoughtcrime.securesms.jobs.InAppPaymentRecurringContextJob] querying whoAmI, the
* active subscription, and then submitting receipt credentials). Stub these calls so those jobs hit a handled
* path and terminate quietly instead of throwing [io.mockk.MockKException] on a job thread, which crashes the
* whole app process (the test asserts nothing, so it surfaces as "Application crashed" with no failed test).
* Tests that supply an active subscription push the job past the [getSubscription] guard to
* [submitReceiptCredentials], so that must be stubbed too. End-to-end coverage of that pipeline is tracked
* separately; here we only keep the process alive and the logs clean.
*/
private fun initialiseAccountAndSubscriptionLookups() {
AppDependencies.accountApi.apply {
every { whoAmI() } returns NetworkResult.Success(WhoAmIResponse(number = "+15555550123"))
}
AppDependencies.donationsApi.apply {
every { getSubscription(any()) } returns NetworkResult.Success(ActiveSubscription.EMPTY)
every { submitReceiptCredentials(any(), any()) } returns NetworkResult.StatusCodeError(NonSuccessfulResponseCodeException(402))
}
}
}
@@ -41,12 +41,11 @@ object MessageContentFuzzer {
/**
* Create an [Envelope].
*/
fun envelope(timestamp: Long, serverGuid: UUID = UUID.randomUUID(), updatedPniBinary: ByteString? = null): Envelope {
fun envelope(timestamp: Long, serverGuid: UUID = UUID.randomUUID()): Envelope {
return Envelope.Builder()
.clientTimestamp(timestamp)
.serverTimestamp(timestamp + 5)
.serverGuidBinary(serverGuid.toByteArray().toByteString())
.also { if (updatedPniBinary != null) it.updatedPniBinary(updatedPniBinary) }
.build()
}
@@ -17,6 +17,7 @@ import org.whispersystems.signalservice.internal.push.PreKeyResponse
import org.whispersystems.signalservice.internal.push.PreKeyResponseItem
import org.whispersystems.signalservice.internal.push.PushServiceSocket
import org.whispersystems.signalservice.internal.push.RegistrationSessionMetadataJson
import org.whispersystems.signalservice.internal.push.SenderCertificate
import org.whispersystems.signalservice.internal.push.VerifyAccountResponse
import org.whispersystems.signalservice.internal.push.WhoAmIResponse
import java.security.SecureRandom
@@ -26,6 +27,8 @@ import java.security.SecureRandom
*/
object MockProvider {
val senderCertificate = SenderCertificate().apply { certificate = ByteArray(0) }
val lockedFailure = PushServiceSocket.RegistrationLockFailure().apply {
svr1Credentials = AuthCredentials.create("username", "password")
svr2Credentials = AuthCredentials.create("username", "password")
@@ -1,82 +0,0 @@
package org.thoughtcrime.securesms.testing
import org.json.JSONObject
import org.thoughtcrime.securesms.util.RemoteConfig
import kotlin.reflect.KProperty0
import kotlin.reflect.jvm.isAccessible
/**
* Declares remote config values a test needs. The [SignalTestRunner] reads this off the
* about-to-run test (class and/or method) and stages the values into [TestRemoteConfig], which
* [org.thoughtcrime.securesms.SignalInstrumentationApplicationContext] seeds into the real
* [RemoteConfig] before the startup `init()` runs.
*
* Method-level annotations override class-level ones for the same key. Values are strings, matching
* how the service delivers config; `"true"`/`"false"` are decoded into real booleans on the way into
* the store (same as [org.signal.network.api.RemoteConfigApi]), other values stay strings.
*
* Prefer the typed [Flag] (which resolves its key from the actual [RemoteConfig] property); use
* [RawFlag] for keys that don't have a [TestRemoteConfigFlag] entry.
*
* ```
* @RemoteConfigForTest(
* flags = [Flag(TestRemoteConfigFlag.INTERNAL_USER, "true")],
* rawFlags = [RawFlag("android.someOtherKey", "1")]
* )
* class MyTest { ... }
* ```
*/
@Retention(AnnotationRetention.RUNTIME)
@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION)
annotation class RemoteConfigForTest(
val flags: Array<Flag> = [],
val rawFlags: Array<RawFlag> = []
)
/** A flag whose key is resolved from the referenced [RemoteConfig] property at runtime. */
@Retention(AnnotationRetention.RUNTIME)
annotation class Flag(val flag: TestRemoteConfigFlag, val value: String)
/** A flag identified by its raw remote config key, for keys without a [TestRemoteConfigFlag] entry. */
@Retention(AnnotationRetention.RUNTIME)
annotation class RawFlag(val key: String, val value: String)
/**
* Typed handles for remote config flags referenced by tests.
*/
enum class TestRemoteConfigFlag(private val property: KProperty0<*>) {
INTERNAL_USER(RemoteConfig::internalUser),
DEFAULT_MAX_BACKOFF(RemoteConfig::defaultMaxBackoff),
DISAPPEAR_MORE(RemoteConfig::disappearMore);
val key: String
get() {
property.isAccessible = true
val delegate = property.getDelegate() ?: error("RemoteConfig.${property.name} has no delegate; only `by remoteX(...)` configs can be referenced by ${TestRemoteConfigFlag::class.simpleName}.")
check(delegate is RemoteConfig.Config<*>) {
"RemoteConfig.${property.name} delegate is ${delegate::class.simpleName}, not RemoteConfig.Config; cannot resolve its remote config key."
}
return delegate.key
}
}
/**
* Process-static bridge between [SignalTestRunner] (which knows the running test) and
* [org.thoughtcrime.securesms.SignalInstrumentationApplicationContext] (which seeds the config).
* Safe because Orchestrator runs each test in a fresh process.
*/
object TestRemoteConfig {
@Volatile
var pending: Map<String, Any> = emptyMap()
/**
* The staged config as a JSON string ready to write into `SignalStore.remoteConfig`. Mirrors
* [org.signal.network.api.RemoteConfigApi]'s decode so `"true"`/`"false"` land as real booleans
* (like the server path) while other values stay strings.
*/
val json: String
get() {
val decoded = pending.mapValues { (_, value) -> (value as? String)?.lowercase()?.toBooleanStrictOrNull() ?: value }
return JSONObject(decoded).toString()
}
}
@@ -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, true))
SignalDatabase.recipients.setCapabilities(recipientId, SignalServiceProfile.Capabilities(true, true))
SignalDatabase.recipients.setProfileSharing(recipientId, true)
SignalDatabase.recipients.markRegistered(recipientId, aci)
val otherIdentity = IdentityKeyPair.generate()
@@ -2,62 +2,15 @@ package org.thoughtcrime.securesms.testing
import android.app.Application
import android.content.Context
import android.os.Bundle
import androidx.test.runner.AndroidJUnitRunner
import org.thoughtcrime.securesms.SignalInstrumentationApplicationContext
/**
* Custom runner that replaces application with [SignalInstrumentationApplicationContext].
*
* Before the application is created, it reads any [RemoteConfigForTest] declared on the
* about-to-run test (passed by the Orchestrator as the `class` argument, `pkg.Class#method`) and
* stages the values in [TestRemoteConfig] so the app can seed them into `RemoteConfig` at startup.
*/
@Suppress("unused")
class SignalTestRunner : AndroidJUnitRunner() {
override fun onCreate(arguments: Bundle?) {
TestRemoteConfig.pending = parseRemoteConfig(arguments?.getString("class"))
super.onCreate(arguments)
}
override fun newApplication(cl: ClassLoader?, className: String?, context: Context?): Application {
return super.newApplication(cl, SignalInstrumentationApplicationContext::class.java.name, context)
}
/**
* Resolves [RemoteConfigForTest] annotations from the targeted test(s). [classArg] is the
* instrumentation `class` argument: a comma-separated list of `pkg.Class` or `pkg.Class#method`.
* Method-level flags override class-level flags for the same key. Reflection failures (e.g. a
* whole-suite run with no `class` arg) fall back to no overrides.
*/
private fun parseRemoteConfig(classArg: String?): Map<String, Any> {
if (classArg.isNullOrBlank()) {
return emptyMap()
}
val flags = mutableMapOf<String, Any>()
for (entry in classArg.split(",")) {
val (className, methodName) = entry.trim().split("#", limit = 2).let { it[0] to it.getOrNull(1) }
try {
// initialize = false: only read annotations, don't run the test class's static init this early.
val testClass = Class.forName(className, false, javaClass.classLoader)
val method = methodName?.let { name -> testClass.declaredMethods.firstOrNull { it.name == name } }
// Class annotation first, then method annotation so method-level flags override class-level ones.
listOfNotNull(
testClass.getAnnotation(RemoteConfigForTest::class.java),
method?.getAnnotation(RemoteConfigForTest::class.java)
).forEach { annotation ->
annotation.flags.forEach { flags[it.flag.key] = it.value }
annotation.rawFlags.forEach { flags[it.key] = it.value }
}
} catch (_: ReflectiveOperationException) {
// Class/method not resolvable in this run; leave overrides as-is.
}
}
return flags
}
}
@@ -1,66 +0,0 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.testing.actions
import android.os.SystemClock
import android.view.View
import androidx.annotation.IdRes
import androidx.recyclerview.widget.RecyclerView
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.PerformException
import androidx.test.espresso.contrib.RecyclerViewActions
import androidx.test.espresso.matcher.ViewMatchers.hasDescendant
import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.platform.app.InstrumentationRegistry
import io.reactivex.rxjava3.schedulers.TestScheduler
import org.hamcrest.Matcher
/**
* Scrolls the [RecyclerView] with id [recyclerViewId] to the view holder whose item view matches [target], or
* whose item view contains a descendant matching [target] (e.g. a preset button inside a container row),
* binding it if necessary, then returns. Off-screen presets/buttons are brought on-screen before a click or
* assertion regardless of device size — important for Firebase Test Lab's varied screens.
*
* The DSL screens use [androidx.recyclerview.widget.ListAdapter], which diffs `submitList` on a background
* thread and posts the result to the main thread. espresso-contrib's [RecyclerViewActions.scrollTo] scans the
* adapter once and fails fast if that diff has not yet committed, so we retry within [timeoutMs], pumping the
* main looper (and, if supplied, advancing [scheduler] to run the Rx work that produces the list) between
* attempts. This is the async-diff analogue of the codebase's existing poll-until-ready test idiom; Android
* exposes no deterministic completion hook for the differ.
*/
fun scrollToDescendant(
@IdRes recyclerViewId: Int,
target: Matcher<View>,
scheduler: TestScheduler? = null,
timeoutMs: Long = 5_000
) {
val deadline = SystemClock.uptimeMillis() + timeoutMs
while (true) {
// A holder's item view may itself be the target (a bare button row) or contain it as a descendant
// (a preset within a container), so try both rather than assume one shape.
if (tryScrollTo(recyclerViewId, target) || tryScrollTo(recyclerViewId, hasDescendant(target))) {
return
}
if (SystemClock.uptimeMillis() >= deadline) {
throw AssertionError("RecyclerView (id=$recyclerViewId) never bound a holder matching $target within ${timeoutMs}ms.")
}
scheduler?.triggerActions()
InstrumentationRegistry.getInstrumentation().waitForIdleSync()
Thread.sleep(50)
}
}
private fun tryScrollTo(@IdRes recyclerViewId: Int, holderMatcher: Matcher<View>): Boolean {
return try {
onView(withId(recyclerViewId)).perform(RecyclerViewActions.scrollTo<RecyclerView.ViewHolder>(holderMatcher))
true
} catch (e: PerformException) {
false
}
}
@@ -0,0 +1,34 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.testing.actions
import android.view.View
import androidx.recyclerview.widget.RecyclerView
import androidx.test.espresso.UiController
import androidx.test.espresso.ViewAction
import androidx.test.espresso.matcher.ViewMatchers.isAssignableFrom
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import org.hamcrest.CoreMatchers.allOf
import org.hamcrest.Matcher
/**
* Scrolls the RecyclerView to the bottom position.
*
* Borrowed from [https://stackoverflow.com/a/55990445](https://stackoverflow.com/a/55990445)
*/
object RecyclerViewScrollToBottomAction : ViewAction {
override fun getDescription(): String = "scroll RecyclerView to bottom"
override fun getConstraints(): Matcher<View> = allOf(isAssignableFrom(RecyclerView::class.java), isDisplayed())
override fun perform(uiController: UiController?, view: View?) {
val recyclerView = view as RecyclerView
val itemCount = recyclerView.adapter?.itemCount
val position = itemCount?.minus(1) ?: 0
recyclerView.scrollToPosition(position)
uiController?.loopMainThreadUntilIdle()
}
}
@@ -1,71 +0,0 @@
package org.thoughtcrime.securesms.testing.incomingmessageobserver
import assertk.assertThat
import assertk.assertions.isEqualTo
import assertk.assertions.isNotNull
import assertk.assertions.isTrue
import org.signal.benchmark.setup.OtherClient
import org.thoughtcrime.securesms.database.MessageTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.whispersystems.signalservice.api.push.SignalServiceAddress
/**
* Reads database state produced by [IncomingMessageObserverRule]-driven tests. Import members
* individually (e.g. `import …IncomingMessageObserverAssertions.assertMessageReceived`) so test
* bodies stay terse.
*/
object IncomingMessageObserverAssertions {
fun OtherClient.recipientId(): RecipientId = Recipient.externalPush(SignalServiceAddress(serviceId, e164)).id
fun findIncomingMessage(from: OtherClient, body: String): MessageRecord? {
val threadId = SignalDatabase.threads.getThreadIdFor(from.recipientId()) ?: return null
return SignalDatabase.messages.getConversation(threadId).use { cursor ->
MessageTable.MmsReader(cursor).use { reader -> reader.firstOrNull { it.body == body } }
}
}
fun findIncomingGroupMessage(from: OtherClient, group: GroupHandle, body: String): MessageRecord? {
val threadId = SignalDatabase.threads.getThreadIdFor(group.recipientId) ?: return null
return SignalDatabase.messages.getConversation(threadId).use { cursor ->
MessageTable.MmsReader(cursor).use { reader ->
reader.firstOrNull { it.body == body && it.fromRecipient.id == from.recipientId() }
}
}
}
fun assertMessageReceived(from: OtherClient, body: String) {
val record = findIncomingMessage(from, body)
assertThat(record, "incoming message with body \"$body\" from ${from.serviceId} not found").isNotNull()
assertThat(record!!.fromRecipient.id, "incoming message sender mismatch for body \"$body\"").isEqualTo(from.recipientId())
}
fun assertGroupMessageReceived(from: OtherClient, group: GroupHandle, body: String) {
val record = findIncomingGroupMessage(from, group, body)
assertThat(record, "group message \"$body\" from ${from.serviceId} in ${group.groupId} not found").isNotNull()
}
fun assertNoMessageReceived(from: OtherClient, body: String) {
val record = findIncomingMessage(from, body)
assertThat(record == null, "expected no message with body \"$body\" from ${from.serviceId}, but found one").isTrue()
}
fun assertNoMessagesInThread(recipientId: RecipientId) {
val threadId = SignalDatabase.threads.getThreadIdFor(recipientId) ?: return
val count = SignalDatabase.messages.getConversation(threadId).use { cursor -> cursor.count }
assertThat(count, "expected thread for $recipientId to be empty, but message count was").isEqualTo(0)
}
fun assertDeliveryReceipt(outgoingMessageId: Long) {
val record = SignalDatabase.messages.getMessageRecord(outgoingMessageId)
assertThat(record.hasDeliveryReceipt(), "expected delivery receipt on outgoing message $outgoingMessageId, but none recorded").isTrue()
}
fun assertReadReceipt(outgoingMessageId: Long) {
val record = SignalDatabase.messages.getMessageRecord(outgoingMessageId)
assertThat(record.hasReadReceipt(), "expected read receipt on outgoing message $outgoingMessageId, but none recorded").isTrue()
}
}
@@ -1,63 +0,0 @@
package org.thoughtcrime.securesms.testing.incomingmessageobserver
import android.app.Application
import org.signal.benchmark.setup.NoOpJob
import org.signal.core.util.UptimeSleepTimer
import org.signal.libsignal.net.Network
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.dependencies.ApplicationDependencyProvider
import org.thoughtcrime.securesms.dependencies.InstrumentationApplicationDependencyProvider
import org.thoughtcrime.securesms.jobmanager.JobManager
import org.thoughtcrime.securesms.jobs.JobManagerFactories
import org.whispersystems.signalservice.api.websocket.SignalWebSocket
import org.whispersystems.signalservice.internal.configuration.SignalServiceConfiguration
import org.whispersystems.signalservice.internal.websocket.BenchmarkWebSocketConnection
import java.util.function.Supplier
import kotlin.time.Duration.Companion.seconds
/**
* Dependency provider used by [org.thoughtcrime.securesms.IncomingMessageObserverInstrumentationApplicationContext].
* Composes [InstrumentationApplicationDependencyProvider] (so existing mocks for the account /
* archive / donations / billing APIs are reused) and overrides:
*
* - the auth and unauth websocket factories with [BenchmarkWebSocketConnection], so tests can
* inject encrypted envelopes through the real ingest pipeline;
* - the job manager, swapping the startup network jobs handled by [NoOpJob.replaceFactories]
* to no-ops so they can't fire against unstubbed mocks during a test.
*/
class IncomingMessageObserverDependencyProvider(
private val application: Application,
default: ApplicationDependencyProvider
) : AppDependencies.Provider by InstrumentationApplicationDependencyProvider(application, default) {
override fun provideAuthWebSocket(
signalServiceConfigurationSupplier: Supplier<SignalServiceConfiguration>,
libSignalNetworkSupplier: Supplier<Network>
): SignalWebSocket.AuthenticatedWebSocket {
return SignalWebSocket.AuthenticatedWebSocket(
connectionFactory = { BenchmarkWebSocketConnection.createAuthInstance() },
canConnect = { true },
sleepTimer = UptimeSleepTimer(),
disconnectTimeoutMs = 15.seconds.inWholeMilliseconds
)
}
override fun provideUnauthWebSocket(
signalServiceConfigurationSupplier: Supplier<SignalServiceConfiguration>,
libSignalNetworkSupplier: Supplier<Network>
): SignalWebSocket.UnauthenticatedWebSocket {
return SignalWebSocket.UnauthenticatedWebSocket(
connectionFactory = { BenchmarkWebSocketConnection.createUnauthInstance() },
canConnect = { true },
sleepTimer = UptimeSleepTimer(),
disconnectTimeoutMs = 15.seconds.inWholeMilliseconds
)
}
override fun provideJobManager(configurationBuilder: JobManager.Configuration.Builder): JobManager {
val config = configurationBuilder
.setJobFactories(NoOpJob.replaceFactories(JobManagerFactories.getJobFactories(application)))
.build()
return JobManager(application, config)
}
}
@@ -1,201 +0,0 @@
package org.thoughtcrime.securesms.testing.incomingmessageobserver
import okio.ByteString.Companion.toByteString
import org.junit.Assume
import org.junit.rules.ExternalResource
import org.signal.benchmark.setup.Generator
import org.signal.benchmark.setup.Harness
import org.signal.benchmark.setup.OtherClient
import org.signal.benchmark.setup.TestUsers
import org.signal.core.util.logging.Log
import org.signal.network.websocket.WebSocketRequestMessage
import org.thoughtcrime.securesms.IncomingMessageObserverInstrumentationApplicationContext
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.jobmanager.Job
import org.thoughtcrime.securesms.jobmanager.JobTracker
import org.thoughtcrime.securesms.jobs.MarkerJob
import org.thoughtcrime.securesms.jobs.PushProcessMessageJob
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.whispersystems.signalservice.internal.push.Envelope
import org.whispersystems.signalservice.internal.websocket.BenchmarkWebSocketConnection
import java.util.concurrent.CopyOnWriteArraySet
import kotlin.random.Random
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
/**
* JUnit rule that drives [org.thoughtcrime.securesms.messages.IncomingMessageObserver] from
* instrumentation tests. Sets up self, registers [peerCount] simulated peers from
* [Harness.otherClients], establishes a Signal double-ratchet session with each, and exposes a
* small DSL for delivering encrypted envelopes through the real ingest pipeline:
*
* ```
* @get:Rule val rule = IncomingMessageObserverRule(peerCount = 2)
*
* @Test fun example() {
* rule.deliver { text("hi") from rule.peers[0] }
* rule.deliver { groupText("hi all", group = rule.testGroup) from rule.peers[0] }
* }
* ```
*
* Run with `-PimoTests`; tests are skipped under the default runner. Throws on drain timeout.
* Mutually exclusive with `SignalDatabaseRule` / `SignalActivityRule` — all three claim the
* local identity.
*/
class IncomingMessageObserverRule(
private val peerCount: Int = 2,
private val drainTimeout: Duration = 30.seconds
) : ExternalResource() {
lateinit var self: Recipient
private set
lateinit var peers: List<OtherClient>
private set
/** Lazily-created group. Touching this from a test triggers setup; tests that don't use groups pay nothing. */
val testGroup: GroupHandle by lazy {
val gid = TestUsers.setupGroup(withLabels = false)
GroupHandle(gid, Recipient.externalGroupExact(gid).id)
}
override fun before() {
Assume.assumeTrue(
"IncomingMessageObserverRule requires the IMO test runner — run with -PimoTests",
AppDependencies.application is IncomingMessageObserverInstrumentationApplicationContext
)
self = TestUsers.setupSelf()
TestUsers.setupTestClients(peerCount)
peers = Harness.otherClients.take(peerCount)
val app = AppDependencies.application as IncomingMessageObserverInstrumentationApplicationContext
app.beginJobLoopForTests()
// IncomingMessageObserver caches `canProcessMessages` from restoreDecisionState at thread
// construction. If it was built before setupSelf() flipped the state it will silently drop
// every message; reset network so a fresh observer is constructed.
AppDependencies.incomingMessageObserver.notifyRestoreDecisionMade()
AppDependencies.startNetwork()
forceObserverConstruction()
val handshakeEnvelopes = peers.map { client ->
client.encrypt(Generator.encryptedTextMessage(System.currentTimeMillis()))
}
deliverEnvelopes(handshakeEnvelopes)
peers.forEach { it.completeSession() }
}
fun deliver(builder: DeliveryBuilder.() -> Unit) {
val collected = DeliveryBuilder().apply(builder).specs
if (collected.isEmpty()) return
deliverEnvelopes(collected.map { it.materialize() })
}
private fun forceObserverConstruction() {
AppDependencies.incomingMessageObserver
}
private fun deliverEnvelopes(envelopes: List<Envelope>) {
val jobManager = AppDependencies.jobManager
val seenQueues = CopyOnWriteArraySet<String>()
val queueListener = object : JobTracker.JobListener {
override fun onStateChanged(job: Job, jobState: JobTracker.JobState) {
job.parameters.queue?.let { queue ->
if (queue.startsWith(PushProcessMessageJob.QUEUE_PREFIX)) {
seenQueues += queue
}
}
}
}
jobManager.addListener({ job: Job -> job.parameters.queue?.startsWith(PushProcessMessageJob.QUEUE_PREFIX) == true }, queueListener)
try {
BenchmarkWebSocketConnection.addPendingMessages(envelopes.map { it.toWebSocketPayload() })
BenchmarkWebSocketConnection.addQueueEmptyMessage()
BenchmarkWebSocketConnection.releaseMessages()
val consumed = BenchmarkWebSocketConnection.awaitAllMessagesConsumed(drainTimeout.inWholeMilliseconds)
check(consumed) { "Timed out waiting for benchmark websocket to consume ${envelopes.size} envelope(s)" }
// PushProcessMessageJob enqueue happens on a background thread after the websocket marks
// messages consumed; this tick lets that settle before we snapshot the queues to wait on.
Thread.sleep(100)
val queuesToDrain = seenQueues.toSet()
Log.d(TAG, "Awaiting ${queuesToDrain.size} PushProcessMessageJob queue(s): $queuesToDrain")
for (queue in queuesToDrain) {
val state = jobManager.runSynchronously(MarkerJob(queue), drainTimeout.inWholeMilliseconds)
check(state.isPresent) { "Timed out waiting for queue $queue to drain" }
}
} finally {
jobManager.removeListener(queueListener)
}
}
companion object {
private val TAG = Log.tag(IncomingMessageObserverRule::class)
private fun Envelope.toWebSocketPayload(): WebSocketRequestMessage = WebSocketRequestMessage(
verb = "PUT",
path = "/api/v1/message",
id = Random.nextLong(),
headers = listOf("X-Signal-Timestamp: $serverTimestamp"),
body = encodeByteString()
)
}
}
/** Identifies the test group created by [IncomingMessageObserverRule]. Hold a reference to pass into the [DeliveryBuilder.groupText] DSL. */
data class GroupHandle(val groupId: GroupId.V2, val recipientId: RecipientId)
/**
* Receiver of the DSL passed to [IncomingMessageObserverRule.deliver]. Construct content with
* [text] / [groupText] / [deliveryReceipts] / [readReceipts] / [malformedEnvelope] and chain
* with the [from] infix to attach a sending peer. Each `from` adds the resulting envelope to
* the batch that will be delivered when the lambda returns.
*/
class DeliveryBuilder internal constructor() {
internal val specs = mutableListOf<EnvelopeSpec>()
fun text(body: String, timestamp: Long = System.currentTimeMillis()): EnvelopeContentSpec = EnvelopeContentSpec.Text(body, timestamp, group = null)
fun groupText(body: String, group: GroupHandle, timestamp: Long = System.currentTimeMillis()): EnvelopeContentSpec = EnvelopeContentSpec.Text(body, timestamp, group)
fun deliveryReceipts(targets: List<Long>, sentAt: Long = System.currentTimeMillis()): EnvelopeContentSpec = EnvelopeContentSpec.DeliveryReceipt(targets, sentAt)
fun readReceipts(targets: List<Long>, sentAt: Long = System.currentTimeMillis()): EnvelopeContentSpec = EnvelopeContentSpec.ReadReceipt(targets, sentAt)
fun malformedEnvelope(timestamp: Long = System.currentTimeMillis()): EnvelopeContentSpec = EnvelopeContentSpec.Malformed(timestamp)
infix fun EnvelopeContentSpec.from(peer: OtherClient) {
specs += EnvelopeSpec(this, peer)
}
}
/** Opaque envelope content returned by [DeliveryBuilder]. Tests never construct or inspect variants directly; the type only appears as a return / receiver of the DSL methods. */
sealed class EnvelopeContentSpec {
internal data class Text(val body: String, val timestamp: Long, val group: GroupHandle?) : EnvelopeContentSpec()
internal data class DeliveryReceipt(val targets: List<Long>, val sentAt: Long) : EnvelopeContentSpec()
internal data class ReadReceipt(val targets: List<Long>, val sentAt: Long) : EnvelopeContentSpec()
internal data class Malformed(val timestamp: Long) : EnvelopeContentSpec()
}
internal data class EnvelopeSpec(val content: EnvelopeContentSpec, val peer: OtherClient) {
fun materialize(): Envelope = when (val c = content) {
is EnvelopeContentSpec.Text ->
peer.encrypt(Generator.encryptedTextMessage(c.timestamp, c.body, c.group?.let { Harness.groupMasterKey }))
is EnvelopeContentSpec.DeliveryReceipt ->
peer.encrypt(Generator.encryptedDeliveryReceipt(c.sentAt, c.targets), c.sentAt)
is EnvelopeContentSpec.ReadReceipt ->
peer.encrypt(Generator.encryptedReadReceipt(c.sentAt, c.targets), c.sentAt)
is EnvelopeContentSpec.Malformed -> {
val valid = peer.encrypt(Generator.encryptedTextMessage(c.timestamp))
val original = valid.content ?: error("Encrypted envelope unexpectedly had no content")
val corrupted = original.toByteArray().also { it[it.size / 2] = (it[it.size / 2].toInt() xor 0x01).toByte() }
valid.copy(content = corrupted.toByteString())
}
}
}
@@ -1,18 +0,0 @@
package org.thoughtcrime.securesms.testing.incomingmessageobserver
import android.app.Application
import android.content.Context
import androidx.test.runner.AndroidJUnitRunner
import org.thoughtcrime.securesms.IncomingMessageObserverInstrumentationApplicationContext
/**
* Test runner that swaps in [IncomingMessageObserverInstrumentationApplicationContext] so the
* `IncomingMessageObserver` test harness can drive a faked websocket. Selected automatically by
* the build when `-PimoTests` is set.
*/
@Suppress("unused")
class IncomingMessageObserverTestRunner : AndroidJUnitRunner() {
override fun newApplication(cl: ClassLoader?, className: String?, context: Context?): Application {
return super.newApplication(cl, IncomingMessageObserverInstrumentationApplicationContext::class.java.name, context)
}
}
@@ -6,14 +6,56 @@
package org.thoughtcrime.securesms
import android.app.Application
import org.signal.benchmark.setup.NoOpJob
import org.signal.core.util.UptimeSleepTimer
import org.signal.libsignal.net.Network
import org.thoughtcrime.securesms.database.JobDatabase
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.dependencies.ApplicationDependencyProvider
import org.thoughtcrime.securesms.jobmanager.Job
import org.thoughtcrime.securesms.jobmanager.JobManager
import org.thoughtcrime.securesms.jobmanager.JobMigrator
import org.thoughtcrime.securesms.jobmanager.impl.FactoryJobPredicate
import org.thoughtcrime.securesms.jobs.AccountConsistencyWorkerJob
import org.thoughtcrime.securesms.jobs.ArchiveBackupIdReservationJob
import org.thoughtcrime.securesms.jobs.AttachmentCompressionJob
import org.thoughtcrime.securesms.jobs.AttachmentUploadJob
import org.thoughtcrime.securesms.jobs.AvatarGroupsV2DownloadJob
import org.thoughtcrime.securesms.jobs.CreateReleaseChannelJob
import org.thoughtcrime.securesms.jobs.DirectoryRefreshJob
import org.thoughtcrime.securesms.jobs.DownloadLatestEmojiDataJob
import org.thoughtcrime.securesms.jobs.EmojiSearchIndexDownloadJob
import org.thoughtcrime.securesms.jobs.FastJobStorage
import org.thoughtcrime.securesms.jobs.FontDownloaderJob
import org.thoughtcrime.securesms.jobs.GroupCallUpdateSendJob
import org.thoughtcrime.securesms.jobs.GroupRingCleanupJob
import org.thoughtcrime.securesms.jobs.GroupV2UpdateSelfProfileKeyJob
import org.thoughtcrime.securesms.jobs.IndividualSendJob
import org.thoughtcrime.securesms.jobs.JobManagerFactories
import org.thoughtcrime.securesms.jobs.LinkedDeviceInactiveCheckJob
import org.thoughtcrime.securesms.jobs.MarkerJob
import org.thoughtcrime.securesms.jobs.MultiDeviceProfileKeyUpdateJob
import org.thoughtcrime.securesms.jobs.PostRegistrationBackupRedemptionJob
import org.thoughtcrime.securesms.jobs.PreKeysSyncJob
import org.thoughtcrime.securesms.jobs.ProfileUploadJob
import org.thoughtcrime.securesms.jobs.PushGroupSendJob
import org.thoughtcrime.securesms.jobs.PushProcessMessageJob
import org.thoughtcrime.securesms.jobs.ReactionSendJob
import org.thoughtcrime.securesms.jobs.RefreshAttributesJob
import org.thoughtcrime.securesms.jobs.RefreshSvrCredentialsJob
import org.thoughtcrime.securesms.jobs.RequestGroupV2InfoJob
import org.thoughtcrime.securesms.jobs.ResetSvrGuessCountJob
import org.thoughtcrime.securesms.jobs.RestoreOptimizedMediaJob
import org.thoughtcrime.securesms.jobs.RetrieveProfileAvatarJob
import org.thoughtcrime.securesms.jobs.RetrieveProfileJob
import org.thoughtcrime.securesms.jobs.RetrieveRemoteAnnouncementsJob
import org.thoughtcrime.securesms.jobs.RotateCertificateJob
import org.thoughtcrime.securesms.jobs.SendDeliveryReceiptJob
import org.thoughtcrime.securesms.jobs.StickerPackDownloadJob
import org.thoughtcrime.securesms.jobs.StorageSyncJob
import org.thoughtcrime.securesms.jobs.StoryOnboardingDownloadJob
import org.thoughtcrime.securesms.jobs.TypingSendJob
import org.thoughtcrime.securesms.net.DeviceTransferBlockingInterceptor
import org.thoughtcrime.securesms.util.TextSecurePreferences
import org.whispersystems.signalservice.api.util.UptimeSleepTimer
import org.whispersystems.signalservice.api.websocket.SignalWebSocket
import org.whispersystems.signalservice.internal.configuration.SignalServiceConfiguration
import org.whispersystems.signalservice.internal.websocket.BenchmarkWebSocketConnection
@@ -55,11 +97,85 @@ class BenchmarkApplicationContext : ApplicationContext() {
)
}
override fun provideJobManager(configurationBuilder: JobManager.Configuration.Builder): JobManager {
val config = configurationBuilder
.setJobFactories(NoOpJob.replaceFactories(JobManagerFactories.getJobFactories(application)))
override fun provideJobManager(): JobManager {
val config = JobManager.Configuration.Builder()
.setJobFactories(filterJobFactories(JobManagerFactories.getJobFactories(application)))
.setConstraintFactories(JobManagerFactories.getConstraintFactories(application))
.setConstraintObservers(JobManagerFactories.getConstraintObservers(application))
.setJobStorage(FastJobStorage(JobDatabase.getInstance(application)))
.setJobMigrator(JobMigrator(TextSecurePreferences.getJobManagerVersion(application), JobManager.CURRENT_VERSION, JobManagerFactories.getJobMigrations(application)))
.addReservedJobRunner(FactoryJobPredicate(PushProcessMessageJob.KEY, MarkerJob.KEY))
.addReservedJobRunner(FactoryJobPredicate(AttachmentUploadJob.KEY, AttachmentCompressionJob.KEY))
.addReservedJobRunner(
FactoryJobPredicate(
IndividualSendJob.KEY,
PushGroupSendJob.KEY,
ReactionSendJob.KEY,
TypingSendJob.KEY,
GroupCallUpdateSendJob.KEY,
SendDeliveryReceiptJob.KEY
)
)
.build()
return JobManager(application, config)
}
private fun filterJobFactories(jobFactories: Map<String, Job.Factory<*>>): Map<String, Job.Factory<*>> {
val blockedJobs = setOf(
AccountConsistencyWorkerJob.KEY,
ArchiveBackupIdReservationJob.KEY,
AvatarGroupsV2DownloadJob.KEY,
CreateReleaseChannelJob.KEY,
DirectoryRefreshJob.KEY,
DownloadLatestEmojiDataJob.KEY,
EmojiSearchIndexDownloadJob.KEY,
FontDownloaderJob.KEY,
GroupRingCleanupJob.KEY,
GroupV2UpdateSelfProfileKeyJob.KEY,
LinkedDeviceInactiveCheckJob.KEY,
MultiDeviceProfileKeyUpdateJob.KEY,
PostRegistrationBackupRedemptionJob.KEY,
PreKeysSyncJob.KEY,
ProfileUploadJob.KEY,
RefreshAttributesJob.KEY,
RefreshSvrCredentialsJob.KEY,
RequestGroupV2InfoJob.KEY,
ResetSvrGuessCountJob.KEY,
RestoreOptimizedMediaJob.KEY,
RetrieveProfileAvatarJob.KEY,
RetrieveProfileJob.KEY,
RetrieveRemoteAnnouncementsJob.KEY,
RotateCertificateJob.KEY,
StickerPackDownloadJob.KEY,
StorageSyncJob.KEY,
StoryOnboardingDownloadJob.KEY
)
return jobFactories.mapValues {
if (it.key in blockedJobs) {
NoOpJob.Factory()
} else {
it.value
}
}
}
}
private class NoOpJob(parameters: Parameters) : Job(parameters) {
companion object {
const val KEY = "NoOpJob"
}
override fun serialize(): ByteArray? = null
override fun getFactoryKey(): String = KEY
override fun run(): Result = Result.success()
override fun onFailure() = Unit
class Factory : Job.Factory<NoOpJob> {
override fun create(parameters: Parameters, serializedData: ByteArray?): NoOpJob {
return NoOpJob(parameters)
}
}
}
}
@@ -23,7 +23,7 @@ import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.recipients.Recipient
import org.whispersystems.signalservice.internal.push.Envelope
import org.whispersystems.signalservice.internal.websocket.BenchmarkWebSocketConnection
import org.signal.network.websocket.WebSocketRequestMessage
import org.whispersystems.signalservice.internal.websocket.WebSocketRequestMessage
import kotlin.random.Random
/**
@@ -32,14 +32,6 @@ class BenchmarkSetupActivity : BaseActivity() {
companion object {
private val TAG = Log.tag(BenchmarkSetupActivity::class)
const val SEARCH_KEYWORD = "lighthouse"
private val SEARCH_VOCABULARY = listOf(
"hello", "world", "signal", "android", "kotlin", "database", "benchmark", "conversation",
"morning", "evening", "weekend", "project", "meeting", "dinner", "coffee", "garden",
"mountain", "river", "forest", "harbor", "market", "library", "concert", "holiday"
)
}
override fun onCreate(savedInstanceState: Bundle?) {
@@ -59,7 +51,6 @@ class BenchmarkSetupActivity : BaseActivity() {
when (intent.extras!!.getString("setup-type")) {
"cold-start" -> setupColdStart()
"conversation-open" -> setupConversationOpen()
"conversation-list-search" -> setupConversationListSearch()
"message-send" -> setupMessageSend()
"group-message-send" -> setupGroupMessageSend()
"group-delivery-receipt" -> setupGroupReceipt(includeMsl = true)
@@ -106,39 +97,6 @@ class BenchmarkSetupActivity : BaseActivity() {
}
}
private fun setupConversationListSearch() {
TestUsers.setupSelf()
val recipientCount = 50
val messagesPerRecipient = 2000
val totalMessages = recipientCount * messagesPerRecipient
val generator = TestMessages.TimestampGenerator(System.currentTimeMillis() - (totalMessages * 2000L) - 60_000L)
TestUsers.setupTestRecipients(recipientCount).forEachIndexed { recipientIndex, recipientId ->
val recipient: Recipient = Recipient.resolved(recipientId)
for (i in 0 until messagesPerRecipient) {
val body = searchableMessageBody(recipientIndex, i)
if (i % 2 == 0) {
TestMessages.insertIncomingTextMessage(other = recipient, body = body, timestamp = generator.nextTimestamp())
} else {
TestMessages.insertOutgoingTextMessage(other = recipient, body = body, timestamp = generator.nextTimestamp())
}
}
SignalDatabase.messages.setAllMessagesRead()
SignalDatabase.threads.update(SignalDatabase.threads.getOrCreateThreadIdFor(recipient = recipient), true)
}
}
private fun searchableMessageBody(recipientIndex: Int, messageIndex: Int): String {
val words = SEARCH_VOCABULARY
val w1 = words[(recipientIndex + messageIndex) % words.size]
val w2 = words[(recipientIndex * 7 + messageIndex * 3) % words.size]
val w3 = words[(recipientIndex * 13 + messageIndex * 5) % words.size]
return "$w1 $w2 $SEARCH_KEYWORD $w3 message $messageIndex"
}
private fun setupMessageSend() {
TestUsers.setupSelf()
TestUsers.setupTestClients(1)
@@ -1,84 +0,0 @@
package org.signal.benchmark.setup
import org.thoughtcrime.securesms.jobmanager.Job
import org.thoughtcrime.securesms.jobs.AccountConsistencyWorkerJob
import org.thoughtcrime.securesms.jobs.ArchiveBackupIdReservationJob
import org.thoughtcrime.securesms.jobs.AvatarGroupsV2DownloadJob
import org.thoughtcrime.securesms.jobs.CreateReleaseChannelJob
import org.thoughtcrime.securesms.jobs.DirectoryRefreshJob
import org.thoughtcrime.securesms.jobs.DownloadLatestEmojiDataJob
import org.thoughtcrime.securesms.jobs.EmojiSearchIndexDownloadJob
import org.thoughtcrime.securesms.jobs.FontDownloaderJob
import org.thoughtcrime.securesms.jobs.GroupRingCleanupJob
import org.thoughtcrime.securesms.jobs.GroupV2UpdateSelfProfileKeyJob
import org.thoughtcrime.securesms.jobs.LinkedDeviceInactiveCheckJob
import org.thoughtcrime.securesms.jobs.MultiDeviceProfileKeyUpdateJob
import org.thoughtcrime.securesms.jobs.PostRegistrationBackupRedemptionJob
import org.thoughtcrime.securesms.jobs.PreKeysSyncJob
import org.thoughtcrime.securesms.jobs.ProfileUploadJob
import org.thoughtcrime.securesms.jobs.RefreshAttributesJob
import org.thoughtcrime.securesms.jobs.RefreshSvrCredentialsJob
import org.thoughtcrime.securesms.jobs.RequestGroupV2InfoJob
import org.thoughtcrime.securesms.jobs.ResetSvrGuessCountJob
import org.thoughtcrime.securesms.jobs.RestoreOptimizedMediaJob
import org.thoughtcrime.securesms.jobs.RetrieveProfileAvatarJob
import org.thoughtcrime.securesms.jobs.RetrieveProfileJob
import org.thoughtcrime.securesms.jobs.RetrieveRemoteAnnouncementsJob
import org.thoughtcrime.securesms.jobs.RotateCertificateJob
import org.thoughtcrime.securesms.jobs.StickerPackDownloadJob
import org.thoughtcrime.securesms.jobs.StorageSyncJob
import org.thoughtcrime.securesms.jobs.StoryOnboardingDownloadJob
/**
* A [Job] that does nothing and always succeeds. Test setups substitute this for jobs whose
* real implementations would hit the network at startup (and so would either generate noise
* against the [DeviceTransferBlockingInterceptor][org.thoughtcrime.securesms.net.DeviceTransferBlockingInterceptor]
* or fail against unstubbed mocks). Use [replaceFactories] to apply the swap.
*/
class NoOpJob(parameters: Parameters) : Job(parameters) {
override fun serialize(): ByteArray? = null
override fun getFactoryKey(): String = KEY
override fun run(): Result = Result.success()
override fun onFailure() = Unit
class Factory : Job.Factory<NoOpJob> {
override fun create(parameters: Parameters, serializedData: ByteArray?): NoOpJob = NoOpJob(parameters)
}
companion object {
const val KEY = "NoOpJob"
private val STARTUP_NETWORK_JOB_KEYS: Set<String> = setOf(
AccountConsistencyWorkerJob.KEY,
ArchiveBackupIdReservationJob.KEY,
AvatarGroupsV2DownloadJob.KEY,
CreateReleaseChannelJob.KEY,
DirectoryRefreshJob.KEY,
DownloadLatestEmojiDataJob.KEY,
EmojiSearchIndexDownloadJob.KEY,
FontDownloaderJob.KEY,
GroupRingCleanupJob.KEY,
GroupV2UpdateSelfProfileKeyJob.KEY,
LinkedDeviceInactiveCheckJob.KEY,
MultiDeviceProfileKeyUpdateJob.KEY,
PostRegistrationBackupRedemptionJob.KEY,
PreKeysSyncJob.KEY,
ProfileUploadJob.KEY,
RefreshAttributesJob.KEY,
RefreshSvrCredentialsJob.KEY,
RequestGroupV2InfoJob.KEY,
ResetSvrGuessCountJob.KEY,
RestoreOptimizedMediaJob.KEY,
RetrieveProfileAvatarJob.KEY,
RetrieveProfileJob.KEY,
RetrieveRemoteAnnouncementsJob.KEY,
RotateCertificateJob.KEY,
StickerPackDownloadJob.KEY,
StorageSyncJob.KEY,
StoryOnboardingDownloadJob.KEY
)
fun replaceFactories(factories: Map<String, Job.Factory<*>>): Map<String, Job.Factory<*>> =
factories.mapValues { if (it.key in STARTUP_NETWORK_JOB_KEYS) Factory() else it.value }
}
}
@@ -241,6 +241,5 @@ class OtherClient(val serviceId: ServiceId, val e164: String, val identityKeyPai
override fun deleteAllStaleOneTimeKyberPreKeys(threshold: Long, minCount: Int) = throw UnsupportedOperationException()
override fun loadLastResortKyberPreKeys(): List<KyberPreKeyRecord> = throw UnsupportedOperationException()
override fun isMultiDevice(): Boolean = throw UnsupportedOperationException()
override fun setMultiDevice(isMultiDevice: Boolean) = throw UnsupportedOperationException()
}
}
@@ -146,7 +146,7 @@ object TestMessages {
private fun imageAttachment(): SignalServiceAttachmentPointer {
return SignalServiceAttachmentPointer(
Cdn.S3.cdnNumber,
SignalServiceAttachmentRemoteId.from("", Cdn.S3.cdnNumber),
SignalServiceAttachmentRemoteId.from(""),
"image/webp",
null,
Optional.empty(),
@@ -170,7 +170,7 @@ object TestMessages {
private fun voiceAttachment(): SignalServiceAttachmentPointer {
return SignalServiceAttachmentPointer(
Cdn.S3.cdnNumber,
SignalServiceAttachmentRemoteId.from("", Cdn.S3.cdnNumber),
SignalServiceAttachmentRemoteId.from(""),
"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, true))
SignalDatabase.recipients.setCapabilities(recipientId, SignalServiceProfile.Capabilities(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, true))
SignalDatabase.recipients.setCapabilities(recipientId, SignalServiceProfile.Capabilities(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)
@@ -8,10 +8,7 @@ package org.whispersystems.signalservice.internal.websocket
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.subjects.BehaviorSubject
import org.signal.network.websocket.WebSocketRequestMessage
import org.signal.network.websocket.WebSocketResponseMessage
import org.signal.network.websocket.WebsocketResponse
import org.signal.core.util.JsonUtils
import org.thoughtcrime.securesms.util.JsonUtils
import org.thoughtcrime.securesms.util.SignalTrace
import org.whispersystems.signalservice.api.websocket.WebSocketConnectionState
import org.whispersystems.signalservice.internal.push.SendMessageResponse
@@ -68,18 +65,6 @@ class BenchmarkWebSocketConnection : WebSocketConnection {
fun addQueueEmptyMessage() {
authInstances.filterNot(BenchmarkWebSocketConnection::isShutdown).forEach { it.addQueueEmptyMessage() }
}
fun awaitAllMessagesConsumed(timeoutMs: Long): Boolean {
val deadline = System.currentTimeMillis() + timeoutMs
while (System.currentTimeMillis() < deadline) {
val activeInstances = synchronized(this) { authInstances.filterNot(BenchmarkWebSocketConnection::isShutdown).toList() }
if (activeInstances.isNotEmpty() && activeInstances.all { it.incomingRequests.isEmpty() && it.incomingSemaphore.availablePermits() == 0 }) {
return true
}
Thread.sleep(25)
}
return false
}
}
override val name: String = "bench-${System.identityHashCode(this)}"
@@ -0,0 +1,13 @@
<?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>
@@ -0,0 +1,5 @@
<?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>
@@ -0,0 +1,4 @@
<?xml version='1.0' encoding='UTF-8'?>
<resources>
<string name="app_name">Signal (Instrumentation)</string>
</resources>
+10 -11
View File
@@ -2,6 +2,8 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-sdk tools:overrideLibrary="androidx.camera.core,androidx.camera.camera2,androidx.camera.lifecycle,androidx.camera.view" />
<!-- ======================================= -->
<!-- Features -->
<!-- ======================================= -->
@@ -38,7 +40,6 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_PHONE_CALL" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_REMOTE_MESSAGING" />
<uses-permission android:name="android.permission.GET_ACCOUNTS" />
@@ -438,7 +439,12 @@
android:theme="@style/Theme.Signal.DayNight.NoActionBar"
android:windowSoftInputMode="adjustResize" />
<activity
android:name=".calls.links.details.CallLinkDetailsActivity"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
android:exported="false"
android:theme="@style/Theme.Signal.DayNight.NoActionBar"
android:windowSoftInputMode="stateAlwaysHidden" />
<activity
android:name=".calls.new.NewCallActivity"
@@ -522,13 +528,6 @@
</intent-filter>
</activity>
<activity
android:name=".preferences.EditProxyActivity"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
android:exported="false"
android:theme="@style/Signal.DayNight.NoActionBar"
android:windowSoftInputMode="adjustResize" />
<activity
android:name=".stories.my.MyStoriesActivity"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
@@ -1373,7 +1372,7 @@
<service
android:name=".gcm.FcmReceiveService"
android:exported="false">
android:exported="true">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
@@ -1406,7 +1405,7 @@
<service
android:name="org.thoughtcrime.securesms.service.webrtc.ActiveCallManager$ActiveCallForegroundService"
android:exported="false"
android:foregroundServiceType="dataSync|microphone|camera|phoneCall|mediaProjection" />
android:foregroundServiceType="dataSync|microphone|camera|phoneCall" />
<service
android:name="com.google.android.datatransport.runtime.scheduling.jobscheduling.JobInfoSchedulerService"
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -24,11 +24,6 @@ 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
}
@@ -39,23 +34,9 @@ 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.
@@ -83,26 +64,10 @@ class ConversationLayoutManager(context: Context) : LinearLayoutManager(context,
} else {
scrollToPosition(pendingScrollPosition)
}
return
} else {
afterScroll?.invoke()
afterScroll = null
}
// 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,8 +13,7 @@ object AppCapabilities {
storage = storageCapable,
versionedExpirationTimer = true,
attachmentBackfill = true,
spqr = true,
usernameChangeSyncMessage = true
spqr = true
)
}
}
@@ -18,7 +18,6 @@ package org.thoughtcrime.securesms;
import android.app.Application;
import android.content.Context;
import android.content.Intent;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
@@ -33,13 +32,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.crypto.AttachmentSecretProvider;
import org.signal.core.util.logging.AndroidLogger;
import org.signal.core.util.logging.Log;
import org.signal.core.util.logging.Scrubber;
@@ -47,14 +43,11 @@ import org.signal.core.util.tracing.Tracer;
import org.signal.glide.SignalGlideCodecs;
import org.signal.libsignal.net.ChatServiceException;
import org.signal.libsignal.protocol.logging.SignalProtocolLoggerProvider;
import org.signal.registration.RegistrationDependencies;
import org.signal.ringrtc.CallManager;
import org.thoughtcrime.securesms.apkupdate.ApkUpdateRefreshListener;
import org.thoughtcrime.securesms.avatar.AvatarPickerStorage;
import org.thoughtcrime.securesms.backup.v2.BackupRepository;
import org.thoughtcrime.securesms.preferences.EditProxyActivity;
import org.thoughtcrime.securesms.conversation.drafts.DraftBlobs;
import org.thoughtcrime.securesms.crypto.AppAttachmentSecretStore;
import org.thoughtcrime.securesms.crypto.AttachmentSecretProvider;
import org.thoughtcrime.securesms.crypto.DatabaseSecretProvider;
import org.thoughtcrime.securesms.database.LogDatabase;
import org.thoughtcrime.securesms.database.SignalDatabase;
@@ -65,7 +58,6 @@ 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;
@@ -82,30 +74,26 @@ 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;
import org.thoughtcrime.securesms.jobs.RefreshAttributesJob;
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;
import org.thoughtcrime.securesms.ratelimit.RateLimitUtil;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.registration.util.RegistrationUtil;
import org.thoughtcrime.securesms.registration.v2.AppRegistrationNetworkController;
import org.thoughtcrime.securesms.registration.v2.AppRegistrationStorageController;
import org.thoughtcrime.securesms.ringrtc.RingRtcLogger;
import org.thoughtcrime.securesms.service.AnalyzeDatabaseAlarmListener;
import org.thoughtcrime.securesms.service.DirectoryRefreshListener;
@@ -115,21 +103,20 @@ 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.BatterySnapshotTracker;
import org.thoughtcrime.securesms.util.CommunicationActions;
import org.thoughtcrime.securesms.util.DeviceProperties;
import org.thoughtcrime.securesms.util.DynamicTheme;
import org.thoughtcrime.securesms.util.Environment;
import org.signal.core.util.PlayServicesUtil;
import org.thoughtcrime.securesms.util.RemoteConfig;
import org.thoughtcrime.securesms.util.SignalLocalMetrics;
import org.thoughtcrime.securesms.util.SignalUncaughtExceptionHandler;
import org.thoughtcrime.securesms.util.SqlCipherLogTarget;
import org.thoughtcrime.securesms.util.SupportEmailUtil;
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;
@@ -178,7 +165,7 @@ public class ApplicationContext extends Application implements AppForegroundObse
SqlCipherLibraryLoader.load();
SignalDatabase.init(this,
DatabaseSecretProvider.getOrCreateDatabaseSecret(this),
AttachmentSecretProvider.getInstance(this, AppAttachmentSecretStore.INSTANCE).getOrCreateAttachmentSecret());
AttachmentSecretProvider.getInstance(this).getOrCreateAttachmentSecret());
Logger.setTarget(SqlCipherLogTarget.INSTANCE);
})
.addBlocking("signal-store", () -> SignalStore.init(this))
@@ -186,9 +173,9 @@ public class ApplicationContext extends Application implements AppForegroundObse
initializeLogging();
Log.i(TAG, "onCreate()");
})
.addBlocking("security-provider", this::initializeSecurityProvider)
.addBlocking("app-dependencies", this::initializeAppDependencies)
.addBlocking("anr-detector", this::startAnrDetector)
.addBlocking("security-provider", this::initializeSecurityProvider)
.addBlocking("crash-handling", this::initializeCrashHandling)
.addBlocking("rx-init", this::initializeRx)
.addBlocking("event-bus", () -> EventBus.builder().logNoSubscriberMessages(false).installDefaultEventBus())
@@ -237,7 +224,7 @@ public class ApplicationContext extends Application implements AppForegroundObse
.addPostRender(RefreshSvrCredentialsJob::enqueueIfNecessary)
.addPostRender(() -> DownloadLatestEmojiDataJob.scheduleIfNecessary(this))
.addPostRender(EmojiSearchIndexDownloadJob::scheduleIfNecessary)
.addPostRender(MessageSendLogCleanupJob::enqueue)
.addPostRender(() -> SignalDatabase.messageLog().trimOldMessages(System.currentTimeMillis(), RemoteConfig.retryRespondMaxAge()))
.addPostRender(() -> JumboEmoji.updateCurrentVersion(this))
.addPostRender(RetrieveRemoteAnnouncementsJob::enqueue)
.addPostRender(AndroidTelecomUtil::registerPhoneAccount)
@@ -266,8 +253,6 @@ public class ApplicationContext extends Application implements AppForegroundObse
long startTime = System.currentTimeMillis();
Log.i(TAG, "App is now visible. Battery: " + DeviceProperties.getBatteryLevel(this) + "% (charging: " + DeviceProperties.isCharging(this) + ")");
BatterySnapshotTracker.emit(this, "foreground");
AppDependencies.getFrameRateTracker().start();
AppDependencies.getMegaphoneRepository().onAppForegrounded();
AppDependencies.getDeadlockDetector().start();
@@ -287,7 +272,7 @@ public class ApplicationContext extends Application implements AppForegroundObse
checkFreeDiskSpace();
MemoryTracker.start();
BackupSubscriptionCheckJob.enqueueIfAble();
CheckKeyTransparencyJob.enqueueIfNecessary(true, false);
CheckKeyTransparencyJob.enqueueIfNecessary(true);
AppDependencies.getAuthWebSocket().registerKeepAliveToken(SignalWebSocket.FOREGROUND_KEEPALIVE);
AppDependencies.getUnauthWebSocket().registerKeepAliveToken(SignalWebSocket.FOREGROUND_KEEPALIVE);
@@ -308,7 +293,6 @@ public class ApplicationContext extends Application implements AppForegroundObse
@Override
public void onBackground() {
Log.i(TAG, "App is no longer visible.");
BatterySnapshotTracker.emit(this, "background");
KeyCachingService.onAppBackgrounded(this);
AppDependencies.getMessageNotifier().clearVisibleThread();
AppDependencies.getFrameRateTracker().stop();
@@ -427,25 +411,11 @@ public class ApplicationContext extends Application implements AppForegroundObse
}
private void initializeRegistrationDependencies() {
RegistrationDependencies.provide(
new RegistrationDependencies(
new AppRegistrationNetworkController(this, AppDependencies.getPushServiceSocket()),
new AppRegistrationStorageController(this),
Environment.IS_LINK_AND_SYNC_AVAILABLE,
null,
context -> {
context.startActivity(new Intent(context, SubmitDebugLogActivity.class));
return Unit.INSTANCE;
},
context -> {
context.startActivity(EditProxyActivity.intent(context));
return Unit.INSTANCE;
},
(context, subject) -> {
String body = SupportEmailUtil.generateSupportEmailBody(context, subject, null, null);
CommunicationActions.openEmail(context, SupportEmailUtil.getSupportEmailAddress(context), subject, body);
return Unit.INSTANCE;
}
org.signal.registration.RegistrationDependencies.Companion.provide(
new org.signal.registration.RegistrationDependencies(
new org.thoughtcrime.securesms.registration.v2.AppRegistrationNetworkController(this, AppDependencies.getPushServiceSocket()),
new org.thoughtcrime.securesms.registration.v2.AppRegistrationStorageController(this),
null
)
);
}
@@ -469,38 +439,14 @@ public class ApplicationContext extends Application implements AppForegroundObse
}
private void initializeFcmCheck() {
if (!SignalStore.account().isRegistered()) {
return;
}
PlayServicesUtil.PlayServicesStatus playServicesStatus = PlayServicesUtil.getPlayServicesStatus(this);
if (playServicesStatus == PlayServicesUtil.PlayServicesStatus.SUCCESS && !SignalStore.account().isFcmEnabled()) {
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. 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()) {
if (SignalStore.account().isRegistered()) {
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 {
Log.d(TAG, "Play Services status: " + playServicesStatus + ", fcmEnabled: false. Skipping FCM check.");
}
}
@@ -590,7 +536,7 @@ public class ApplicationContext extends Application implements AppForegroundObse
@WorkerThread
private void initializeBlobProvider() {
AppDependencies.getBlobs().initialize(this, DraftBlobs.INSTANCE::deleteOrphanedDraftFiles);
BlobProvider.getInstance().initialize(this);
}
@WorkerThread
@@ -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.signal.core.util.ConfigurationUtil;
import org.thoughtcrime.securesms.util.ConfigurationUtil;
import org.thoughtcrime.securesms.util.WindowUtil;
import org.thoughtcrime.securesms.util.dynamiclanguage.DynamicLanguageContextWrapper;
@@ -1,14 +1,12 @@
package org.thoughtcrime.securesms;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.lifecycle.LifecycleOwner;
import com.bumptech.glide.RequestManager;
import org.thoughtcrime.securesms.conversationlist.model.ConversationSet;
import org.thoughtcrime.securesms.database.model.ThreadWithRecipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.database.model.ThreadRecord;
import java.util.Locale;
import java.util.Set;
@@ -16,14 +14,14 @@ import java.util.Set;
public interface BindableConversationListItem extends Unbindable {
void bind(@NonNull LifecycleOwner lifecycleOwner,
@NonNull ThreadWithRecipient thread,
@NonNull ThreadRecord thread,
@NonNull RequestManager requestManager, @NonNull Locale locale,
@NonNull Set<Long> typingThreads,
@NonNull ConversationSet selectedConversations,
@Nullable RecipientId activeRecipientId);
long activeThreadId);
void setSelectedConversations(@NonNull ConversationSet conversations);
void setActiveRecipientId(@Nullable RecipientId activeRecipientId);
void setActiveThreadId(long activeThreadId);
void updateTypingIndicator(@NonNull Set<Long> typingThreads);
void updateTimestamp();
}
@@ -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,25 +23,45 @@ 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)
{
buildReportSpamFor(context, recipient, onReportSpam, onBlockAndReportSpam).show();
SimpleTask.run(lifecycle,
() -> buildReportSpamFor(context, recipient, onReportSpam, onBlockAndReportSpam),
AlertDialog.Builder::show);
}
public static void showBlockFor(@NonNull Context context,
@NonNull Lifecycle lifecycle,
@NonNull Recipient recipient,
@NonNull Runnable onBlock)
{
buildBlockFor(context, recipient, onBlock, null).show();
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);
}
public static void showUnblockFor(@NonNull Context context,
@NonNull Lifecycle lifecycle,
@NonNull Recipient recipient,
@NonNull Runnable onUnblock)
{
buildUnblockFor(context, recipient, onUnblock).show();
SimpleTask.run(lifecycle,
() -> buildUnblockFor(context, recipient, onUnblock),
AlertDialog.Builder::show);
}
@WorkerThread
@@ -34,6 +34,7 @@ import org.thoughtcrime.securesms.contacts.sync.ContactDiscovery;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme;
import org.thoughtcrime.securesms.util.DynamicTheme;
import org.thoughtcrime.securesms.util.ServiceUtil;
import java.io.IOException;
import java.lang.ref.WeakReference;
@@ -48,7 +49,8 @@ import java.util.function.Consumer;
*/
public abstract class ContactSelectionActivity extends PassphraseRequiredActivity
implements SwipeRefreshLayout.OnRefreshListener,
ContactSelectionListFragment.OnContactSelectedListener
ContactSelectionListFragment.OnContactSelectedListener,
ContactSelectionListFragment.ScrollCallback
{
private static final String TAG = Log.tag(ContactSelectionActivity.class);
@@ -134,6 +136,17 @@ public abstract class ContactSelectionActivity extends PassphraseRequiredActivit
@Override
public void onContactDeselected(@NonNull Optional<RecipientId> recipientId, String number, @NonNull Optional<ChatType> chatType) {}
@Override
public void onBeginScroll() {
hideKeyboard();
}
private void hideKeyboard() {
ServiceUtil.getInputMethodManager(this)
.hideSoftInputFromWindow(toolbar.getWindowToken(), 0);
toolbar.clearFocus();
}
private static class RefreshDirectoryTask extends AsyncTask<Context, Void, Void> {
private final WeakReference<ContactSelectionActivity> activity;
@@ -1,19 +1,16 @@
package org.thoughtcrime.securesms
import android.content.Context
import org.thoughtcrime.securesms.ContactSelectionListModels.FindByPhoneNumberModel
import org.thoughtcrime.securesms.ContactSelectionListModels.FindByUsernameModel
import org.thoughtcrime.securesms.ContactSelectionListModels.FindContactsBannerModel
import org.thoughtcrime.securesms.ContactSelectionListModels.FindContactsModel
import org.thoughtcrime.securesms.ContactSelectionListModels.InviteToSignalModel
import org.thoughtcrime.securesms.ContactSelectionListModels.MoreHeaderModel
import org.thoughtcrime.securesms.ContactSelectionListModels.NewGroupModel
import org.thoughtcrime.securesms.ContactSelectionListModels.RefreshContactsModel
import android.view.View
import android.widget.TextView
import com.google.android.material.button.MaterialButton
import org.thoughtcrime.securesms.contacts.paged.ContactSearchAdapter
import org.thoughtcrime.securesms.contacts.paged.ContactSearchConfiguration
import org.thoughtcrime.securesms.contacts.paged.ContactSearchData
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel
import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder
class ContactSelectionListAdapter(
context: Context,
@@ -26,19 +23,152 @@ class ContactSelectionListAdapter(
) : ContactSearchAdapter(context, fixedContacts, displayOptions, onClickCallbacks, longClickCallbacks, storyContextMenuCallbacks, callButtonClickCallbacks) {
init {
ContactSelectionListModels.registerNewGroup(this, onClickCallbacks::onNewGroupClicked)
ContactSelectionListModels.registerInviteToSignal(this, onClickCallbacks::onInviteToSignalClicked)
ContactSelectionListModels.registerFindContacts(this, onClickCallbacks::onFindContactsClicked)
ContactSelectionListModels.registerFindContactsBanner(this, onClickCallbacks::onDismissFindContactsBannerClicked, onClickCallbacks::onFindContactsClicked)
ContactSelectionListModels.registerRefreshContacts(this, onClickCallbacks::onRefreshContactsClicked)
ContactSelectionListModels.registerMoreHeader(this)
ContactSelectionListModels.registerEmpty(this)
ContactSelectionListModels.registerFindByUsername(this, onClickCallbacks::onFindByUsernameClicked)
ContactSelectionListModels.registerFindByPhoneNumber(this, onClickCallbacks::onFindByPhoneNumberClicked)
registerFactory(NewGroupModel::class.java, LayoutFactory({ NewGroupViewHolder(it, onClickCallbacks::onNewGroupClicked) }, R.layout.contact_selection_new_group_item))
registerFactory(InviteToSignalModel::class.java, LayoutFactory({ InviteToSignalViewHolder(it, onClickCallbacks::onInviteToSignalClicked) }, R.layout.contact_selection_invite_action_item))
registerFactory(FindContactsModel::class.java, LayoutFactory({ FindContactsViewHolder(it, onClickCallbacks::onFindContactsClicked) }, R.layout.contact_selection_find_contacts_item))
registerFactory(FindContactsBannerModel::class.java, LayoutFactory({ FindContactsBannerViewHolder(it, onClickCallbacks::onDismissFindContactsBannerClicked, onClickCallbacks::onFindContactsClicked) }, R.layout.contact_selection_find_contacts_banner_item))
registerFactory(RefreshContactsModel::class.java, LayoutFactory({ RefreshContactsViewHolder(it, onClickCallbacks::onRefreshContactsClicked) }, R.layout.contact_selection_refresh_action_item))
registerFactory(MoreHeaderModel::class.java, LayoutFactory({ MoreHeaderViewHolder(it) }, R.layout.contact_search_section_header))
registerFactory(EmptyModel::class.java, LayoutFactory({ EmptyViewHolder(it) }, R.layout.contact_selection_empty_state))
registerFactory(FindByUsernameModel::class.java, LayoutFactory({ FindByUsernameViewHolder(it, onClickCallbacks::onFindByUsernameClicked) }, R.layout.contact_selection_find_by_username_item))
registerFactory(FindByPhoneNumberModel::class.java, LayoutFactory({ FindByPhoneNumberViewHolder(it, onClickCallbacks::onFindByPhoneNumberClicked) }, R.layout.contact_selection_find_by_phone_number_item))
}
class NewGroupModel : MappingModel<NewGroupModel> {
override fun areItemsTheSame(newItem: NewGroupModel): Boolean = true
override fun areContentsTheSame(newItem: NewGroupModel): Boolean = true
}
class InviteToSignalModel : MappingModel<InviteToSignalModel> {
override fun areItemsTheSame(newItem: InviteToSignalModel): Boolean = true
override fun areContentsTheSame(newItem: InviteToSignalModel): Boolean = true
}
class RefreshContactsModel : MappingModel<RefreshContactsModel> {
override fun areItemsTheSame(newItem: RefreshContactsModel): Boolean = true
override fun areContentsTheSame(newItem: RefreshContactsModel): Boolean = true
}
class FindContactsModel : MappingModel<FindContactsModel> {
override fun areItemsTheSame(newItem: FindContactsModel): Boolean = true
override fun areContentsTheSame(newItem: FindContactsModel): Boolean = true
}
class FindContactsBannerModel : MappingModel<FindContactsBannerModel> {
override fun areItemsTheSame(newItem: FindContactsBannerModel): Boolean = true
override fun areContentsTheSame(newItem: FindContactsBannerModel): Boolean = true
}
class FindByUsernameModel : MappingModel<FindByUsernameModel> {
override fun areItemsTheSame(newItem: FindByUsernameModel): Boolean = true
override fun areContentsTheSame(newItem: FindByUsernameModel): Boolean = true
}
class FindByPhoneNumberModel : MappingModel<FindByPhoneNumberModel> {
override fun areItemsTheSame(newItem: FindByPhoneNumberModel): Boolean = true
override fun areContentsTheSame(newItem: FindByPhoneNumberModel): Boolean = true
}
class MoreHeaderModel : MappingModel<MoreHeaderModel> {
override fun areItemsTheSame(newItem: MoreHeaderModel): Boolean = true
override fun areContentsTheSame(newItem: MoreHeaderModel): Boolean = true
}
private class InviteToSignalViewHolder(itemView: View, onClickListener: () -> Unit) : MappingViewHolder<InviteToSignalModel>(itemView) {
init {
itemView.setOnClickListener { onClickListener() }
}
override fun bind(model: InviteToSignalModel) = Unit
}
private class NewGroupViewHolder(itemView: View, onClickListener: () -> Unit) : MappingViewHolder<NewGroupModel>(itemView) {
init {
itemView.setOnClickListener { onClickListener() }
}
override fun bind(model: NewGroupModel) = Unit
}
private class RefreshContactsViewHolder(itemView: View, onClickListener: () -> Unit) : MappingViewHolder<RefreshContactsModel>(itemView) {
init {
itemView.setOnClickListener { onClickListener() }
}
override fun bind(model: RefreshContactsModel) = Unit
}
private class FindContactsViewHolder(itemView: View, onClickListener: () -> Unit) : MappingViewHolder<FindContactsModel>(itemView) {
init {
itemView.setOnClickListener { onClickListener() }
}
override fun bind(model: FindContactsModel) = Unit
}
private class FindContactsBannerViewHolder(itemView: View, onDismissListener: () -> Unit, onClickListener: () -> Unit) : MappingViewHolder<FindContactsBannerModel>(itemView) {
init {
itemView.findViewById<MaterialButton>(R.id.no_thanks_button).setOnClickListener { onDismissListener() }
itemView.findViewById<MaterialButton>(R.id.allow_contacts_button).setOnClickListener { onClickListener() }
}
override fun bind(model: FindContactsBannerModel) = Unit
}
private class MoreHeaderViewHolder(itemView: View) : MappingViewHolder<MoreHeaderModel>(itemView) {
private val headerTextView: TextView = itemView.findViewById(R.id.section_header)
override fun bind(model: MoreHeaderModel) {
headerTextView.setText(R.string.contact_selection_activity__more)
}
}
private class EmptyViewHolder(itemView: View) : MappingViewHolder<EmptyModel>(itemView) {
private val emptyText: TextView = itemView.findViewById(R.id.search_no_results)
override fun bind(model: EmptyModel) {
emptyText.text = context.getString(R.string.SearchFragment_no_results, model.empty.query ?: "")
}
}
private class FindByPhoneNumberViewHolder(itemView: View, onClickListener: () -> Unit) : MappingViewHolder<FindByPhoneNumberModel>(itemView) {
init {
itemView.setOnClickListener { onClickListener() }
}
override fun bind(model: FindByPhoneNumberModel) = Unit
}
private class FindByUsernameViewHolder(itemView: View, onClickListener: () -> Unit) : MappingViewHolder<FindByUsernameModel>(itemView) {
init {
itemView.setOnClickListener { onClickListener() }
}
override fun bind(model: FindByUsernameModel) = Unit
}
class ArbitraryRepository : org.thoughtcrime.securesms.contacts.paged.ArbitraryRepository {
enum class ArbitraryRow(val code: String) {
NEW_GROUP("new-group"),
INVITE_TO_SIGNAL("invite-to-signal"),
MORE_HEADING("more-heading"),
REFRESH_CONTACTS("refresh-contacts"),
FIND_CONTACTS("find-contacts"),
FIND_CONTACTS_BANNER("find-contacts-banner"),
FIND_BY_USERNAME("find-by-username"),
FIND_BY_PHONE_NUMBER("find-by-phone-number");
companion object {
fun fromCode(code: String) = entries.first { it.code == code }
}
}
override fun getSize(section: ContactSearchConfiguration.Section.Arbitrary, query: String?): Int {
return section.types.size
}
@@ -49,15 +179,15 @@ class ContactSelectionListAdapter(
}
override fun getMappingModel(arbitrary: ContactSearchData.Arbitrary): MappingModel<*> {
return when (ContactSelectionListModels.ArbitraryRow.fromCode(arbitrary.type)) {
ContactSelectionListModels.ArbitraryRow.NEW_GROUP -> NewGroupModel()
ContactSelectionListModels.ArbitraryRow.INVITE_TO_SIGNAL -> InviteToSignalModel()
ContactSelectionListModels.ArbitraryRow.MORE_HEADING -> MoreHeaderModel()
ContactSelectionListModels.ArbitraryRow.REFRESH_CONTACTS -> RefreshContactsModel()
ContactSelectionListModels.ArbitraryRow.FIND_CONTACTS -> FindContactsModel()
ContactSelectionListModels.ArbitraryRow.FIND_CONTACTS_BANNER -> FindContactsBannerModel()
ContactSelectionListModels.ArbitraryRow.FIND_BY_PHONE_NUMBER -> FindByPhoneNumberModel()
ContactSelectionListModels.ArbitraryRow.FIND_BY_USERNAME -> FindByUsernameModel()
return when (ArbitraryRow.fromCode(arbitrary.type)) {
ArbitraryRow.NEW_GROUP -> NewGroupModel()
ArbitraryRow.INVITE_TO_SIGNAL -> InviteToSignalModel()
ArbitraryRow.MORE_HEADING -> MoreHeaderModel()
ArbitraryRow.REFRESH_CONTACTS -> RefreshContactsModel()
ArbitraryRow.FIND_CONTACTS -> FindContactsModel()
ArbitraryRow.FIND_CONTACTS_BANNER -> FindContactsBannerModel()
ArbitraryRow.FIND_BY_PHONE_NUMBER -> FindByPhoneNumberModel()
ArbitraryRow.FIND_BY_USERNAME -> FindByUsernameModel()
}
}
}
@@ -18,8 +18,10 @@ package org.thoughtcrime.securesms;
import android.Manifest;
import org.signal.core.ui.logging.LoggingFragment;
import android.annotation.SuppressLint;
import android.content.Context;
import android.graphics.Rect;
import android.os.AsyncTask;
import android.os.Bundle;
import android.text.TextUtils;
@@ -36,24 +38,27 @@ import androidx.appcompat.app.AlertDialog;
import androidx.constraintlayout.widget.ConstraintLayout;
import androidx.constraintlayout.widget.ConstraintSet;
import androidx.lifecycle.ViewModelProvider;
import androidx.recyclerview.widget.DefaultItemAnimator;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout;
import androidx.transition.AutoTransition;
import androidx.transition.TransitionManager;
import org.thoughtcrime.securesms.contacts.paged.ContactSearchView;
import org.thoughtcrime.securesms.contacts.paged.ContactSearchViewModel;
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import org.jetbrains.annotations.NotNull;
import org.signal.core.ui.logging.LoggingFragment;
import org.signal.core.ui.permissions.Permissions;
import org.signal.core.util.concurrent.LifecycleDisposable;
import org.signal.core.util.concurrent.SimpleTask;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.calls.YouAreAlreadyInACallSnackbar;
import org.thoughtcrime.securesms.components.RecyclerViewFastScroller;
import org.thoughtcrime.securesms.contacts.ContactChipViewModel;
import org.thoughtcrime.securesms.contacts.ContactSelectionDisplayMode;
import org.thoughtcrime.securesms.contacts.HeaderAction;
import org.thoughtcrime.securesms.contacts.LetterHeaderDecoration;
import org.thoughtcrime.securesms.contacts.SelectedContact;
import org.thoughtcrime.securesms.contacts.SelectedContacts;
import org.thoughtcrime.securesms.contacts.paged.ChatType;
@@ -66,19 +71,18 @@ import org.thoughtcrime.securesms.contacts.paged.ContactSearchPagedDataSourceRep
import org.thoughtcrime.securesms.contacts.paged.ContactSearchRepository;
import org.thoughtcrime.securesms.contacts.paged.ContactSearchSortOrder;
import org.thoughtcrime.securesms.contacts.paged.ContactSearchState;
import org.thoughtcrime.securesms.contacts.paged.ContactSearchView;
import org.thoughtcrime.securesms.contacts.paged.ContactSearchViewModel;
import org.thoughtcrime.securesms.contacts.selection.ContactSelectionArguments;
import org.thoughtcrime.securesms.contacts.sync.ContactDiscovery;
import org.thoughtcrime.securesms.database.RecipientTable;
import org.thoughtcrime.securesms.groups.SelectionLimits;
import org.thoughtcrime.securesms.groups.ui.GroupLimitDialog;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.signal.core.ui.permissions.Permissions;
import org.thoughtcrime.securesms.profiles.manage.UsernameRepository;
import org.thoughtcrime.securesms.search.SearchRepository;
import org.thoughtcrime.securesms.profiles.manage.UsernameRepository.UsernameAciFetchResult;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.search.SearchRepository;
import org.thoughtcrime.securesms.util.CommunicationActions;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.UsernameUtil;
@@ -88,13 +92,14 @@ import org.thoughtcrime.securesms.util.adapter.mapping.MappingModelList;
import org.thoughtcrime.securesms.util.views.SimpleProgressDialog;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.stream.Collectors;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.function.Consumer;
import java.util.stream.Collectors;
import io.reactivex.rxjava3.disposables.Disposable;
import kotlin.Unit;
@@ -121,17 +126,22 @@ public final class ContactSelectionListFragment extends LoggingFragment {
private SwipeRefreshLayout swipeRefresh;
private String cursorFilter;
private ContactSearchView contactSearchView;
private RecyclerViewFastScroller fastScroller;
private RecyclerView chipRecycler;
private OnSelectionLimitReachedListener onSelectionLimitReachedListener;
private MappingAdapter contactChipAdapter;
private ContactChipViewModel contactChipViewModel;
private LifecycleDisposable lifecycleDisposable;
private HeaderActionProvider headerActionProvider;
private TextView headerActionView;
private ContactSearchViewModel contactSearchViewModel;
@Nullable private RecyclerView innerRecyclerView;
@Nullable private LinearLayoutManager innerLayoutManager;
@Nullable private NewConversationCallback newConversationCallback;
@Nullable private FindByCallback findByCallback;
@Nullable private NewCallCallback newCallCallback;
@Nullable private ScrollCallback scrollCallback;
@Nullable private OnItemLongClickListener onItemLongClickListener;
private SelectionLimits selectionLimit = SelectionLimits.NO_LIMITS;
private Set<RecipientId> currentSelection;
@@ -158,6 +168,14 @@ public final class ContactSelectionListFragment extends LoggingFragment {
setNewCallCallback((NewCallCallback) context);
}
if (getParentFragment() instanceof ScrollCallback) {
setScrollCallback((ScrollCallback) getParentFragment());
}
if (context instanceof ScrollCallback) {
setScrollCallback((ScrollCallback) context);
}
if (getParentFragment() instanceof OnContactSelectedListener) {
setOnContactSelectedListener((OnContactSelectedListener) getParentFragment());
}
@@ -203,6 +221,10 @@ public final class ContactSelectionListFragment extends LoggingFragment {
this.newCallCallback = callback;
}
public void setScrollCallback(@Nullable ScrollCallback callback) {
this.scrollCallback = callback;
}
public void setOnContactSelectedListener(@Nullable OnContactSelectedListener listener) {
this.onContactSelectedListener = listener;
}
@@ -237,8 +259,10 @@ public final class ContactSelectionListFragment extends LoggingFragment {
emptyText = view.findViewById(android.R.id.empty);
contactSearchView = view.findViewById(R.id.recycler_view);
swipeRefresh = view.findViewById(R.id.swipe_refresh);
fastScroller = view.findViewById(R.id.fast_scroller);
chipRecycler = view.findViewById(R.id.chipRecycler);
constraintLayout = view.findViewById(R.id.container);
headerActionView = view.findViewById(R.id.header_action);
contactChipViewModel = new ViewModelProvider(this).get(ContactChipViewModel.class);
contactChipAdapter = new MappingAdapter();
@@ -280,12 +304,138 @@ public final class ContactSelectionListFragment extends LoggingFragment {
false,
new ContactSelectionListAdapter.ArbitraryRepository(),
new SearchRepository(requireContext().getString(R.string.note_to_self)),
new ContactSearchPagedDataSourceRepository(requireContext(), requireContext().getString(R.string.note_to_self)),
fixedContacts,
false
new ContactSearchPagedDataSourceRepository(requireContext()),
fixedContacts
)
).get(ContactSearchViewModel.class);
List<RecyclerView.OnScrollListener> scrollListeners = new ArrayList<>();
final HeaderAction headerAction;
if (headerActionProvider != null) {
headerAction = headerActionProvider.getHeaderAction();
headerActionView.setEnabled(true);
headerActionView.setText(headerAction.getLabel());
headerActionView.setCompoundDrawablesRelativeWithIntrinsicBounds(headerAction.getIcon(), 0, 0, 0);
headerActionView.setOnClickListener(v -> headerAction.getAction().run());
scrollListeners.add(new RecyclerView.OnScrollListener() {
private final Rect bounds = new Rect();
@Override
public void onScrolled(@NonNull RecyclerView rv, int dx, int dy) {
if (hideLetterHeaders() || innerLayoutManager == null) {
return;
}
int firstPosition = innerLayoutManager.findFirstVisibleItemPosition();
if (firstPosition == 0) {
View firstChild = rv.getChildAt(0);
rv.getDecoratedBoundsWithMargins(firstChild, bounds);
headerActionView.setTranslationY(bounds.top);
}
}
});
} else {
headerActionView.setEnabled(false);
}
scrollListeners.add(new RecyclerView.OnScrollListener() {
@Override
public void onScrollStateChanged(@NonNull RecyclerView rv, int newState) {
if (newState == RecyclerView.SCROLL_STATE_DRAGGING && scrollCallback != null) {
scrollCallback.onBeginScroll();
}
}
});
float contentBottomPaddingDp = fragmentArgs.getRecyclerPadBottom() != -1
? fragmentArgs.getRecyclerPadBottom() / getResources().getDisplayMetrics().density
: 0f;
ContactSearchAdapter.AdapterFactory adapterFactory =
(context, fc, displayOptions, callbacks, longClickCallbacks, storyContextMenuCallbacks, callButtonClickCallbacks) ->
new ContactSelectionListAdapter(
context,
fc,
displayOptions,
new ContactSelectionListAdapter.OnContactSelectionClick() {
@Override
public void onDismissFindContactsBannerClicked() {
SignalStore.uiHints().markDismissedContactsPermissionBanner();
contactSearchViewModel.refresh();
}
@Override
public void onFindContactsClicked() {
requestContactPermissions();
}
@Override
public void onRefreshContactsClicked() {
if (onRefreshListener != null && !isRefreshing()) {
setRefreshing(true);
onRefreshListener.onRefresh();
}
}
@Override
public void onNewGroupClicked() {
newConversationCallback.onNewGroup(false);
}
@Override
public void onFindByPhoneNumberClicked() {
findByCallback.onFindByPhoneNumber();
}
@Override
public void onFindByUsernameClicked() {
findByCallback.onFindByUsername();
}
@Override
public void onInviteToSignalClicked() {
if (newConversationCallback != null) {
newConversationCallback.onInvite();
}
if (newCallCallback != null) {
newCallCallback.onInvite();
}
}
@Override
public void onStoryClicked(@NonNull View view1, @NonNull ContactSearchData.Story story, boolean isSelected) {
throw new UnsupportedOperationException();
}
@Override
public void onKnownRecipientClicked(@NonNull View view1, @NonNull ContactSearchData.KnownRecipient knownRecipient, boolean isSelected) {
listClickListener.onItemClick(knownRecipient.getContactSearchKey());
}
@Override
public void onExpandClicked(@NonNull ContactSearchData.Expand expand) {
callbacks.onExpandClicked(expand);
}
@Override
public void onUnknownRecipientClicked(@NonNull View view, @NonNull ContactSearchData.UnknownRecipient unknownRecipient, boolean isSelected) {
listClickListener.onItemClick(unknownRecipient.getContactSearchKey());
}
@Override
public void onChatTypeClicked(@NonNull View view, @NonNull ContactSearchData.ChatTypeRow chatTypeRow, boolean isSelected) {
listClickListener.onItemClick(chatTypeRow.getContactSearchKey());
}
},
(anchorView, data) -> listClickListener.onItemLongClick(anchorView, data.getContactSearchKey()),
storyContextMenuCallbacks,
new CallButtonClickCallbacks()
);
contactSearchView.bind(
contactSearchViewModel,
getChildFragmentManager(),
@@ -302,83 +452,25 @@ public final class ContactSelectionListFragment extends LoggingFragment {
onLoadFinished(size);
}
},
ContactSelectionListModels.composeEntries(
new ContactSelectionListModels.Callback() {
@Override
public void onNewGroupClicked() {
newConversationCallback.onNewGroup(false);
}
@Override
public void onInviteToSignalClicked() {
if (newConversationCallback != null) {
newConversationCallback.onInvite();
}
if (newCallCallback != null) {
newCallCallback.onInvite();
}
}
@Override
public void onFindContactsClicked() {
requestContactPermissions();
}
@Override
public void onDismissFindContactsBannerClicked() {
SignalStore.uiHints().markDismissedContactsPermissionBanner();
contactSearchViewModel.refresh();
}
@Override
public void onRefreshContactsClicked() {
if (onRefreshListener != null && !isRefreshing()) {
setRefreshing(true);
onRefreshListener.onRefresh();
}
}
@Override
public void onFindByUsernameClicked() {
findByCallback.onFindByUsername();
}
@Override
public void onFindByPhoneNumberClicked() {
findByCallback.onFindByPhoneNumber();
}
Collections.singletonList(new LetterHeaderDecoration(requireContext(), this::hideLetterHeaders)),
contentBottomPaddingDp,
adapterFactory,
scrollListeners,
rv -> {
innerRecyclerView = rv;
innerLayoutManager = (LinearLayoutManager) rv.getLayoutManager();
rv.setItemAnimator(new DefaultItemAnimator() {
@Override
public boolean canReuseUpdatedViewHolder(@NonNull RecyclerView.ViewHolder viewHolder) {
return true;
}
),
new ContactSearchAdapter.ClickCallbacks() {
@Override
public void onStoryClicked(@NotNull View view, ContactSearchData.@NotNull Story story, boolean isSelected) {
throw new UnsupportedOperationException();
}
@Override
public void onKnownRecipientClicked(@NotNull View view, ContactSearchData.@NotNull KnownRecipient knownRecipient, boolean isSelected) {
listClickListener.onItemClick(knownRecipient.getContactSearchKey());
}
@Override
public void onExpandClicked(ContactSearchData.@NotNull Expand expand) {
contactSearchViewModel.expandSection(expand.getSectionKey());
}
@Override
public void onChatTypeClicked(@NotNull View view, ContactSearchData.@NotNull ChatTypeRow chatTypeRow, boolean isSelected) {
listClickListener.onItemClick(chatTypeRow.getContactSearchKey());
}
@Override
public void onUnknownRecipientClicked(@NotNull View view, ContactSearchData.@NotNull UnknownRecipient unknownRecipient, boolean isSelected) {
listClickListener.onItemClick(unknownRecipient.getContactSearchKey());
}
},
(anchorView, data) -> listClickListener.onItemLongClick(anchorView, data.getContactSearchKey()),
null,
new CallButtonClickCallbacks()
@Override
public void onAnimationFinished(@NonNull RecyclerView.ViewHolder viewHolder) {
contactSearchView.setAlpha(1f);
}
});
}
);
return view;
@@ -503,23 +595,32 @@ public final class ContactSelectionListFragment extends LoggingFragment {
public void reset() {
contactSearchViewModel.clearSelection();
contactSearchViewModel.refresh();
contactSearchViewModel.setFastScrollEnabled(false);
fastScroller.setVisibility(View.GONE);
headerActionView.setVisibility(View.GONE);
}
private void onLoadFinished(int count) {
if (resetPositionOnCommit) {
if (resetPositionOnCommit && innerRecyclerView != null) {
resetPositionOnCommit = false;
contactSearchViewModel.requestScrollPosition(0);
innerRecyclerView.scrollToPosition(0);
}
swipeRefresh.setVisibility(View.VISIBLE);
emptyText.setText(R.string.contact_selection_group_activity__no_contacts);
boolean useFastScroller = count > 20;
if (useFastScroller) {
contactSearchViewModel.setFastScrollEnabled(true);
if (useFastScroller && innerRecyclerView != null) {
fastScroller.setVisibility(View.VISIBLE);
fastScroller.setRecyclerView(innerRecyclerView);
} else {
contactSearchViewModel.setFastScrollEnabled(false);
fastScroller.setRecyclerView(null);
fastScroller.setVisibility(View.GONE);
}
if (headerActionView.isEnabled() && !hasQueryFilter()) {
headerActionView.setVisibility(View.VISIBLE);
} else {
headerActionView.setVisibility(View.GONE);
}
}
@@ -600,18 +701,7 @@ public final class ContactSelectionListFragment extends LoggingFragment {
boolean isUnknown = contact instanceof ContactSearchKey.UnknownRecipientKey;
SelectedContact selectedContact = contact.requireSelectedContact();
boolean needsSelfCheck = !canSelectSelf && !selectedContact.hasUsername();
if (needsSelfCheck) {
lifecycleDisposable.add(contactChipViewModel.isSelf(selectedContact)
.subscribe(isSelf -> onItemClickResolved(contact, selectedContact, isUnknown, isSelf)));
} else {
onItemClickResolved(contact, selectedContact, isUnknown, false);
}
}
private void onItemClickResolved(ContactSearchKey contact, SelectedContact selectedContact, boolean isUnknown, boolean isSelf) {
if (isSelf) {
if (!canSelectSelf && !selectedContact.hasUsername() && Recipient.self().getId().equals(selectedContact.getOrCreateRecipientId())) {
Toast.makeText(requireContext(), R.string.ContactSelectionListFragment_you_do_not_need_to_add_yourself_to_the_group, Toast.LENGTH_SHORT).show();
return;
}
@@ -700,8 +790,8 @@ public final class ContactSelectionListFragment extends LoggingFragment {
}
public boolean onItemLongClick(View anchorView, ContactSearchKey item) {
if (onItemLongClickListener != null) {
return onItemLongClickListener.onLongClick(anchorView, item, isDisplayingContextMenu -> contactSearchViewModel.setDisplayingContextMenu(isDisplayingContextMenu));
if (onItemLongClickListener != null && innerRecyclerView != null) {
return onItemLongClickListener.onLongClick(anchorView, item, innerRecyclerView);
} else {
return false;
}
@@ -843,19 +933,19 @@ public final class ContactSelectionListFragment extends LoggingFragment {
!SignalStore.uiHints().getDismissedContactsPermissionBanner() &&
!hasQuery)
{
builder.arbitrary(ContactSelectionListModels.ArbitraryRow.FIND_CONTACTS_BANNER.getCode());
builder.arbitrary(ContactSelectionListAdapter.ArbitraryRepository.ArbitraryRow.FIND_CONTACTS_BANNER.getCode());
}
if (fragmentArgs.getEnableCreateNewGroup() && !hasQuery) {
builder.arbitrary(ContactSelectionListModels.ArbitraryRow.NEW_GROUP.getCode());
builder.arbitrary(ContactSelectionListAdapter.ArbitraryRepository.ArbitraryRow.NEW_GROUP.getCode());
}
if (fragmentArgs.getEnableFindByUsername() && !hasQuery) {
builder.arbitrary(ContactSelectionListModels.ArbitraryRow.FIND_BY_USERNAME.getCode());
builder.arbitrary(ContactSelectionListAdapter.ArbitraryRepository.ArbitraryRow.FIND_BY_USERNAME.getCode());
}
if (fragmentArgs.getEnableFindByPhoneNumber() && !hasQuery) {
builder.arbitrary(ContactSelectionListModels.ArbitraryRow.FIND_BY_PHONE_NUMBER.getCode());
builder.arbitrary(ContactSelectionListAdapter.ArbitraryRepository.ArbitraryRow.FIND_BY_PHONE_NUMBER.getCode());
}
if (includeChatTypes && !hasQuery) {
@@ -877,12 +967,10 @@ public final class ContactSelectionListFragment extends LoggingFragment {
}
boolean hideHeader = newCallCallback != null || (newConversationCallback != null && !hasQuery);
HeaderAction sectionHeaderAction = (headerActionProvider != null && !hasQuery) ? headerActionProvider.getHeaderAction() : null;
builder.addSection(new ContactSearchConfiguration.Section.Individuals(
includeSelf ? new RecipientTable.IncludeSelfMode.IncludeWithRemap(getString(R.string.note_to_self)) : RecipientTable.IncludeSelfMode.Exclude.INSTANCE,
transportType,
!hideHeader,
sectionHeaderAction,
null,
!hideLetterHeaders(),
newConversationCallback != null ? ContactSearchSortOrder.RECENCY : ContactSearchSortOrder.NATURAL
@@ -929,13 +1017,13 @@ public final class ContactSelectionListFragment extends LoggingFragment {
}
private void addMoreSection(@NonNull ContactSearchConfiguration.Builder builder) {
builder.arbitrary(ContactSelectionListModels.ArbitraryRow.MORE_HEADING.getCode());
builder.arbitrary(ContactSelectionListAdapter.ArbitraryRepository.ArbitraryRow.MORE_HEADING.getCode());
if (hasContactsPermissions(requireContext())) {
builder.arbitrary(ContactSelectionListModels.ArbitraryRow.REFRESH_CONTACTS.getCode());
builder.arbitrary(ContactSelectionListAdapter.ArbitraryRepository.ArbitraryRow.REFRESH_CONTACTS.getCode());
} else if (SignalStore.uiHints().getDismissedContactsPermissionBanner()) {
builder.arbitrary(ContactSelectionListModels.ArbitraryRow.FIND_CONTACTS.getCode());
builder.arbitrary(ContactSelectionListAdapter.ArbitraryRepository.ArbitraryRow.FIND_CONTACTS.getCode());
}
builder.arbitrary(ContactSelectionListModels.ArbitraryRow.INVITE_TO_SIGNAL.getCode());
builder.arbitrary(ContactSelectionListAdapter.ArbitraryRepository.ArbitraryRow.INVITE_TO_SIGNAL.getCode());
}
private static @Nullable ContactSearchConfiguration.TransportType resolveTransportType(boolean includePushContacts, boolean includeSmsContacts) {
@@ -1025,11 +1113,15 @@ public final class ContactSelectionListFragment extends LoggingFragment {
void onInvite();
}
public interface ScrollCallback {
void onBeginScroll();
}
public interface HeaderActionProvider {
@NonNull HeaderAction getHeaderAction();
}
public interface OnItemLongClickListener {
boolean onLongClick(View anchorView, ContactSearchKey contactSearchKey, Consumer<Boolean> setIsDisplayingContextMenu);
boolean onLongClick(View anchorView, ContactSearchKey contactSearchKey, RecyclerView recyclerView);
}
}
@@ -1,299 +0,0 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms
import android.view.View
import android.widget.FrameLayout
import android.widget.TextView
import com.google.android.material.button.MaterialButton
import org.thoughtcrime.securesms.contacts.paged.ContactSearchModels.EmptyModel
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel
import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder
import org.thoughtcrime.securesms.util.adapter.mapping.compose.MappingEntryProvider
import org.thoughtcrime.securesms.util.adapter.mapping.compose.MappingEntryProviderBuilder
/**
* Holds the [MappingModel]s and [MappingViewHolder]s used by [ContactSelectionListAdapter] on top of
* the base set in [org.thoughtcrime.securesms.contacts.paged.ContactSearchModels], along with helpers
* for registering them on a [MappingAdapter] (RecyclerView) or building a [MappingEntryProvider]
* (Compose).
*/
object ContactSelectionListModels {
fun registerNewGroup(mappingAdapter: MappingAdapter, onClick: () -> Unit) {
mappingAdapter.registerFactory(
NewGroupModel::class.java,
LayoutFactory({ NewGroupViewHolder(it, onClick) }, R.layout.contact_selection_new_group_item)
)
}
fun registerInviteToSignal(mappingAdapter: MappingAdapter, onClick: () -> Unit) {
mappingAdapter.registerFactory(
InviteToSignalModel::class.java,
LayoutFactory({ InviteToSignalViewHolder(it, onClick) }, R.layout.contact_selection_invite_action_item)
)
}
fun registerFindContacts(mappingAdapter: MappingAdapter, onClick: () -> Unit) {
mappingAdapter.registerFactory(
FindContactsModel::class.java,
LayoutFactory({ FindContactsViewHolder(it, onClick) }, R.layout.contact_selection_find_contacts_item)
)
}
fun registerFindContactsBanner(mappingAdapter: MappingAdapter, onDismiss: () -> Unit, onClick: () -> Unit) {
mappingAdapter.registerFactory(
FindContactsBannerModel::class.java,
LayoutFactory({ FindContactsBannerViewHolder(it, onDismiss, onClick) }, R.layout.contact_selection_find_contacts_banner_item)
)
}
fun registerRefreshContacts(mappingAdapter: MappingAdapter, onClick: () -> Unit) {
mappingAdapter.registerFactory(
RefreshContactsModel::class.java,
LayoutFactory({ RefreshContactsViewHolder(it, onClick) }, R.layout.contact_selection_refresh_action_item)
)
}
fun registerMoreHeader(mappingAdapter: MappingAdapter) {
mappingAdapter.registerFactory(
MoreHeaderModel::class.java,
LayoutFactory({ MoreHeaderViewHolder(it) }, R.layout.contact_search_section_header)
)
}
fun registerEmpty(mappingAdapter: MappingAdapter) {
mappingAdapter.registerFactory(
EmptyModel::class.java,
LayoutFactory({ EmptyViewHolder(it) }, R.layout.contact_selection_empty_state)
)
}
fun registerFindByUsername(mappingAdapter: MappingAdapter, onClick: () -> Unit) {
mappingAdapter.registerFactory(
FindByUsernameModel::class.java,
LayoutFactory({ FindByUsernameViewHolder(it, onClick) }, R.layout.contact_selection_find_by_username_item)
)
}
fun registerFindByPhoneNumber(mappingAdapter: MappingAdapter, onClick: () -> Unit) {
mappingAdapter.registerFactory(
FindByPhoneNumberModel::class.java,
LayoutFactory({ FindByPhoneNumberViewHolder(it, onClick) }, R.layout.contact_selection_find_by_phone_number_item)
)
}
/**
* Returns a [MappingEntryProvider] containing the same set of view holders registered by the
* adapter-side `register*` methods, suitable for use with a Compose `MappingLazyColumn`.
*/
@JvmStatic
fun composeEntries(
callback: Callback
): MappingEntryProvider<Any> {
return MappingEntryProviderBuilder<Any>().apply {
viewHolder<NewGroupModel> { context ->
LayoutFactory(
{ view -> NewGroupViewHolder(view, callback::onNewGroupClicked) },
R.layout.contact_selection_new_group_item
).createViewHolder(FrameLayout(context))
}
viewHolder<InviteToSignalModel> { context ->
LayoutFactory(
{ view -> InviteToSignalViewHolder(view, callback::onInviteToSignalClicked) },
R.layout.contact_selection_invite_action_item
).createViewHolder(FrameLayout(context))
}
viewHolder<FindContactsModel> { context ->
LayoutFactory(
{ view -> FindContactsViewHolder(view, callback::onFindContactsClicked) },
R.layout.contact_selection_find_contacts_item
).createViewHolder(FrameLayout(context))
}
viewHolder<FindContactsBannerModel> { context ->
LayoutFactory(
{ view -> FindContactsBannerViewHolder(view, callback::onDismissFindContactsBannerClicked, callback::onFindContactsClicked) },
R.layout.contact_selection_find_contacts_banner_item
).createViewHolder(FrameLayout(context))
}
viewHolder<RefreshContactsModel> { context ->
LayoutFactory(
{ view -> RefreshContactsViewHolder(view, callback::onRefreshContactsClicked) },
R.layout.contact_selection_refresh_action_item
).createViewHolder(FrameLayout(context))
}
viewHolder<MoreHeaderModel> { context ->
LayoutFactory(
{ view -> MoreHeaderViewHolder(view) },
R.layout.contact_search_section_header
).createViewHolder(FrameLayout(context))
}
viewHolder<EmptyModel> { context ->
LayoutFactory(
{ view -> EmptyViewHolder(view) },
R.layout.contact_selection_empty_state
).createViewHolder(FrameLayout(context))
}
viewHolder<FindByUsernameModel> { context ->
LayoutFactory(
{ view -> FindByUsernameViewHolder(view, callback::onFindByUsernameClicked) },
R.layout.contact_selection_find_by_username_item
).createViewHolder(FrameLayout(context))
}
viewHolder<FindByPhoneNumberModel> { context ->
LayoutFactory(
{ view -> FindByPhoneNumberViewHolder(view, callback::onFindByPhoneNumberClicked) },
R.layout.contact_selection_find_by_phone_number_item
).createViewHolder(FrameLayout(context))
}
}.build()
}
interface Callback {
fun onNewGroupClicked()
fun onInviteToSignalClicked()
fun onFindContactsClicked()
fun onDismissFindContactsBannerClicked()
fun onRefreshContactsClicked()
fun onFindByUsernameClicked()
fun onFindByPhoneNumberClicked()
}
enum class ArbitraryRow(val code: String) {
NEW_GROUP("new-group"),
INVITE_TO_SIGNAL("invite-to-signal"),
MORE_HEADING("more-heading"),
REFRESH_CONTACTS("refresh-contacts"),
FIND_CONTACTS("find-contacts"),
FIND_CONTACTS_BANNER("find-contacts-banner"),
FIND_BY_USERNAME("find-by-username"),
FIND_BY_PHONE_NUMBER("find-by-phone-number");
companion object {
fun fromCode(code: String) = entries.first { it.code == code }
}
}
class NewGroupModel : MappingModel<NewGroupModel> {
override fun areItemsTheSame(newItem: NewGroupModel): Boolean = true
override fun areContentsTheSame(newItem: NewGroupModel): Boolean = true
}
class InviteToSignalModel : MappingModel<InviteToSignalModel> {
override fun areItemsTheSame(newItem: InviteToSignalModel): Boolean = true
override fun areContentsTheSame(newItem: InviteToSignalModel): Boolean = true
}
class RefreshContactsModel : MappingModel<RefreshContactsModel> {
override fun areItemsTheSame(newItem: RefreshContactsModel): Boolean = true
override fun areContentsTheSame(newItem: RefreshContactsModel): Boolean = true
}
class FindContactsModel : MappingModel<FindContactsModel> {
override fun areItemsTheSame(newItem: FindContactsModel): Boolean = true
override fun areContentsTheSame(newItem: FindContactsModel): Boolean = true
}
class FindContactsBannerModel : MappingModel<FindContactsBannerModel> {
override fun areItemsTheSame(newItem: FindContactsBannerModel): Boolean = true
override fun areContentsTheSame(newItem: FindContactsBannerModel): Boolean = true
}
class FindByUsernameModel : MappingModel<FindByUsernameModel> {
override fun areItemsTheSame(newItem: FindByUsernameModel): Boolean = true
override fun areContentsTheSame(newItem: FindByUsernameModel): Boolean = true
}
class FindByPhoneNumberModel : MappingModel<FindByPhoneNumberModel> {
override fun areItemsTheSame(newItem: FindByPhoneNumberModel): Boolean = true
override fun areContentsTheSame(newItem: FindByPhoneNumberModel): Boolean = true
}
class MoreHeaderModel : MappingModel<MoreHeaderModel> {
override fun areItemsTheSame(newItem: MoreHeaderModel): Boolean = true
override fun areContentsTheSame(newItem: MoreHeaderModel): Boolean = true
}
private class InviteToSignalViewHolder(itemView: View, onClickListener: () -> Unit) : MappingViewHolder<InviteToSignalModel>(itemView) {
init {
itemView.setOnClickListener { onClickListener() }
}
override fun bind(model: InviteToSignalModel) = Unit
}
private class NewGroupViewHolder(itemView: View, onClickListener: () -> Unit) : MappingViewHolder<NewGroupModel>(itemView) {
init {
itemView.setOnClickListener { onClickListener() }
}
override fun bind(model: NewGroupModel) = Unit
}
private class RefreshContactsViewHolder(itemView: View, onClickListener: () -> Unit) : MappingViewHolder<RefreshContactsModel>(itemView) {
init {
itemView.setOnClickListener { onClickListener() }
}
override fun bind(model: RefreshContactsModel) = Unit
}
private class FindContactsViewHolder(itemView: View, onClickListener: () -> Unit) : MappingViewHolder<FindContactsModel>(itemView) {
init {
itemView.setOnClickListener { onClickListener() }
}
override fun bind(model: FindContactsModel) = Unit
}
private class FindContactsBannerViewHolder(itemView: View, onDismissListener: () -> Unit, onClickListener: () -> Unit) : MappingViewHolder<FindContactsBannerModel>(itemView) {
init {
itemView.findViewById<MaterialButton>(R.id.no_thanks_button).setOnClickListener { onDismissListener() }
itemView.findViewById<MaterialButton>(R.id.allow_contacts_button).setOnClickListener { onClickListener() }
}
override fun bind(model: FindContactsBannerModel) = Unit
}
private class MoreHeaderViewHolder(itemView: View) : MappingViewHolder<MoreHeaderModel>(itemView) {
private val headerTextView: TextView = itemView.findViewById(R.id.section_header)
override fun bind(model: MoreHeaderModel) {
headerTextView.setText(R.string.contact_selection_activity__more)
}
}
private class EmptyViewHolder(itemView: View) : MappingViewHolder<EmptyModel>(itemView) {
private val emptyText: TextView = itemView.findViewById(R.id.search_no_results)
override fun bind(model: EmptyModel) {
emptyText.text = context.getString(R.string.SearchFragment_no_results, model.empty.query ?: "")
}
}
private class FindByPhoneNumberViewHolder(itemView: View, onClickListener: () -> Unit) : MappingViewHolder<FindByPhoneNumberModel>(itemView) {
init {
itemView.setOnClickListener { onClickListener() }
}
override fun bind(model: FindByPhoneNumberModel) = Unit
}
private class FindByUsernameViewHolder(itemView: View, onClickListener: () -> Unit) : MappingViewHolder<FindByUsernameModel>(itemView) {
init {
itemView.setOnClickListener { onClickListener() }
}
override fun bind(model: FindByUsernameModel) = Unit
}
}

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