Compare commits

..

1 Commits

Author SHA1 Message Date
Alex Hart ae8e050891 Bump version to 8.13.2 2026-06-03 15:35:01 -03:00
1264 changed files with 59556 additions and 59825 deletions
-1
View File
@@ -1,2 +1 @@
*.ai binary
**/src/screenshotTest*/reference/**/*.png filter=lfs diff=lfs merge=lfs -text
+6 -22
View File
@@ -5,7 +5,7 @@ on:
push:
branches:
- 'main'
- '8.**'
- '7.**'
permissions:
contents: read # to fetch code (actions/checkout)
@@ -16,41 +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 17
uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5
# gh api repos/actions/setup-java/commits/v5 --jq '.sha'
with:
distribution: temurin
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 (ci); pushes to main / 8.x branches run the full
# Android lint (qa).
- name: Build with Gradle
env:
SIGNAL_BUILD_CACHE_URL: ${{ secrets.SIGNAL_BUILD_CACHE_URL }}
SIGNAL_BUILD_CACHE_USER: ${{ secrets.SIGNAL_BUILD_CACHE_USER }}
SIGNAL_BUILD_CACHE_PASSWORD: ${{ secrets.SIGNAL_BUILD_CACHE_PASSWORD }}
SIGNAL_BUILD_CACHE_PUSH: ${{ startsWith(github.ref, 'refs/heads/8.') }}
run: ./gradlew ${{ github.event_name == 'pull_request' && 'ci' || 'qa' }}
run: ./gradlew qa
- name: Archive reports for failed build
if: ${{ failure() }}
+6 -14
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 17
uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5
# gh api repos/actions/setup-java/commits/v5 --jq '.sha'
with:
distribution: temurin
java-version: 17
- 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 }}
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@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
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
+1 -1
View File
@@ -11,7 +11,7 @@ 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: Build image
run: |
+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
+3 -16
View File
@@ -16,7 +16,6 @@ plugins {
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")
@@ -28,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 = 1710
val canonicalVersionName = "8.17.0"
val currentHotfixVersion = 0
val canonicalVersionCode = 1699
val canonicalVersionName = "8.13.2"
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
@@ -128,16 +127,9 @@ 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()
@@ -720,11 +712,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)
+36802 -411
View File
File diff suppressed because one or more lines are too long
-5
View File
@@ -8,11 +8,6 @@
-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*(**);
}
@@ -9,11 +9,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
/**
@@ -32,13 +30,6 @@ class SignalInstrumentationApplicationContext : ApplicationContext() {
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() {
@@ -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)
}
}
}
@@ -20,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
@@ -67,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)
@@ -80,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())
@@ -107,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)
@@ -143,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())
@@ -178,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)
@@ -204,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))
@@ -259,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()
@@ -278,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()
@@ -297,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()
@@ -317,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()
@@ -5,10 +5,10 @@
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.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),
@@ -431,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)
}
@@ -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
)
}
}
@@ -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
@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()
)
}
}
@@ -42,10 +42,8 @@ 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.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(
@@ -142,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
@@ -32,8 +32,8 @@ 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.providers.BlobProvider
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.stories.landing.StoriesLandingFragment
@@ -348,13 +348,11 @@ class MainNavigationLaunchTest {
await(description = "no new ConversationFragment after Empty detail intent") {
recorder.createdArgs.size == baseline
}
// The user-visible signal that we're "back on the list" is the chat list fragment
// being attached, not just the VM saying CHATS.
awaitListFragment(launched, MainNavigationListLocation.CHATS)
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}"
}
@@ -571,7 +569,7 @@ class MainNavigationLaunchTest {
}
private fun realBlob(bytes: ByteArray, mimeType: String): Uri {
return AppDependencies.blobs
return BlobProvider.getInstance()
.forData(bytes)
.withMimeType(mimeType)
.createForSingleSessionInMemory()
@@ -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()
}
}
@@ -1,81 +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);
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
}
}
@@ -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)
@@ -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)
+1 -1
View File
@@ -1371,7 +1371,7 @@
<service
android:name=".gcm.FcmReceiveService"
android:exported="false">
android:exported="true">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -24,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
)
}
}
@@ -33,13 +33,10 @@ import net.zetetic.database.Logger;
import org.conscrypt.ConscryptSignal;
import org.greenrobot.eventbus.EventBus;
import org.signal.aesgcmprovider.AesGcmProvider;
import org.signal.core.util.AppForegroundObserver;
import org.signal.core.util.DiskUtil;
import org.signal.core.util.MemoryTracker;
import org.signal.core.util.Util;
import org.signal.core.util.concurrent.AnrDetector;
import org.signal.core.util.concurrent.SignalExecutors;
import org.signal.core.util.crypto.AttachmentSecretProvider;
import org.signal.core.util.logging.AndroidLogger;
import org.signal.core.util.logging.Log;
import org.signal.core.util.logging.Scrubber;
@@ -47,13 +44,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.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;
@@ -64,7 +59,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;
@@ -81,7 +75,6 @@ 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;
@@ -90,6 +83,7 @@ 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;
@@ -100,11 +94,10 @@ import org.thoughtcrime.securesms.messageprocessingalarm.RoutineMessageFetchRece
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;
@@ -114,10 +107,11 @@ 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.signal.core.util.AppForegroundObserver;
import org.thoughtcrime.securesms.util.AppStartup;
import org.thoughtcrime.securesms.util.BatterySnapshotTracker;
import org.thoughtcrime.securesms.util.DeviceProperties;
import org.thoughtcrime.securesms.util.DynamicTheme;
import org.thoughtcrime.securesms.util.Environment;
@@ -127,6 +121,7 @@ import org.thoughtcrime.securesms.util.SignalLocalMetrics;
import org.thoughtcrime.securesms.util.SignalUncaughtExceptionHandler;
import org.thoughtcrime.securesms.util.SqlCipherLogTarget;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.signal.core.util.Util;
import org.thoughtcrime.securesms.util.VersionTracker;
import org.thoughtcrime.securesms.util.dynamiclanguage.DynamicLanguageContextWrapper;
import org.whispersystems.signalservice.api.websocket.SignalWebSocket;
@@ -175,7 +170,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))
@@ -234,7 +229,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)
@@ -263,8 +258,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();
@@ -284,7 +277,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);
@@ -305,7 +298,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();
@@ -424,11 +416,10 @@ 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,
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,
context -> {
context.startActivity(new Intent(context, SubmitDebugLogActivity.class));
@@ -578,7 +569,7 @@ public class ApplicationContext extends Application implements AppForegroundObse
@WorkerThread
private void initializeBlobProvider() {
AppDependencies.getBlobs().initialize(this, DraftBlobs.INSTANCE::deleteOrphanedDraftFiles);
BlobProvider.getInstance().initialize(this);
}
@WorkerThread
@@ -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 java.util.Locale;
import java.util.Set;
@@ -20,10 +18,10 @@ public interface BindableConversationListItem extends Unbindable {
@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();
}
@@ -281,8 +281,7 @@ public final class ContactSelectionListFragment extends LoggingFragment {
new ContactSelectionListAdapter.ArbitraryRepository(),
new SearchRepository(requireContext().getString(R.string.note_to_self)),
new ContactSearchPagedDataSourceRepository(requireContext()),
fixedContacts,
false
fixedContacts
)
).get(ContactSearchViewModel.class);
@@ -600,18 +599,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;
}
@@ -5,6 +5,7 @@
package org.thoughtcrime.securesms
import android.Manifest
import android.annotation.SuppressLint
import android.app.Activity
import android.content.Context
@@ -14,6 +15,7 @@ import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import android.view.ViewTreeObserver
import android.widget.Toast
import androidx.activity.SystemBarStyle
import androidx.activity.compose.BackHandler
import androidx.activity.compose.setContent
@@ -70,8 +72,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.createSavedStateHandle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.ui.NavDisplay
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import io.reactivex.rxjava3.subjects.PublishSubject
@@ -85,7 +85,6 @@ import kotlinx.coroutines.withContext
import org.signal.core.ui.BottomSheetUtil
import org.signal.core.ui.compose.Snackbars
import org.signal.core.ui.compose.theme.SignalTheme
import org.signal.core.ui.navigation.TransitionSpecs
import org.signal.core.ui.permissions.Permissions
import org.signal.core.ui.rememberIsSplitPane
import org.signal.core.util.AppForegroundObserver
@@ -106,8 +105,6 @@ import org.thoughtcrime.securesms.calls.log.CallLogFragment
import org.thoughtcrime.securesms.calls.new.NewCallActivity
import org.thoughtcrime.securesms.calls.quality.CallQuality
import org.thoughtcrime.securesms.calls.quality.CallQualityBottomSheetFragment
import org.thoughtcrime.securesms.chats.ConversationTransitionState
import org.thoughtcrime.securesms.chats.chatsNavEntries
import org.thoughtcrime.securesms.components.DebugLogsPromptDialogFragment
import org.thoughtcrime.securesms.components.PromptBatterySaverDialogFragment
import org.thoughtcrime.securesms.components.compose.ConnectivityWarningBottomSheet
@@ -137,6 +134,7 @@ import org.thoughtcrime.securesms.devicetransfer.olddevice.OldDeviceExitActivity
import org.thoughtcrime.securesms.groups.ui.creategroup.CreateGroupActivity
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.lock.v2.CreateSvrPinActivity
import org.thoughtcrime.securesms.main.ChatNavGraphState
import org.thoughtcrime.securesms.main.DetailsScreenNavHost
import org.thoughtcrime.securesms.main.MainBottomChrome
import org.thoughtcrime.securesms.main.MainBottomChromeCallback
@@ -145,6 +143,7 @@ import org.thoughtcrime.securesms.main.MainContentLayoutData
import org.thoughtcrime.securesms.main.MainMegaphoneState
import org.thoughtcrime.securesms.main.MainNavigationBar
import org.thoughtcrime.securesms.main.MainNavigationDetailLocation
import org.thoughtcrime.securesms.main.MainNavigationDetailLocationEffect
import org.thoughtcrime.securesms.main.MainNavigationListLocation
import org.thoughtcrime.securesms.main.MainNavigationRail
import org.thoughtcrime.securesms.main.MainNavigationRouter
@@ -158,10 +157,12 @@ import org.thoughtcrime.securesms.main.MainToolbarState
import org.thoughtcrime.securesms.main.MainToolbarViewModel
import org.thoughtcrime.securesms.main.Material3OnScrollHelperBinder
import org.thoughtcrime.securesms.main.callNavGraphBuilder
import org.thoughtcrime.securesms.main.chatNavGraphBuilder
import org.thoughtcrime.securesms.main.navigateToDetailLocation
import org.thoughtcrime.securesms.main.rememberDetailNavHostController
import org.thoughtcrime.securesms.main.rememberFocusRequester
import org.thoughtcrime.securesms.main.storiesNavGraphBuilder
import org.thoughtcrime.securesms.mediasend.camerax.CameraXRemoteConfig
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionActivity
import org.thoughtcrime.securesms.mediasend.v3.mediaSendLauncher
import org.thoughtcrime.securesms.megaphone.Megaphone
@@ -196,6 +197,7 @@ import org.thoughtcrime.securesms.window.NavigationType
import org.thoughtcrime.securesms.window.rememberThreePaneScaffoldNavigatorDelegate
import org.whispersystems.signalservice.api.websocket.WebSocketConnectionState
import kotlin.time.Duration.Companion.minutes
import org.signal.core.ui.R as CoreUiR
class MainActivity :
PassphraseRequiredActivity(),
@@ -496,15 +498,18 @@ class MainActivity :
}
}
val convoTransitionState = ConversationTransitionState.remember(isSplitPane)
val chatNavGraphState = ChatNavGraphState.remember(isSplitPane)
val mutableInteractionSource = remember { MutableInteractionSource() }
MainNavigationDetailLocationEffect(mainNavigationViewModel, chatNavGraphState::writeGraphicsLayerToBitmap)
LaunchedEffect(convoTransitionState) {
mainNavigationViewModel.setChatListSnapshotCaptureProvider { convoTransitionState.writeGraphicsLayerToBitmap() }
}
LaunchedEffect(isSplitPane) {
mainNavigationViewModel.onSplitPaneChanged(isSplitPane)
val chatsNavHostController = rememberDetailNavHostController(
onRequestFocus = rememberFocusRequester(
mainNavigationViewModel = mainNavigationViewModel,
currentListLocation = mainNavigationState.currentListLocation,
isTargetListLocation = { it in listOf(MainNavigationListLocation.CHATS, MainNavigationListLocation.ARCHIVE) }
)
) {
chatNavGraphBuilder(chatNavGraphState)
}
val callsNavHostController = rememberDetailNavHostController(
@@ -526,23 +531,22 @@ class MainActivity :
}
LaunchedEffect(Unit) {
fun navigateToLocation(location: MainNavigationDetailLocation) {
suspend fun navigateToLocation(location: MainNavigationDetailLocation) {
when (location) {
is MainNavigationDetailLocation.Empty -> {
when (mainNavigationState.currentListLocation) {
MainNavigationListLocation.CHATS, MainNavigationListLocation.ARCHIVE -> {
throw IllegalStateException("Navigation to ${mainNavigationState.currentListLocation} should be handled by ChatsBackStack.")
}
MainNavigationListLocation.CHATS, MainNavigationListLocation.ARCHIVE -> chatsNavHostController
MainNavigationListLocation.CALLS -> callsNavHostController
MainNavigationListLocation.STORIES -> storiesNavHostController
}.navigateToDetailLocation(location)
}
is MainNavigationDetailLocation.Conversation, is MainNavigationDetailLocation.Chats -> {
throw IllegalStateException("Navigation to $location should be handled by ChatsBackStack.")
is MainNavigationDetailLocation.Conversation -> {
chatNavGraphState.writeGraphicsLayerToBitmap()
chatsNavHostController.navigateToDetailLocation(location)
}
is MainNavigationDetailLocation.Chats -> chatsNavHostController.navigateToDetailLocation(location)
is MainNavigationDetailLocation.CallLinkDetails -> callsNavHostController.navigateToDetailLocation(location)
is MainNavigationDetailLocation.Calls -> callsNavHostController.navigateToDetailLocation(location)
is MainNavigationDetailLocation.Stories -> storiesNavHostController.navigateToDetailLocation(location)
@@ -556,9 +560,7 @@ class MainActivity :
}
val scope = rememberCoroutineScope()
BackHandler(paneExpansionState.currentAnchor == detailOnlyAnchor) {
mainNavigationViewModel.goTo(MainNavigationDetailLocation.Empty)
scope.launch {
paneExpansionState.animateTo(listOnlyAnchor)
}
@@ -617,7 +619,7 @@ class MainActivity :
AppScaffold(
navigator = wrappedNavigator,
modifier = convoTransitionState.writeContentToGraphicsLayer(),
modifier = chatNavGraphState.writeContentToGraphicsLayer(),
paneExpansionState = paneExpansionState,
contentWindowInsets = WindowInsets(),
snackbarHost = {
@@ -728,13 +730,9 @@ class MainActivity :
primaryContent = {
when (mainNavigationState.currentListLocation) {
MainNavigationListLocation.CHATS, MainNavigationListLocation.ARCHIVE -> {
NavDisplay(
backStack = mainNavigationViewModel.chatsBackStackEntries,
onBack = { mainNavigationViewModel.popChatsDetailLocation() },
transitionSpec = TransitionSpecs.HorizontalSlide.transitionSpec,
popTransitionSpec = TransitionSpecs.HorizontalSlide.popTransitionSpec,
predictivePopTransitionSpec = TransitionSpecs.HorizontalSlide.predictivePopTransitionSpec,
entryProvider = entryProvider { chatsNavEntries(convoTransitionState) }
DetailsScreenNavHost(
navHostController = chatsNavHostController,
contentLayoutData = contentLayoutData
)
}
@@ -763,7 +761,7 @@ class MainActivity :
} else {
null
},
animatorFactory = if (mainNavigationState.currentListLocation.isChatsTab) {
animatorFactory = if (mainNavigationState.currentListLocation == MainNavigationListLocation.CHATS || mainNavigationState.currentListLocation == MainNavigationListLocation.ARCHIVE) {
noEnterTransitionFactory
} else {
AppScaffoldAnimationStateFactory.Default
@@ -1054,13 +1052,6 @@ class MainActivity :
private fun handleConversationIntent(intent: Intent) {
if (ConversationIntents.isConversationIntent(intent)) {
if (!isTrustedConversationIntent(intent)) {
Log.w(TAG, "Received a conversation intent through an exported entry point. Ignoring its extras.")
intent.action = null
setIntent(intent)
return
}
mainNavigationViewModel.goTo(MainNavigationListLocation.CHATS)
mainNavigationViewModel.goTo(MainNavigationDetailLocation.Conversation(ConversationIntents.readArgsFromBundle(intent.extras!!)))
intent.action = null
@@ -1068,14 +1059,6 @@ class MainActivity :
}
}
/**
* While MainActivity isn't exporting, we have launcher aliases that are, so we verify that someone isn't launching us through those befre
* respecting various intent attributes.
*/
private fun isTrustedConversationIntent(intent: Intent): Boolean {
return intent.component?.className == MainActivity::class.java.name
}
private fun handleGroupLinkInIntent(intent: Intent) {
intent.data?.let { data ->
CommunicationActions.handlePotentialGroupLinkUrl(this, data.toString())
@@ -1177,7 +1160,24 @@ class MainActivity :
}
}
onGranted()
if (CameraXRemoteConfig.isSupported()) {
onGranted()
} else {
Permissions.with(this@MainActivity)
.request(Manifest.permission.CAMERA)
.ifNecessary()
.withRationaleDialog(getString(R.string.CameraXFragment_allow_access_camera), getString(R.string.CameraXFragment_to_capture_photos_and_video_allow_camera), CoreUiR.drawable.symbol_camera_24)
.withPermanentDenialDialog(
getString(R.string.CameraXFragment_signal_needs_camera_access_capture_photos),
null,
R.string.CameraXFragment_allow_access_camera,
R.string.CameraXFragment_to_capture_photos_videos,
supportFragmentManager
)
.onAllGranted(onGranted)
.onAnyDenied { Toast.makeText(this@MainActivity, R.string.CameraXFragment_signal_needs_camera_access_capture_photos, Toast.LENGTH_LONG).show() }
.execute()
}
}
inner class ToolbarCallback : MainToolbarCallback {
@@ -135,8 +135,12 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
Intent intent = getIntentForState(applicationState);
if (intent != null) {
Log.d(TAG, "routeApplicationState(), intent: " + intent.getComponent());
if (applicationState == STATE_WELCOME_PUSH_SCREEN && Environment.USE_NEW_REGISTRATION) {
startActivity(intent);
} else {
startActivity(intent);
finish();
}
}
}
@@ -223,7 +227,7 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
private Intent getPushRegistrationIntent() {
if (Environment.USE_NEW_REGISTRATION) {
return org.signal.registration.RegistrationActivity.createIntent(this, MainActivity.clearTop(this));
return org.signal.registration.RegistrationActivity.createIntent(this);
} else {
return RegistrationActivity.newIntentForNewRegistration(this, getIntent());
}
@@ -1,27 +1,107 @@
package org.thoughtcrime.securesms;
import android.app.Activity;
import android.content.Intent;
import android.database.Cursor;
import android.os.Bundle;
import android.provider.ContactsContract;
import android.text.TextUtils;
import android.widget.Toast;
import androidx.activity.ComponentActivity;
import androidx.lifecycle.ViewModelProvider;
import androidx.annotation.NonNull;
public class SystemContactsEntrypointActivity extends ComponentActivity {
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.conversation.ConversationIntents;
import org.thoughtcrime.securesms.conversation.NewConversationActivity;
import org.thoughtcrime.securesms.database.SignalDatabase;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.util.Rfc5724Uri;
import java.net.URISyntaxException;
public class SystemContactsEntrypointActivity extends Activity {
private static final String TAG = Log.tag(SystemContactsEntrypointActivity.class);
@Override
protected void onCreate(Bundle savedInstanceState) {
startActivity(getNextIntent(getIntent()));
finish();
super.onCreate(savedInstanceState);
}
SystemContactsEntrypointViewModel viewModel = new ViewModelProvider(this).get(SystemContactsEntrypointViewModel.class);
private Intent getNextIntent(Intent original) {
DestinationAndBody destination;
viewModel.getContactAction().observe(this, nextStep -> {
if (nextStep.getShowSpecifyRecipientToast()) {
if (original.getData() != null && "content".equals(original.getData().getScheme())) {
destination = getDestinationForSyncAdapter(original);
} else {
destination = getDestinationForView(original);
}
final Intent nextIntent;
if (TextUtils.isEmpty(destination.destination)) {
nextIntent = NewConversationActivity.createIntent(this, destination.getBody());
Toast.makeText(this, R.string.ConversationActivity_specify_recipient, Toast.LENGTH_LONG).show();
} else {
Recipient recipient = Recipient.external(destination.getDestination());
if (recipient != null) {
long threadId = SignalDatabase.threads().getOrCreateThreadIdFor(recipient);
nextIntent = ConversationIntents.createBuilderSync(this, recipient.getId(), threadId)
.withDraftText(destination.getBody())
.build();
} else {
nextIntent = NewConversationActivity.createIntent(this, destination.getBody());
Toast.makeText(this, R.string.ConversationActivity_specify_recipient, Toast.LENGTH_LONG).show();
}
startActivity(nextStep.getIntent());
finish();
});
}
return nextIntent;
}
viewModel.resolveNextStep(getIntent());
private @NonNull DestinationAndBody getDestinationForView(Intent intent) {
try {
Rfc5724Uri smsUri = new Rfc5724Uri(intent.getData().toString());
return new DestinationAndBody(smsUri.getPath(), smsUri.getQueryParams().get("body"));
} catch (URISyntaxException e) {
Log.w(TAG, "unable to parse RFC5724 URI from intent", e);
return new DestinationAndBody("", "");
}
}
private @NonNull DestinationAndBody getDestinationForSyncAdapter(Intent intent) {
Cursor cursor = null;
try {
cursor = getContentResolver().query(intent.getData(), null, null, null, null);
if (cursor != null && cursor.moveToNext()) {
return new DestinationAndBody(cursor.getString(cursor.getColumnIndexOrThrow(ContactsContract.RawContacts.Data.DATA1)), "");
}
return new DestinationAndBody("", "");
} finally {
if (cursor != null) cursor.close();
}
}
private static class DestinationAndBody {
private final String destination;
private final String body;
private DestinationAndBody(String destination, String body) {
this.destination = destination;
this.body = body;
}
public String getDestination() {
return destination;
}
public String getBody() {
return body;
}
}
}
@@ -1,100 +0,0 @@
package org.thoughtcrime.securesms
import android.content.Context
import android.content.Intent
import android.provider.ContactsContract
import android.text.TextUtils
import androidx.annotation.WorkerThread
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.conversation.ConversationIntents
import org.thoughtcrime.securesms.conversation.NewConversationActivity
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.Rfc5724Uri
import java.net.URISyntaxException
class SystemContactsEntrypointViewModel : ViewModel() {
companion object {
private val TAG = Log.tag(SystemContactsEntrypointViewModel::class.java)
}
private val internalContactAction = MutableLiveData<ContactAction>()
val contactAction: LiveData<ContactAction> = internalContactAction
fun resolveNextStep(original: Intent) {
viewModelScope.launch {
val result = withContext(Dispatchers.IO) {
getContactAction(AppDependencies.application, original)
}
internalContactAction.value = result
}
}
@WorkerThread
private fun getContactAction(context: Context, original: Intent): ContactAction {
val destination = if (original.data != null && "content" == original.data?.scheme) {
getDestinationForSyncAdapter(context, original)
} else {
getDestinationForView(original)
}
val destinationAddress = destination.destination
if (TextUtils.isEmpty(destinationAddress)) {
return ContactAction(NewConversationActivity.createIntent(context, destination.body), true)
}
val recipient = Recipient.external(destinationAddress!!)
if (recipient != null) {
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(recipient)
val nextIntent = ConversationIntents.createBuilderSync(context, recipient.id, threadId)
.withDraftText(destination.body)
.build()
return ContactAction(nextIntent, false)
}
return ContactAction(NewConversationActivity.createIntent(context, destination.body), true)
}
private fun getDestinationForView(intent: Intent): DestinationAndBody {
return try {
val smsUri = Rfc5724Uri(intent.data.toString())
DestinationAndBody(smsUri.path, smsUri.queryParams["body"])
} catch (e: URISyntaxException) {
Log.w(TAG, "unable to parse RFC5724 URI from intent", e)
DestinationAndBody("", "")
}
}
private fun getDestinationForSyncAdapter(context: Context, intent: Intent): DestinationAndBody {
context.contentResolver.query(intent.data!!, null, null, null, null).use { cursor ->
if (cursor != null && cursor.moveToNext()) {
return DestinationAndBody(cursor.getString(cursor.getColumnIndexOrThrow(ContactsContract.RawContacts.Data.DATA1)), "")
}
return DestinationAndBody("", "")
}
}
data class ContactAction(
val intent: Intent,
val showSpecifyRecipientToast: Boolean
)
private data class DestinationAndBody(
val destination: String?,
val body: String?
)
}
@@ -61,36 +61,21 @@ object ApkUpdateInstaller {
return
}
if (!userInitiated && !shouldAutoUpdate()) {
if (!isMatchingDigest(context, downloadId, digest)) {
Log.w(TAG, "DownloadId matches, but digest does not! Bad download or inconsistent state. Failing and clearing state.")
SignalStore.apkUpdate.clearDownloadAttributes()
ApkUpdateNotifications.showInstallFailed(context, ApkUpdateNotifications.FailureReason.UNKNOWN)
return
}
if (!isMatchingDigest(context, downloadId, digest)) {
Log.w(TAG, "DownloadId matches, but digest does not! Bad download or inconsistent state. Failing and clearing state.")
SignalStore.apkUpdate.clearDownloadAttributes()
ApkUpdateNotifications.showInstallFailed(context, ApkUpdateNotifications.FailureReason.UNKNOWN)
return
}
if (!userInitiated && !shouldAutoUpdate()) {
Log.w(TAG, "Not user-initiated and not eligible for auto-update. Prompting. (API=${Build.VERSION.SDK_INT}, Foreground=${AppForegroundObserver.isForegrounded()}, AutoUpdate=${SignalStore.apkUpdate.autoUpdate})")
ApkUpdateNotifications.showInstallPrompt(context, downloadId)
return
}
try {
context
.getDownloadManager()
.openDownloadedFile(downloadId)
.use { parcelFileDescriptor ->
val stream = FileInputStream(parcelFileDescriptor.fileDescriptor)
if (!MessageDigest.isEqual(FileUtils.getFileDigest(stream), digest)) {
Log.w(TAG, "DownloadId matches, but digest does not! Bad download or inconsistent state. Failing and clearing state.")
SignalStore.apkUpdate.clearDownloadAttributes()
ApkUpdateNotifications.showInstallFailed(context, ApkUpdateNotifications.FailureReason.UNKNOWN)
return
}
stream.channel.position(0)
installApk(context, downloadId, stream, userInitiated)
}
installApk(context, downloadId, userInitiated)
} catch (e: IOException) {
Log.w(TAG, "Hit IOException when trying to install APK!", e)
SignalStore.apkUpdate.clearDownloadAttributes()
@@ -103,13 +88,17 @@ object ApkUpdateInstaller {
}
@Throws(IOException::class, SecurityException::class)
private fun installApk(context: Context, downloadId: Long, apkInputStream: InputStream, userInitiated: Boolean) {
private fun installApk(context: Context, downloadId: Long, userInitiated: Boolean) {
val apkInputStream: InputStream? = getDownloadedApkInputStream(context, downloadId)
if (apkInputStream == null) {
Log.w(TAG, "Could not open download APK input stream!")
return
}
Log.d(TAG, "Beginning APK install...")
val packageInstaller: PackageInstaller = context.packageManager.packageInstaller
val sessionParams = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL).apply {
// Reject the session if the APK's declared package name doesn't match ours.
setAppPackageName(context.packageName)
// At this point, we always want to set this if possible, since we've already prompted the user with our own notification when necessary.
// This lets us skip the system-generated notification.
if (Build.VERSION.SDK_INT >= 31) {
@@ -144,6 +133,15 @@ object ApkUpdateInstaller {
session.commit(installerPendingIntent.intentSender)
}
private fun getDownloadedApkInputStream(context: Context, downloadId: Long): InputStream? {
return try {
FileInputStream(context.getDownloadManager().openDownloadedFile(downloadId).fileDescriptor)
} catch (e: IOException) {
Log.w(TAG, e)
null
}
}
private fun isDownloadSuccessful(context: Context, downloadId: Long): Boolean {
val query = DownloadManager.Query().setFilterById(downloadId)
val cursor = context.getDownloadManager().query(query)
@@ -31,7 +31,7 @@ fun Attachment.toAttachmentPointer(context: Context): AttachmentPointer? {
}
try {
val remoteId = SignalServiceAttachmentRemoteId.from(attachment.remoteLocation!!, attachment.cdn.cdnNumber)
val remoteId = SignalServiceAttachmentRemoteId.from(attachment.remoteLocation!!)
var attachmentWidth = attachment.width
var attachmentHeight = attachment.height
@@ -1,13 +1,9 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.core.models.database
package org.thoughtcrime.securesms.attachments
import android.os.Parcelable
import com.fasterxml.jackson.annotation.JsonProperty
import kotlinx.parcelize.Parcelize
import org.signal.core.util.DatabaseId
@Parcelize
data class AttachmentId(
@@ -41,16 +41,12 @@ enum class Cdn(private val value: Int) {
}
fun fromCdnNumber(cdnNumber: Int): Cdn {
return fromCdnNumberOrNull(cdnNumber) ?: throw UnsupportedOperationException("Invalid CDN number: $cdnNumber")
}
fun fromCdnNumberOrNull(cdnNumber: Int): Cdn? {
return when (cdnNumber) {
-1 -> S3
0 -> CDN_0
2 -> CDN_2
3 -> CDN_3
else -> null
else -> throw UnsupportedOperationException("Invalid CDN number: $cdnNumber")
}
}
}
@@ -4,7 +4,6 @@ import android.net.Uri
import android.os.Parcel
import androidx.core.os.ParcelCompat
import org.signal.blurhash.BlurHash
import org.signal.core.models.database.AttachmentId
import org.signal.core.models.media.TransformProperties
import org.signal.core.util.ParcelUtil
import org.thoughtcrime.securesms.audio.AudioHash
@@ -5,7 +5,6 @@ import android.os.Parcel
import androidx.annotation.VisibleForTesting
import org.signal.blurhash.BlurHash
import org.signal.core.util.Base64
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.database.AttachmentTable
import org.thoughtcrime.securesms.stickers.StickerLocator
import org.whispersystems.signalservice.api.InvalidMessageStructureException
@@ -77,8 +76,6 @@ class PointerAttachment : Attachment {
override val thumbnailUri: Uri? = null
companion object {
private val TAG = Log.tag(PointerAttachment::class)
@JvmStatic
fun forPointers(pointers: Optional<List<SignalServiceAttachment>>): List<Attachment> {
if (!pointers.isPresent) {
@@ -105,13 +102,6 @@ class PointerAttachment : Attachment {
return Optional.empty()
}
val cdnNumber = pointer.get().asPointer().cdnNumber
val cdn = Cdn.fromCdnNumberOrNull(cdnNumber)
if (cdn == null) {
Log.w(TAG, "Encountered an attachment pointer with an unsupported CDN number ($cdnNumber). Skipping attachment.")
return Optional.empty()
}
val encodedKey: String? = pointer.get().asPointer().key?.let { Base64.encodeWithPadding(it) }
return Optional.of(
@@ -120,7 +110,7 @@ class PointerAttachment : Attachment {
transferState = transferState,
size = pointer.get().asPointer().size.orElse(0).toLong(),
fileName = pointer.get().asPointer().fileName.orElse(null),
cdn = cdn,
cdn = Cdn.fromCdnNumber(pointer.get().asPointer().cdnNumber),
location = pointer.get().asPointer().remoteId.toString(),
key = encodedKey,
iv = null,
@@ -155,13 +145,7 @@ class PointerAttachment : Attachment {
return Optional.empty()
}
val cdnNumber = thumbnail?.asPointer()?.cdnNumber ?: 0
val cdn = Cdn.fromCdnNumberOrNull(cdnNumber)
if (cdn == null) {
Log.w(TAG, "Encountered a quote thumbnail with an unsupported CDN number ($cdnNumber). Skipping attachment.")
return Optional.empty()
}
val cdn = Cdn.fromCdnNumber(thumbnail?.asPointer()?.cdnNumber ?: 0)
if (cdn == Cdn.S3) {
return Optional.empty()
}
@@ -11,11 +11,10 @@ import androidx.annotation.Nullable;
import org.signal.core.util.ThreadUtil;
import org.signal.core.util.concurrent.SignalExecutors;
import org.signal.core.util.contentproviders.BlobProvider;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.components.voice.VoiceNoteDraft;
import org.thoughtcrime.securesms.dependencies.AppDependencies;
import org.thoughtcrime.securesms.notifications.v2.InChatNotificationSoundSuppressor;
import org.thoughtcrime.securesms.providers.BlobProvider;
import org.thoughtcrime.securesms.util.MediaUtil;
import java.io.IOException;
@@ -89,9 +88,9 @@ public class AudioRecorder {
ParcelFileDescriptor fds[] = ParcelFileDescriptor.createPipe();
BlobProvider.BlobBuilder blobBuilder = AppDependencies.getBlobs()
.forData(new ParcelFileDescriptor.AutoCloseInputStream(fds[0]), 0)
.withMimeType(MediaUtil.AUDIO_AAC);
BlobProvider.BlobBuilder blobBuilder = BlobProvider.getInstance()
.forData(new ParcelFileDescriptor.AutoCloseInputStream(fds[0]), 0)
.withMimeType(MediaUtil.AUDIO_AAC);
recordingUri = blobBuilder.buildUriForDraftAttachment();
recordingUriFuture = blobBuilder.createForDraftAttachmentAsync(context);
@@ -33,7 +33,7 @@ public final class AudioWaveFormGenerator {
*/
@WorkerThread
public static @NonNull AudioFileInfo generateWaveForm(@NonNull Context context, @NonNull Uri uri) throws IOException {
try (MediaInput dataSource = DecryptableUriMediaInput.INSTANCE.createForUri(context, uri)) {
try (MediaInput dataSource = DecryptableUriMediaInput.createForUri(context, uri)) {
long[] wave = new long[BAR_COUNT];
int[] waveSamples = new int[BAR_COUNT];
@@ -7,9 +7,9 @@ import androidx.annotation.AnyThread
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.schedulers.Schedulers
import io.reactivex.rxjava3.subjects.SingleSubject
import org.signal.core.models.database.AttachmentId
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.attachments.Attachment
import org.thoughtcrime.securesms.attachments.AttachmentId
import org.thoughtcrime.securesms.attachments.DatabaseAttachment
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.databaseprotos.AudioWaveFormData
@@ -38,8 +38,8 @@ object AvatarPickerStorage {
.getAllAvatars()
.filterIsInstance<Avatar.Photo>()
val inDatabaseFileNames = photoAvatars.mapTo(mutableSetOf()) { PartAuthority.getAvatarPickerFilename(it.uri) }
val onDiskFileNames = avatarFiles.mapTo(mutableSetOf()) { it.name }
val inDatabaseFileNames = photoAvatars.map { PartAuthority.getAvatarPickerFilename(it.uri) }
val onDiskFileNames = avatarFiles.map { it.name }
val inDatabaseButNotOnDisk = inDatabaseFileNames - onDiskFileNames
val onDiskButNotInDatabase = onDiskFileNames - inDatabaseFileNames
@@ -11,9 +11,9 @@ import androidx.appcompat.content.res.AppCompatResources
import com.airbnb.lottie.SimpleColorFilter
import org.signal.core.models.media.Media
import org.signal.core.util.concurrent.SignalExecutors
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.mms.PartAuthority
import org.thoughtcrime.securesms.profiles.AvatarHelper
import org.thoughtcrime.securesms.providers.BlobProvider
import org.thoughtcrime.securesms.util.MediaUtil
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
@@ -80,7 +80,7 @@ object AvatarRenderer {
private fun renderPhoto(context: Context, avatar: Avatar.Photo, onAvatarRendered: (Media) -> Unit) {
SignalExecutors.BOUNDED.execute {
val blob = AppDependencies.blobs
val blob = BlobProvider.getInstance()
.forData(AvatarPickerStorage.read(context, PartAuthority.getAvatarPickerFilename(avatar.uri)), avatar.size)
.createForSingleSessionOnDisk(context)
@@ -124,7 +124,7 @@ object AvatarRenderer {
val bytes = outStream.toByteArray()
val inStream = ByteArrayInputStream(bytes)
val uri = AppDependencies.blobs.forData(inStream, bytes.size.toLong()).createForSingleSessionOnDisk(context)
val uri = BlobProvider.getInstance().forData(inStream, bytes.size.toLong()).createForSingleSessionOnDisk(context)
onAvatarRendered(createMedia(uri, bytes.size.toLong()))
}
@@ -11,7 +11,7 @@ import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.avatar.AvatarBundler
import org.thoughtcrime.securesms.avatar.AvatarPickerStorage
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.providers.BlobProvider
import org.thoughtcrime.securesms.scribbles.ImageEditorFragment
class PhotoEditorFragment : Fragment(R.layout.avatar_photo_editor_fragment), ImageEditorFragment.Controller {
@@ -39,15 +39,15 @@ class PhotoEditorFragment : Fragment(R.layout.avatar_photo_editor_fragment), Ima
SignalExecutors.BOUNDED.execute {
val editedImageUri = imageEditorFragment.renderToSingleUseBlob()
val size = AppDependencies.blobs.getFileSize(editedImageUri) ?: 0
val inputStream = AppDependencies.blobs.getStream(applicationContext, editedImageUri)
val size = BlobProvider.getFileSize(editedImageUri) ?: 0
val inputStream = BlobProvider.getInstance().getStream(applicationContext, editedImageUri)
val onDiskUri = AvatarPickerStorage.save(applicationContext, inputStream)
val photo = AvatarBundler.extractPhoto(args.photoAvatar)
val database = SignalDatabase.avatarPicker
val newPhoto = photo.copy(uri = onDiskUri, size = size)
database.update(newPhoto)
AppDependencies.blobs.delete(requireContext(), photo.uri)
BlobProvider.getInstance().delete(requireContext(), photo.uri)
ThreadUtil.runOnMain {
setFragmentResult(REQUEST_KEY_EDIT, AvatarBundler.bundlePhoto(newPhoto))
@@ -1,11 +1,13 @@
package org.thoughtcrime.securesms.avatar.picker
import android.Manifest
import android.app.Activity
import android.content.Intent
import android.os.Bundle
import android.view.Gravity
import android.view.View
import android.widget.PopupMenu
import android.widget.Toast
import androidx.activity.result.ActivityResultLauncher
import androidx.appcompat.widget.Toolbar
import androidx.fragment.app.Fragment
@@ -28,10 +30,12 @@ import org.thoughtcrime.securesms.avatar.vector.VectorAvatarCreationFragment
import org.thoughtcrime.securesms.components.ButtonStripItemView
import org.thoughtcrime.securesms.components.recyclerview.GridDividerDecoration
import org.thoughtcrime.securesms.mediasend.AvatarSelectionActivity
import org.thoughtcrime.securesms.mediasend.camerax.CameraXRemoteConfig
import org.thoughtcrime.securesms.util.ViewUtil
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import org.thoughtcrime.securesms.util.navigation.safeNavigate
import org.thoughtcrime.securesms.util.visible
import org.signal.core.ui.R as CoreUiR
/**
* Primary Avatar picker fragment, displays current user avatar and a list of recently used avatars and defaults.
@@ -219,8 +223,22 @@ class AvatarPickerFragment : Fragment(R.layout.avatar_picker_fragment) {
@Suppress("DEPRECATION")
private fun openCameraCapture() {
val intent = AvatarSelectionActivity.getIntentForCameraCapture(requireContext())
startActivityForResult(intent, REQUEST_CODE_SELECT_IMAGE)
if (CameraXRemoteConfig.isSupported()) {
val intent = AvatarSelectionActivity.getIntentForCameraCapture(requireContext())
startActivityForResult(intent, REQUEST_CODE_SELECT_IMAGE)
} else {
Permissions.with(this)
.request(Manifest.permission.CAMERA)
.ifNecessary()
.onAllGranted {
val intent = AvatarSelectionActivity.getIntentForCameraCapture(requireContext())
startActivityForResult(intent, REQUEST_CODE_SELECT_IMAGE)
}
.withRationaleDialog(getString(R.string.CameraXFragment_allow_access_camera), getString(R.string.CameraXFragment_to_capture_photos_allow_camera), CoreUiR.drawable.symbol_camera_24)
.withPermanentDenialDialog(getString(R.string.AvatarSelectionBottomSheetDialogFragment__taking_a_photo_requires_the_camera_permission), null, R.string.CameraXFragment_allow_access_camera, R.string.CameraXFragment_to_capture_photos, getParentFragmentManager())
.onAnyDenied { Toast.makeText(requireContext(), R.string.AvatarSelectionBottomSheetDialogFragment__taking_a_photo_requires_the_camera_permission, Toast.LENGTH_SHORT).show() }
.execute()
}
}
@Suppress("DEPRECATION")
@@ -16,9 +16,9 @@ import org.thoughtcrime.securesms.avatar.AvatarRenderer
import org.thoughtcrime.securesms.avatar.Avatars
import org.thoughtcrime.securesms.conversation.colors.AvatarColor
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.profiles.AvatarHelper
import org.thoughtcrime.securesms.providers.BlobProvider
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.NameUtil
import org.whispersystems.signalservice.api.util.StreamDetails
@@ -36,7 +36,7 @@ class AvatarPickerRepository(context: Context) {
try {
val bytes = StreamUtil.readFully(details.stream)
Avatar.Photo(
AppDependencies.blobs.forData(bytes).createForSingleSessionInMemory(),
BlobProvider.getInstance().forData(bytes).createForSingleSessionInMemory(),
details.length,
Avatar.DatabaseId.DoNotPersist
)
@@ -56,7 +56,7 @@ class AvatarPickerRepository(context: Context) {
try {
val bytes = AvatarHelper.getAvatarBytes(applicationContext, recipient.id)
Avatar.Photo(
AppDependencies.blobs.forData(bytes).createForSingleSessionInMemory(),
BlobProvider.getInstance().forData(bytes).createForSingleSessionInMemory(),
AvatarHelper.getAvatarLength(applicationContext, recipient.id),
Avatar.DatabaseId.DoNotPersist
)
@@ -6,24 +6,20 @@
package org.thoughtcrime.securesms.backup
import androidx.annotation.WorkerThread
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.withContext
import org.signal.core.models.database.AttachmentId
import org.signal.core.util.bytes
import org.signal.core.util.logging.Log
import org.signal.core.util.throttleLatest
import org.thoughtcrime.securesms.BuildConfig
import org.thoughtcrime.securesms.attachments.AttachmentId
import org.thoughtcrime.securesms.backup.v2.BackupRepository
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.dependencies.AppDependencies
@@ -50,8 +46,6 @@ object ArchiveUploadProgress {
private val TAG = Log.tag(ArchiveUploadProgress::class)
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val _progress: MutableSharedFlow<Unit> = MutableSharedFlow(replay = 1)
private var uploadProgress: ArchiveUploadProgressState = SignalStore.backup.archiveUploadState ?: ArchiveUploadProgressState(
@@ -67,7 +61,7 @@ object ArchiveUploadProgress {
/**
* Observe this to get updates on the current upload progress.
*/
val progress: SharedFlow<ArchiveUploadProgressState> = _progress
val progress: Flow<ArchiveUploadProgressState> = _progress
.throttleLatest(500.milliseconds) {
uploadProgress.state == ArchiveUploadProgressState.State.None ||
(uploadProgress.state == ArchiveUploadProgressState.State.UploadBackupFile && uploadProgress.backupFileUploadedBytes == 0L) ||
@@ -120,11 +114,6 @@ object ArchiveUploadProgress {
}
.onStart { emit(uploadProgress) }
.flowOn(Dispatchers.IO)
.shareIn(scope, SharingStarted.Eagerly, replay = 1)
init {
_progress.tryEmit(Unit)
}
val inProgress
get() = uploadProgress.state != ArchiveUploadProgressState.State.None && uploadProgress.state != ArchiveUploadProgressState.State.UserCanceled
@@ -11,7 +11,7 @@ import org.signal.core.util.Conversions;
import org.signal.core.util.logging.Log;
import org.signal.libsignal.protocol.kdf.HKDF;
import org.signal.libsignal.protocol.util.ByteUtil;
import org.signal.core.models.database.AttachmentId;
import org.thoughtcrime.securesms.attachments.AttachmentId;
import org.thoughtcrime.securesms.backup.proto.Attachment;
import org.thoughtcrime.securesms.backup.proto.Avatar;
import org.thoughtcrime.securesms.backup.proto.BackupFrame;
@@ -6,7 +6,7 @@ import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.signal.core.util.logging.Log;
import org.signal.core.util.crypto.KeyStoreHelper;
import org.thoughtcrime.securesms.crypto.KeyStoreHelper;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
/**
@@ -19,13 +19,13 @@ import org.signal.core.util.SetUtil;
import org.signal.core.util.SqlUtil;
import org.signal.core.util.Stopwatch;
import org.signal.core.util.logging.Log;
import org.signal.core.models.database.AttachmentId;
import org.thoughtcrime.securesms.attachments.AttachmentId;
import org.thoughtcrime.securesms.backup.proto.KeyValue;
import org.thoughtcrime.securesms.backup.proto.SharedPreference;
import org.thoughtcrime.securesms.backup.proto.SqlStatement;
import org.signal.core.util.crypto.AttachmentSecret;
import org.signal.core.util.crypto.ClassicDecryptingPartInputStream;
import org.signal.core.util.crypto.ModernDecryptingPartInputStream;
import org.thoughtcrime.securesms.crypto.AttachmentSecret;
import org.thoughtcrime.securesms.crypto.ClassicDecryptingPartInputStream;
import org.thoughtcrime.securesms.crypto.ModernDecryptingPartInputStream;
import org.thoughtcrime.securesms.database.AttachmentTable;
import org.thoughtcrime.securesms.database.BackupMediaSnapshotTable;
import org.thoughtcrime.securesms.database.EmojiSearchTable;
@@ -24,8 +24,8 @@ import org.thoughtcrime.securesms.backup.proto.KeyValue;
import org.thoughtcrime.securesms.backup.proto.SharedPreference;
import org.thoughtcrime.securesms.backup.proto.SqlStatement;
import org.thoughtcrime.securesms.backup.proto.Sticker;
import org.signal.core.util.crypto.AttachmentSecret;
import org.signal.core.util.crypto.ModernEncryptingPartOutputStream;
import org.thoughtcrime.securesms.crypto.AttachmentSecret;
import org.thoughtcrime.securesms.crypto.ModernEncryptingPartOutputStream;
import org.thoughtcrime.securesms.database.AttachmentTable;
import org.thoughtcrime.securesms.database.EmojiSearchTable;
import org.thoughtcrime.securesms.database.KeyValueDatabase;
@@ -16,13 +16,13 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.update
import org.signal.core.models.database.AttachmentId
import org.signal.core.util.bytes
import org.signal.core.util.concurrent.SignalExecutors
import org.signal.core.util.logging.Log
import org.signal.core.util.safeUnregisterReceiver
import org.signal.core.util.throttleLatest
import org.thoughtcrime.securesms.BuildConfig
import org.thoughtcrime.securesms.attachments.AttachmentId
import org.thoughtcrime.securesms.backup.RestoreState
import org.thoughtcrime.securesms.database.DatabaseObserver
import org.thoughtcrime.securesms.database.SignalDatabase
@@ -31,9 +31,6 @@ import org.thoughtcrime.securesms.jobmanager.impl.BatteryNotLowConstraint
import org.thoughtcrime.securesms.jobmanager.impl.DiskSpaceNotLowConstraint
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint
import org.thoughtcrime.securesms.jobmanager.impl.WifiConstraint
import org.thoughtcrime.securesms.jobs.CheckRestoreMediaLeftJob
import org.thoughtcrime.securesms.jobs.RestoreAttachmentJob
import org.thoughtcrime.securesms.jobs.RestoreLocalAttachmentJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicInteger
@@ -160,22 +157,6 @@ object ArchiveRestoreProgress {
update()
}
/**
* Self-heal hook for restores that appear active (banner showing, media still remaining) but have no jobs left actually working on them.
*/
fun checkForStalledRestore() {
SignalExecutors.BOUNDED.execute {
val stalled = SignalStore.backup.restoreState.isMediaRestoreOperation &&
SignalDatabase.attachments.getRemainingRestorableAttachmentSize() > 0L &&
AppDependencies.jobManager.areFactoriesEmpty(setOf(RestoreAttachmentJob.KEY, RestoreLocalAttachmentJob.KEY, CheckRestoreMediaLeftJob.KEY))
if (stalled) {
Log.w(TAG, "Detected a stalled media restore with no active jobs. Enqueueing a check job to recover.")
CheckRestoreMediaLeftJob.enqueueStalledRecoveryCheck()
}
}
}
fun clearLocalRestoreDirectoryError() {
SignalStore.backup.localRestoreDirectoryError = false
update()
@@ -34,7 +34,6 @@ import org.signal.core.models.backup.BackupId
import org.signal.core.models.backup.MediaName
import org.signal.core.models.backup.MediaRootBackupKey
import org.signal.core.models.backup.MessageBackupKey
import org.signal.core.models.database.AttachmentId
import org.signal.core.util.Base64
import org.signal.core.util.Base64.decodeBase64OrThrow
import org.signal.core.util.CursorUtil
@@ -47,7 +46,6 @@ import org.signal.core.util.bytes
import org.signal.core.util.concurrent.LimitedWorker
import org.signal.core.util.concurrent.SignalDispatchers
import org.signal.core.util.concurrent.SignalExecutors
import org.signal.core.util.crypto.AttachmentSecretProvider
import org.signal.core.util.decodeOrNull
import org.signal.core.util.forceForeignKeyConstraintsEnabled
import org.signal.core.util.fullWalCheckpoint
@@ -74,7 +72,9 @@ import org.signal.network.NetworkResult
import org.signal.network.StatusCodeErrorAction
import org.signal.network.api.SvrBApi
import org.signal.network.exceptions.NonSuccessfulResponseCodeException
import org.signal.network.rest.toNetworkResult
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.attachments.AttachmentId
import org.thoughtcrime.securesms.attachments.Cdn
import org.thoughtcrime.securesms.attachments.DatabaseAttachment
import org.thoughtcrime.securesms.backup.ArchiveUploadProgress
@@ -94,7 +94,7 @@ import org.thoughtcrime.securesms.backup.v2.ui.BackupAlert
import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsType
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
import org.thoughtcrime.securesms.components.settings.app.subscription.RecurringInAppPaymentRepository
import org.thoughtcrime.securesms.crypto.AppAttachmentSecretStore
import org.thoughtcrime.securesms.crypto.AttachmentSecretProvider
import org.thoughtcrime.securesms.crypto.DatabaseSecretProvider
import org.thoughtcrime.securesms.database.AttachmentTable
import org.thoughtcrime.securesms.database.BackupMediaSnapshotTable.ArchiveMediaItem
@@ -141,6 +141,7 @@ import org.thoughtcrime.securesms.logsubmit.SubmitDebugLogRepository
import org.thoughtcrime.securesms.net.SignalNetwork
import org.thoughtcrime.securesms.notifications.NotificationChannels
import org.thoughtcrime.securesms.notifications.NotificationIds
import org.thoughtcrime.securesms.providers.BlobProvider
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.service.BackupMediaRestoreService
@@ -717,7 +718,7 @@ object BackupRepository {
SignalDatabase(
context = context,
databaseSecret = DatabaseSecretProvider.getOrCreateDatabaseSecret(context),
attachmentSecret = AttachmentSecretProvider.getInstance(context, AppAttachmentSecretStore).getOrCreateAttachmentSecret(),
attachmentSecret = AttachmentSecretProvider.getInstance(context).getOrCreateAttachmentSecret(),
name = "$baseName.db"
)
}
@@ -1464,7 +1465,6 @@ object BackupRepository {
}
SignalDatabase.remappedRecords.clearCache()
SignalDatabase.remappedRecords.trimStaleMappings()
AppDependencies.recipientCache.clear()
AppDependencies.recipientCache.warmUp()
SignalDatabase.threads.clearCache()
@@ -2235,7 +2235,7 @@ object BackupRepository {
}
Log.i(TAG, "[remoteRestore] Downloading backup")
val tempBackupFile = AppDependencies.blobs.forNonAutoEncryptingSingleSessionOnDisk(AppDependencies.application)
val tempBackupFile = BlobProvider.getInstance().forNonAutoEncryptingSingleSessionOnDisk(AppDependencies.application)
when (val result = downloadBackupFile(tempBackupFile, progressListener)) {
is NetworkResult.Success -> Log.i(TAG, "[remoteRestore] Download successful")
else -> {
@@ -2355,7 +2355,7 @@ object BackupRepository {
}
Log.i(TAG, "[restoreLinkAndSyncBackup] Downloading backup")
val tempBackupFile = AppDependencies.blobs.forNonAutoEncryptingSingleSessionOnDisk(AppDependencies.application)
val tempBackupFile = BlobProvider.getInstance().forNonAutoEncryptingSingleSessionOnDisk(AppDependencies.application)
when (val result = AppDependencies.signalServiceMessageReceiver.retrieveLinkAndSyncBackup(response.cdn, response.key, tempBackupFile, progressListener)) {
is NetworkResult.Success -> Log.i(TAG, "[restoreLinkAndSyncBackup] Download successful")
else -> {
@@ -2558,9 +2558,6 @@ enum class BackupMode {
val isLocalBackup: Boolean
get() = this == LOCAL
val isPlaintextExport: Boolean
get() = this == PLAINTEXT_EXPORT
}
/**
@@ -5,8 +5,8 @@
package org.thoughtcrime.securesms.backup.v2
import org.signal.core.models.database.AttachmentId
import org.signal.core.util.concurrent.SignalExecutors
import org.thoughtcrime.securesms.attachments.AttachmentId
import org.thoughtcrime.securesms.attachments.DatabaseAttachment
import org.thoughtcrime.securesms.database.AttachmentTable
import org.thoughtcrime.securesms.database.model.MessageRecord
@@ -5,7 +5,7 @@
package org.thoughtcrime.securesms.backup.v2
import org.signal.core.models.database.AttachmentId
import org.thoughtcrime.securesms.attachments.AttachmentId
import org.whispersystems.signalservice.api.archive.BatchArchiveMediaResponse
/**
@@ -123,7 +123,7 @@ fun DatabaseAttachment.createArchiveAttachmentPointer(useArchiveCdn: Boolean): S
throw InvalidAttachmentException("empty content id")
}
SignalServiceAttachmentRemoteId.from(remoteLocation, cdn.cdnNumber) to cdn.cdnNumber
SignalServiceAttachmentRemoteId.from(remoteLocation) to cdn.cdnNumber
}
val key = Base64.decode(remoteKey)
@@ -5,8 +5,8 @@
package org.thoughtcrime.securesms.backup.v2.database
import org.signal.core.models.database.AttachmentId
import org.thoughtcrime.securesms.attachments.Attachment
import org.thoughtcrime.securesms.attachments.AttachmentId
import org.thoughtcrime.securesms.database.AttachmentTable
fun AttachmentTable.restoreWallpaperAttachment(attachment: Attachment): AttachmentId? {
@@ -167,11 +167,7 @@ fun MessageTable.getMessagesForBackup(db: SignalDatabase, backupTime: Long, self
.where(
buildString {
append("$STORY_TYPE = 0 AND $PARENT_STORY_ID <= 0 AND $SCHEDULED_DATE = -1 AND ")
if (exportState.backupMode.isPlaintextExport) {
append("$EXPIRES_IN == 0")
} else {
append("($EXPIRES_IN == 0 OR $EXPIRES_IN > ${1.days.inWholeMilliseconds})")
}
append("($EXPIRES_IN == 0 OR $EXPIRES_IN > ${1.days.inWholeMilliseconds})")
append(" AND $DATE_RECEIVED >= $lastSeenReceivedTime $cutoffQuery")
}
)
@@ -42,7 +42,6 @@ import org.signal.archive.proto.Text
import org.signal.archive.proto.ThreadMergeChatUpdate
import org.signal.archive.proto.ViewOnceMessage
import org.signal.core.models.ServiceId
import org.signal.core.models.database.AttachmentId
import org.signal.core.util.Base64
import org.signal.core.util.EventTimer
import org.signal.core.util.Hex
@@ -69,6 +68,7 @@ import org.signal.core.util.requireLong
import org.signal.core.util.requireLongOrNull
import org.signal.core.util.requireString
import org.signal.core.util.toByteArray
import org.thoughtcrime.securesms.attachments.AttachmentId
import org.thoughtcrime.securesms.attachments.DatabaseAttachment
import org.thoughtcrime.securesms.backup.v2.BackupMode
import org.thoughtcrime.securesms.backup.v2.ExportOddities
@@ -435,7 +435,7 @@ class ChatItemArchiveExporter(
else -> {
val attachments = extraData.attachmentsById[record.id]
val sticker = attachments?.firstOrNull { dbAttachment -> dbAttachment.isSticker && !dbAttachment.quote }
val sticker = attachments?.firstOrNull { dbAttachment -> dbAttachment.isSticker }
if (sticker?.stickerLocator != null) {
builder.stickerMessage = sticker.toRemoteStickerMessage(sentTimestamp = record.dateSent, reactions = extraData.reactionsById[id], exportState = exportState)
@@ -647,22 +647,15 @@ private fun BackupMessageRecord.toBasicChatItemBuilder(selfRecipientId: Recipien
}
}
if (!MessageTypes.isExpirationTimerUpdate(record.type) && builder.expiresInMs != null) {
if (exportState.backupMode.isPlaintextExport) {
if (!MessageTypes.isExpirationTimerUpdate(record.type) && builder.expiresInMs != null && builder.expireStartDate != null) {
val cutoffDuration = ChatItemArchiveExporter.EXPIRATION_CUTOFF.inWholeMilliseconds
val expiresAt = builder.expireStartDate!! + builder.expiresInMs!!
val threshold = if (exportState.backupMode.isLinkAndSync) backupStartTime else backupStartTime + cutoffDuration
if (expiresAt < threshold || (builder.expiresInMs!! <= cutoffDuration && !exportState.backupMode.isLinkAndSync)) {
Log.w(TAG, ExportSkips.messageExpiresTooSoon(record.dateSent))
return null
}
if (builder.expireStartDate != null) {
val cutoffDuration = ChatItemArchiveExporter.EXPIRATION_CUTOFF.inWholeMilliseconds
val expiresAt = builder.expireStartDate!! + builder.expiresInMs!!
val threshold = if (exportState.backupMode.isLinkAndSync) backupStartTime else backupStartTime + cutoffDuration
if (expiresAt < threshold || (builder.expiresInMs!! <= cutoffDuration && !exportState.backupMode.isLinkAndSync)) {
Log.w(TAG, ExportSkips.messageExpiresTooSoon(record.dateSent))
return null
}
}
}
if (builder.expireStartDate != null && builder.expiresInMs == null) {
@@ -859,8 +852,8 @@ private fun BackupMessageRecord.toRemotePaymentNotificationUpdate(db: SignalData
PaymentNotification()
} else {
PaymentNotification(
amountMob = payment.amount.requireMobileCoin().amountDecimalString,
feeMob = payment.fee.requireMobileCoin().amountDecimalString,
amountMob = payment.amount.serializeAmountString(),
feeMob = payment.fee.serializeAmountString(),
note = payment.note.takeUnless { it.isEmpty() },
transactionDetails = payment.toRemoteTransactionDetails()
)
@@ -7,10 +7,10 @@ package org.thoughtcrime.securesms.backup.v2.importer
import androidx.core.content.contentValuesOf
import org.signal.archive.proto.Chat
import org.signal.core.models.database.AttachmentId
import org.signal.core.util.SqlUtil
import org.signal.core.util.insertInto
import org.signal.core.util.toInt
import org.thoughtcrime.securesms.attachments.AttachmentId
import org.thoughtcrime.securesms.backup.v2.ImportState
import org.thoughtcrime.securesms.backup.v2.database.restoreWallpaperAttachment
import org.thoughtcrime.securesms.backup.v2.util.parseChatWallpaper
@@ -60,6 +60,7 @@ import org.thoughtcrime.securesms.database.documents.NetworkFailureSet
import org.thoughtcrime.securesms.database.model.GroupCallUpdateDetailsUtil
import org.thoughtcrime.securesms.database.model.Mention
import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList
import org.thoughtcrime.securesms.database.model.databaseprotos.CryptoValue
import org.thoughtcrime.securesms.database.model.databaseprotos.GV2UpdateDescription
import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge
import org.thoughtcrime.securesms.database.model.databaseprotos.MessageExtras
@@ -84,7 +85,7 @@ import org.thoughtcrime.securesms.util.Environment
import org.thoughtcrime.securesms.util.MessageUtil
import org.whispersystems.signalservice.api.payments.Money
import org.whispersystems.signalservice.internal.push.DataMessage
import java.math.BigDecimal
import java.math.BigInteger
import java.sql.SQLException
import java.util.Optional
import java.util.UUID
@@ -1063,8 +1064,8 @@ class ChatItemArchiveImporter(
private fun ContentValues.addPaymentTombstoneNoMetadata(paymentNotification: PaymentNotification) {
put(MessageTable.TYPE, getAsLong(MessageTable.TYPE) or MessageTypes.SPECIAL_TYPE_PAYMENTS_TOMBSTONE)
val amount = paymentNotification.amountMob?.tryParseMoney()?.let { CryptoValueUtil.moneyToCryptoValue(it) }
val fee = paymentNotification.feeMob?.tryParseMoney()?.let { CryptoValueUtil.moneyToCryptoValue(it) }
val amount = tryParseCryptoValue(paymentNotification.amountMob)
val fee = tryParseCryptoValue(paymentNotification.feeMob)
put(
MessageTable.MESSAGE_EXTRAS,
MessageExtras(
@@ -1118,15 +1119,26 @@ class ChatItemArchiveImporter(
return null
}
return try {
Money.mobileCoin(BigDecimal(this))
} catch (e: NumberFormatException) {
null
} catch (e: ArithmeticException) {
val amountCryptoValue = tryParseCryptoValue(this)
return if (amountCryptoValue != null) {
CryptoValueUtil.cryptoValueToMoney(amountCryptoValue)
} else {
null
}
}
private fun tryParseCryptoValue(bigIntegerString: String?): CryptoValue? {
if (bigIntegerString == null) {
return null
}
val amount = try {
BigInteger(bigIntegerString).toString()
} catch (e: NumberFormatException) {
return null
}
return CryptoValue(mobileCoinValue = CryptoValue.MobileCoinValue(picoMobileCoin = amount))
}
private fun ContentValues.addQuote(quote: Quote) {
this.put(MessageTable.QUOTE_ID, quote.targetSentTimestamp ?: MessageTable.QUOTE_TARGET_MISSING_ID)
this.put(MessageTable.QUOTE_AUTHOR, importState.requireLocalRecipientId(quote.authorId).serialize())
@@ -16,7 +16,6 @@ import org.signal.archive.stream.EncryptedBackupReader
import org.signal.core.models.backup.BackupId
import org.signal.core.models.backup.MediaName
import org.signal.core.models.backup.MessageBackupKey
import org.signal.core.models.database.AttachmentId
import org.signal.core.util.Stopwatch
import org.signal.core.util.StreamUtil
import org.signal.core.util.Util
@@ -24,6 +23,7 @@ import org.signal.core.util.logging.Log
import org.signal.core.util.readFully
import org.signal.core.util.toJson
import org.signal.libsignal.crypto.Aes256Ctr32
import org.thoughtcrime.securesms.attachments.AttachmentId
import org.thoughtcrime.securesms.backup.LocalExportProgress
import org.thoughtcrime.securesms.backup.v2.BackupRepository
import org.thoughtcrime.securesms.database.AttachmentTable
@@ -12,12 +12,11 @@ import org.signal.archive.proto.AccountData
import org.signal.archive.proto.ChatStyle
import org.signal.archive.proto.Frame
import org.signal.archive.stream.BackupFrameEmitter
import org.signal.core.models.database.AttachmentId
import org.signal.core.util.UuidUtil
import org.signal.core.util.logging.Log
import org.signal.core.util.toByteArray
import org.signal.libsignal.zkgroup.backups.BackupLevel
import org.signal.mediasend.SentMediaQuality
import org.thoughtcrime.securesms.attachments.AttachmentId
import org.thoughtcrime.securesms.backup.v2.ExportState
import org.thoughtcrime.securesms.backup.v2.ImportState
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
@@ -472,19 +471,19 @@ object AccountDataArchiveProcessor {
}
}
private fun SentMediaQuality.toRemoteSentMediaQuality(): AccountData.SentMediaQuality {
private fun org.thoughtcrime.securesms.mms.SentMediaQuality.toRemoteSentMediaQuality(): AccountData.SentMediaQuality {
return when (this) {
SentMediaQuality.STANDARD -> AccountData.SentMediaQuality.STANDARD
SentMediaQuality.HIGH -> AccountData.SentMediaQuality.HIGH
org.thoughtcrime.securesms.mms.SentMediaQuality.STANDARD -> AccountData.SentMediaQuality.STANDARD
org.thoughtcrime.securesms.mms.SentMediaQuality.HIGH -> AccountData.SentMediaQuality.HIGH
}
}
private fun AccountData.SentMediaQuality?.toLocalSentMediaQuality(): SentMediaQuality {
private fun AccountData.SentMediaQuality?.toLocalSentMediaQuality(): org.thoughtcrime.securesms.mms.SentMediaQuality {
return when (this) {
AccountData.SentMediaQuality.HIGH -> SentMediaQuality.HIGH
AccountData.SentMediaQuality.STANDARD -> SentMediaQuality.STANDARD
AccountData.SentMediaQuality.UNKNOWN_QUALITY -> SentMediaQuality.STANDARD
null -> SentMediaQuality.STANDARD
AccountData.SentMediaQuality.HIGH -> org.thoughtcrime.securesms.mms.SentMediaQuality.HIGH
AccountData.SentMediaQuality.STANDARD -> org.thoughtcrime.securesms.mms.SentMediaQuality.STANDARD
AccountData.SentMediaQuality.UNKNOWN_QUALITY -> org.thoughtcrime.securesms.mms.SentMediaQuality.STANDARD
null -> org.thoughtcrime.securesms.mms.SentMediaQuality.STANDARD
}
}
@@ -1,85 +0,0 @@
package org.thoughtcrime.securesms.backup.v2.ui.subscription
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.horizontalGutters
import org.thoughtcrime.securesms.R
/**
* Bottom sheet shown when confirming your recovery key after saving to password manager
*/
@Composable
fun ConfirmRecoveryKeySheet(
onConfirm: () -> Unit = {},
onSeeAgain: () -> Unit = {},
modifier: Modifier = Modifier
) {
Column(
modifier = modifier.horizontalGutters(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
imageVector = ImageVector.vectorResource(R.drawable.backup_confirm_80),
tint = Color.Unspecified,
contentDescription = null,
modifier = Modifier.padding(top = 24.dp, bottom = 16.dp)
)
Text(
text = stringResource(R.string.MessageBackupsKeyVerifyScreen__confirm_your_backup_key),
style = MaterialTheme.typography.titleLarge,
textAlign = TextAlign.Center,
modifier = Modifier.padding(bottom = 12.dp)
)
Text(
text = stringResource(R.string.MessageBackupsKeyRecordScreen__confirm_that_your_recovery),
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.size(60.dp))
Buttons.LargeTonal(onClick = onConfirm) {
Text(text = stringResource(R.string.MessageBackupsKeyRecordScreen__confirm_recovery))
}
TextButton(
onClick = onSeeAgain,
modifier = Modifier.padding(vertical = 16.dp)
) {
Text(text = stringResource(R.string.MessageBackupsKeyRecordScreen__see_key_again))
}
}
}
@DayNightPreviews
@Composable
private fun ConfirmRecoveryKeyPreview() {
Previews.BottomSheetContentPreview {
ConfirmRecoveryKeySheet(
onConfirm = {},
onSeeAgain = {},
modifier = Modifier.fillMaxSize()
)
}
}
@@ -68,8 +68,7 @@ class MessageBackupsFlowFragment : ComposeFragment(), InAppPaymentCheckoutDelega
private val viewModel: MessageBackupsFlowViewModel by viewModel {
MessageBackupsFlowViewModel(
initialTierSelection = requireArguments().getSerializableCompat(TIER, MessageBackupTier::class.java),
googlePlayApiAvailability = GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(requireContext()),
isCredentialManagerSupported = AndroidCredentialRepository.isCredentialManagerSupported(requireContext())
googlePlayApiAvailability = GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(requireContext())
)
}
@@ -156,32 +155,6 @@ class MessageBackupsFlowFragment : ComposeFragment(), InAppPaymentCheckoutDelega
val context = LocalContext.current
val passwordManagerSettingsIntent = AndroidCredentialRepository.getCredentialManagerSettingsIntent(requireContext())
MessageBackupsKeyRecordScreen(
backupKey = state.accountEntropyPool.displayValue,
keySaveState = state.backupKeySaveState,
canOpenPasswordManagerSettings = passwordManagerSettingsIntent != null,
onNavigationClick = viewModel::goToPreviousStage,
mode = remember {
MessageBackupsKeyRecordMode.Passkey(
onSaveToPasswordManager = viewModel::onBackupKeySaveRequested,
onSaveManually = viewModel::goToRecordManually,
onSaveSuccessful = viewModel::goToNextStage
)
},
onCopyToClipboardClick = { Util.copyToClipboard(context, it, CLIPBOARD_TIMEOUT_SECONDS) },
onRequestSaveToPasswordManager = viewModel::onBackupKeySaveRequested,
onConfirmSaveToPasswordManager = viewModel::onBackupKeySaveConfirmed,
onSaveStateCleared = viewModel::onBackupKeySaveStateCleared,
onSaveToPasswordManagerComplete = viewModel::onBackupKeySaveCompleted,
onGoToPasswordManagerSettingsClick = { requireContext().startActivity(passwordManagerSettingsIntent) },
notifyKeyIsSameAsOnDeviceBackupKey = SignalStore.backup.newLocalBackupsEnabled
)
}
composable(route = MessageBackupsStage.Route.BACKUP_KEY_RECORD_MANUALLY.name) {
val context = LocalContext.current
val passwordManagerSettingsIntent = AndroidCredentialRepository.getCredentialManagerSettingsIntent(requireContext())
MessageBackupsKeyRecordScreen(
backupKey = state.accountEntropyPool.displayValue,
keySaveState = state.backupKeySaveState,
@@ -53,7 +53,6 @@ import kotlin.time.Duration.Companion.seconds
class MessageBackupsFlowViewModel(
private val initialTierSelection: MessageBackupTier?,
googlePlayApiAvailability: Int,
private val isCredentialManagerSupported: Boolean,
startScreen: MessageBackupsStage = if (SignalStore.backup.backupTier == null) MessageBackupsStage.EDUCATION else MessageBackupsStage.TYPE_SELECTION
) : ViewModel(), BackupKeyCredentialManagerHandler {
@@ -239,9 +238,8 @@ class MessageBackupsFlowViewModel(
when (it.stage) {
MessageBackupsStage.CANCEL -> error("Unsupported state transition from terminal state CANCEL")
MessageBackupsStage.EDUCATION -> it.copy(stage = MessageBackupsStage.BACKUP_KEY_EDUCATION)
MessageBackupsStage.BACKUP_KEY_EDUCATION -> it.copy(stage = if (isCredentialManagerSupported) MessageBackupsStage.BACKUP_KEY_RECORD else MessageBackupsStage.BACKUP_KEY_RECORD_MANUALLY)
MessageBackupsStage.BACKUP_KEY_RECORD -> it.copy(stage = MessageBackupsStage.TYPE_SELECTION)
MessageBackupsStage.BACKUP_KEY_RECORD_MANUALLY -> it.copy(stage = MessageBackupsStage.BACKUP_KEY_VERIFY)
MessageBackupsStage.BACKUP_KEY_EDUCATION -> it.copy(stage = MessageBackupsStage.BACKUP_KEY_RECORD)
MessageBackupsStage.BACKUP_KEY_RECORD -> it.copy(stage = MessageBackupsStage.BACKUP_KEY_VERIFY)
MessageBackupsStage.BACKUP_KEY_VERIFY -> it.copy(stage = MessageBackupsStage.TYPE_SELECTION)
MessageBackupsStage.TYPE_SELECTION -> validateTypeAndUpdateState(it)
MessageBackupsStage.CHECKOUT_SHEET -> it.copy(stage = MessageBackupsStage.PROCESS_PAYMENT)
@@ -264,8 +262,7 @@ class MessageBackupsFlowViewModel(
MessageBackupsStage.EDUCATION -> MessageBackupsStage.CANCEL
MessageBackupsStage.BACKUP_KEY_EDUCATION -> MessageBackupsStage.EDUCATION
MessageBackupsStage.BACKUP_KEY_RECORD -> MessageBackupsStage.BACKUP_KEY_EDUCATION
MessageBackupsStage.BACKUP_KEY_RECORD_MANUALLY -> if (isCredentialManagerSupported) MessageBackupsStage.BACKUP_KEY_RECORD else MessageBackupsStage.BACKUP_KEY_EDUCATION
MessageBackupsStage.BACKUP_KEY_VERIFY -> MessageBackupsStage.BACKUP_KEY_RECORD_MANUALLY
MessageBackupsStage.BACKUP_KEY_VERIFY -> MessageBackupsStage.BACKUP_KEY_RECORD
MessageBackupsStage.TYPE_SELECTION -> MessageBackupsStage.BACKUP_KEY_RECORD
MessageBackupsStage.CHECKOUT_SHEET -> MessageBackupsStage.TYPE_SELECTION
MessageBackupsStage.CREATING_IN_APP_PAYMENT -> MessageBackupsStage.CREATING_IN_APP_PAYMENT
@@ -280,12 +277,6 @@ class MessageBackupsFlowViewModel(
}
}
fun goToRecordManually() {
internalStateFlow.update {
it.copy(stage = MessageBackupsStage.BACKUP_KEY_RECORD_MANUALLY)
}
}
fun onMessageBackupTierUpdated(messageBackupTier: MessageBackupTier) {
internalStateFlow.update {
it.copy(
@@ -5,7 +5,6 @@
package org.thoughtcrime.securesms.backup.v2.ui.subscription
import android.widget.Toast
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.compose.foundation.Image
@@ -29,7 +28,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
@@ -48,7 +46,6 @@ import org.signal.core.ui.compose.SignalIcons
import org.signal.core.ui.compose.horizontalGutters
import org.signal.core.ui.compose.theme.SignalTheme
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.compose.rememberBiometricsAuthentication
import org.signal.core.ui.R as CoreUiR
enum class MessageBackupsKeyEducationScreenMode {
@@ -83,14 +80,6 @@ fun MessageBackupsKeyEducationScreen(
mode: MessageBackupsKeyEducationScreenMode = MessageBackupsKeyEducationScreenMode.DEFAULT
) {
val scrollState = rememberScrollState()
val context = LocalContext.current
val biometrics = rememberBiometricsAuthentication(
promptTitle = stringResource(R.string.RemoteBackupsSettingsFragment__unlock_to_view_backup_key),
educationSheetMessage = stringResource(R.string.RemoteBackupsSettingsFragment__to_view_your_key),
onAuthenticationFailed = {
Toast.makeText(context, R.string.RemoteBackupsSettingsFragment__authentication_required, Toast.LENGTH_SHORT).show()
}
)
Scaffolds.Settings(
title = "",
@@ -150,7 +139,7 @@ fun MessageBackupsKeyEducationScreen(
.padding(top = 16.dp, bottom = 24.dp)
) {
Buttons.LargeTonal(
onClick = { biometrics.withBiometricsAuthentication(onNextClick) },
onClick = onNextClick,
modifier = Modifier.align(Alignment.Center)
) {
Text(
@@ -6,22 +6,14 @@
package org.thoughtcrime.securesms.backup.v2.ui.subscription
import android.content.Context
import android.widget.Toast
import androidx.activity.compose.BackHandler
import androidx.activity.compose.LocalOnBackPressedDispatcherOwner
import androidx.annotation.UiContext
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.SizeTransform
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
@@ -30,7 +22,6 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.SnackbarHostState
@@ -43,16 +34,9 @@ import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.CompositingStrategy
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
@@ -64,8 +48,6 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.launch
import org.signal.core.ui.compose.BottomSheets
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Dialogs
@@ -77,15 +59,11 @@ import org.signal.core.ui.compose.horizontalGutters
import org.signal.core.ui.compose.theme.SignalTheme
import org.signal.core.util.Util
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.backup.v2.ui.warning.ClipStage
import org.thoughtcrime.securesms.backup.v2.ui.warning.RecoveryKeyWarningSheetContent
import org.thoughtcrime.securesms.backup.v2.ui.warning.RecoveryKeyWarningSheetEvent
import org.thoughtcrime.securesms.components.TemporaryScreenshotSecurity
import org.thoughtcrime.securesms.components.settings.app.backups.remote.BackupKeyCredentialManagerHandler
import org.thoughtcrime.securesms.components.settings.app.backups.remote.BackupKeySaveState
import org.thoughtcrime.securesms.fonts.MonoTypeface
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.storage.AndroidCredentialRepository
import org.thoughtcrime.securesms.util.storage.CredentialManagerError
import org.thoughtcrime.securesms.util.storage.CredentialManagerResult
@@ -102,11 +80,6 @@ sealed interface MessageBackupsKeyRecordMode {
val isOptimizedStorageEnabled: Boolean,
val canRotateKey: Boolean
) : MessageBackupsKeyRecordMode
data class Passkey(
val onSaveToPasswordManager: () -> Unit,
val onSaveManually: () -> Unit,
val onSaveSuccessful: () -> Unit
) : MessageBackupsKeyRecordMode
}
/**
@@ -147,7 +120,6 @@ fun MessageBackupsKeyRecordScreen(
* Screen displaying the backup key allowing the user to write it down
* or copy it.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MessageBackupsKeyRecordScreen(
backupKey: String,
@@ -158,94 +130,21 @@ fun MessageBackupsKeyRecordScreen(
onRequestSaveToPasswordManager: () -> Unit = {},
onConfirmSaveToPasswordManager: () -> Unit = {},
onSaveToPasswordManagerComplete: (CredentialManagerResult) -> Unit = {},
onSaveStateCleared: () -> Unit = {},
onGoToPasswordManagerSettingsClick: () -> Unit = {},
mode: MessageBackupsKeyRecordMode = MessageBackupsKeyRecordMode.Next(onNextClick = {}),
notifyKeyIsSameAsOnDeviceBackupKey: Boolean = false
) {
TemporaryScreenshotSecurity.bind()
val context = LocalContext.current
val coroutineScope = rememberCoroutineScope()
val snackbarHostState = remember { SnackbarHostState() }
val backupKeyString = remember(backupKey) {
backupKey.chunked(4).joinToString(" ")
}
val showAsPasskey = mode is MessageBackupsKeyRecordMode.Passkey
var showExpandedPasskey by remember { mutableStateOf(false) }
if (mode is MessageBackupsKeyRecordMode.Next || mode is MessageBackupsKeyRecordMode.Passkey) {
if (mode is MessageBackupsKeyRecordMode.Next) {
RecordScreenBackHandler()
}
var displayRecoveryKeyCopyWarning by remember { mutableStateOf(false) }
if (displayRecoveryKeyCopyWarning) {
val context = LocalContext.current
val url = stringResource(R.string.recovery_key_phishing_support_url)
val events: (RecoveryKeyWarningSheetEvent) -> Unit = {
when (it) {
RecoveryKeyWarningSheetEvent.GotItClick -> {
onCopyToClipboardClick(backupKeyString)
displayRecoveryKeyCopyWarning = false
}
RecoveryKeyWarningSheetEvent.LearnMoreClick -> {
CommunicationActions.openBrowserLink(context, url)
displayRecoveryKeyCopyWarning = false
}
RecoveryKeyWarningSheetEvent.DoNotShareClick -> error("Not supported")
RecoveryKeyWarningSheetEvent.ShareKeyClick -> error("Not supported")
}
}
ModalBottomSheet(
onDismissRequest = { displayRecoveryKeyCopyWarning = false },
dragHandle = { BottomSheets.Handle() },
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
) {
RecoveryKeyWarningSheetContent(
clipStage = ClipStage.COPY,
events = events
)
}
}
var displayKeyVerificationError by remember { mutableStateOf(false) }
if (displayKeyVerificationError) {
ConfirmationFailureDialog(mode) {
displayKeyVerificationError = false
}
}
var displayConfirmKey by remember { mutableStateOf(false) }
if (displayConfirmKey) {
val context = LocalContext.current
val credentialId = stringResource(R.string.MessageBackupsKeyRecordScreen__backup_key_password_manager_id)
val successMessage = stringResource(R.string.MessageBackupsKeyRecordScreen__recover_key_confirmed)
ModalBottomSheet(
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
containerColor = SignalTheme.colors.colorSurface1,
onDismissRequest = { displayConfirmKey = false }
) {
ConfirmRecoveryKeySheet(
onConfirm = {
coroutineScope.launch {
val retrieved = getKeyFromCredentialManager(context, credentialId)
if (retrieved == backupKey) {
Toast.makeText(context, successMessage, Toast.LENGTH_SHORT).show()
(mode as? MessageBackupsKeyRecordMode.Passkey)?.onSaveSuccessful()
} else {
displayKeyVerificationError = true
}
}
displayConfirmKey = false
},
onSeeAgain = { displayConfirmKey = false }
)
}
}
Scaffolds.Settings(
title = "",
navigationIcon = SignalIcons.ArrowStart.imageVector,
@@ -279,11 +178,7 @@ fun MessageBackupsKeyRecordScreen(
item {
Text(
text = if (showAsPasskey) {
stringResource(R.string.MessageBackupsKeyRecordScreen__save_your_recovery_key)
} else {
stringResource(R.string.MessageBackupsKeyRecordScreen__record_your_backup_key)
},
text = stringResource(R.string.MessageBackupsKeyRecordScreen__record_your_backup_key),
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier.padding(top = 16.dp)
)
@@ -292,8 +187,6 @@ fun MessageBackupsKeyRecordScreen(
item {
val text = if (notifyKeyIsSameAsOnDeviceBackupKey) {
stringResource(R.string.MessageBackupsKeyRecordScreen__this_key_is_the_same_as_your_on_device_recovery_key)
} else if (showAsPasskey) {
stringResource(R.string.MessageBackupsKeyRecordScreen__your_recovery_key)
} else {
stringResource(R.string.MessageBackupsKeyRecordScreen__this_key_is_required_to_recover)
}
@@ -308,113 +201,41 @@ fun MessageBackupsKeyRecordScreen(
}
item {
AnimatedContent(
targetState = showAsPasskey && !showExpandedPasskey,
transitionSpec = { fadeIn() togetherWith fadeOut() using SizeTransform(clip = false) },
label = "passkey",
modifier = Modifier.padding(top = 24.dp, bottom = 16.dp)
) { isCollapsed ->
if (isCollapsed) {
Box(
contentAlignment = Alignment.CenterEnd,
modifier = Modifier.background(
color = SignalTheme.colors.colorSurface1,
shape = RoundedCornerShape(50.dp)
)
) {
Text(
text = backupKeyString,
maxLines = 1,
style = MaterialTheme.typography.bodyLarge
.copy(
fontSize = 18.sp,
fontWeight = FontWeight(400),
letterSpacing = 1.44.sp,
lineHeight = 36.sp,
textAlign = TextAlign.Center,
fontFamily = MonoTypeface.fontFamily()
),
modifier = Modifier
.padding(10.dp)
.graphicsLayer { compositingStrategy = CompositingStrategy.Offscreen }
.drawWithContent {
drawContent()
drawRect(
brush = Brush.horizontalGradient(
0f to Color.Black,
0.75f to Color.Transparent
),
blendMode = BlendMode.DstIn
)
}
)
Row(
modifier = Modifier
.background(
color = SignalTheme.colors.colorSurface1,
shape = RoundedCornerShape(50.dp)
)
.padding(horizontal = 12.dp)
.clickable(onClick = { showExpandedPasskey = true }),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = ImageVector.vectorResource(R.drawable.symbol_tap_20),
contentDescription = stringResource(R.string.MessageBackupsKeyRecordScreen__see_full_key)
)
Text(
text = stringResource(R.string.MessageBackupsKeyRecordScreen__see_full_key),
style = MaterialTheme.typography.labelMedium,
modifier = Modifier.padding(start = 4.dp, end = 12.dp)
)
}
}
} else {
Box(
modifier = Modifier
.background(
color = SignalTheme.colors.colorSurface1,
shape = RoundedCornerShape(10.dp)
)
.padding(24.dp)
) {
Text(
text = backupKeyString,
style = MaterialTheme.typography.bodyLarge
.copy(
fontSize = 18.sp,
fontWeight = FontWeight(400),
letterSpacing = 1.44.sp,
lineHeight = 36.sp,
textAlign = TextAlign.Center,
fontFamily = MonoTypeface.fontFamily()
)
)
}
}
}
}
if (!showAsPasskey || showExpandedPasskey) {
item {
Buttons.Small(
onClick = {
if (mode is MessageBackupsKeyRecordMode.CreateNewKey) {
displayRecoveryKeyCopyWarning = true
} else {
onCopyToClipboardClick(backupKeyString)
}
}
) {
Text(
text = stringResource(R.string.MessageBackupsKeyRecordScreen__copy_to_clipboard)
Box(
modifier = Modifier
.padding(top = 24.dp, bottom = 16.dp)
.background(
color = SignalTheme.colors.colorSurface1,
shape = RoundedCornerShape(10.dp)
)
}
.padding(24.dp)
) {
Text(
text = backupKeyString,
style = MaterialTheme.typography.bodyLarge
.copy(
fontSize = 18.sp,
fontWeight = FontWeight(400),
letterSpacing = 1.44.sp,
lineHeight = 36.sp,
textAlign = TextAlign.Center,
fontFamily = MonoTypeface.fontFamily()
)
)
}
}
if (!showAsPasskey && AndroidCredentialRepository.isCredentialManagerSupported(context)) {
item {
Buttons.Small(
onClick = { onCopyToClipboardClick(backupKeyString) }
) {
Text(
text = stringResource(R.string.MessageBackupsKeyRecordScreen__copy_to_clipboard)
)
}
}
if (AndroidCredentialRepository.isCredentialManagerSupported) {
item {
Buttons.Small(
onClick = { onRequestSaveToPasswordManager() }
@@ -435,10 +256,6 @@ fun MessageBackupsKeyRecordScreen(
is MessageBackupsKeyRecordMode.CreateNewKey -> {
CreateNewKeyButton(mode)
}
is MessageBackupsKeyRecordMode.Passkey -> {
SaveButtons(mode)
}
}
}
@@ -465,12 +282,7 @@ fun MessageBackupsKeyRecordScreen(
is BackupKeySaveState.Success -> {
val snackbarMessage = stringResource(R.string.MessageBackupsKeyRecordScreen__save_to_password_manager_success)
LaunchedEffect(keySaveState) {
if (showAsPasskey) {
displayConfirmKey = true
} else {
snackbarHostState.showSnackbar(snackbarMessage)
}
onSaveStateCleared()
snackbarHostState.showSnackbar(snackbarMessage)
}
}
@@ -487,24 +299,6 @@ fun MessageBackupsKeyRecordScreen(
}
}
@Composable
private fun SaveButtons(mode: MessageBackupsKeyRecordMode.Passkey) {
Buttons.LargeTonal(
onClick = mode.onSaveToPasswordManager
) {
Text(text = stringResource(R.string.MessageBackupsKeyRecordScreen__save_to_password_manager))
}
TextButton(
onClick = mode.onSaveManually,
modifier = Modifier
.padding(vertical = 24.dp)
.horizontalGutters()
) {
Text(text = stringResource(R.string.MessageBackupsKeyRecordScreen__save_key_manually))
}
}
@Composable
private fun NextButton(onNextClick: () -> Unit) {
Box(
@@ -740,26 +534,6 @@ private fun KeyLimitExceededDialog(
)
}
@Composable
private fun ConfirmationFailureDialog(mode: MessageBackupsKeyRecordMode, onDismiss: () -> Unit) {
Dialogs.AdvancedAlertDialog(
title = stringResource(R.string.MessageBackupsKeyRecordScreen__recover_key_error),
body = stringResource(R.string.MessageBackupsKeyRecordScreen__recover_key_error_body),
positive = stringResource(R.string.MessageBackupsKeyRecordScreen__save_to_password_manager),
onPositive = {
(mode as? MessageBackupsKeyRecordMode.Passkey)?.onSaveToPasswordManager()
onDismiss()
},
neutral = stringResource(R.string.MessageBackupsKeyRecordScreen__save_key_manually),
onNeutral = {
(mode as? MessageBackupsKeyRecordMode.Passkey)?.onSaveManually()
onDismiss()
},
negative = stringResource(android.R.string.cancel),
onNegative = onDismiss
)
}
private suspend fun saveKeyToCredentialManager(
@UiContext activityContext: Context,
backupKey: String
@@ -771,13 +545,6 @@ private suspend fun saveKeyToCredentialManager(
)
}
private suspend fun getKeyFromCredentialManager(
@UiContext activityContext: Context,
id: String
): String? {
return AndroidCredentialRepository.getCredential(activityContext, id)
}
@DayNightPreviews
@Composable
private fun MessageBackupsKeyRecordScreenPreview() {
@@ -810,23 +577,6 @@ private fun MessageBackupsKeyRecordScreenSameAsOnDeviceKeyPreview() {
}
}
@DayNightPreviews
@Composable
private fun MessageBackupsKeySaveScreenPreview() {
Previews.Preview {
MessageBackupsKeyRecordScreen(
backupKey = (0 until 63).map { (('A'..'Z') + ('0'..'9')).random() }.joinToString("") + "0",
keySaveState = null,
canOpenPasswordManagerSettings = true,
mode = MessageBackupsKeyRecordMode.Passkey(
onSaveToPasswordManager = {},
onSaveManually = {},
onSaveSuccessful = {}
)
)
}
}
@DayNightPreviews
@Composable
private fun SaveKeyConfirmationDialogPreview() {
@@ -15,7 +15,6 @@ enum class MessageBackupsStage(
EDUCATION(route = Route.EDUCATION),
BACKUP_KEY_EDUCATION(route = Route.BACKUP_KEY_EDUCATION),
BACKUP_KEY_RECORD(route = Route.BACKUP_KEY_RECORD),
BACKUP_KEY_RECORD_MANUALLY(route = Route.BACKUP_KEY_RECORD_MANUALLY),
BACKUP_KEY_VERIFY(route = Route.BACKUP_KEY_VERIFY),
TYPE_SELECTION(route = Route.TYPE_SELECTION),
CREATING_IN_APP_PAYMENT(route = Route.TYPE_SELECTION),
@@ -33,7 +32,6 @@ enum class MessageBackupsStage(
EDUCATION,
BACKUP_KEY_EDUCATION,
BACKUP_KEY_RECORD,
BACKUP_KEY_RECORD_MANUALLY,
BACKUP_KEY_VERIFY,
TYPE_SELECTION;
@@ -61,7 +61,7 @@ class VerifyBackupKeyActivity : PassphraseRequiredActivity() {
// Matches existing behavior: show a generic "authentication required" toast.
Toast.makeText(
context,
R.string.RemoteBackupsSettingsFragment__authentication_required,
R.string.RemoteBackupsSettingsFragment__authenticatino_required,
Toast.LENGTH_SHORT
).show()
}
@@ -1,35 +0,0 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2.ui.warning
import org.signal.core.models.AccountEntropyPool
/**
* Detects whether a block of text contains the user's own [AccountEntropyPool] (recovery key).
*
* We scan anywhere within the text and try to match the key in as many forms as possible:
* upper/lowercase, with or without grouping spaces, and with or without the display characters
* (e.g. '#'/'=') used to disambiguate 'O'/'0'. Matching against the user's actual key (rather than
* just the AEP shape) avoids false positives on any 64-character in-alphabet string.
*/
object RecoveryKeyDetector {
/**
* @param text the text to scan
* @param recoveryKey the user's own recovery key, or null if they don't have one yet
* @return true if [text] contains [recoveryKey] in any of its accepted forms. Always false when
* [recoveryKey] is null, so callers can bypass the check entirely for users without a key.
*/
fun containsRecoveryKey(text: String?, recoveryKey: AccountEntropyPool?): Boolean {
if (recoveryKey == null || text.isNullOrBlank() || text.length < AccountEntropyPool.LENGTH) {
return false
}
val normalized = AccountEntropyPool.removeIllegalCharacters(AccountEntropyPool.formatForStorage(text)).lowercase()
return normalized.contains(recoveryKey.value)
}
}
@@ -1,51 +0,0 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2.ui.warning
import androidx.fragment.app.Fragment
import org.thoughtcrime.securesms.components.ComposeText
import org.thoughtcrime.securesms.keyvalue.SignalStore
/**
* Wires this [ComposeText] so that pasting the user's own recovery key first shows
* [RecoveryKeyPasteWarningFragment], warning against sharing it. The paste only completes if the
* user explicitly confirms via that warning.
*
* Must be called once the [host]'s view has been created, as it registers a fragment result
* listener scoped to the host's view lifecycle.
*
* @param onWarningShown invoked just before the warning is shown. Hosts that auto-dismiss when the
* keyboard hides (e.g. [org.thoughtcrime.securesms.components.KeyboardEntryDialogFragment]) can use
* this to suppress that behavior while the warning is up.
* @param onWarningDismissed invoked when the warning is dismissed by any path, after the paste (if
* any) has been applied. Hosts can use this to restore the suppressed state and re-focus the input.
*/
fun ComposeText.guardAgainstRecoveryKeyPaste(
host: Fragment,
onWarningShown: () -> Unit = {},
onWarningDismissed: () -> Unit = {}
) {
var pendingPaste: CharSequence? = null
host.childFragmentManager.setFragmentResultListener(RecoveryKeyPasteWarningFragment.REQUEST_KEY, host.viewLifecycleOwner) { _, bundle ->
if (bundle.getBoolean(RecoveryKeyPasteWarningFragment.REQUEST_KEY)) {
pendingPaste?.let { insertText(it) }
}
pendingPaste = null
onWarningDismissed()
}
setOnPasteListener { pasteText ->
if (RecoveryKeyDetector.containsRecoveryKey(pasteText?.toString(), SignalStore.account.accountEntropyPoolOrNull)) {
pendingPaste = pasteText
onWarningShown()
RecoveryKeyPasteWarningFragment().show(host.childFragmentManager, null)
true
} else {
false
}
}
}
@@ -1,78 +0,0 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2.ui.warning
import android.content.DialogInterface
import android.os.Bundle
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.fragment.app.setFragmentResult
import org.signal.core.ui.compose.ComposeBottomSheetDialogFragment
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.backup.v2.ui.warning.RecoveryKeyPasteWarningFragment.Companion.REQUEST_KEY
import org.thoughtcrime.securesms.util.CommunicationActions
/**
* Displayed via the [org.thoughtcrime.securesms.components.settings.conversation.ConversationSettingsFragment] whenever the user
* attempts to paste their recovery key into the input field.
*
* A result is always delivered to [REQUEST_KEY] when this fragment is dismissed, with the boolean
* indicating whether the user chose to proceed with the paste. The host can rely on this firing for
* every dismissal path (paste, decline, or cancel) to restore its own state.
*/
class RecoveryKeyPasteWarningFragment : ComposeBottomSheetDialogFragment() {
companion object {
const val REQUEST_KEY = "recovery_key_request"
}
private var shouldPaste = false
override fun onDismiss(dialog: DialogInterface) {
setFragmentResult(
REQUEST_KEY,
Bundle().apply {
putBoolean(REQUEST_KEY, shouldPaste)
}
)
super.onDismiss(dialog)
}
@Composable
override fun SheetContent() {
val context = LocalContext.current
val url = stringResource(R.string.recovery_key_phishing_support_url)
val eventHandler: (RecoveryKeyWarningSheetEvent) -> Unit = {
when (it) {
RecoveryKeyWarningSheetEvent.DoNotShareClick -> {
dismissAllowingStateLoss()
}
RecoveryKeyWarningSheetEvent.GotItClick -> {
error("Not supported for paste")
}
RecoveryKeyWarningSheetEvent.LearnMoreClick -> {
CommunicationActions.openBrowserLink(context, url)
dismissAllowingStateLoss()
}
RecoveryKeyWarningSheetEvent.ShareKeyClick -> {
shouldPaste = true
dismissAllowingStateLoss()
}
}
}
RecoveryKeyWarningSheetContent(
clipStage = ClipStage.PASTE,
events = eventHandler
)
}
}
@@ -1,172 +0,0 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2.ui.warning
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextLinkStyles
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.withLink
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.horizontalGutters
import org.thoughtcrime.securesms.R
@Composable
fun RecoveryKeyWarningSheetContent(
clipStage: ClipStage,
events: (RecoveryKeyWarningSheetEvent) -> Unit,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier.horizontalGutters(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
imageVector = ImageVector.vectorResource(R.drawable.ic_warning_40),
tint = MaterialTheme.colorScheme.error,
contentDescription = null,
modifier = Modifier
.padding(top = 20.dp, bottom = 16.dp)
.size(80.dp)
.background(color = MaterialTheme.colorScheme.errorContainer, shape = CircleShape)
.padding(20.dp)
)
Text(
text = stringResource(R.string.RecoveryKeyWarningSheetContent__do_not_share_your_recovery_key),
style = MaterialTheme.typography.titleLarge,
textAlign = TextAlign.Center,
modifier = Modifier.padding(bottom = 12.dp)
)
Text(
text = buildAnnotatedString {
withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) {
append(stringResource(R.string.RecoveryKeyWarningSheetContent__signal_will_never_message_you))
}
append(" ")
append(stringResource(R.string.RecoveryKeyWarningSheetContent__for_your_recovery_key_never_respond))
if (clipStage == ClipStage.PASTE) {
append(" ")
withLink(
link = LinkAnnotation.Clickable(
tag = "learn-more",
styles = TextLinkStyles(
style = SpanStyle(
color = MaterialTheme.colorScheme.primary
)
)
) {
events(RecoveryKeyWarningSheetEvent.LearnMoreClick)
}
) {
append(stringResource(R.string.RecoveryKeyWarningSheetContent__learn_more_period))
}
}
},
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 75.dp)
)
when (clipStage) {
ClipStage.COPY -> CopyActionButtons(events = events)
ClipStage.PASTE -> PasteActionButtons(events = events)
}
Spacer(modifier = Modifier.size(16.dp))
}
}
@Composable
fun CopyActionButtons(events: (RecoveryKeyWarningSheetEvent) -> Unit) {
Buttons.LargeTonal(onClick = {
events(RecoveryKeyWarningSheetEvent.GotItClick)
}) {
Text(text = stringResource(R.string.RecoveryKeyWarningSheetContent__got_it))
}
TextButton(onClick = {
events(RecoveryKeyWarningSheetEvent.LearnMoreClick)
}) {
Text(text = stringResource(R.string.RecoveryKeyWarningSheetContent__learn_more))
}
}
@Composable
fun PasteActionButtons(events: (RecoveryKeyWarningSheetEvent) -> Unit) {
Buttons.LargeTonal(onClick = {
events(RecoveryKeyWarningSheetEvent.DoNotShareClick)
}) {
Text(text = stringResource(R.string.RecoveryKeyWarningSheetContent__do_not_share_key))
}
TextButton(
colors = ButtonDefaults.textButtonColors(
contentColor = MaterialTheme.colorScheme.error
),
onClick = {
events(RecoveryKeyWarningSheetEvent.ShareKeyClick)
}
) {
Text(text = stringResource(R.string.RecoveryKeyWarningSheetContent__share_key))
}
}
enum class ClipStage {
COPY,
PASTE
}
@DayNightPreviews
@Composable
private fun RecoveryKeyWarningSheetContentCopyPreview() {
Previews.BottomSheetPreview {
RecoveryKeyWarningSheetContent(
clipStage = ClipStage.COPY,
events = {},
modifier = Modifier.fillMaxSize()
)
}
}
@DayNightPreviews
@Composable
private fun RecoveryKeyWarningSheetContentPastePreview() {
Previews.BottomSheetPreview {
RecoveryKeyWarningSheetContent(
clipStage = ClipStage.PASTE,
events = {},
modifier = Modifier.fillMaxSize()
)
}
}
@@ -1,13 +0,0 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2.ui.warning
sealed interface RecoveryKeyWarningSheetEvent {
data object DoNotShareClick : RecoveryKeyWarningSheetEvent
data object ShareKeyClick : RecoveryKeyWarningSheetEvent
data object GotItClick : RecoveryKeyWarningSheetEvent
data object LearnMoreClick : RecoveryKeyWarningSheetEvent
}
@@ -11,7 +11,6 @@ import org.signal.archive.proto.FilePointer
import org.signal.core.util.Base64
import org.signal.core.util.UuidUtil
import org.signal.core.util.isNotNullOrBlank
import org.signal.core.util.logging.Log
import org.signal.core.util.nullIfBlank
import org.signal.core.util.orNull
import org.signal.libsignal.usernames.BaseUsernameException
@@ -33,8 +32,6 @@ import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentRemo
import java.util.Optional
import org.signal.archive.proto.AvatarColor as RemoteAvatarColor
private const val TAG = "ArchiveConverter"
/**
* Converts a [FilePointer] to a local [Attachment] object for inserting into the database.
*/
@@ -61,16 +58,10 @@ fun FilePointer?.toLocalAttachment(
return when (attachmentType) {
AttachmentType.ARCHIVE -> {
val cdnNumber = locatorInfo.transitCdnNumber ?: Cdn.CDN_0.cdnNumber
if (Cdn.fromCdnNumberOrNull(cdnNumber) == null) {
Log.w(TAG, "Encountered an archived attachment with an unsupported CDN number ($cdnNumber). Skipping attachment.")
return null
}
ArchivedAttachment(
contentType = contentType,
size = locatorInfo.size.toLong(),
cdn = cdnNumber,
cdn = locatorInfo.transitCdnNumber ?: Cdn.CDN_0.cdnNumber,
uploadTimestamp = locatorInfo.transitTierUploadTimestamp ?: 0,
key = locatorInfo.key.toByteArray(),
cdnKey = locatorInfo.transitCdnKey?.nullIfBlank(),
@@ -96,7 +87,7 @@ fun FilePointer?.toLocalAttachment(
AttachmentType.TRANSIT -> {
val signalAttachmentPointer = SignalServiceAttachmentPointer(
cdnNumber = locatorInfo.transitCdnNumber ?: Cdn.CDN_0.cdnNumber,
remoteId = SignalServiceAttachmentRemoteId.from(locatorInfo.transitCdnKey!!, locatorInfo.transitCdnNumber ?: Cdn.CDN_0.cdnNumber),
remoteId = SignalServiceAttachmentRemoteId.from(locatorInfo.transitCdnKey!!),
contentType = contentType,
key = locatorInfo.key.toByteArray(),
size = Optional.ofNullable(locatorInfo.size),
@@ -7,8 +7,8 @@ package org.thoughtcrime.securesms.backup.v2.util
import org.signal.archive.proto.ChatStyle
import org.signal.archive.proto.FilePointer
import org.signal.core.models.database.AttachmentId
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.attachments.AttachmentId
import org.thoughtcrime.securesms.backup.v2.BackupMode
import org.thoughtcrime.securesms.backup.v2.ImportState
import org.thoughtcrime.securesms.conversation.colors.ChatColors
@@ -306,5 +306,7 @@ class GiftFlowConfirmationFragment :
override fun navigateToDonationPending(inAppPayment: InAppPaymentTable.InAppPayment) = error("Not supported for gifts")
override fun exitCheckoutFlow() = Unit
override fun exitCheckoutFlow() {
requireActivity().finishAfterTransition()
}
}
@@ -4,6 +4,7 @@ import android.graphics.Color
import android.os.Bundle
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.widget.Toolbar
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager
import androidx.navigation.fragment.findNavController
@@ -18,25 +19,29 @@ import org.thoughtcrime.securesms.conversation.mutiselect.forward.SearchConfigur
import org.thoughtcrime.securesms.database.RecipientTable
import org.thoughtcrime.securesms.util.activityViewModel
import org.thoughtcrime.securesms.util.navigation.safeNavigate
import org.thoughtcrime.securesms.util.viewModel
import kotlin.getValue
/**
* Allows the user to select a recipient to send a gift to.
*/
class GiftFlowRecipientSelectionFragment : Fragment(R.layout.multiselect_forward_activity), MultiselectForwardFragment.Callback, SearchConfigurationProvider {
class GiftFlowRecipientSelectionFragment : Fragment(R.layout.gift_flow_recipient_selection_fragment), MultiselectForwardFragment.Callback, SearchConfigurationProvider {
private val viewModel: GiftFlowViewModel by activityViewModel {
GiftFlowViewModel()
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val toolbar = view.findViewById<Toolbar>(R.id.toolbar)
toolbar.setNavigationOnClickListener { requireActivity().onBackPressed() }
if (savedInstanceState == null) {
childFragmentManager.beginTransaction()
.replace(
R.id.fragment_container,
R.id.multiselect_container,
MultiselectForwardFragment.create(
MultiselectForwardFragmentArgs(
multiShareArgs = emptyList(),
title = R.string.GiftFlowRecipientSelectionFragment__choose_recipient,
forceDisableAddMessage = true,
selectSingleRecipient = true
)
@@ -74,10 +79,6 @@ class GiftFlowRecipientSelectionFragment : Fragment(R.layout.multiselect_forward
override fun exitFlow() = Unit
override fun navigateUp() {
requireActivity().onBackPressedDispatcher.onBackPressed()
}
override fun onSearchInputFocused() = Unit
override fun setResult(bundle: Bundle) {

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