mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-06-25 08:35:46 +01:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ae8e050891 |
@@ -1,2 +1 @@
|
||||
*.ai binary
|
||||
**/src/screenshotTest*/reference/**/*.png filter=lfs diff=lfs merge=lfs -text
|
||||
|
||||
@@ -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() }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: |
|
||||
|
||||
@@ -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
@@ -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
File diff suppressed because one or more lines are too long
@@ -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
@@ -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() {
|
||||
|
||||
+1
-1
@@ -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(),
|
||||
|
||||
-393
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+14
-14
@@ -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()
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+4
-4
@@ -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(),
|
||||
|
||||
+7
-22
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
+6
-15
@@ -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()))
|
||||
}
|
||||
}
|
||||
+2
-17
@@ -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)
|
||||
}
|
||||
}
|
||||
+4
-73
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+12
-11
@@ -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())) }
|
||||
}
|
||||
|
||||
+174
@@ -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
|
||||
}
|
||||
+2
-17
@@ -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())
|
||||
+6
-12
@@ -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
|
||||
-52
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+4
-13
@@ -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)
|
||||
+4
-7
@@ -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())
|
||||
+120
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
+3
-2
@@ -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()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+16
-12
@@ -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
|
||||
|
||||
+5
-7
@@ -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(),
|
||||
|
||||
-338
@@ -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)
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
+90
-10
@@ -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
|
||||
|
||||
+2
-6
@@ -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")
|
||||
|
||||
+3
-3
@@ -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
|
||||
|
||||
/**
|
||||
|
||||
+1
-1
@@ -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)
|
||||
|
||||
+1
-1
@@ -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? {
|
||||
|
||||
+1
-5
@@ -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")
|
||||
}
|
||||
)
|
||||
|
||||
+10
-17
@@ -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()
|
||||
)
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+20
-8
@@ -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
|
||||
|
||||
+9
-10
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
-85
@@ -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()
|
||||
)
|
||||
}
|
||||
}
|
||||
+1
-28
@@ -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,
|
||||
|
||||
+3
-12
@@ -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(
|
||||
|
||||
+1
-12
@@ -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(
|
||||
|
||||
+34
-284
@@ -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() {
|
||||
|
||||
-2
@@ -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;
|
||||
|
||||
|
||||
+1
-1
@@ -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()
|
||||
}
|
||||
|
||||
-35
@@ -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)
|
||||
}
|
||||
}
|
||||
-51
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
-78
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
-172
@@ -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()
|
||||
)
|
||||
}
|
||||
}
|
||||
-13
@@ -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
|
||||
}
|
||||
+2
-11
@@ -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
|
||||
|
||||
+3
-1
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
+8
-7
@@ -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
Reference in New Issue
Block a user