Compare commits

...

51 Commits

Author SHA1 Message Date
dependabot[bot] d18e0172ae ci: bump actions/cache in the actions group across 1 directory
Bumps the actions group with 1 update in the / directory: [actions/cache](https://github.com/actions/cache).


Updates `actions/cache` from 5.0.5 to 6.0.0
- [Release notes](https://github.com/actions/cache/releases)
- [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md)
- [Commits](https://github.com/actions/cache/compare/27d5ce7f107fe9357f9df03efb73ab90386fccae...2c8a9bd7457de244a408f35966fab2fb45fda9c8)

---
updated-dependencies:
- dependency-name: actions/cache
  dependency-version: 6.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-24 19:25:21 +00:00
jeffrey-signal c7a8f58f29 Bump version to 8.17.0 2026-06-24 15:18:44 -04:00
jeffrey-signal 9a12e84ad4 Update baseline profile. 2026-06-24 15:06:19 -04:00
jeffrey-signal e500dd0283 Update translations and other static files. 2026-06-24 14:55:55 -04:00
Cody Henthorne aa9ba9c668 Fix crash when retrying a failed restore of offloaded media. 2026-06-24 14:49:54 -04:00
Greyson Parrelli 5553ef6a99 Add some slack to screenshot tests. 2026-06-24 14:49:53 -04:00
Jeffrey Starke 141a128429 Prevent conversation settings screens from stacking when switching recipients. 2026-06-24 14:49:53 -04:00
Jeffrey Starke 52750e726a Fix active chat highlighting when navigating directly to conversation settings. 2026-06-24 14:49:53 -04:00
Alex Hart 97897a84aa Gate "Allow sealed sender from anyone" to primary device. 2026-06-24 14:49:53 -04:00
Michelle Tang 78ad67baad Fix spinner build. 2026-06-24 14:49:52 -04:00
Michelle Tang 13aafbfefd Add bio auth when viewing a recovery key. 2026-06-24 14:49:52 -04:00
Jeffrey Starke 22dddeb3b7 Hide delete button on call link details for non-admin users. 2026-06-24 14:49:52 -04:00
Michelle Tang 5c64a91864 Present the backup key as a passphrase. 2026-06-24 14:49:52 -04:00
Michelle Tang 66b6c1656c Rotate send admin delete config. 2026-06-24 14:49:51 -04:00
Michelle Tang 44d1c8c4bb Check for deletion in edit message. 2026-06-24 14:49:51 -04:00
Alex Hart 599d55ac0b Fix send button default color. 2026-06-24 14:49:51 -04:00
Greyson Parrelli 9cbe204141 Auto-populate phone number in regV5. 2026-06-24 14:49:51 -04:00
andrew-signal 8615dfc463 Bump libsignal to v0.96.3 2026-06-24 14:49:50 -04:00
Greyson Parrelli 72050dbe64 Make same-group-membership helper available for all users. 2026-06-24 14:49:50 -04:00
andrew-signal 9dac02fa1c Bump libsignal to v0.96.2
Co-authored-by: Cody Henthorne <cody@signal.org>
2026-06-24 14:49:50 -04:00
Alex Hart 7fdaf6f706 Add dontations screenshot tests. 2026-06-24 14:49:50 -04:00
Alex Hart 4316136723 Move BlobProvider and supporting classes to core-util. 2026-06-24 14:49:49 -04:00
Greyson Parrelli f375750cf8 Fix storageCapable bug in regV5. 2026-06-24 14:49:49 -04:00
Greyson Parrelli c55f281213 Improve pin creation UX in regV5. 2026-06-24 14:49:49 -04:00
Greyson Parrelli 64496d1d92 Fix regV5 integration points. 2026-06-24 14:49:48 -04:00
Greyson Parrelli 8eac4d3a57 Improve regV5 logging. 2026-06-24 14:49:48 -04:00
Greyson Parrelli 7b5f7cd808 Add pin opt-out support to regV5. 2026-06-24 14:49:48 -04:00
Greyson Parrelli 5b99c6681c Add new updateVerificationMetadata task. 2026-06-24 14:49:48 -04:00
dependabot[bot] f989ad4014 ci: bump the actions group with 2 updates
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-24 14:49:47 -04:00
dependabot[bot] ce5c023f3b Bump cryptography from 48.0.0 to 48.0.1 in /reproducible-builds/apkdiff
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-24 14:49:47 -04:00
Cody Henthorne f22567e4fb Ensure video send UX uses same max video constraint as transcoding. 2026-06-24 14:49:47 -04:00
jeffrey-signal cdb73d4b8a Migrate chats tab to nav 3.
- resolves signalapp/Signal-Android#8947
- resolves signalapp/Signal-Android#14777
- resolves signalapp/Signal-Android#14784
- resolves signalapp/Signal-Android#14800
- resolves signalapp/Signal-Android#14803
2026-06-24 14:49:47 -04:00
Greyson Parrelli 48901f64c7 Update plaintext export rules. 2026-06-22 12:46:53 -04:00
Alex Hart ab4a38d565 Move CameraXFragment to feature:media-send. 2026-06-22 10:17:05 -03:00
Greyson Parrelli 4e077bbb52 Fix CallTable null check. 2026-06-22 00:33:33 -04:00
Greyson Parrelli 4f17aa2b17 Add battery logging. 2026-06-22 00:02:29 -04:00
Greyson Parrelli e7808eb842 Improve search performance. 2026-06-18 16:59:03 -04:00
Greyson Parrelli fe0f7ee5e7 Add a search benchmark. 2026-06-18 16:09:12 -04:00
Cody Henthorne c4846d92da Add request backfill attachment support for linked devices. 2026-06-18 16:09:12 -04:00
Alex Hart 83cb48d119 Move the VideoEditorFragment to the media-send feature module. 2026-06-18 16:09:12 -04:00
Cody Henthorne 987f92245d Fix instrumentation tests relying on mocked SignalStore and RemoteConfig. 2026-06-18 16:09:12 -04:00
Alex Hart aecd17b2f0 MediaSelectScreen rework. 2026-06-18 16:09:12 -04:00
Alex Hart a7b4a5d93d Rip out Camera1. 2026-06-18 16:09:12 -04:00
Greyson Parrelli c8d2a06676 Bump version to 8.16.1 2026-06-18 16:08:27 -04:00
Greyson Parrelli 42d114e75b Update baseline profile. 2026-06-18 15:56:52 -04:00
Greyson Parrelli c2abe2fc33 Update translations and other static files. 2026-06-18 15:49:24 -04:00
Michelle Tang fb1c7c346e Turn off KT. 2026-06-18 15:02:55 -04:00
Greyson Parrelli 0b196db4b6 Ensure we always set text size on conversation item. 2026-06-17 16:53:14 -04:00
Greyson Parrelli bdd1858602 Allow text to be selectable in message details. 2026-06-17 15:54:33 -04:00
Cody Henthorne 915181fbb7 Prevent IncomingMessageObserver foreground service crash. 2026-06-17 15:13:36 -04:00
Greyson Parrelli 5f375dc9a6 Don't stop websocket foreground service for unregistered if already running. 2026-06-17 14:25:46 -04:00
696 changed files with 26876 additions and 18948 deletions
+2 -2
View File
@@ -16,14 +16,14 @@ jobs:
runs-on: ubuntu-latest-8-cores
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
# gh api repos/actions/checkout/commits/v6 --jq '.sha'
with:
submodules: true
lfs: true
- name: set up JDK 17
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5
uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5
# gh api repos/actions/setup-java/commits/v5 --jq '.sha'
with:
distribution: temurin
+4 -4
View File
@@ -16,14 +16,14 @@ jobs:
runs-on: ubuntu-latest-8-cores
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
# 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@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5
uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5
# gh api repos/actions/setup-java/commits/v5 --jq '.sha'
with:
distribution: temurin
@@ -47,7 +47,7 @@ jobs:
- name: Cache base apk
id: cache-base
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
# gh api repos/actions/cache/commits/v5 --jq '.sha'
with:
path: diffuse-base.apk
@@ -61,7 +61,7 @@ jobs:
if: steps.cache-base.outputs.cache-hit != 'true'
run: mv app/build/outputs/apk/playProd/release/*arm64*.apk diffuse-base.apk
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
# gh api repos/actions/checkout/commits/v6 --jq '.sha'
with:
submodules: true
+1 -1
View File
@@ -11,7 +11,7 @@ jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
# gh api repos/actions/checkout/commits/v6 --jq '.sha'
- name: Build image
run: |
+15 -2
View File
@@ -16,6 +16,7 @@ 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")
@@ -27,8 +28,8 @@ plugins {
val staticIps = Properties().apply { file("static-ips.properties").reader().use { load(it) } }
staticIps.stringPropertyNames().forEach { rootProject.extra[it] = staticIps.getProperty(it) }
val canonicalVersionCode = 1708
val canonicalVersionName = "8.16.0"
val canonicalVersionCode = 1710
val canonicalVersionName = "8.17.0"
val currentHotfixVersion = 0
val maxHotfixVersions = 100
@@ -127,9 +128,16 @@ 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()
@@ -712,6 +720,11 @@ 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)
+5
View File
@@ -8,6 +8,11 @@
-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 +9,11 @@ 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
/**
@@ -30,6 +32,13 @@ 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() {
@@ -26,16 +26,16 @@ 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.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 = BlobProvider.getInstance().forData(byteArrayOf(1, 2, 3, 4, 5)).createForSingleSessionInMemory()
val blob = AppDependencies.blobs.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 = BlobProvider.getInstance().forData(byteArrayOf(1, 2, 3, 4, 5)).createForSingleSessionInMemory()
val blob = AppDependencies.blobs.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 = BlobProvider.getInstance().forData(byteArrayOf(1, 2, 3, 4, 5)).createForSingleSessionInMemory()
val blob = AppDependencies.blobs.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 = BlobProvider.getInstance().forData(uncompressData).createForSingleSessionInMemory()
val blobUncompressed = AppDependencies.blobs.forData(uncompressData).createForSingleSessionInMemory()
val previousAttachment = createAttachment(1, BlobProvider.getInstance().forData(compressedData).createForSingleSessionInMemory(), TransformProperties.empty())
val previousAttachment = createAttachment(1, AppDependencies.blobs.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 = BlobProvider.getInstance().forData(uncompressData).createForSingleSessionInMemory()
val blobUncompressed = AppDependencies.blobs.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 = BlobProvider.getInstance().forData(byteArrayOf(1, 2, 3, 4, 5)).createForSingleSessionInMemory()
val blob = AppDependencies.blobs.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 = BlobProvider.getInstance().forData(uncompressData).createForSingleSessionInMemory()
val blobUncompressed = AppDependencies.blobs.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 = BlobProvider.getInstance().forData(uncompressData).createForSingleSessionInMemory()
val blobUncompressed = AppDependencies.blobs.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 = BlobProvider.getInstance().forData(uncompressData).createForSingleSessionInMemory()
val blobUncompressed = AppDependencies.blobs.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 = BlobProvider.getInstance().forData(uncompressData).createForSingleSessionInMemory()
val blobUncompressed = AppDependencies.blobs.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()
@@ -19,14 +19,14 @@ 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.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 = BlobProvider.getInstance().forData(data).createForSingleSessionInMemory()
val uri = AppDependencies.blobs.forData(data).createForSingleSessionInMemory()
val attachment = UriAttachmentBuilder.build(
id = Random.nextLong(),
@@ -15,14 +15,13 @@ 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
@@ -40,7 +39,7 @@ class AttachmentCompressionJobTest {
StreamUtil.readFully(it)
}
val blob = BlobProvider.getInstance().forData(imageBytes).createForSingleSessionOnDisk(AppDependencies.application)
val blob = AppDependencies.blobs.forData(imageBytes).createForSingleSessionOnDisk(AppDependencies.application)
val firstPreUpload = createAttachment(1, blob, TransformProperties.empty())
val firstDatabaseAttachment = SignalDatabase.attachments.insertAttachmentForPreUpload(firstPreUpload)
@@ -32,10 +32,18 @@ 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.util.RemoteConfig
import org.thoughtcrime.securesms.testing.TestRemoteConfigFlag
import java.util.UUID
@RemoteConfigForTest(
flags = [
Flag(TestRemoteConfigFlag.INTERNAL_USER, "true"),
Flag(TestRemoteConfigFlag.DEFAULT_MAX_BACKOFF, "1")
]
)
class BackupDeleteJobTest {
@get:Rule
@@ -43,10 +51,6 @@ 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)
@@ -60,29 +64,24 @@ class BackupDeleteJobTest {
@Test
fun givenUserNotRegistered_whenIRun_thenIExpectFailure() {
mockkObject(SignalStore) {
every { SignalStore.account.isRegistered } returns false
SignalStore.account.setRegistered(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() {
mockkObject(SignalStore) {
every { SignalStore.account.isRegistered } returns true
every { SignalStore.account.isLinkedDevice } returns true
SignalStore.account.deviceId = 2
val job = BackupDeleteJob()
val job = BackupDeleteJob()
val result = job.run()
val result = job.run()
assertThat(result.isFailure).isTrue()
}
assertThat(result.isFailure).isTrue()
}
@Test
@@ -42,8 +42,10 @@ import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobmanager.Job
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.net.SignalNetwork
import org.thoughtcrime.securesms.testing.Flag
import org.thoughtcrime.securesms.testing.RemoteConfigForTest
import org.thoughtcrime.securesms.testing.SignalActivityRule
import org.thoughtcrime.securesms.util.RemoteConfig
import org.thoughtcrime.securesms.testing.TestRemoteConfigFlag
import org.whispersystems.signalservice.api.storage.IAPSubscriptionId
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription.ChargeFailure
@@ -55,6 +57,7 @@ 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 {
@@ -67,9 +70,6 @@ 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,28 +142,22 @@ class BackupSubscriptionCheckJobTest {
@Test
fun givenUserIsNotRegistered_whenIRun_thenIExpectSuccessAndEarlyExit() {
mockkObject(SignalStore.account) {
every { SignalStore.account.e164 } returns "+15555550101"
every { SignalStore.account.isRegistered } returns false
SignalStore.account.setRegistered(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() {
mockkObject(SignalStore.account) {
every { SignalStore.account.e164 } returns "+15555550101"
every { SignalStore.account.isLinkedDevice } returns true
SignalStore.account.deviceId = 2
val job = BackupSubscriptionCheckJob.create()
val result = job.run()
val job = BackupSubscriptionCheckJob.create()
val result = job.run()
assertEarlyExit(result)
}
assertEarlyExit(result)
}
@Test
@@ -32,8 +32,8 @@ import org.thoughtcrime.securesms.conversation.ConversationIntents
import org.thoughtcrime.securesms.conversation.v2.ConversationFragment
import org.thoughtcrime.securesms.conversationlist.ConversationListArchiveFragment
import org.thoughtcrime.securesms.conversationlist.ConversationListFragment
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionActivity
import org.thoughtcrime.securesms.providers.BlobProvider
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.stories.landing.StoriesLandingFragment
@@ -348,11 +348,13 @@ 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}"
}
@@ -569,7 +571,7 @@ class MainNavigationLaunchTest {
}
private fun realBlob(bytes: ByteArray, mimeType: String): Uri {
return BlobProvider.getInstance()
return AppDependencies.blobs
.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 = BlobProvider.getInstance().forData(data).createForSingleSessionInMemory()
val uri: Uri = AppDependencies.blobs.forData(data).createForSingleSessionInMemory()
val attachment: UriAttachment = UriAttachmentBuilder.build(
id = Random.nextLong(),
@@ -0,0 +1,338 @@
/*
* 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()
}
}
@@ -0,0 +1,81 @@
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()
}
}
@@ -2,15 +2,62 @@ 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,6 +32,14 @@ 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?) {
@@ -51,6 +59,7 @@ 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)
@@ -97,6 +106,39 @@ 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)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -39,6 +39,7 @@ 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;
@@ -46,14 +47,15 @@ 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.crypto.AttachmentSecretProvider;
import org.thoughtcrime.securesms.conversation.drafts.DraftBlobs;
import org.thoughtcrime.securesms.crypto.AppAttachmentSecretStore;
import org.thoughtcrime.securesms.crypto.DatabaseSecretProvider;
import org.thoughtcrime.securesms.database.LogDatabase;
import org.thoughtcrime.securesms.database.SQLiteDatabase;
import org.thoughtcrime.securesms.database.SignalDatabase;
import org.thoughtcrime.securesms.database.SqlCipherLibraryLoader;
import org.thoughtcrime.securesms.dependencies.AppDependencies;
@@ -98,10 +100,11 @@ 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,6 +117,7 @@ import org.thoughtcrime.securesms.service.webrtc.ActiveCallManager;
import org.thoughtcrime.securesms.service.webrtc.AndroidTelecomUtil;
import org.thoughtcrime.securesms.storage.StorageSyncHelper;
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;
@@ -171,7 +175,7 @@ public class ApplicationContext extends Application implements AppForegroundObse
SqlCipherLibraryLoader.load();
SignalDatabase.init(this,
DatabaseSecretProvider.getOrCreateDatabaseSecret(this),
AttachmentSecretProvider.getInstance(this).getOrCreateAttachmentSecret());
AttachmentSecretProvider.getInstance(this, AppAttachmentSecretStore.INSTANCE).getOrCreateAttachmentSecret());
Logger.setTarget(SqlCipherLogTarget.INSTANCE);
})
.addBlocking("signal-store", () -> SignalStore.init(this))
@@ -259,6 +263,8 @@ 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();
@@ -299,6 +305,7 @@ 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();
@@ -417,10 +424,10 @@ public class ApplicationContext extends Application implements AppForegroundObse
}
private void initializeRegistrationDependencies() {
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),
RegistrationDependencies.provide(
new RegistrationDependencies(
new AppRegistrationNetworkController(this, AppDependencies.getPushServiceSocket()),
new AppRegistrationStorageController(this),
Environment.IS_LINK_AND_SYNC_AVAILABLE,
null,
context -> {
@@ -571,7 +578,7 @@ public class ApplicationContext extends Application implements AppForegroundObse
@WorkerThread
private void initializeBlobProvider() {
BlobProvider.getInstance().initialize(this);
AppDependencies.getBlobs().initialize(this, DraftBlobs.INSTANCE::deleteOrphanedDraftFiles);
}
@WorkerThread
@@ -1,12 +1,14 @@
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;
@@ -18,10 +20,10 @@ public interface BindableConversationListItem extends Unbindable {
@NonNull RequestManager requestManager, @NonNull Locale locale,
@NonNull Set<Long> typingThreads,
@NonNull ConversationSet selectedConversations,
long activeThreadId);
@Nullable RecipientId activeRecipientId);
void setSelectedConversations(@NonNull ConversationSet conversations);
void setActiveThreadId(long activeThreadId);
void setActiveRecipientId(@Nullable RecipientId activeRecipientId);
void updateTypingIndicator(@NonNull Set<Long> typingThreads);
void updateTimestamp();
}
@@ -5,7 +5,6 @@
package org.thoughtcrime.securesms
import android.Manifest
import android.annotation.SuppressLint
import android.app.Activity
import android.content.Context
@@ -15,7 +14,6 @@ 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
@@ -72,6 +70,8 @@ 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,6 +85,7 @@ 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
@@ -105,6 +106,8 @@ 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
@@ -134,7 +137,6 @@ 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
@@ -143,7 +145,6 @@ 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
@@ -157,12 +158,10 @@ 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
@@ -197,7 +196,6 @@ 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(),
@@ -498,18 +496,15 @@ class MainActivity :
}
}
val chatNavGraphState = ChatNavGraphState.remember(isSplitPane)
val convoTransitionState = ConversationTransitionState.remember(isSplitPane)
val mutableInteractionSource = remember { MutableInteractionSource() }
MainNavigationDetailLocationEffect(mainNavigationViewModel, chatNavGraphState::writeGraphicsLayerToBitmap)
val chatsNavHostController = rememberDetailNavHostController(
onRequestFocus = rememberFocusRequester(
mainNavigationViewModel = mainNavigationViewModel,
currentListLocation = mainNavigationState.currentListLocation,
isTargetListLocation = { it in listOf(MainNavigationListLocation.CHATS, MainNavigationListLocation.ARCHIVE) }
)
) {
chatNavGraphBuilder(chatNavGraphState)
LaunchedEffect(convoTransitionState) {
mainNavigationViewModel.setChatListSnapshotCaptureProvider { convoTransitionState.writeGraphicsLayerToBitmap() }
}
LaunchedEffect(isSplitPane) {
mainNavigationViewModel.onSplitPaneChanged(isSplitPane)
}
val callsNavHostController = rememberDetailNavHostController(
@@ -531,22 +526,23 @@ class MainActivity :
}
LaunchedEffect(Unit) {
suspend fun navigateToLocation(location: MainNavigationDetailLocation) {
fun navigateToLocation(location: MainNavigationDetailLocation) {
when (location) {
is MainNavigationDetailLocation.Empty -> {
when (mainNavigationState.currentListLocation) {
MainNavigationListLocation.CHATS, MainNavigationListLocation.ARCHIVE -> chatsNavHostController
MainNavigationListLocation.CHATS, MainNavigationListLocation.ARCHIVE -> {
throw IllegalStateException("Navigation to ${mainNavigationState.currentListLocation} should be handled by ChatsBackStack.")
}
MainNavigationListLocation.CALLS -> callsNavHostController
MainNavigationListLocation.STORIES -> storiesNavHostController
}.navigateToDetailLocation(location)
}
is MainNavigationDetailLocation.Conversation -> {
chatNavGraphState.writeGraphicsLayerToBitmap()
chatsNavHostController.navigateToDetailLocation(location)
is MainNavigationDetailLocation.Conversation, is MainNavigationDetailLocation.Chats -> {
throw IllegalStateException("Navigation to $location should be handled by ChatsBackStack.")
}
is MainNavigationDetailLocation.Chats -> chatsNavHostController.navigateToDetailLocation(location)
is MainNavigationDetailLocation.CallLinkDetails -> callsNavHostController.navigateToDetailLocation(location)
is MainNavigationDetailLocation.Calls -> callsNavHostController.navigateToDetailLocation(location)
is MainNavigationDetailLocation.Stories -> storiesNavHostController.navigateToDetailLocation(location)
@@ -560,6 +556,7 @@ class MainActivity :
}
val scope = rememberCoroutineScope()
BackHandler(paneExpansionState.currentAnchor == detailOnlyAnchor) {
mainNavigationViewModel.goTo(MainNavigationDetailLocation.Empty)
scope.launch {
@@ -620,7 +617,7 @@ class MainActivity :
AppScaffold(
navigator = wrappedNavigator,
modifier = chatNavGraphState.writeContentToGraphicsLayer(),
modifier = convoTransitionState.writeContentToGraphicsLayer(),
paneExpansionState = paneExpansionState,
contentWindowInsets = WindowInsets(),
snackbarHost = {
@@ -731,9 +728,13 @@ class MainActivity :
primaryContent = {
when (mainNavigationState.currentListLocation) {
MainNavigationListLocation.CHATS, MainNavigationListLocation.ARCHIVE -> {
DetailsScreenNavHost(
navHostController = chatsNavHostController,
contentLayoutData = contentLayoutData
NavDisplay(
backStack = mainNavigationViewModel.chatsBackStackEntries,
onBack = { mainNavigationViewModel.popChatsDetailLocation() },
transitionSpec = TransitionSpecs.HorizontalSlide.transitionSpec,
popTransitionSpec = TransitionSpecs.HorizontalSlide.popTransitionSpec,
predictivePopTransitionSpec = TransitionSpecs.HorizontalSlide.predictivePopTransitionSpec,
entryProvider = entryProvider { chatsNavEntries(convoTransitionState) }
)
}
@@ -762,7 +763,7 @@ class MainActivity :
} else {
null
},
animatorFactory = if (mainNavigationState.currentListLocation == MainNavigationListLocation.CHATS || mainNavigationState.currentListLocation == MainNavigationListLocation.ARCHIVE) {
animatorFactory = if (mainNavigationState.currentListLocation.isChatsTab) {
noEnterTransitionFactory
} else {
AppScaffoldAnimationStateFactory.Default
@@ -1176,24 +1177,7 @@ class MainActivity :
}
}
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()
}
onGranted()
}
inner class ToolbarCallback : MainToolbarCallback {
@@ -135,12 +135,8 @@ 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();
}
}
}
@@ -227,7 +223,7 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
private Intent getPushRegistrationIntent() {
if (Environment.USE_NEW_REGISTRATION) {
return org.signal.registration.RegistrationActivity.createIntent(this);
return org.signal.registration.RegistrationActivity.createIntent(this, MainActivity.clearTop(this));
} else {
return RegistrationActivity.newIntentForNewRegistration(this, getIntent());
}
@@ -11,10 +11,11 @@ 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;
@@ -88,9 +89,9 @@ public class AudioRecorder {
ParcelFileDescriptor fds[] = ParcelFileDescriptor.createPipe();
BlobProvider.BlobBuilder blobBuilder = BlobProvider.getInstance()
.forData(new ParcelFileDescriptor.AutoCloseInputStream(fds[0]), 0)
.withMimeType(MediaUtil.AUDIO_AAC);
BlobProvider.BlobBuilder blobBuilder = AppDependencies.getBlobs()
.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.createForUri(context, uri)) {
try (MediaInput dataSource = DecryptableUriMediaInput.INSTANCE.createForUri(context, uri)) {
long[] wave = new long[BAR_COUNT];
int[] waveSamples = new int[BAR_COUNT];
@@ -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 = BlobProvider.getInstance()
val blob = AppDependencies.blobs
.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 = BlobProvider.getInstance().forData(inStream, bytes.size.toLong()).createForSingleSessionOnDisk(context)
val uri = AppDependencies.blobs.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.providers.BlobProvider
import org.thoughtcrime.securesms.dependencies.AppDependencies
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 = BlobProvider.getFileSize(editedImageUri) ?: 0
val inputStream = BlobProvider.getInstance().getStream(applicationContext, editedImageUri)
val size = AppDependencies.blobs.getFileSize(editedImageUri) ?: 0
val inputStream = AppDependencies.blobs.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)
BlobProvider.getInstance().delete(requireContext(), photo.uri)
AppDependencies.blobs.delete(requireContext(), photo.uri)
ThreadUtil.runOnMain {
setFragmentResult(REQUEST_KEY_EDIT, AvatarBundler.bundlePhoto(newPhoto))
@@ -1,13 +1,11 @@
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
@@ -30,12 +28,10 @@ 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.
@@ -223,22 +219,8 @@ class AvatarPickerFragment : Fragment(R.layout.avatar_picker_fragment) {
@Suppress("DEPRECATION")
private fun openCameraCapture() {
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()
}
val intent = AvatarSelectionActivity.getIntentForCameraCapture(requireContext())
startActivityForResult(intent, REQUEST_CODE_SELECT_IMAGE)
}
@Suppress("DEPRECATION")
@@ -16,9 +16,9 @@ import org.thoughtcrime.securesms.avatar.AvatarRenderer
import org.thoughtcrime.securesms.avatar.Avatars
import org.thoughtcrime.securesms.conversation.colors.AvatarColor
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.profiles.AvatarHelper
import org.thoughtcrime.securesms.providers.BlobProvider
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.NameUtil
import org.whispersystems.signalservice.api.util.StreamDetails
@@ -36,7 +36,7 @@ class AvatarPickerRepository(context: Context) {
try {
val bytes = StreamUtil.readFully(details.stream)
Avatar.Photo(
BlobProvider.getInstance().forData(bytes).createForSingleSessionInMemory(),
AppDependencies.blobs.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(
BlobProvider.getInstance().forData(bytes).createForSingleSessionInMemory(),
AppDependencies.blobs.forData(bytes).createForSingleSessionInMemory(),
AvatarHelper.getAvatarLength(applicationContext, recipient.id),
Avatar.DatabaseId.DoNotPersist
)
@@ -6,7 +6,7 @@ import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.crypto.KeyStoreHelper;
import org.signal.core.util.crypto.KeyStoreHelper;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
/**
@@ -23,9 +23,9 @@ import org.signal.core.models.database.AttachmentId;
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.crypto.AttachmentSecret;
import org.thoughtcrime.securesms.crypto.ClassicDecryptingPartInputStream;
import org.thoughtcrime.securesms.crypto.ModernDecryptingPartInputStream;
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.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.thoughtcrime.securesms.crypto.AttachmentSecret;
import org.thoughtcrime.securesms.crypto.ModernEncryptingPartOutputStream;
import org.signal.core.util.crypto.AttachmentSecret;
import org.signal.core.util.crypto.ModernEncryptingPartOutputStream;
import org.thoughtcrime.securesms.database.AttachmentTable;
import org.thoughtcrime.securesms.database.EmojiSearchTable;
import org.thoughtcrime.securesms.database.KeyValueDatabase;
@@ -47,6 +47,7 @@ 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
@@ -93,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.AttachmentSecretProvider
import org.thoughtcrime.securesms.crypto.AppAttachmentSecretStore
import org.thoughtcrime.securesms.crypto.DatabaseSecretProvider
import org.thoughtcrime.securesms.database.AttachmentTable
import org.thoughtcrime.securesms.database.BackupMediaSnapshotTable.ArchiveMediaItem
@@ -140,7 +141,6 @@ 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 +717,7 @@ object BackupRepository {
SignalDatabase(
context = context,
databaseSecret = DatabaseSecretProvider.getOrCreateDatabaseSecret(context),
attachmentSecret = AttachmentSecretProvider.getInstance(context).getOrCreateAttachmentSecret(),
attachmentSecret = AttachmentSecretProvider.getInstance(context, AppAttachmentSecretStore).getOrCreateAttachmentSecret(),
name = "$baseName.db"
)
}
@@ -2235,7 +2235,7 @@ object BackupRepository {
}
Log.i(TAG, "[remoteRestore] Downloading backup")
val tempBackupFile = BlobProvider.getInstance().forNonAutoEncryptingSingleSessionOnDisk(AppDependencies.application)
val tempBackupFile = AppDependencies.blobs.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 = BlobProvider.getInstance().forNonAutoEncryptingSingleSessionOnDisk(AppDependencies.application)
val tempBackupFile = AppDependencies.blobs.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,6 +2558,9 @@ enum class BackupMode {
val isLocalBackup: Boolean
get() = this == LOCAL
val isPlaintextExport: Boolean
get() = this == PLAINTEXT_EXPORT
}
/**
@@ -167,7 +167,11 @@ fun MessageTable.getMessagesForBackup(db: SignalDatabase, backupTime: Long, self
.where(
buildString {
append("$STORY_TYPE = 0 AND $PARENT_STORY_ID <= 0 AND $SCHEDULED_DATE = -1 AND ")
append("($EXPIRES_IN == 0 OR $EXPIRES_IN > ${1.days.inWholeMilliseconds})")
if (exportState.backupMode.isPlaintextExport) {
append("$EXPIRES_IN == 0")
} else {
append("($EXPIRES_IN == 0 OR $EXPIRES_IN > ${1.days.inWholeMilliseconds})")
}
append(" AND $DATE_RECEIVED >= $lastSeenReceivedTime $cutoffQuery")
}
)
@@ -647,15 +647,22 @@ private fun BackupMessageRecord.toBasicChatItemBuilder(selfRecipientId: Recipien
}
}
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)) {
if (!MessageTypes.isExpirationTimerUpdate(record.type) && builder.expiresInMs != null) {
if (exportState.backupMode.isPlaintextExport) {
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) {
@@ -17,6 +17,7 @@ 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.backup.v2.ExportState
import org.thoughtcrime.securesms.backup.v2.ImportState
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
@@ -471,19 +472,19 @@ object AccountDataArchiveProcessor {
}
}
private fun org.thoughtcrime.securesms.mms.SentMediaQuality.toRemoteSentMediaQuality(): AccountData.SentMediaQuality {
private fun SentMediaQuality.toRemoteSentMediaQuality(): AccountData.SentMediaQuality {
return when (this) {
org.thoughtcrime.securesms.mms.SentMediaQuality.STANDARD -> AccountData.SentMediaQuality.STANDARD
org.thoughtcrime.securesms.mms.SentMediaQuality.HIGH -> AccountData.SentMediaQuality.HIGH
SentMediaQuality.STANDARD -> AccountData.SentMediaQuality.STANDARD
SentMediaQuality.HIGH -> AccountData.SentMediaQuality.HIGH
}
}
private fun AccountData.SentMediaQuality?.toLocalSentMediaQuality(): org.thoughtcrime.securesms.mms.SentMediaQuality {
private fun AccountData.SentMediaQuality?.toLocalSentMediaQuality(): SentMediaQuality {
return when (this) {
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
AccountData.SentMediaQuality.HIGH -> SentMediaQuality.HIGH
AccountData.SentMediaQuality.STANDARD -> SentMediaQuality.STANDARD
AccountData.SentMediaQuality.UNKNOWN_QUALITY -> SentMediaQuality.STANDARD
null -> SentMediaQuality.STANDARD
}
}
@@ -0,0 +1,85 @@
package org.thoughtcrime.securesms.backup.v2.ui.subscription
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.horizontalGutters
import org.thoughtcrime.securesms.R
/**
* Bottom sheet shown when confirming your recovery key after saving to password manager
*/
@Composable
fun ConfirmRecoveryKeySheet(
onConfirm: () -> Unit = {},
onSeeAgain: () -> Unit = {},
modifier: Modifier = Modifier
) {
Column(
modifier = modifier.horizontalGutters(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
imageVector = ImageVector.vectorResource(R.drawable.backup_confirm_80),
tint = Color.Unspecified,
contentDescription = null,
modifier = Modifier.padding(top = 24.dp, bottom = 16.dp)
)
Text(
text = stringResource(R.string.MessageBackupsKeyVerifyScreen__confirm_your_backup_key),
style = MaterialTheme.typography.titleLarge,
textAlign = TextAlign.Center,
modifier = Modifier.padding(bottom = 12.dp)
)
Text(
text = stringResource(R.string.MessageBackupsKeyRecordScreen__confirm_that_your_recovery),
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.size(60.dp))
Buttons.LargeTonal(onClick = onConfirm) {
Text(text = stringResource(R.string.MessageBackupsKeyRecordScreen__confirm_recovery))
}
TextButton(
onClick = onSeeAgain,
modifier = Modifier.padding(vertical = 16.dp)
) {
Text(text = stringResource(R.string.MessageBackupsKeyRecordScreen__see_key_again))
}
}
}
@DayNightPreviews
@Composable
private fun ConfirmRecoveryKeyPreview() {
Previews.BottomSheetContentPreview {
ConfirmRecoveryKeySheet(
onConfirm = {},
onSeeAgain = {},
modifier = Modifier.fillMaxSize()
)
}
}
@@ -68,7 +68,8 @@ class MessageBackupsFlowFragment : ComposeFragment(), InAppPaymentCheckoutDelega
private val viewModel: MessageBackupsFlowViewModel by viewModel {
MessageBackupsFlowViewModel(
initialTierSelection = requireArguments().getSerializableCompat(TIER, MessageBackupTier::class.java),
googlePlayApiAvailability = GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(requireContext())
googlePlayApiAvailability = GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(requireContext()),
isCredentialManagerSupported = AndroidCredentialRepository.isCredentialManagerSupported(requireContext())
)
}
@@ -155,6 +156,32 @@ class MessageBackupsFlowFragment : ComposeFragment(), InAppPaymentCheckoutDelega
val context = LocalContext.current
val passwordManagerSettingsIntent = AndroidCredentialRepository.getCredentialManagerSettingsIntent(requireContext())
MessageBackupsKeyRecordScreen(
backupKey = state.accountEntropyPool.displayValue,
keySaveState = state.backupKeySaveState,
canOpenPasswordManagerSettings = passwordManagerSettingsIntent != null,
onNavigationClick = viewModel::goToPreviousStage,
mode = remember {
MessageBackupsKeyRecordMode.Passkey(
onSaveToPasswordManager = viewModel::onBackupKeySaveRequested,
onSaveManually = viewModel::goToRecordManually,
onSaveSuccessful = viewModel::goToNextStage
)
},
onCopyToClipboardClick = { Util.copyToClipboard(context, it, CLIPBOARD_TIMEOUT_SECONDS) },
onRequestSaveToPasswordManager = viewModel::onBackupKeySaveRequested,
onConfirmSaveToPasswordManager = viewModel::onBackupKeySaveConfirmed,
onSaveStateCleared = viewModel::onBackupKeySaveStateCleared,
onSaveToPasswordManagerComplete = viewModel::onBackupKeySaveCompleted,
onGoToPasswordManagerSettingsClick = { requireContext().startActivity(passwordManagerSettingsIntent) },
notifyKeyIsSameAsOnDeviceBackupKey = SignalStore.backup.newLocalBackupsEnabled
)
}
composable(route = MessageBackupsStage.Route.BACKUP_KEY_RECORD_MANUALLY.name) {
val context = LocalContext.current
val passwordManagerSettingsIntent = AndroidCredentialRepository.getCredentialManagerSettingsIntent(requireContext())
MessageBackupsKeyRecordScreen(
backupKey = state.accountEntropyPool.displayValue,
keySaveState = state.backupKeySaveState,
@@ -53,6 +53,7 @@ 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 {
@@ -238,8 +239,9 @@ 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 = MessageBackupsStage.BACKUP_KEY_RECORD)
MessageBackupsStage.BACKUP_KEY_RECORD -> it.copy(stage = MessageBackupsStage.BACKUP_KEY_VERIFY)
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_VERIFY -> it.copy(stage = MessageBackupsStage.TYPE_SELECTION)
MessageBackupsStage.TYPE_SELECTION -> validateTypeAndUpdateState(it)
MessageBackupsStage.CHECKOUT_SHEET -> it.copy(stage = MessageBackupsStage.PROCESS_PAYMENT)
@@ -262,7 +264,8 @@ class MessageBackupsFlowViewModel(
MessageBackupsStage.EDUCATION -> MessageBackupsStage.CANCEL
MessageBackupsStage.BACKUP_KEY_EDUCATION -> MessageBackupsStage.EDUCATION
MessageBackupsStage.BACKUP_KEY_RECORD -> MessageBackupsStage.BACKUP_KEY_EDUCATION
MessageBackupsStage.BACKUP_KEY_VERIFY -> MessageBackupsStage.BACKUP_KEY_RECORD
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.TYPE_SELECTION -> MessageBackupsStage.BACKUP_KEY_RECORD
MessageBackupsStage.CHECKOUT_SHEET -> MessageBackupsStage.TYPE_SELECTION
MessageBackupsStage.CREATING_IN_APP_PAYMENT -> MessageBackupsStage.CREATING_IN_APP_PAYMENT
@@ -277,6 +280,12 @@ class MessageBackupsFlowViewModel(
}
}
fun goToRecordManually() {
internalStateFlow.update {
it.copy(stage = MessageBackupsStage.BACKUP_KEY_RECORD_MANUALLY)
}
}
fun onMessageBackupTierUpdated(messageBackupTier: MessageBackupTier) {
internalStateFlow.update {
it.copy(
@@ -5,6 +5,7 @@
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
@@ -28,6 +29,7 @@ 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
@@ -46,6 +48,7 @@ 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 {
@@ -80,6 +83,14 @@ 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 = "",
@@ -139,7 +150,7 @@ fun MessageBackupsKeyEducationScreen(
.padding(top = 16.dp, bottom = 24.dp)
) {
Buttons.LargeTonal(
onClick = onNextClick,
onClick = { biometrics.withBiometricsAuthentication(onNextClick) },
modifier = Modifier.align(Alignment.Center)
) {
Text(
@@ -6,14 +6,22 @@
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
@@ -22,6 +30,7 @@ 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
@@ -34,9 +43,16 @@ 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
@@ -48,6 +64,7 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.launch
import org.signal.core.ui.compose.BottomSheets
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.DayNightPreviews
@@ -85,6 +102,11 @@ sealed interface MessageBackupsKeyRecordMode {
val isOptimizedStorageEnabled: Boolean,
val canRotateKey: Boolean
) : MessageBackupsKeyRecordMode
data class Passkey(
val onSaveToPasswordManager: () -> Unit,
val onSaveManually: () -> Unit,
val onSaveSuccessful: () -> Unit
) : MessageBackupsKeyRecordMode
}
/**
@@ -136,18 +158,24 @@ 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(" ")
}
if (mode is MessageBackupsKeyRecordMode.Next) {
val showAsPasskey = mode is MessageBackupsKeyRecordMode.Passkey
var showExpandedPasskey by remember { mutableStateOf(false) }
if (mode is MessageBackupsKeyRecordMode.Next || mode is MessageBackupsKeyRecordMode.Passkey) {
RecordScreenBackHandler()
}
@@ -183,6 +211,41 @@ fun MessageBackupsKeyRecordScreen(
}
}
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,
@@ -216,7 +279,11 @@ fun MessageBackupsKeyRecordScreen(
item {
Text(
text = stringResource(R.string.MessageBackupsKeyRecordScreen__record_your_backup_key),
text = if (showAsPasskey) {
stringResource(R.string.MessageBackupsKeyRecordScreen__save_your_recovery_key)
} else {
stringResource(R.string.MessageBackupsKeyRecordScreen__record_your_backup_key)
},
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier.padding(top = 16.dp)
)
@@ -225,6 +292,8 @@ 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)
}
@@ -239,47 +308,113 @@ fun MessageBackupsKeyRecordScreen(
}
item {
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()
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
)
}
)
)
}
}
item {
Buttons.Small(
onClick = {
if (mode is MessageBackupsKeyRecordMode.CreateNewKey) {
displayRecoveryKeyCopyWarning = true
} else {
onCopyToClipboardClick(backupKeyString)
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()
)
)
}
}
) {
Text(
text = stringResource(R.string.MessageBackupsKeyRecordScreen__copy_to_clipboard)
)
}
}
if (AndroidCredentialRepository.isCredentialManagerSupported) {
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)
)
}
}
}
if (!showAsPasskey && AndroidCredentialRepository.isCredentialManagerSupported(context)) {
item {
Buttons.Small(
onClick = { onRequestSaveToPasswordManager() }
@@ -300,6 +435,10 @@ fun MessageBackupsKeyRecordScreen(
is MessageBackupsKeyRecordMode.CreateNewKey -> {
CreateNewKeyButton(mode)
}
is MessageBackupsKeyRecordMode.Passkey -> {
SaveButtons(mode)
}
}
}
@@ -326,7 +465,12 @@ fun MessageBackupsKeyRecordScreen(
is BackupKeySaveState.Success -> {
val snackbarMessage = stringResource(R.string.MessageBackupsKeyRecordScreen__save_to_password_manager_success)
LaunchedEffect(keySaveState) {
snackbarHostState.showSnackbar(snackbarMessage)
if (showAsPasskey) {
displayConfirmKey = true
} else {
snackbarHostState.showSnackbar(snackbarMessage)
}
onSaveStateCleared()
}
}
@@ -343,6 +487,24 @@ 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(
@@ -578,6 +740,26 @@ 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
@@ -589,6 +771,13 @@ private suspend fun saveKeyToCredentialManager(
)
}
private suspend fun getKeyFromCredentialManager(
@UiContext activityContext: Context,
id: String
): String? {
return AndroidCredentialRepository.getCredential(activityContext, id)
}
@DayNightPreviews
@Composable
private fun MessageBackupsKeyRecordScreenPreview() {
@@ -621,6 +810,23 @@ private fun MessageBackupsKeyRecordScreenSameAsOnDeviceKeyPreview() {
}
}
@DayNightPreviews
@Composable
private fun MessageBackupsKeySaveScreenPreview() {
Previews.Preview {
MessageBackupsKeyRecordScreen(
backupKey = (0 until 63).map { (('A'..'Z') + ('0'..'9')).random() }.joinToString("") + "0",
keySaveState = null,
canOpenPasswordManagerSettings = true,
mode = MessageBackupsKeyRecordMode.Passkey(
onSaveToPasswordManager = {},
onSaveManually = {},
onSaveSuccessful = {}
)
)
}
}
@DayNightPreviews
@Composable
private fun SaveKeyConfirmationDialogPreview() {
@@ -15,6 +15,7 @@ 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),
@@ -32,6 +33,7 @@ enum class MessageBackupsStage(
EDUCATION,
BACKUP_KEY_EDUCATION,
BACKUP_KEY_RECORD,
BACKUP_KEY_RECORD_MANUALLY,
BACKUP_KEY_VERIFY,
TYPE_SELECTION;
@@ -61,7 +61,7 @@ class VerifyBackupKeyActivity : PassphraseRequiredActivity() {
// Matches existing behavior: show a generic "authentication required" toast.
Toast.makeText(
context,
R.string.RemoteBackupsSettingsFragment__authenticatino_required,
R.string.RemoteBackupsSettingsFragment__authentication_required,
Toast.LENGTH_SHORT
).show()
}
@@ -34,6 +34,7 @@ import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsType
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.InAppPaymentCheckoutDelegate
import org.thoughtcrime.securesms.database.InAppPaymentTable
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.util.storage.AndroidCredentialRepository
import org.thoughtcrime.securesms.util.viewModel
/**
@@ -61,6 +62,7 @@ abstract class UpgradeToPaidTierBottomSheet : ComposeBottomSheetDialogFragment()
MessageBackupsFlowViewModel(
initialTierSelection = MessageBackupTier.PAID,
googlePlayApiAvailability = GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(requireContext()),
isCredentialManagerSupported = AndroidCredentialRepository.isCredentialManagerSupported(requireContext()),
startScreen = MessageBackupsStage.TYPE_SELECTION
)
}
@@ -221,7 +221,7 @@ fun CallLinkDetailsScreen(
)
}
if (state.callLink.credentials?.adminPassBytes != null) {
if (state.callLink.canModify) {
item {
Rows.TextRow(
text = stringResource(
@@ -273,13 +273,15 @@ fun CallLinkDetailsScreen(
)
}
item {
Rows.TextRow(
text = stringResource(id = R.string.CallLinkDetailsFragment__delete_call_link),
icon = SignalIcons.Trash.imageVector,
foregroundTint = MaterialTheme.colorScheme.error,
onClick = callback::onDeleteClicked
)
if (state.callLink.canModify) {
item {
Rows.TextRow(
text = stringResource(id = R.string.CallLinkDetailsFragment__delete_call_link),
icon = SignalIcons.Trash.imageVector,
foregroundTint = MaterialTheme.colorScheme.error,
onClick = callback::onDeleteClicked
)
}
}
}
@@ -0,0 +1,85 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.chats
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewmodel.compose.SavedStateHandleSaveableApi
import androidx.lifecycle.viewmodel.compose.saveable
import org.thoughtcrime.securesms.main.MainNavigationDetailLocation
import org.thoughtcrime.securesms.recipients.RecipientId
/**
* Controls the navigation stack used by the chats screen.
*/
@OptIn(SavedStateHandleSaveableApi::class)
class ChatsBackStack(savedStateHandle: SavedStateHandle) {
companion object {
private const val KEY = "chats_back_stack"
val saver: Saver<SnapshotStateList<MainNavigationDetailLocation>, ArrayList<MainNavigationDetailLocation>> = Saver(
save = { ArrayList(it) },
restore = { mutableStateListOf(*it.toTypedArray()) }
)
}
val entries: SnapshotStateList<MainNavigationDetailLocation> = savedStateHandle.saveable(
key = KEY,
saver = saver
) {
mutableStateListOf(MainNavigationDetailLocation.Empty)
}
val activeRecipientId: RecipientId?
get() = entries.asReversed().firstNotNullOfOrNull {
when (it) {
is MainNavigationDetailLocation.Conversation -> it.conversationArgs.recipientId
is MainNavigationDetailLocation.Chats -> it.controllerKey
else -> null
}
}
val isEmpty: Boolean
get() = entries.singleOrNull() is MainNavigationDetailLocation.Empty
/**
* Pushes an entry onto the stack.
*/
fun push(location: MainNavigationDetailLocation) {
when (location) {
is MainNavigationDetailLocation.Empty, entries.lastOrNull() -> Unit
is MainNavigationDetailLocation.Conversation -> {
entries.removeAll { it !is MainNavigationDetailLocation.Empty }
entries.add(location)
}
else -> entries.add(location)
}
}
/**
* Pops the top entry off the stack. Returns true if something was popped, false if the stack is already at its root.
*/
fun pop(): Boolean {
if (entries.size <= 1) return false
entries.removeAt(entries.lastIndex)
return true
}
/**
* Resets the stack to its base empty state.
*/
fun reset() {
entries.removeAll { it !is MainNavigationDetailLocation.Empty }
if (entries.isEmpty()) {
entries.add(MainNavigationDetailLocation.Empty)
}
}
}
@@ -0,0 +1,161 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.chats
import android.os.Bundle
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.fragment.compose.AndroidFragment
import androidx.fragment.compose.rememberFragmentState
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.navigation3.runtime.EntryProviderScope
import androidx.navigation3.runtime.NavKey
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import org.signal.core.ui.navigation.TransitionSpecs
import org.thoughtcrime.securesms.MainNavigator
import org.thoughtcrime.securesms.components.settings.conversation.ConversationSettingsNavHostFragment
import org.thoughtcrime.securesms.compose.FragmentBackHandler
import org.thoughtcrime.securesms.compose.FragmentBackPressedState
import org.thoughtcrime.securesms.conversation.ConversationIntents
import org.thoughtcrime.securesms.conversation.v2.ConversationFragment
import org.thoughtcrime.securesms.main.EmptyDetailScreen
import org.thoughtcrime.securesms.main.MainNavigationDetailLocation
import org.thoughtcrime.securesms.messagedetails.MessageDetailsFragment
fun EntryProviderScope<NavKey>.chatsNavEntries(
transitionState: ConversationTransitionState
) {
entry<MainNavigationDetailLocation.Empty> {
NoConvoSelectedEntry()
}
entry<MainNavigationDetailLocation.Conversation>(
// disable slide animation - it's unnecessary in split pane mode and is handled by ConversationLoadingMask for single pane mode.
metadata = TransitionSpecs.None.metadata
) { route ->
ConversationEntry(route, transitionState)
}
entry<MainNavigationDetailLocation.Chats.MessageDetails> { route ->
MessageDetailsEntry(route)
}
entry<MainNavigationDetailLocation.Chats.ConversationSettings> { route ->
ConversationSettingsEntry(route)
}
}
@Composable
private fun NoConvoSelectedEntry() {
EmptyDetailScreen()
}
@Composable
private fun ConversationEntry(
route: MainNavigationDetailLocation.Conversation,
transitionState: ConversationTransitionState
) {
val context = LocalContext.current
val navigatorProvider = context as? MainNavigator.NavigatorProvider
val fragmentState = key(route) { rememberFragmentState() }
val arguments = requireNotNull(ConversationIntents.createBuilderSync(context, route.conversationArgs).build().extras) {
"Handed null Conversation intent arguments."
}
val fragmentContentReady = remember { MutableStateFlow(false) }
val backPressedState = remember { FragmentBackPressedState() }
FragmentBackHandler(backPressedState)
ConversationLoadingMask(
transitionState = transitionState,
contentReady = fragmentContentReady,
onFirstRender = { navigatorProvider?.onFirstRender() }
) { modifier ->
AndroidFragment(
clazz = ConversationFragment::class.java,
fragmentState = fragmentState,
arguments = arguments,
modifier = modifier
.background(MaterialTheme.colorScheme.background)
.fillMaxSize()
) { fragment ->
backPressedState.attach(fragment)
fragment.viewLifecycleOwner.lifecycleScope.launch {
fragment.repeatOnLifecycle(Lifecycle.State.STARTED) {
fragment.didFirstFrameRender.collectLatest { fragmentContentReady.value = it }
}
}
}
}
}
@Composable
private fun MessageDetailsEntry(route: MainNavigationDetailLocation.Chats.MessageDetails) {
val navigatorProvider = LocalContext.current as? MainNavigator.NavigatorProvider
val fragmentState = key(route) { rememberFragmentState() }
LaunchedEffect(Unit) {
navigatorProvider?.onFirstRender()
}
AndroidFragment(
clazz = MessageDetailsFragment::class.java,
fragmentState = fragmentState,
arguments = MessageDetailsFragment.args(route.recipientId, route.messageId),
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.statusBarsPadding()
.navigationBarsPadding()
)
}
@Composable
private fun ConversationSettingsEntry(route: MainNavigationDetailLocation.Chats.ConversationSettings) {
val navigatorProvider = LocalContext.current as? MainNavigator.NavigatorProvider
val fragmentState = key(route) { rememberFragmentState() }
val arguments: Bundle? by produceState(null, route.recipientId) {
value = ConversationSettingsNavHostFragment.createArgs(route.recipientId)
}
LaunchedEffect(Unit) {
navigatorProvider?.onFirstRender()
}
arguments?.let { args ->
val backPressedState = remember { FragmentBackPressedState() }
FragmentBackHandler(backPressedState)
AndroidFragment(
clazz = ConversationSettingsNavHostFragment::class.java,
fragmentState = fragmentState,
arguments = args,
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.statusBarsPadding()
.navigationBarsPadding()
) { fragment ->
backPressedState.attach(fragment)
}
}
}
@@ -0,0 +1,128 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.chats
import androidx.compose.animation.core.Transition
import androidx.compose.animation.core.animateDp
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.updateTransition
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withTimeoutOrNull
import org.thoughtcrime.securesms.window.AppScaffoldAnimationDefaults
import org.thoughtcrime.securesms.window.AppScaffoldAnimationState
import kotlin.time.Duration.Companion.seconds
/**
* Wraps [content] with an animation that crossfades a snapshotted chat list bitmap over the conversation fragment while it loads its first frame.
*
* @param contentReady emits when the fragment's first frame has been rendered.
* @param onFirstRender signals that this composable has content ready to display, so the parent activity can proceed with its first draw.
* @param content will be animated in as the overlay fades out.
*/
@Composable
fun ConversationLoadingMask(
transitionState: ConversationTransitionState,
contentReady: StateFlow<Boolean>,
onFirstRender: () -> Unit,
content: @Composable (chatModifier: Modifier) -> Unit
) {
// it can take a long time to load content, so we use a "fake" chat list image to delay displaying the fragment
// and prevent pop-in. When there's no bitmap (e.g. returning from a sub-route), skip the animation.
var shouldDisplayFragment by remember {
val hasBitmap = transitionState.chatBitmap != null
mutableStateOf(!hasBitmap)
}
val transition: Transition<Boolean> = updateTransition(shouldDisplayFragment)
val bitmap = transitionState.chatBitmap
val fakeChatListAnimationState = transition.fakeChatListAnimationState()
val chatAnimationState = transition.chatAnimationState(hasFake = bitmap != null)
LaunchedEffect(transition.currentState, transition.isRunning) {
if (transition.currentState && !transition.isRunning) {
transitionState.clearBitmap()
}
}
LaunchedEffect(shouldDisplayFragment) {
onFirstRender()
}
LaunchedEffect(contentReady) {
if (!shouldDisplayFragment) {
withTimeoutOrNull(5.seconds) {
contentReady.first { it }
}
shouldDisplayFragment = true
}
}
val chatModifier = Modifier.graphicsLayer {
with(chatAnimationState) { applyChildValues() }
}
Box(modifier = Modifier.fillMaxSize()) {
content(chatModifier)
if (bitmap != null) {
Image(
bitmap = bitmap,
contentDescription = null,
modifier = Modifier
.graphicsLayer {
with(fakeChatListAnimationState) { applyChildValues() }
}
.fillMaxSize()
)
}
}
}
@Composable
private fun Transition<Boolean>.fakeChatListAnimationState(): AppScaffoldAnimationState {
val alpha = animateFloat(transitionSpec = { AppScaffoldAnimationDefaults.tween() }) { if (it) 0f else 1f }
val offset = animateDp(transitionSpec = { AppScaffoldAnimationDefaults.tween() }) { if (it) (-48).dp else 0.dp }
return remember {
AppScaffoldAnimationState(
offset = offset,
alpha = alpha
)
}
}
@Composable
private fun Transition<Boolean>.chatAnimationState(hasFake: Boolean): AppScaffoldAnimationState {
val alpha = animateFloat(transitionSpec = { AppScaffoldAnimationDefaults.tween() }) { if (it) 1f else 0f }
return if (!hasFake) {
remember {
AppScaffoldAnimationState(
offset = mutableStateOf(0.dp),
alpha = alpha
)
}
} else {
val offset = animateDp(transitionSpec = { AppScaffoldAnimationDefaults.tween() }) { if (it) 0.dp else 48.dp }
remember {
AppScaffoldAnimationState(
offset = offset,
alpha = alpha
)
}
}
}
@@ -0,0 +1,70 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.chats
import android.os.Build
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.layer.GraphicsLayer
import androidx.compose.ui.graphics.layer.drawLayer
import androidx.compose.ui.graphics.rememberGraphicsLayer
/**
* Allows the setting of a "fake" bitmap driven by a graphics layer to coordinate delayed animations
* in lieu of proper support for postponing enter transitions.
*/
@Stable
class ConversationTransitionState private constructor(
val isSplitPane: Boolean,
val graphicsLayer: GraphicsLayer
) {
companion object {
@Composable
fun remember(isSplitPane: Boolean): ConversationTransitionState {
val graphicsLayer = rememberGraphicsLayer()
return remember(isSplitPane) {
ConversationTransitionState(isSplitPane, graphicsLayer)
}
}
}
var chatBitmap: ImageBitmap? by mutableStateOf(null)
private set
private var hasWrittenToGraphicsLayer: Boolean by mutableStateOf(false)
suspend fun writeGraphicsLayerToBitmap() {
// toImageBitmap() uses LayerSnapshot which has format compatibility issues on Android 7 and below
if (Build.VERSION.SDK_INT >= 26 && !isSplitPane && hasWrittenToGraphicsLayer) {
chatBitmap = graphicsLayer.toImageBitmap()
}
}
fun writeContentToGraphicsLayer(): Modifier {
if (isSplitPane) return Modifier
return Modifier.drawWithContent {
graphicsLayer.record {
this@drawWithContent.drawContent()
hasWrittenToGraphicsLayer = true
}
drawLayer(graphicsLayer)
}
}
fun clearBitmap() {
chatBitmap = null
}
}
@@ -20,8 +20,8 @@ data class ViewColorSet(
) : Parcelable {
companion object {
val PRIMARY = ViewColorSet(
foreground = ViewColor.ColorResource(CoreUiR.color.signal_colorOnPrimary),
background = ViewColor.ColorResource(CoreUiR.color.signal_colorPrimary)
foreground = ViewColor.ColorResource(CoreUiR.color.signal_light_colorOnPrimary),
background = ViewColor.ColorResource(CoreUiR.color.signal_light_colorPrimary)
)
fun forCustomColor(@ColorInt customColor: Int): ViewColorSet {
@@ -39,6 +39,7 @@ import org.thoughtcrime.securesms.audio.AudioWaveForms;
import org.thoughtcrime.securesms.components.voice.VoiceNotePlaybackState;
import org.thoughtcrime.securesms.database.AttachmentTable;
import org.thoughtcrime.securesms.events.PartProgressEvent;
import org.thoughtcrime.securesms.jobs.AttachmentBackfill;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.mms.AudioSlide;
import org.thoughtcrime.securesms.mms.SlideClickListener;
@@ -82,9 +83,11 @@ public final class AudioView extends FrameLayout {
private boolean isPlaying;
private long durationMillis;
private AudioSlide audioSlide;
private boolean showControls;
private Callbacks callbacks;
private Disposable disposable = Disposable.disposed();
private Disposable disposable = Disposable.disposed();
private Disposable awaitingDisposable = Disposable.disposed();
private final Observer<VoiceNotePlaybackState> playbackStateObserver = this::onPlaybackState;
@@ -159,6 +162,12 @@ public final class AudioView extends FrameLayout {
protected void onAttachedToWindow() {
super.onAttachedToWindow();
if (!EventBus.getDefault().isRegistered(this)) EventBus.getDefault().register(this);
awaitingDisposable = AttachmentBackfill.awaitingChanges()
.observeOn(AndroidSchedulers.mainThread())
.subscribe(ignored -> {
if (audioSlide != null) presentTransferControls(audioSlide, showControls);
}, t -> Log.w(TAG, "Error observing backfill awaiting state.", t));
}
@Override
@@ -166,6 +175,7 @@ public final class AudioView extends FrameLayout {
super.onDetachedFromWindow();
EventBus.getDefault().unregister(this);
disposable.dispose();
awaitingDisposable.dispose();
}
public void setProgressAndPlayBackgroundTint(@ColorInt int color) {
@@ -197,27 +207,8 @@ public final class AudioView extends FrameLayout {
}
}
if (showControls && audio.isPendingDownload()) {
controlToggle.displayQuick(downloadContainer);
seekBar.setEnabled(false);
downloadButton.setOnClickListener(new DownloadClickedListener(audio));
if (circleProgress != null) {
if (circleProgress.isSpinning()) circleProgress.stopSpinning();
circleProgress.setVisibility(View.GONE);
}
} else if (showControls && audio.getTransferState() == AttachmentTable.TRANSFER_PROGRESS_STARTED) {
controlToggle.displayQuick(progressAndPlay);
seekBar.setEnabled(false);
showPlayButton();
if (circleProgress != null) {
circleProgress.setVisibility(View.VISIBLE);
circleProgress.spin();
}
} else {
seekBar.setEnabled(true);
if (circleProgress != null && circleProgress.isSpinning()) circleProgress.stopSpinning();
showPlayButton();
}
this.showControls = showControls;
presentTransferControls(audio, showControls);
if (seekBar instanceof WaveFormSeekBarView) {
WaveFormSeekBarView waveFormView = (WaveFormSeekBarView) seekBar;
@@ -263,6 +254,38 @@ public final class AudioView extends FrameLayout {
}
}
private void presentTransferControls(@NonNull AudioSlide audio, boolean showControls) {
DatabaseAttachment dbAttachment = audio.asAttachment() instanceof DatabaseAttachment ? (DatabaseAttachment) audio.asAttachment() : null;
if (dbAttachment != null && dbAttachment.transferState == AttachmentTable.TRANSFER_PROGRESS_DONE && AttachmentBackfill.isAwaitingBackfill(dbAttachment.attachmentId)) {
AttachmentBackfill.onAttachmentTerminal(dbAttachment.attachmentId, dbAttachment.mmsId);
}
boolean awaitingBackfill = dbAttachment != null && AttachmentBackfill.isAwaitingBackfill(dbAttachment.attachmentId);
if (showControls && audio.isPendingDownload() && !awaitingBackfill) {
controlToggle.displayQuick(downloadContainer);
seekBar.setEnabled(false);
downloadButton.setOnClickListener(new DownloadClickedListener(audio));
if (circleProgress != null) {
if (circleProgress.isSpinning()) circleProgress.stopSpinning();
circleProgress.setVisibility(View.GONE);
}
} else if (showControls && (audio.getTransferState() == AttachmentTable.TRANSFER_PROGRESS_STARTED || awaitingBackfill)) {
controlToggle.displayQuick(progressAndPlay);
seekBar.setEnabled(false);
showPlayButton();
if (circleProgress != null) {
circleProgress.setVisibility(View.VISIBLE);
if (!circleProgress.isSpinning()) circleProgress.spin();
}
} else {
seekBar.setEnabled(true);
if (circleProgress != null && circleProgress.isSpinning()) circleProgress.stopSpinning();
showPlayButton();
}
}
public void setDownloadClickListener(@Nullable SlideClickListener listener) {
this.downloadListener = listener;
}
@@ -28,6 +28,7 @@ import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.attachments.DatabaseAttachment;
import org.thoughtcrime.securesms.database.AttachmentTable;
import org.thoughtcrime.securesms.events.PartProgressEvent;
import org.thoughtcrime.securesms.jobs.AttachmentBackfill;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.mms.Slide;
import org.thoughtcrime.securesms.mms.SlideClickListener;
@@ -36,6 +37,9 @@ import org.whispersystems.signalservice.api.util.OptionalUtil;
import java.util.Collections;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.disposables.Disposable;
public class DocumentView extends FrameLayout {
private static final String TAG = Log.tag(DocumentView.class);
@@ -55,6 +59,9 @@ public class DocumentView extends FrameLayout {
private @Nullable SlidesClickedListener cancelTransferClickListener;
private @Nullable SlidesClickedListener resendTransferClickListener;
private @Nullable Slide documentSlide;
private boolean showControls;
private Disposable awaitingDisposable = Disposable.disposed();
public DocumentView(@NonNull Context context) {
this(context, null);
@@ -98,12 +105,19 @@ public class DocumentView extends FrameLayout {
if (!EventBus.getDefault().isRegistered(this)) {
EventBus.getDefault().register(this);
}
awaitingDisposable = AttachmentBackfill.awaitingChanges()
.observeOn(AndroidSchedulers.mainThread())
.subscribe(ignored -> {
if (documentSlide != null) presentTransferControls(documentSlide, showControls);
}, t -> Log.w(TAG, "Error observing backfill awaiting state.", t));
}
@Override
protected void onDetachedFromWindow() {
super.onDetachedFromWindow();
EventBus.getDefault().unregister(this);
awaitingDisposable.dispose();
}
public void setDownloadClickListener(@Nullable SlideClickListener listener) {
@@ -126,22 +140,8 @@ public class DocumentView extends FrameLayout {
final boolean showControls,
final boolean showSingleLineFilename)
{
if (showControls && documentSlide.getTransferState() == AttachmentTable.TRANSFER_PROGRESS_STARTED) {
controlToggle.displayQuick(stopUploadButton);
downloadProgress.spin();
stopUploadButton.setOnClickListener(new CancelTransferListener(documentSlide));
} else if (showControls && documentSlide.getUri() != null && documentSlide.isPendingDownload()) {
controlToggle.displayQuick(uploadButton);
uploadButton.setOnClickListener(new ResendTransferClickListener(documentSlide));
if (downloadProgress.isSpinning()) downloadProgress.stopSpinning();
} else if (showControls && documentSlide.getUri() == null && documentSlide.isPendingDownload()) {
controlToggle.displayQuick(downloadButton);
downloadButton.setOnClickListener(new DownloadClickedListener(documentSlide));
if (downloadProgress.isSpinning()) downloadProgress.stopSpinning();
} else {
controlToggle.displayQuick(iconContainer);
if (downloadProgress.isSpinning()) downloadProgress.stopSpinning();
}
this.showControls = showControls;
presentTransferControls(documentSlide, showControls);
this.documentSlide = documentSlide;
@@ -172,6 +172,33 @@ public class DocumentView extends FrameLayout {
}
}
private void presentTransferControls(@NonNull Slide documentSlide, boolean showControls) {
DatabaseAttachment dbAttachment = documentSlide.asAttachment() instanceof DatabaseAttachment ? (DatabaseAttachment) documentSlide.asAttachment() : null;
if (dbAttachment != null && dbAttachment.transferState == AttachmentTable.TRANSFER_PROGRESS_DONE && AttachmentBackfill.isAwaitingBackfill(dbAttachment.attachmentId)) {
AttachmentBackfill.onAttachmentTerminal(dbAttachment.attachmentId, dbAttachment.mmsId);
}
boolean awaitingBackfill = dbAttachment != null && AttachmentBackfill.isAwaitingBackfill(dbAttachment.attachmentId);
if (showControls && (documentSlide.getTransferState() == AttachmentTable.TRANSFER_PROGRESS_STARTED || awaitingBackfill)) {
controlToggle.displayQuick(stopUploadButton);
if (!downloadProgress.isSpinning()) downloadProgress.spin();
stopUploadButton.setOnClickListener(awaitingBackfill ? null : new CancelTransferListener(documentSlide));
} else if (showControls && documentSlide.getUri() != null && documentSlide.isPendingDownload()) {
controlToggle.displayQuick(uploadButton);
uploadButton.setOnClickListener(new ResendTransferClickListener(documentSlide));
if (downloadProgress.isSpinning()) downloadProgress.stopSpinning();
} else if (showControls && documentSlide.getUri() == null && documentSlide.isPendingDownload()) {
controlToggle.displayQuick(downloadButton);
downloadButton.setOnClickListener(new DownloadClickedListener(documentSlide));
if (downloadProgress.isSpinning()) downloadProgress.stopSpinning();
} else {
controlToggle.displayQuick(iconContainer);
if (downloadProgress.isSpinning()) downloadProgress.stopSpinning();
}
}
@Override
public void setFocusable(boolean focusable) {
super.setFocusable(focusable);
@@ -27,7 +27,7 @@ import org.thoughtcrime.securesms.components.subsampling.AttachmentRegionDecoder
import org.signal.glide.decryptableuri.DecryptableUri;
import org.thoughtcrime.securesms.mms.PartAuthority;
import org.thoughtcrime.securesms.util.ActionRequestListener;
import org.thoughtcrime.securesms.util.BitmapUtil;
import org.signal.core.util.bitmaps.BitmapUtil;
import org.thoughtcrime.securesms.util.MediaUtil;
import java.io.IOException;
@@ -7,8 +7,8 @@ import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.schedulers.Schedulers
import org.signal.core.util.JsonUtils
import org.signal.network.NetworkResult
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.net.SignalNetwork
import org.thoughtcrime.securesms.providers.BlobProvider
class ExportAccountDataRepository {
@@ -44,7 +44,7 @@ class ExportAccountDataRepository {
tree["text"].asText()
}
val uri = BlobProvider.getInstance()
val uri = AppDependencies.blobs
.forData(dataStr.encodeToByteArray())
.withMimeType(mimeType)
.withFileName(fileName)
@@ -27,6 +27,9 @@ interface BackupKeyCredentialManagerHandler {
/** Called when the user confirms they want to save the backup key to the password manager. */
fun onBackupKeySaveConfirmed() = updateBackupKeySaveState(BackupKeySaveState.AwaitingCredentialManager(isRetry = false))
/** Called to clear the key save state. */
fun onBackupKeySaveStateCleared() = updateBackupKeySaveState(null)
/** Handles the password manager save operation response. */
fun onBackupKeySaveCompleted(result: CredentialManagerResult) {
when (result) {
@@ -368,7 +368,7 @@ private fun RemoteBackupsSettingsContent(
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__authenticatino_required, Toast.LENGTH_SHORT).show()
Toast.makeText(context, R.string.RemoteBackupsSettingsFragment__authentication_required, Toast.LENGTH_SHORT).show()
}
)
@@ -25,9 +25,9 @@ import org.signal.core.ui.compose.Scaffolds
import org.signal.core.ui.compose.SignalIcons
import org.signal.core.ui.compose.Texts
import org.signal.core.util.bytes
import org.signal.mediasend.SentMediaQuality
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.compose.rememberStatusBarColorNestedScrollModifier
import org.thoughtcrime.securesms.mms.SentMediaQuality
import org.thoughtcrime.securesms.util.AttachmentUtil
import org.thoughtcrime.securesms.util.navigation.safeNavigate
import org.thoughtcrime.securesms.webrtc.CallDataMode
@@ -1,6 +1,6 @@
package org.thoughtcrime.securesms.components.settings.app.data
import org.thoughtcrime.securesms.mms.SentMediaQuality
import org.signal.mediasend.SentMediaQuality
import org.thoughtcrime.securesms.webrtc.CallDataMode
data class DataAndStorageSettingsState(
@@ -6,11 +6,11 @@ import androidx.lifecycle.ViewModelProvider
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import org.signal.mediasend.SentMediaQuality
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.keyvalue.SettingsValues.ForceWebsocketMode
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.messages.IncomingMessageObserver
import org.thoughtcrime.securesms.mms.SentMediaQuality
import org.thoughtcrime.securesms.util.PlayServicesUtil
import org.thoughtcrime.securesms.util.TextSecurePreferences
import org.thoughtcrime.securesms.webrtc.CallDataMode
@@ -57,7 +57,6 @@ import org.thoughtcrime.securesms.jobs.LocalBackupJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.keyvalue.protos.LocalBackupCreationProgress
import org.thoughtcrime.securesms.net.SignalNetwork
import org.thoughtcrime.securesms.providers.BlobProvider
import org.thoughtcrime.securesms.recipients.Recipient
import java.io.FileOutputStream
import java.io.IOException
@@ -136,7 +135,7 @@ class InternalBackupPlaygroundViewModel : ViewModel() {
fun validateBackup() {
_state.value = _state.value.copy(statusMessage = "Exporting to a temporary file...")
val tempFile = BlobProvider.getInstance().forNonAutoEncryptingSingleSessionOnDisk(AppDependencies.application)
val tempFile = AppDependencies.blobs.forNonAutoEncryptingSingleSessionOnDisk(AppDependencies.application)
disposables += Single
.fromCallable {
@@ -207,7 +206,7 @@ class InternalBackupPlaygroundViewModel : ViewModel() {
SignalExecutors.BOUNDED_IO.execute {
Log.d(TAG, "Downloading file...")
val tempBackupFile = BlobProvider.getInstance().forNonAutoEncryptingSingleSessionOnDisk(AppDependencies.application)
val tempBackupFile = AppDependencies.blobs.forNonAutoEncryptingSingleSessionOnDisk(AppDependencies.application)
when (val result = BackupRepository.downloadBackupFile(tempBackupFile)) {
is NetworkResult.Success -> Log.i(TAG, "Download successful")
@@ -9,7 +9,6 @@ sealed interface LabsSettingsEvents {
data class ToggleIndividualChatPlaintextExport(val enabled: Boolean) : LabsSettingsEvents
data class ToggleStoryArchive(val enabled: Boolean) : LabsSettingsEvents
data class ToggleIncognito(val enabled: Boolean) : LabsSettingsEvents
data class ToggleGroupSuggestionsForMembers(val enabled: Boolean) : LabsSettingsEvents
data class ToggleBetterSearch(val enabled: Boolean) : LabsSettingsEvents
data class ToggleAutoLowerHand(val enabled: Boolean) : LabsSettingsEvents
data class ToggleStarredMessages(val enabled: Boolean) : LabsSettingsEvents
@@ -116,15 +116,6 @@ private fun LabsSettingsContent(
)
}
item {
Rows.ToggleRow(
checked = state.groupSuggestionsForMembers,
text = "Group Suggestions for Members",
label = "When creating a group, show existing groups that have the exact same members.",
onCheckChanged = { onEvent(LabsSettingsEvents.ToggleGroupSuggestionsForMembers(it)) }
)
}
item {
Rows.ToggleRow(
checked = state.betterSearch,
@@ -12,7 +12,6 @@ data class LabsSettingsState(
val individualChatPlaintextExport: Boolean = false,
val storyArchive: Boolean = false,
val incognito: Boolean = false,
val groupSuggestionsForMembers: Boolean = false,
val betterSearch: Boolean = false,
val autoLowerHand: Boolean = false,
val starredMessages: Boolean = false
@@ -29,10 +29,6 @@ class LabsSettingsViewModel : ViewModel() {
SignalStore.labs.incognito = event.enabled
_state.value = _state.value.copy(incognito = event.enabled)
}
is LabsSettingsEvents.ToggleGroupSuggestionsForMembers -> {
SignalStore.labs.groupSuggestionsForMembers = event.enabled
_state.value = _state.value.copy(groupSuggestionsForMembers = event.enabled)
}
is LabsSettingsEvents.ToggleBetterSearch -> {
SignalStore.labs.betterSearch = event.enabled
_state.value = _state.value.copy(betterSearch = event.enabled)
@@ -54,7 +50,6 @@ class LabsSettingsViewModel : ViewModel() {
individualChatPlaintextExport = SignalStore.labs.individualChatPlaintextExport,
storyArchive = SignalStore.labs.storyArchive,
incognito = SignalStore.labs.incognito,
groupSuggestionsForMembers = SignalStore.labs.groupSuggestionsForMembers,
betterSearch = SignalStore.labs.betterSearch,
autoLowerHand = SignalStore.labs.autoLowerHand,
starredMessages = SignalStore.labs.starredMessages
@@ -45,6 +45,7 @@ import org.signal.core.ui.compose.Texts
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.compose.rememberStatusBarColorNestedScrollModifier
import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.RemoteConfig
import org.thoughtcrime.securesms.util.viewModel
/**
@@ -273,13 +274,15 @@ private fun AdvancedPrivacySettingsScreen(
)
}
item {
Rows.ToggleRow(
checked = state.allowSealedSenderFromAnyone,
text = stringResource(R.string.preferences_communication__sealed_sender_allow_from_anyone),
label = stringResource(R.string.preferences_communication__sealed_sender_allow_from_anyone_description),
onCheckChanged = callbacks::onAllowSealedSenderFromAnyoneChanged
)
if (state.isPrimaryDevice) {
item {
Rows.ToggleRow(
checked = state.allowSealedSenderFromAnyone,
text = stringResource(R.string.preferences_communication__sealed_sender_allow_from_anyone),
label = stringResource(R.string.preferences_communication__sealed_sender_allow_from_anyone_description),
onCheckChanged = callbacks::onAllowSealedSenderFromAnyoneChanged
)
}
}
item {
@@ -298,29 +301,31 @@ private fun AdvancedPrivacySettingsScreen(
)
}
item {
Dividers.Default()
}
item {
val label = buildAnnotatedString {
append(stringResource(R.string.preferences_automatic_key_verification_body))
append(" ")
withLink(
LinkAnnotation.Clickable("learn-more", linkInteractionListener = {
callbacks.onAutomaticVerificationLearnMoreClick()
})
) {
append(stringResource(R.string.LearnMoreTextView_learn_more))
}
if (RemoteConfig.internalUser) {
item {
Dividers.Default()
}
Rows.ToggleRow(
checked = state.allowAutomaticKeyVerification,
text = AnnotatedString(stringResource(R.string.preferences_automatic_key_verification)),
label = label,
onCheckChanged = callbacks::onAllowAutomaticVerificationChanged
)
item {
val label = buildAnnotatedString {
append(stringResource(R.string.preferences_automatic_key_verification_body))
append(" ")
withLink(
LinkAnnotation.Clickable("learn-more", linkInteractionListener = {
callbacks.onAutomaticVerificationLearnMoreClick()
})
) {
append(stringResource(R.string.LearnMoreTextView_learn_more))
}
}
Rows.ToggleRow(
checked = state.allowAutomaticKeyVerification,
text = AnnotatedString(stringResource(R.string.preferences_automatic_key_verification)),
label = label,
onCheckChanged = callbacks::onAllowAutomaticVerificationChanged
)
}
}
}
}
@@ -339,7 +344,8 @@ private fun AdvancedPrivacySettingsScreenPreview() {
showSealedSenderStatusIcon = false,
allowSealedSenderFromAnyone = false,
showProgressSpinner = false,
allowAutomaticKeyVerification = false
allowAutomaticKeyVerification = false,
isPrimaryDevice = true
),
callbacks = AdvancedPrivacySettingsCallbacks.Empty
)
@@ -8,7 +8,8 @@ data class AdvancedPrivacySettingsState(
val showSealedSenderStatusIcon: Boolean,
val allowSealedSenderFromAnyone: Boolean,
val showProgressSpinner: Boolean,
val allowAutomaticKeyVerification: Boolean
val allowAutomaticKeyVerification: Boolean,
val isPrimaryDevice: Boolean
)
enum class CensorshipCircumventionState(val available: Boolean) {
@@ -105,7 +105,8 @@ class AdvancedPrivacySettingsViewModel(
AppDependencies.application
),
showProgressSpinner = false,
allowAutomaticKeyVerification = SignalStore.settings.automaticVerificationEnabled
allowAutomaticKeyVerification = SignalStore.settings.automaticVerificationEnabled,
isPrimaryDevice = SignalStore.account.isPrimaryDevice
)
}
@@ -5,6 +5,7 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway
import androidx.annotation.VisibleForTesting
import com.google.i18n.phonenumbers.PhoneNumberUtil
import org.signal.core.util.orNull
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppDonations
@@ -45,6 +46,10 @@ sealed interface GatewayOrderStrategy {
)
}
private data class Fixed(
override val orderedGateways: Set<InAppPaymentData.PaymentMethodType>
) : GatewayOrderStrategy
companion object {
fun getStrategy(self: Recipient = Recipient.self()): GatewayOrderStrategy {
val e164 = self.e164.orNull() ?: return Default
@@ -57,5 +62,10 @@ sealed interface GatewayOrderStrategy {
Default
}
}
@VisibleForTesting
fun forTesting(vararg orderedGateways: InAppPaymentData.PaymentMethodType): GatewayOrderStrategy {
return Fixed(linkedSetOf(*orderedGateways))
}
}
}
@@ -330,37 +330,52 @@ private fun GatewaySelectorBottomSheetContentReadyOneTimeGiftDonationPreview() {
@Composable
@VisibleForTesting
fun rememberGatewaySelectorBottomSheetContentPreviewState(type: InAppPaymentType): GatewaySelectorState.Ready {
fun rememberGatewaySelectorBottomSheetContentPreviewState(
type: InAppPaymentType,
gatewayOrderStrategy: GatewayOrderStrategy = GatewayOrderStrategy.getStrategy(
self = Recipient(
isResolving = false,
e164Value = "+15555555555"
)
),
availableGateways: Set<InAppPaymentData.PaymentMethodType> = setOf(
InAppPaymentData.PaymentMethodType.GOOGLE_PAY,
InAppPaymentData.PaymentMethodType.PAYPAL,
InAppPaymentData.PaymentMethodType.CARD,
InAppPaymentData.PaymentMethodType.SEPA_DEBIT,
InAppPaymentData.PaymentMethodType.IDEAL
)
): GatewaySelectorState.Ready {
return remember {
GatewaySelectorState.Ready(
inAppPayment = InAppPaymentTable.InAppPayment(
id = InAppPaymentTable.InAppPaymentId(1),
type = type,
state = InAppPaymentTable.State.CREATED,
insertedAt = 1.milliseconds,
updatedAt = 1.milliseconds,
notified = true,
subscriberId = null,
endOfPeriod = 0.milliseconds,
data = InAppPaymentData(
badge = BadgeList.Badge(
name = type.name.lowercase()
),
amount = FiatValue(currencyCode = "USD", amount = BigDecimal.TEN.toDecimalValue())
)
),
gatewayOrderStrategy = GatewayOrderStrategy.getStrategy(
self = Recipient(
isResolving = false,
e164Value = "+15555555555"
)
),
isGooglePayAvailable = true,
isPayPalAvailable = true,
isCreditCardAvailable = true,
isSEPADebitAvailable = true,
isIDEALAvailable = true,
inAppPayment = createInAppPaymentPreview(type),
gatewayOrderStrategy = gatewayOrderStrategy,
isGooglePayAvailable = InAppPaymentData.PaymentMethodType.GOOGLE_PAY in availableGateways,
isPayPalAvailable = InAppPaymentData.PaymentMethodType.PAYPAL in availableGateways,
isCreditCardAvailable = InAppPaymentData.PaymentMethodType.CARD in availableGateways,
isSEPADebitAvailable = InAppPaymentData.PaymentMethodType.SEPA_DEBIT in availableGateways,
isIDEALAvailable = InAppPaymentData.PaymentMethodType.IDEAL in availableGateways,
sepaEuroMaximum = FiatMoney(BigDecimal.ONE, Currency.getInstance("USD"))
)
}
}
@VisibleForTesting
fun createInAppPaymentPreview(type: InAppPaymentType): InAppPaymentTable.InAppPayment {
return InAppPaymentTable.InAppPayment(
id = InAppPaymentTable.InAppPaymentId(1),
type = type,
state = InAppPaymentTable.State.CREATED,
insertedAt = 1.milliseconds,
updatedAt = 1.milliseconds,
notified = true,
subscriberId = null,
endOfPeriod = 0.milliseconds,
data = InAppPaymentData(
badge = BadgeList.Badge(
name = type.name.lowercase()
),
amount = FiatValue(currencyCode = "USD", amount = BigDecimal.TEN.toDecimalValue())
)
)
}
@@ -185,7 +185,7 @@ class BankTransferDetailsFragment : ComposeFragment(), InAppPaymentCheckoutDeleg
@DayNightPreviews
@Composable
private fun BankTransferDetailsContentPreview() {
fun BankTransferDetailsContentPreview() {
Previews.Preview {
BankTransferDetailsContent(
state = BankTransferDetailsState(
@@ -206,7 +206,7 @@ private fun BankTransferDetailsContentPreview() {
}
@Composable
private fun BankTransferDetailsContent(
fun BankTransferDetailsContent(
state: BankTransferDetailsState,
onNavigationClick: () -> Unit,
onNameChanged: (String) -> Unit,
@@ -44,16 +44,19 @@ import androidx.navigation.navGraphViewModels
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.ComposeFragment
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.Scaffolds
import org.signal.core.ui.compose.SignalIcons
import org.signal.core.ui.compose.Texts
import org.signal.core.util.getParcelableCompat
import org.signal.donations.InAppPaymentType
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.TemporaryScreenshotSecurity
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationSerializationHelper.toFiatMoney
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.InAppPaymentCheckoutDelegate
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.InAppPaymentProcessorAction
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.InAppPaymentProcessorActionResult
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.createInAppPaymentPreview
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.stripe.StripePaymentInProgressFragment
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.stripe.StripePaymentInProgressViewModel
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.transfer.BankTransferRequestKeys
@@ -189,19 +192,25 @@ class IdealTransferDetailsFragment : ComposeFragment(), InAppPaymentCheckoutDele
@Preview
@Composable
private fun IdealTransferDetailsContentPreview() {
IdealTransferDetailsContent(
state = IdealTransferDetailsState(),
idealDirections = R.string.IdealTransferDetailsFragment__enter_your_bank,
donateLabel = "Donate $5/month",
onNavigationClick = {},
onLearnMoreClick = {},
onSelectBankClick = {},
onNameChanged = {},
onEmailChanged = {},
onFocusChanged = { _, _ -> },
onDonateClick = {}
)
fun IdealTransferDetailsContentPreview() {
Previews.Preview {
IdealTransferDetailsContent(
state = IdealTransferDetailsState(
inAppPayment = createInAppPaymentPreview(InAppPaymentType.RECURRING_DONATION),
name = "Miles Morales",
email = "miles@example.com"
),
idealDirections = R.string.IdealTransferDetailsFragment__enter_your_bank,
donateLabel = "Donate $5/month",
onNavigationClick = {},
onLearnMoreClick = {},
onSelectBankClick = {},
onNameChanged = {},
onEmailChanged = {},
onFocusChanged = { _, _ -> },
onDonateClick = {}
)
}
}
@Composable
@@ -24,8 +24,8 @@ import kotlinx.coroutines.withContext
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.database.model.InAppPaymentReceiptRecord
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
import org.thoughtcrime.securesms.providers.BlobProvider
import org.thoughtcrime.securesms.util.DateUtils
import java.io.ByteArrayOutputStream
import java.util.Locale
@@ -75,7 +75,7 @@ object ReceiptImageRenderer {
val bitmap = view.drawToBitmap()
bitmap.compress(Bitmap.CompressFormat.PNG, 0, outputStream)
BlobProvider.getInstance()
AppDependencies.blobs
.forData(outputStream.toByteArray())
.withMimeType("image/png")
.withFileName("Signal-Donation-Receipt.png")
@@ -75,10 +75,11 @@ import org.thoughtcrime.securesms.components.settings.app.usernamelinks.QrCodeDa
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.QrCodeState
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.UsernameQrCodeColorScheme
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.main.UsernameLinkSettingsState.ActiveTab
import org.thoughtcrime.securesms.providers.BlobProvider
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.util.CommunicationActions
import java.io.ByteArrayOutputStream
import java.util.UUID
import org.signal.mediasend.R as MediaSendR
@OptIn(ExperimentalPermissionsApi::class)
class UsernameLinkSettingsFragment : ComposeFragment() {
@@ -158,8 +159,8 @@ class UsernameLinkSettingsFragment : ComposeFragment() {
Permissions.with(this)
.request(Manifest.permission.CAMERA)
.ifNecessary()
.withPermanentDenialDialog(getString(R.string.CameraXFragment_signal_needs_camera_access_scan_qr_code), null, R.string.CameraXFragment_allow_access_camera, R.string.CameraXFragment_to_scan_qr_codes, parentFragmentManager)
.onAnyDenied { Toast.makeText(requireContext(), R.string.CameraXFragment_signal_needs_camera_access_scan_qr_code, Toast.LENGTH_LONG).show() }
.withPermanentDenialDialog(getString(MediaSendR.string.CameraXFragment_signal_needs_camera_access_scan_qr_code), null, MediaSendR.string.CameraXFragment_allow_access_camera, MediaSendR.string.CameraXFragment_to_scan_qr_codes, parentFragmentManager)
.onAnyDenied { Toast.makeText(requireContext(), MediaSendR.string.CameraXFragment_signal_needs_camera_access_scan_qr_code, Toast.LENGTH_LONG).show() }
.execute()
}
}
@@ -404,7 +405,7 @@ private fun shareQrBadge(activity: Activity, badge: Bitmap?) {
badge.compress(Bitmap.CompressFormat.PNG, 100, byteArrayOutputStream)
byteArrayOutputStream.flush()
val bytes = byteArrayOutputStream.toByteArray()
val shareUri = BlobProvider.getInstance()
val shareUri = AppDependencies.blobs
.forData(bytes)
.withMimeType("image/png")
.withFileName("SignalUsernameQr.png")
@@ -36,6 +36,7 @@ import org.signal.core.ui.compose.theme.SignalTheme
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.qr.QrCrosshair
import org.thoughtcrime.securesms.recipients.Recipient
import org.signal.mediasend.R as MediaSendR
/**
* A screen that allows you to scan a QR code to start a chat.
@@ -116,7 +117,7 @@ fun UsernameQrScanScreen(
.padding(48.dp)
) {
Text(
text = stringResource(R.string.CameraXFragment_to_scan_qr_code_allow_camera),
text = stringResource(MediaSendR.string.CameraXFragment_to_scan_qr_code_allow_camera),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodyLarge,
color = Color.White
@@ -125,7 +126,7 @@ fun UsernameQrScanScreen(
colors = ButtonDefaults.filledTonalButtonColors(),
onClick = onOpenCameraClicked
) {
Text(stringResource(R.string.CameraXFragment_allow_access))
Text(stringResource(MediaSendR.string.CameraXFragment_allow_access))
}
}
}
@@ -47,6 +47,7 @@ import org.signal.core.util.permissions.PermissionCompat
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.signal.mediasend.R as MediaSendR
/**
* Prompts the user to scan a username QR code. Uses the activity result to communicate the recipient that was found, or null if no valid usernames were scanned.
@@ -132,8 +133,8 @@ class UsernameQrScannerActivity : AppCompatActivity() {
Permissions.with(this)
.request(Manifest.permission.CAMERA)
.ifNecessary()
.withPermanentDenialDialog(getString(R.string.CameraXFragment_signal_needs_camera_access_scan_qr_code), null, R.string.CameraXFragment_allow_access_camera, R.string.CameraXFragment_to_scan_qr_codes, supportFragmentManager)
.onAnyDenied { Toast.makeText(this, R.string.CameraXFragment_signal_needs_camera_access_scan_qr_code, Toast.LENGTH_LONG).show() }
.withPermanentDenialDialog(getString(MediaSendR.string.CameraXFragment_signal_needs_camera_access_scan_qr_code), null, MediaSendR.string.CameraXFragment_allow_access_camera, MediaSendR.string.CameraXFragment_to_scan_qr_codes, supportFragmentManager)
.onAnyDenied { Toast.makeText(this, MediaSendR.string.CameraXFragment_signal_needs_camera_access_scan_qr_code, Toast.LENGTH_LONG).show() }
.execute()
}
@@ -1,6 +1,5 @@
package org.thoughtcrime.securesms.components.settings.conversation
import android.Manifest
import android.app.ActivityOptions
import android.content.ActivityNotFoundException
import android.content.Context
@@ -96,7 +95,6 @@ import org.thoughtcrime.securesms.main.MainNavigationChatDetailRouter
import org.thoughtcrime.securesms.main.MainNavigationDetailLocation
import org.thoughtcrime.securesms.mediaoverview.MediaOverviewActivity
import org.thoughtcrime.securesms.mediapreview.MediaIntentFactory
import org.thoughtcrime.securesms.mediasend.camerax.CameraXRemoteConfig
import org.thoughtcrime.securesms.messagerequests.MessageRequestRepository
import org.thoughtcrime.securesms.nicknames.NicknameActivity
import org.thoughtcrime.securesms.profiles.edit.CreateProfileActivity
@@ -481,23 +479,8 @@ class ConversationSettingsFragment :
.setMessage(R.string.ConversationSettingsFragment__only_admins_of_this_group_can_add_to_its_story)
.setPositiveButton(android.R.string.ok) { d, _ -> d.dismiss() }
.show()
} else if (CameraXRemoteConfig.isSupported()) {
addToGroupStoryDelegate.addToStory(state.recipient.id)
} else {
Permissions.with(this@ConversationSettingsFragment)
.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,
getParentFragmentManager()
)
.onAllGranted { addToGroupStoryDelegate.addToStory(state.recipient.id) }
.onAnyDenied { Toast.makeText(requireContext(), R.string.CameraXFragment_signal_needs_camera_access_capture_photos, Toast.LENGTH_LONG).show() }
.execute()
addToGroupStoryDelegate.addToStory(state.recipient.id)
}
},
onVideoClick = {
@@ -20,7 +20,7 @@ object ConversationSettingsNavigator {
recipient: Recipient
) {
if (activity is MainNavigationChatDetailRouter) {
activity.goToChatDetail(MainNavigationDetailLocation.Chats.ConversationSettings(recipient.id, isContentRoot = true))
activity.goToChatDetail(MainNavigationDetailLocation.Chats.ConversationSettings(recipient.id))
return
}
@@ -18,6 +18,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.signal.core.ui.compose.ComposeFragment
import org.signal.core.util.Util
import org.signal.core.util.bitmaps.BitmapUtil
import org.signal.core.util.concurrent.SignalExecutors
import org.signal.core.util.isAbsent
import org.signal.core.util.logging.Log
@@ -38,11 +39,9 @@ import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.mms.IncomingMessage
import org.thoughtcrime.securesms.mms.OutgoingMessage
import org.thoughtcrime.securesms.profiles.AvatarHelper
import org.thoughtcrime.securesms.providers.BlobProvider
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientForeverObserver
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.BitmapUtil
import org.thoughtcrime.securesms.util.MediaUtil
import java.util.Objects
import kotlin.random.Random
@@ -88,7 +87,7 @@ class InternalConversationSettingsFragment : ComposeFragment(), InternalConversa
)
val stream = BitmapUtil.toCompressedJpeg(bitmap)
val bytes = stream.readBytes()
val uri = BlobProvider.getInstance().forData(bytes).createForSingleSessionOnDisk(requireContext())
val uri = AppDependencies.blobs.forData(bytes).createForSingleSessionOnDisk(requireContext())
return UriAttachment(
uri = uri,
contentType = MediaUtil.IMAGE_JPEG,
@@ -12,9 +12,14 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.AbstractComposeView
import androidx.compose.ui.platform.ViewCompositionStrategy
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import org.greenrobot.eventbus.EventBus
import org.greenrobot.eventbus.Subscribe
import org.greenrobot.eventbus.ThreadMode
import org.signal.core.models.database.AttachmentId
import org.signal.core.ui.compose.theme.SignalTheme
import org.signal.core.util.ByteSize
import org.signal.core.util.ThrottledDebouncer
@@ -25,6 +30,7 @@ import org.thoughtcrime.securesms.attachments.DatabaseAttachment
import org.thoughtcrime.securesms.components.RecyclerViewParentTransitionController
import org.thoughtcrime.securesms.database.AttachmentTable
import org.thoughtcrime.securesms.events.PartProgressEvent
import org.thoughtcrime.securesms.jobs.AttachmentBackfill
import org.thoughtcrime.securesms.mms.Slide
import org.thoughtcrime.securesms.util.MediaUtil
import java.util.UUID
@@ -48,6 +54,11 @@ class TransferControlView @JvmOverloads constructor(context: Context, attrs: Att
private val progressUpdateDebouncer = ThrottledDebouncer(100)
private var previousRenderState: TransferControlsRenderState = TransferControlsRenderState.Gone
/** Active while view is attached */
private var renderScope: CoroutineScope? = null
/** Per-instance id so a single recycled view can be isolated in logcat when [VERBOSE_DEVELOPMENT_LOGGING] is on. */
private val viewId by lazy { UUID.randomUUID().toString().take(8) }
@@ -72,39 +83,56 @@ class TransferControlView @JvmOverloads constructor(context: Context, attrs: Att
override fun onAttachedToWindow() {
super.onAttachedToWindow()
if (!EventBus.getDefault().isRegistered(this)) EventBus.getDefault().register(this)
renderScope = CoroutineScope(Dispatchers.Main.immediate).also { scope ->
scope.launch {
AttachmentBackfill.awaiting.collect { renderState() }
}
}
}
override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
EventBus.getDefault().unregister(this)
renderScope?.cancel()
renderScope = null
}
fun isGone(): Boolean {
return TransferControls.deriveRenderState(state) is TransferControlsRenderState.Gone
val state = TransferControls.deriveRenderState(state = state, awaitingAttachmentIds = state.slides.attachmentIdsAwaitingBackfill())
return state is TransferControlsRenderState.Gone
}
private fun updateState(stateFactory: (TransferControlViewState) -> TransferControlViewState) {
val newState = stateFactory(state)
state = stateFactory(state)
renderState()
}
val oldRender = TransferControls.deriveRenderState(state)
val newRender = TransferControls.deriveRenderState(newState)
state = newState
/**
* Derives and applies the render state by combining [state] with the current awaiting attachments. Both the state and
* awaiting paths change events funnel through here.
*/
private fun renderState() {
val oldRenderState = previousRenderState
val newRenderState = TransferControls.deriveRenderState(state, state.slides.attachmentIdsAwaitingBackfill())
previousRenderState = newRenderState
if (oldRender == newRender) {
if (oldRenderState == newRenderState) {
return
}
verboseLog { "render $oldRender -> $newRender slides=[${slidesAsLogString(newState.slides)}]" }
verboseLog { "render $oldRenderState -> $newRenderState slides=[${slidesAsLogString(state.slides)}]" }
if (oldRender is TransferControlsRenderState.InProgress && oldRender.isProgressOnlyDifference(newRender)) {
// Only throttle noisy progress changes
if (oldRenderState is TransferControlsRenderState.InProgress && oldRenderState.isProgressOnlyDifference(newRenderState)) {
progressUpdateDebouncer.publish {
renderState = newRender
renderState = newRenderState
visibility = VISIBLE
}
} else {
progressUpdateDebouncer.clear()
renderState = newRender
if (newRender !is TransferControlsRenderState.Gone) {
renderState = newRenderState
if (newRenderState !is TransferControlsRenderState.Gone) {
visibility = VISIBLE
}
}
@@ -133,6 +161,7 @@ class TransferControlView @JvmOverloads constructor(context: Context, attrs: Att
fun setSlides(slides: List<Slide>) {
require(slides.isNotEmpty()) { "Must provide at least one slide." }
clearResolvedBackfills(slides)
updateState { state ->
val isNewSlideSet = !isUpdateToExistingSet(state, slides)
val networkProgress: MutableMap<Attachment, Progress> = if (isNewSlideSet) HashMap() else state.networkProgress.toMutableMap()
@@ -201,10 +230,6 @@ class TransferControlView @JvmOverloads constructor(context: Context, attrs: Att
updateState { it.copy(isVisible = isVisible) }
}
fun setAwaitingPrimaryResponse(awaiting: Boolean) {
updateState { it.copy(awaitingPrimaryResponse = awaiting) }
}
override fun setFocusable(focusable: Boolean) {
super.setFocusable(false)
updateState { it.copy(isFocusable = focusable) }
@@ -215,6 +240,22 @@ class TransferControlView @JvmOverloads constructor(context: Context, attrs: Att
updateState { it.copy(isClickable = clickable) }
}
private fun List<Slide>.attachmentIdsAwaitingBackfill(): Set<AttachmentId> {
return this
.mapNotNullTo(HashSet()) { (it.asAttachment() as? DatabaseAttachment)?.attachmentId }
.apply { retainAll(AttachmentBackfill.awaiting.value) }
}
/** Tells [AttachmentBackfill] to stop awaiting any backfilled attachment that this view now sees as DONE. */
private fun clearResolvedBackfills(slides: List<Slide>) {
for (slide in slides) {
val attachment = slide.asAttachment() as? DatabaseAttachment ?: continue
if (attachment.transferState == AttachmentTable.TRANSFER_PROGRESS_DONE && attachment.attachmentId in AttachmentBackfill.awaiting.value) {
AttachmentBackfill.onAttachmentTerminal(attachment.attachmentId, attachment.mmsId)
}
}
}
private fun MutableMap<Attachment, Progress>.applyProgress(attachment: Attachment, update: Progress) {
if (update.completed < 0.bytes) {
remove(attachment)
@@ -21,6 +21,5 @@ data class TransferControlViewState(
val networkProgress: Map<Attachment, TransferControlView.Progress> = HashMap(),
val compressionProgress: Map<Attachment, TransferControlView.Progress> = HashMap(),
val playableWhileDownloading: Boolean = false,
val isUpload: Boolean = false,
val awaitingPrimaryResponse: Boolean = false
val isUpload: Boolean = false
)
@@ -5,9 +5,11 @@
package org.thoughtcrime.securesms.components.transfercontrols
import org.signal.core.models.database.AttachmentId
import org.signal.core.util.ByteSize
import org.signal.core.util.bytes
import org.thoughtcrime.securesms.attachments.Attachment
import org.thoughtcrime.securesms.attachments.DatabaseAttachment
import org.thoughtcrime.securesms.database.AttachmentTable
import org.thoughtcrime.securesms.mms.Slide
import org.thoughtcrime.securesms.util.MediaUtil
@@ -42,7 +44,10 @@ object TransferControls {
data class Bytes(val completed: ByteSize, val total: ByteSize) : ProgressLabel
}
fun deriveRenderState(state: TransferControlViewState): TransferControlsRenderState {
fun deriveRenderState(
state: TransferControlViewState,
awaitingAttachmentIds: Set<AttachmentId> = emptySet()
): TransferControlsRenderState {
if (state.slides.isEmpty()) {
return TransferControlsRenderState.Gone
}
@@ -55,14 +60,17 @@ object TransferControls {
return TransferControlsRenderState.Gone
}
if (state.awaitingPrimaryResponse) {
// If any attachments are being backfilled, overwrite with in progress state to maintain spinner
val awaitingBackfill = state.slides.any { (it.asAttachment() as? DatabaseAttachment)?.attachmentId in awaitingAttachmentIds }
if (awaitingBackfill) {
val downloading = state.slides.any { it.transferState == AttachmentTable.TRANSFER_PROGRESS_STARTED }
return TransferControlsRenderState.InProgress(
isUpload = false,
placement = if (state.slides.size == 1) Placement.CENTER else Placement.CORNER,
progress = null,
progress = if (downloading) calculateProgress(state) else null,
showPlayButton = false,
cancelable = false,
label = null
cancelable = downloading,
label = if (downloading) progressLabel(state) else null
)
}
@@ -173,10 +181,6 @@ object TransferControls {
return weightedProgress / weightedTotal
}
/**
* Internal, view-free mirror of the legacy state machine. Kept verbatim from the original view to preserve behavior; the
* resulting [Mode] is mapped to a [TransferControlsRenderState] by [deriveRenderState].
*/
private fun deriveMode(state: TransferControlViewState): Mode {
if (state.slides.isEmpty()) {
return Mode.GONE
@@ -279,9 +283,6 @@ object TransferControls {
private const val UPLOAD_TASK_WEIGHT = 1
/**
* A weighting compared to [UPLOAD_TASK_WEIGHT]
*/
private const val COMPRESSION_TASK_WEIGHT = 3
private enum class Mode {
@@ -319,7 +319,7 @@ private fun CallInfo(
}
}
if (controlAndInfoState.callLink?.credentials?.adminPassBytes != null) {
if (controlAndInfoState.callLink?.canModify == true) {
item {
if (!participantsState.inCallLobby) {
Dividers.Default()
@@ -91,11 +91,15 @@ class ContactSearchViewModel(
private val internalFastScrollerEnabled = MutableStateFlow(false)
private val internalDisplayingContextMenu = MutableStateFlow(false)
private val internalScrollRequests = MutableSharedFlow<ScrollRequest>(extraBufferCapacity = 1)
private val internalSearchInProgress = MutableStateFlow(false)
val fastScrollerEnabled: StateFlow<Boolean> = internalFastScrollerEnabled
val isDisplayingContextMenu: StateFlow<Boolean> = internalDisplayingContextMenu
val scrollRequests: SharedFlow<ScrollRequest> = internalScrollRequests
/** True while a [setConfiguration] call is fetching results off the main thread. Suitable for driving a loading indicator. */
val searchInProgress: StateFlow<Boolean> = internalSearchInProgress
val query: StateFlow<String?> = rawQuery
init {
@@ -151,17 +155,22 @@ class ContactSearchViewModel(
}
suspend fun setConfiguration(contactSearchConfiguration: ContactSearchConfiguration) {
val (pagedDataSource, size) = withContext(Dispatchers.IO) {
val source = ContactSearchPagedDataSource(
contactSearchConfiguration,
arbitraryRepository = arbitraryRepository,
searchRepository = searchRepository,
contactSearchPagedDataSourceRepository = contactSearchPagedDataSourceRepository
)
source to source.size()
internalSearchInProgress.value = true
try {
val (pagedDataSource, size) = withContext(Dispatchers.IO) {
val source = ContactSearchPagedDataSource(
contactSearchConfiguration,
arbitraryRepository = arbitraryRepository,
searchRepository = searchRepository,
contactSearchPagedDataSourceRepository = contactSearchPagedDataSourceRepository
)
source to source.size()
}
internalTotalCount.value = size
pagedData.value = PagedData.createForStateFlow(pagedDataSource, pagingConfig, data.value)
} finally {
internalSearchInProgress.value = false
}
internalTotalCount.value = size
pagedData.value = PagedData.createForStateFlow(pagedDataSource, pagingConfig, data.value)
}
fun setQuery(query: String?) {
@@ -312,3 +321,19 @@ fun ContactSearchViewModel.bindAdapterToLifecycle(
}
}
}
/**
* Observes [ContactSearchViewModel.searchInProgress] scoped to the given [LifecycleOwner], invoking
* [onSearchInProgressChanged] on the main thread whenever the loading state changes. Designed for Java
* callers that want to drive a loading indicator.
*/
fun ContactSearchViewModel.bindSearchInProgressToLifecycle(
lifecycleOwner: LifecycleOwner,
onSearchInProgressChanged: (Boolean) -> Unit
) {
lifecycleOwner.lifecycleScope.launch {
lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
searchInProgress.collect { onSearchInProgressChanged(it) }
}
}
}
@@ -28,7 +28,7 @@ import org.thoughtcrime.securesms.database.SignalDatabase;
import org.thoughtcrime.securesms.profiles.ProfileName;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.util.BitmapDecodingException;
import org.signal.core.util.bitmaps.BitmapDecodingException;
import org.thoughtcrime.securesms.util.ImageCompressionUtil;
import org.thoughtcrime.securesms.util.SignalE164Util;
import org.thoughtcrime.securesms.util.SpanUtil;
@@ -18,8 +18,8 @@ import org.thoughtcrime.securesms.contactshare.Contact.Email;
import org.thoughtcrime.securesms.contactshare.Contact.Name;
import org.thoughtcrime.securesms.contactshare.Contact.Phone;
import org.thoughtcrime.securesms.contactshare.Contact.PostalAddress;
import org.thoughtcrime.securesms.dependencies.AppDependencies;
import org.thoughtcrime.securesms.mms.PartAuthority;
import org.thoughtcrime.securesms.providers.BlobProvider;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.util.SignalE164Util;
@@ -98,8 +98,8 @@ public class SharedContactRepository {
Log.w(TAG, "Failed to parse the vcard.", e);
}
if (BlobProvider.AUTHORITY.equals(uri.getAuthority())) {
BlobProvider.getInstance().delete(context, uri);
if (AppDependencies.getBlobs().getAuthority().equals(uri.getAuthority())) {
AppDependencies.getBlobs().delete(context, uri);
}
return contact;
@@ -1074,7 +1074,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
* Whether interactions like swipe-to-reply and direct media opening should be suppressed.
*/
private boolean isSuppressedInteractionMode() {
return isCondensedMode() || displayMode instanceof ConversationItemDisplayMode.Starred;
return isCondensedMode() || displayMode instanceof ConversationItemDisplayMode.Starred || displayMode == ConversationItemDisplayMode.Detailed.INSTANCE;
}
private boolean isStoryReaction(MessageRecord messageRecord) {
@@ -1154,10 +1154,23 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
boolean messageRequestAccepted,
boolean hasWallpaper)
{
bodyText.setClickable(false);
bodyText.setFocusable(false);
boolean isMessageDetails = displayMode == ConversationItemDisplayMode.Detailed.INSTANCE;
bodyText.setTextSize(TypedValue.COMPLEX_UNIT_SP, SignalStore.settings().getMessageFontSize());
bodyText.setMovementMethod(LongClickMovementMethod.getInstance(getContext()));
bodyText.setTextIsSelectable(isMessageDetails);
if (isMessageDetails) {
bodyText.setOnTouchListener(null);
bodyText.setOnLongClickListener(null);
bodyText.setOnClickListener(null);
} else {
bodyText.setClickable(false);
bodyText.setFocusable(false);
bodyText.setOnTouchListener(doubleTapEditTouchListener);
bodyText.setOnLongClickListener(passthroughClickListener);
bodyText.setOnClickListener(passthroughClickListener);
bodyText.setMovementMethod(LongClickMovementMethod.getInstance(getContext()));
}
bodyText.setOverflowText(null);
bodyText.setMaxLength(-1);
@@ -0,0 +1,46 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.conversation.drafts
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.components.voice.VoiceNoteDraft
import org.thoughtcrime.securesms.database.SignalDatabase.Companion.drafts
import org.thoughtcrime.securesms.dependencies.AppDependencies
import java.io.File
object DraftBlobs {
private val TAG = Log.tag(DraftBlobs::class)
fun deleteOrphanedDraftFiles(directory: File) {
val files = directory.listFiles()
if (files == null || files.size == 0) {
Log.d(TAG, "No attachment drafts exist. Skipping.")
return
}
val draftDatabase = drafts
val voiceNoteDrafts = draftDatabase.getAllVoiceNoteDrafts()
val draftFileNames = voiceNoteDrafts
.asSequence()
.map { VoiceNoteDraft.fromDraft(it) }
.map(VoiceNoteDraft::uri)
.mapNotNull { AppDependencies.blobs.buildFileName(it) }
.toList()
for (file in files) {
if (!draftFileNames.contains(file.getName())) {
if (file.delete()) {
Log.d(TAG, "Deleted orphaned attachment draft: " + file.getName())
} else {
Log.d(TAG, "Failed to delete orphaned attachment draft: " + file.getName())
}
}
}
}
}
@@ -40,7 +40,6 @@ import org.thoughtcrime.securesms.mms.QuoteId
import org.thoughtcrime.securesms.mms.Slide
import org.thoughtcrime.securesms.mms.SlideFactory
import org.thoughtcrime.securesms.mms.StickerSlide
import org.thoughtcrime.securesms.providers.BlobProvider
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.MediaUtil
import org.thoughtcrime.securesms.util.concurrent.SerialMonoLifoExecutor
@@ -161,7 +160,7 @@ class DraftRepository(
fun deleteVoiceNoteDraftData(draft: DraftTable.Draft?) {
if (draft != null) {
SignalExecutors.BOUNDED.execute {
BlobProvider.getInstance().delete(context, Uri.parse(draft.value).buildUpon().clearQuery().build())
AppDependencies.blobs.delete(context, Uri.parse(draft.value).buildUpon().clearQuery().build())
}
}
}
@@ -25,14 +25,11 @@ import org.thoughtcrime.securesms.contactshare.Contact
import org.thoughtcrime.securesms.contactshare.ContactShareEditActivity
import org.thoughtcrime.securesms.conversation.MessageSendType
import org.thoughtcrime.securesms.conversation.colors.ChatColors
import org.thoughtcrime.securesms.conversation.v2.ConversationActivityResultContracts.Callbacks
import org.thoughtcrime.securesms.giph.ui.GiphyActivity
import org.thoughtcrime.securesms.maps.PlacePickerActivity
import org.thoughtcrime.securesms.mediasend.MediaSendActivityResult
import org.thoughtcrime.securesms.mediasend.camerax.CameraXRemoteConfig
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionActivity
import org.thoughtcrime.securesms.recipients.RecipientId
import org.signal.core.ui.R as CoreUiR
/**
* This encapsulates the logic for interacting with other activities used throughout a conversation. The gist
@@ -76,22 +73,8 @@ class ConversationActivityResultContracts(private val fragment: Fragment, privat
}
fun launchCamera(recipientId: RecipientId, isReply: Boolean) {
if (CameraXRemoteConfig.isSupported()) {
cameraLauncher.launch(MediaSelectionInput(emptyList(), recipientId, null, isReply))
fragment.requireActivity().overridePendingTransition(R.anim.camera_slide_from_bottom, R.anim.stationary)
} else {
Permissions.with(fragment)
.request(Manifest.permission.CAMERA)
.ifNecessary()
.withRationaleDialog(fragment.getString(R.string.CameraXFragment_allow_access_camera), fragment.getString(R.string.CameraXFragment_to_capture_photos_and_video_allow_camera), CoreUiR.drawable.symbol_camera_24)
.withPermanentDenialDialog(fragment.getString(R.string.CameraXFragment_signal_needs_camera_access_capture_photos), null, R.string.CameraXFragment_allow_access_camera, R.string.CameraXFragment_to_capture_photos_videos, fragment.parentFragmentManager)
.onAllGranted {
cameraLauncher.launch(MediaSelectionInput(emptyList(), recipientId, null, isReply))
fragment.requireActivity().overridePendingTransition(R.anim.camera_slide_from_bottom, R.anim.stationary)
}
.onAnyDenied { Toast.makeText(fragment.requireContext(), R.string.CameraXFragment_signal_needs_camera_access_capture_photos, Toast.LENGTH_LONG).show() }
.execute()
}
cameraLauncher.launch(MediaSelectionInput(emptyList(), recipientId, null, isReply))
fragment.requireActivity().overridePendingTransition(R.anim.camera_slide_from_bottom, R.anim.stationary)
}
fun launchMediaEditor(mediaList: List<Media>, recipientId: RecipientId, text: CharSequence?) {
@@ -281,6 +281,7 @@ import org.thoughtcrime.securesms.groups.ui.migration.GroupsV1MigrationInfoBotto
import org.thoughtcrime.securesms.groups.ui.migration.GroupsV1MigrationSuggestionsDialog
import org.thoughtcrime.securesms.groups.v2.GroupBlockJoinRequestResult
import org.thoughtcrime.securesms.invites.InviteActions
import org.thoughtcrime.securesms.jobs.AttachmentBackfill
import org.thoughtcrime.securesms.jobs.ServiceOutageDetectionJob
import org.thoughtcrime.securesms.keyboard.KeyboardPage
import org.thoughtcrime.securesms.keyboard.KeyboardPagerFragment
@@ -310,7 +311,7 @@ import org.thoughtcrime.securesms.mms.AudioSlide
import org.thoughtcrime.securesms.mms.DocumentSlide
import org.thoughtcrime.securesms.mms.GifSlide
import org.thoughtcrime.securesms.mms.ImageSlide
import org.thoughtcrime.securesms.mms.MediaConstraints
import org.thoughtcrime.securesms.mms.PushMediaConstraints
import org.thoughtcrime.securesms.mms.QuoteModel
import org.thoughtcrime.securesms.mms.Slide
import org.thoughtcrime.securesms.mms.SlideDeck
@@ -325,7 +326,6 @@ import org.thoughtcrime.securesms.polls.PollOption
import org.thoughtcrime.securesms.polls.PollRecord
import org.thoughtcrime.securesms.profiles.manage.EditProfileActivity
import org.thoughtcrime.securesms.profiles.spoofing.ReviewCardDialogFragment
import org.thoughtcrime.securesms.providers.BlobProvider
import org.thoughtcrime.securesms.ratelimit.RecaptchaProofBottomSheetFragment
import org.thoughtcrime.securesms.ratelimit.RecaptchaRequiredEvent
import org.thoughtcrime.securesms.reactions.ReactionsBottomSheetDialogFragment
@@ -1012,7 +1012,7 @@ class ConversationFragment :
override fun onGifSelectSuccess(blobUri: Uri, width: Int, height: Int) {
setMedia(
uri = blobUri,
mediaType = SlideFactory.MediaType.from(BlobProvider.getMimeType(blobUri))!!,
mediaType = SlideFactory.MediaType.from(AppDependencies.blobs.getMimeType(blobUri))!!,
width = width,
height = height,
videoGif = true
@@ -1408,6 +1408,16 @@ class ConversationFragment :
}
}
lifecycleScope.launch {
lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
AttachmentBackfill.failures.collect { failure ->
if (failure.threadId == args.threadId) {
showAttachmentBackfillFailureDialog(failure.reason)
}
}
}
}
if (TextSecurePreferences.getServiceOutage(context)) {
AppDependencies.jobManager.add(ServiceOutageDetectionJob())
}
@@ -2169,7 +2179,7 @@ class ConversationFragment :
inputPanel.clickOnComposeInput()
}
is ShareOrDraftData.SetLocation -> attachmentManager.setLocation(data.location, MediaConstraints.getPushMediaConstraints())
is ShareOrDraftData.SetLocation -> attachmentManager.setLocation(data.location, PushMediaConstraints(null))
is ShareOrDraftData.SetEditMessage -> {
composeText.setDraftText(data.draftText)
@@ -3104,6 +3114,19 @@ class ConversationFragment :
dialogBuilder.show()
}
private fun showAttachmentBackfillFailureDialog(reason: AttachmentBackfill.FailureReason) {
val messageRes = when (reason) {
AttachmentBackfill.FailureReason.TIMEOUT -> R.string.ConversationFragment_attachment_backfill_timeout
AttachmentBackfill.FailureReason.NOT_FOUND -> R.string.ConversationFragment_attachment_backfill_not_found
}
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.ConversationFragment_attachment_backfill_failed_title)
.setMessage(messageRes)
.setPositiveButton(android.R.string.ok, null)
.show()
}
private fun handleDisplayDetails(conversationMessage: ConversationMessage) {
val recipientSnapshot = viewModel.recipientSnapshot ?: return
chatRouter.goToChatDetail(MainNavigationDetailLocation.Chats.MessageDetails(recipientSnapshot.id, MessageId(conversationMessage.messageRecord.id)))
@@ -86,7 +86,6 @@ import org.thoughtcrime.securesms.mms.Slide
import org.thoughtcrime.securesms.mms.SlideDeck
import org.thoughtcrime.securesms.polls.Poll
import org.thoughtcrime.securesms.profiles.spoofing.ReviewRecipient
import org.thoughtcrime.securesms.providers.BlobProvider
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.recipients.RecipientUtil
@@ -681,7 +680,7 @@ class ConversationRepository(
val thumbnailUri = thumbnailSlide.uri ?: return@fromCallable null
val inputStream = PartAuthority.getAttachmentStream(applicationContext, thumbnailUri)
val tempUri = BlobProvider.getInstance().forData(inputStream, thumbnailSlide.fileSize)
val tempUri = AppDependencies.blobs.forData(inputStream, thumbnailSlide.fileSize)
.withMimeType(thumbnailSlide.contentType)
.createForSingleSessionOnDisk(applicationContext)
@@ -826,9 +825,9 @@ class ConversationRepository(
SignalExecutors.BOUNDED_IO.execute {
slides
.mapNotNull(Slide::getUri)
.filter(BlobProvider::isAuthority)
.filter { AppDependencies.blobs.isAuthority(it) }
.forEach {
BlobProvider.getInstance().delete(applicationContext, it)
AppDependencies.blobs.delete(applicationContext, it)
}
}
}
@@ -8,6 +8,7 @@ import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.lifecycle.LifecycleOwner;
import androidx.recyclerview.widget.DiffUtil;
import androidx.recyclerview.widget.ListAdapter;
@@ -20,6 +21,7 @@ import org.thoughtcrime.securesms.BindableConversationListItem;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.conversationlist.model.Conversation;
import org.thoughtcrime.securesms.conversationlist.model.ConversationSet;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.util.CachedInflater;
import org.thoughtcrime.securesms.util.ViewUtil;
@@ -53,11 +55,11 @@ class ConversationListAdapter extends ListAdapter<Conversation, RecyclerView.Vie
private final OnConversationClickListener onConversationClickListener;
private final ClearFilterViewHolder.OnClearFilterClickListener onClearFilterClicked;
private final EmptyFolderViewHolder.OnFolderSettingsClickListener onFolderSettingsClicked;
private final Set<Long> typingSet = new HashSet<>();
private final Set<Long> typingSet = new HashSet<>();
private ConversationSet selectedConversations = new ConversationSet();
private long activeThreadId = 0;
private PagingController pagingController;
private ConversationSet selectedConversations = new ConversationSet();
private @Nullable RecipientId activeRecipientId = null;
private PagingController pagingController;
protected ConversationListAdapter(@NonNull LifecycleOwner lifecycleOwner,
@NonNull RequestManager requestManager,
@@ -154,7 +156,7 @@ class ConversationListAdapter extends ListAdapter<Conversation, RecyclerView.Vie
case TYPING_INDICATOR -> vh.getConversationListItem().updateTypingIndicator(typingSet);
case SELECTION -> vh.getConversationListItem().setSelectedConversations(selectedConversations);
case TIMESTAMP -> vh.getConversationListItem().updateTimestamp();
case ACTIVE -> vh.getConversationListItem().setActiveThreadId(activeThreadId);
case ACTIVE -> vh.getConversationListItem().setActiveRecipientId(activeRecipientId);
}
}
}
@@ -173,7 +175,7 @@ class ConversationListAdapter extends ListAdapter<Conversation, RecyclerView.Vie
Locale.getDefault(),
typingSet,
selectedConversations,
activeThreadId);
activeRecipientId);
} else if (holder.getItemViewType() == TYPE_HEADER) {
HeaderViewHolder casted = (HeaderViewHolder) holder;
Conversation conversation = Objects.requireNonNull(getItem(position));
@@ -232,8 +234,8 @@ class ConversationListAdapter extends ListAdapter<Conversation, RecyclerView.Vie
notifyItemRangeChanged(0, getItemCount(), Payload.SELECTION);
}
void setActiveThreadId(long activeThreadId) {
this.activeThreadId = activeThreadId;
void setActiveRecipientId(@Nullable RecipientId activeRecipientId) {
this.activeRecipientId = activeRecipientId;
notifyItemRangeChanged(0, getItemCount(), Payload.ACTIVE);
}
@@ -44,7 +44,6 @@ import androidx.annotation.DrawableRes;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.content.res.AppCompatResources;
import org.signal.core.ui.compose.Snackbars;
import androidx.compose.ui.platform.ComposeView;
import androidx.coordinatorlayout.widget.CoordinatorLayout;
import androidx.core.content.ContextCompat;
@@ -66,7 +65,13 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import org.greenrobot.eventbus.EventBus;
import org.greenrobot.eventbus.Subscribe;
import org.greenrobot.eventbus.ThreadMode;
import org.signal.core.ui.BottomSheetUtil;
import org.signal.core.ui.WindowSizeClassExtensionsKt;
import org.signal.core.ui.compose.Snackbars;
import org.signal.core.ui.view.Stub;
import org.signal.core.util.AppForegroundObserver;
import org.signal.core.util.DimensionUnit;
import org.signal.core.util.ServiceUtil;
import org.signal.core.util.Stopwatch;
import org.signal.core.util.concurrent.LifecycleDisposable;
import org.signal.core.util.concurrent.SignalExecutors;
@@ -88,13 +93,13 @@ import org.thoughtcrime.securesms.badges.self.expired.ExpiredOneTimeBadgeBottomS
import org.thoughtcrime.securesms.badges.self.expired.MonthlyDonationCanceledBottomSheetDialogFragment;
import org.thoughtcrime.securesms.banner.Banner;
import org.thoughtcrime.securesms.banner.BannerManager;
import org.thoughtcrime.securesms.banner.banners.ArchiveRestoreStatusBanner;
import org.thoughtcrime.securesms.banner.banners.ArchiveUploadStatusBanner;
import org.thoughtcrime.securesms.banner.banners.CdsPermanentErrorBanner;
import org.thoughtcrime.securesms.banner.banners.CdsTemporaryErrorBanner;
import org.thoughtcrime.securesms.banner.banners.DeprecatedBuildBanner;
import org.thoughtcrime.securesms.banner.banners.DeprecatedSdkBanner;
import org.thoughtcrime.securesms.banner.banners.DozeBanner;
import org.thoughtcrime.securesms.banner.banners.ArchiveRestoreStatusBanner;
import org.thoughtcrime.securesms.banner.banners.OutdatedBuildBanner;
import org.thoughtcrime.securesms.banner.banners.ServiceOutageBanner;
import org.thoughtcrime.securesms.banner.banners.UnauthorizedBanner;
@@ -123,7 +128,6 @@ import org.thoughtcrime.securesms.contacts.paged.ContactSearchRepository;
import org.thoughtcrime.securesms.contacts.paged.ContactSearchState;
import org.thoughtcrime.securesms.contacts.paged.ContactSearchViewModel;
import org.thoughtcrime.securesms.contacts.paged.ContactSearchViewModelKt;
import org.thoughtcrime.securesms.search.SearchRepository;
import org.thoughtcrime.securesms.contacts.selection.ContactSelectionArguments;
import org.thoughtcrime.securesms.conversation.ConversationUpdateTick;
import org.thoughtcrime.securesms.conversationlist.chatfilter.ConversationFilterRequest;
@@ -156,23 +160,19 @@ import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.search.MessageResult;
import org.thoughtcrime.securesms.search.SearchFilter;
import org.thoughtcrime.securesms.search.SearchFilterBottomSheet;
import org.thoughtcrime.securesms.search.SearchRepository;
import org.thoughtcrime.securesms.sms.MessageSender;
import org.signal.core.util.AppForegroundObserver;
import org.thoughtcrime.securesms.util.AppStartup;
import org.signal.core.ui.BottomSheetUtil;
import org.signal.core.ui.view.Stub;
import org.thoughtcrime.securesms.util.CachedInflater;
import org.thoughtcrime.securesms.util.ConversationUtil;
import org.signal.core.util.ServiceUtil;
import org.thoughtcrime.securesms.util.RemoteConfig;
import org.thoughtcrime.securesms.util.SignalLocalMetrics;
import org.thoughtcrime.securesms.util.SignalProxyUtil;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.SnapToTopDataObserver;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.ViewUtil;
import org.thoughtcrime.securesms.util.adapter.mapping.PagingMappingAdapter;
import org.thoughtcrime.securesms.verify.SelfVerificationFailureSheet;
import org.signal.core.ui.WindowSizeClassExtensionsKt;
import org.whispersystems.signalservice.api.websocket.WebSocketConnectionState;
import java.lang.ref.WeakReference;
@@ -208,12 +208,16 @@ public class ConversationListFragment extends MainFragment implements Conversati
private static final String TAG = Log.tag(ConversationListFragment.class);
private static final long SEARCH_LOADING_SHOW_DELAY_MS = 150L;
private static final int MAX_CHATS_ABOVE_FOLD = 7;
private static final int MAX_CONTACTS_ABOVE_FOLD = 5;
private static final int MAX_GROUP_MEMBERSHIPS_ABOVE_FOLD = 5;
private View coordinator;
private RecyclerView chatFolderList;
private RecyclerView list;
private View searchLoading;
private boolean searchInProgress;
private Stub<ComposeView> bannerView;
private ConversationListFilterPullView pullView;
private AppBarLayout pullViewAppBarLayout;
@@ -221,6 +225,11 @@ public class ConversationListFragment extends MainFragment implements Conversati
private RecyclerView.Adapter activeAdapter;
private ConversationListAdapter defaultAdapter;
private PagingMappingAdapter<ContactSearchKey> searchAdapter;
private final Runnable showSearchLoadingRunnable = () -> {
if (searchLoading != null && searchInProgress && activeAdapter == searchAdapter) {
searchLoading.setVisibility(View.VISIBLE);
}
};
private SnapToTopDataObserver snapToTopDataObserver;
private Drawable archiveDrawable;
private AppForegroundObserver.Listener appForegroundObserver;
@@ -317,6 +326,7 @@ public class ConversationListFragment extends MainFragment implements Conversati
chatFolderList = view.findViewById(R.id.chat_folder_list);
list = view.findViewById(R.id.list);
searchLoading = view.findViewById(R.id.search_loading);
bottomActionBar = view.findViewById(R.id.conversation_list_bottom_action_bar);
bannerView = new Stub<>(view.findViewById(R.id.banner_compose_view));
voiceNotePlayerViewStub = new Stub<>(view.findViewById(R.id.voice_note_player));
@@ -352,6 +362,11 @@ public class ConversationListFragment extends MainFragment implements Conversati
);
ContactSearchViewModelKt.bindAdapterToLifecycle(contactSearchViewModel, getViewLifecycleOwner(), searchAdapter, this::mapSearchStateToConfiguration);
ContactSearchViewModelKt.bindSearchInProgressToLifecycle(contactSearchViewModel, getViewLifecycleOwner(), inProgress -> {
searchInProgress = inProgress;
updateSearchLoadingVisibility();
return Unit.INSTANCE;
});
initializeSearchFilterListener();
@@ -464,11 +479,11 @@ public class ConversationListFragment extends MainFragment implements Conversati
}));
if (isSplitPane(getResources())) {
lifecycleDisposable.add(mainNavigationViewModel.getObservableActiveChatThreadId()
lifecycleDisposable.add(mainNavigationViewModel.getObservableActiveRecipientId()
.subscribeOn(AndroidSchedulers.mainThread())
.subscribe(defaultAdapter::setActiveThreadId));
.subscribe(id -> defaultAdapter.setActiveRecipientId(id.orElse(null))));
} else {
defaultAdapter.setActiveThreadId(0);
defaultAdapter.setActiveRecipientId(null);
}
requireCallback().bindScrollHelper(list, getViewLifecycleOwner(), chatFolderList, color -> {
@@ -517,6 +532,11 @@ public class ConversationListFragment extends MainFragment implements Conversati
defaultAdapter = null;
searchAdapter = null;
if (searchLoading != null) {
searchLoading.removeCallbacks(showSearchLoadingRunnable);
searchLoading = null;
}
dismissProgressDialog();
super.onDestroyView();
@@ -956,6 +976,25 @@ public class ConversationListFragment extends MainFragment implements Conversati
} else {
defaultAdapter.unregisterAdapterDataObserver(snapToTopDataObserver);
}
updateSearchLoadingVisibility();
}
private void updateSearchLoadingVisibility() {
if (searchLoading == null) {
return;
}
boolean shouldShow = searchInProgress && activeAdapter == searchAdapter;
searchLoading.removeCallbacks(showSearchLoadingRunnable);
if (shouldShow) {
if (searchLoading.getVisibility() != View.VISIBLE) {
searchLoading.postDelayed(showSearchLoadingRunnable, SEARCH_LOADING_SHOW_DELAY_MS);
}
} else {
searchLoading.setVisibility(View.GONE);
}
}
private void initializeTypingObserver() {
@@ -50,7 +50,9 @@ import com.makeramen.roundedimageview.RoundedDrawable;
import org.signal.core.util.ContextUtil;
import org.signal.core.util.DimensionUnit;
import org.signal.core.util.StringUtil;
import org.signal.core.util.Util;
import org.signal.core.util.logging.Log;
import org.signal.glide.decryptableuri.DecryptableUri;
import org.thoughtcrime.securesms.BindableConversationListItem;
import org.thoughtcrime.securesms.OverlayTransformation;
import org.thoughtcrime.securesms.R;
@@ -74,7 +76,6 @@ import org.thoughtcrime.securesms.database.model.ThreadWithRecipient;
import org.thoughtcrime.securesms.database.model.UpdateDescription;
import org.thoughtcrime.securesms.fonts.SignalSymbols.Glyph;
import org.thoughtcrime.securesms.glide.targets.GlideLiveDataTarget;
import org.signal.glide.decryptableuri.DecryptableUri;
import org.thoughtcrime.securesms.recipients.LiveRecipient;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
@@ -85,7 +86,6 @@ import org.thoughtcrime.securesms.util.MediaUtil;
import org.thoughtcrime.securesms.util.SearchUtil;
import org.thoughtcrime.securesms.util.SignalE164Util;
import org.thoughtcrime.securesms.util.SpanUtil;
import org.signal.core.util.Util;
import org.thoughtcrime.securesms.util.livedata.LiveDataUtil;
import java.util.List;
@@ -214,9 +214,9 @@ public final class ConversationListItem extends ConstraintLayout implements Bind
@NonNull Locale locale,
@NonNull Set<Long> typingThreads,
@NonNull ConversationSet selectedConversations,
long activeThreadId)
@Nullable RecipientId activeRecipientId)
{
bindThread(lifecycleOwner, thread, glideRequests, locale, typingThreads, selectedConversations, null, false, true, activeThreadId);
bindThread(lifecycleOwner, thread, glideRequests, locale, typingThreads, selectedConversations, null, false, true, activeRecipientId);
}
public void bindThread(@NonNull LifecycleOwner lifecycleOwner,
@@ -228,7 +228,7 @@ public final class ConversationListItem extends ConstraintLayout implements Bind
@Nullable String highlightSubstring,
boolean appendSystemContactIcon,
boolean showPinned,
long activeThreadId)
@Nullable RecipientId activeRecipientId)
{
this.threadId = thread.getThreadId();
this.requestManager = requestManager;
@@ -285,7 +285,7 @@ public final class ConversationListItem extends ConstraintLayout implements Bind
this.archivedView.setVisibility(View.GONE);
}
setActiveThreadId(activeThreadId);
setActiveRecipientId(activeRecipientId);
setStatusIcons(thread);
setSelectedConversations(selectedConversations);
setBadgeFromRecipient(recipient.get());
@@ -336,7 +336,7 @@ public final class ConversationListItem extends ConstraintLayout implements Bind
alertView.setNone();
setSelectedConversations(new ConversationSet());
setActiveThreadId(0);
setActiveRecipientId(null);
setBadgeFromRecipient(recipient.get());
contactPhotoImage.setAvatar(requestManager, recipient.get(), !batchMode, false);
}
@@ -376,7 +376,7 @@ public final class ConversationListItem extends ConstraintLayout implements Bind
alertView.setNone();
setSelectedConversations(new ConversationSet());
setActiveThreadId(0);
setActiveRecipientId(null);
setBadgeFromRecipient(recipient.get());
contactPhotoImage.setAvatar(requestManager, recipient.get(), !batchMode);
}
@@ -397,7 +397,7 @@ public final class ConversationListItem extends ConstraintLayout implements Bind
if (this.recipient != null) {
observeRecipient(null, null);
setSelectedConversations(new ConversationSet());
setActiveThreadId(0);
setActiveRecipientId(null);
contactPhotoImage.setAvatar(requestManager, null, !batchMode);
}
@@ -407,8 +407,8 @@ public final class ConversationListItem extends ConstraintLayout implements Bind
}
@Override
public void setActiveThreadId(long activeThreadId) {
setActivated(activeThreadId > 0 && this.threadId == activeThreadId);
public void setActiveRecipientId(@Nullable RecipientId activeRecipientId) {
setActivated(activeRecipientId != null && this.recipient != null && this.recipient.getId().equals(activeRecipientId));
}
@Override
@@ -6,11 +6,13 @@ import android.widget.FrameLayout;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.lifecycle.LifecycleOwner;
import com.bumptech.glide.RequestManager;
import org.thoughtcrime.securesms.BindableConversationListItem;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.conversationlist.model.ConversationSet;
import org.thoughtcrime.securesms.database.model.ThreadWithRecipient;
@@ -47,7 +49,7 @@ public class ConversationListItemAction extends FrameLayout implements BindableC
@NonNull Locale locale,
@NonNull Set<Long> typingThreads,
@NonNull ConversationSet selectedConversations,
long activeThreadId)
@Nullable RecipientId activeRecipientId)
{
this.description.setText(getContext().getString(R.string.ConversationListItemAction_archived_conversations_d, thread.getUnreadCount()));
}
@@ -58,7 +60,7 @@ public class ConversationListItemAction extends FrameLayout implements BindableC
}
@Override
public void setActiveThreadId(long activeThreadId) {
public void setActiveRecipientId(@Nullable RecipientId activeRecipientId) {
}
@@ -229,7 +229,7 @@ object ConversationListSearchModels {
model.thread.query,
true,
false,
0
null
)
}
}
@@ -0,0 +1,28 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.crypto
import android.content.Context
import org.signal.core.util.crypto.AttachmentSecretStore
import org.thoughtcrime.securesms.util.TextSecurePreferences
object AppAttachmentSecretStore : AttachmentSecretStore {
override fun getAttachmentUnencryptedSecret(context: Context): String? {
return TextSecurePreferences.getAttachmentUnencryptedSecret(context)
}
override fun getAttachmentEncryptedSecret(context: Context): String? {
return TextSecurePreferences.getAttachmentEncryptedSecret(context)
}
override fun setAttachmentEncryptedSecret(context: Context, secret: String) {
TextSecurePreferences.setAttachmentEncryptedSecret(context, secret)
}
override fun setAttachmentUnencryptedSecret(context: Context, secret: String?) {
TextSecurePreferences.setAttachmentUnencryptedSecret(context, secret)
}
}
@@ -5,6 +5,7 @@ import android.content.Context;
import androidx.annotation.NonNull;
import org.signal.core.util.crypto.KeyStoreHelper;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import java.io.IOException;
@@ -37,8 +37,13 @@ import org.signal.core.util.SqlUtil
import org.signal.core.util.ThreadUtil
import org.signal.core.util.Util
import org.signal.core.util.UuidUtil
import org.signal.core.util.bitmaps.BitmapDecodingException
import org.signal.core.util.copyTo
import org.signal.core.util.count
import org.signal.core.util.crypto.AttachmentSecret
import org.signal.core.util.crypto.ClassicDecryptingPartInputStream
import org.signal.core.util.crypto.ModernDecryptingPartInputStream
import org.signal.core.util.crypto.ModernEncryptingPartOutputStream
import org.signal.core.util.delete
import org.signal.core.util.deleteAll
import org.signal.core.util.drain
@@ -78,10 +83,6 @@ import org.thoughtcrime.securesms.attachments.WallpaperAttachment
import org.thoughtcrime.securesms.audio.AudioHash
import org.thoughtcrime.securesms.backup.v2.ArchivedMediaObject
import org.thoughtcrime.securesms.backup.v2.exporters.ChatItemArchiveExporter
import org.thoughtcrime.securesms.crypto.AttachmentSecret
import org.thoughtcrime.securesms.crypto.ClassicDecryptingPartInputStream
import org.thoughtcrime.securesms.crypto.ModernDecryptingPartInputStream
import org.thoughtcrime.securesms.crypto.ModernEncryptingPartOutputStream
import org.thoughtcrime.securesms.database.AttachmentTable.Companion.DATA_FILE
import org.thoughtcrime.securesms.database.AttachmentTable.Companion.DATA_HASH_END
import org.thoughtcrime.securesms.database.AttachmentTable.Companion.PREUPLOAD_MESSAGE_ID
@@ -102,7 +103,6 @@ import org.thoughtcrime.securesms.mms.MediaStream
import org.thoughtcrime.securesms.mms.MmsException
import org.thoughtcrime.securesms.mms.PartAuthority
import org.thoughtcrime.securesms.stickers.StickerLocator
import org.thoughtcrime.securesms.util.BitmapDecodingException
import org.thoughtcrime.securesms.util.FileUtils
import org.thoughtcrime.securesms.util.ImageCompressionUtil
import org.thoughtcrime.securesms.util.MediaUtil
@@ -1701,7 +1701,7 @@ class AttachmentTable(
fun setRestoreTransferState(restorableAttachments: Collection<AttachmentId>, state: Int) {
val prefix = when (state) {
TRANSFER_RESTORE_OFFLOADED -> "$TRANSFER_STATE != $TRANSFER_PROGRESS_PERMANENT_FAILURE AND"
TRANSFER_RESTORE_IN_PROGRESS -> "($TRANSFER_STATE = $TRANSFER_NEEDS_RESTORE OR $TRANSFER_STATE = $TRANSFER_RESTORE_OFFLOADED) AND"
TRANSFER_RESTORE_IN_PROGRESS -> "($TRANSFER_STATE = $TRANSFER_NEEDS_RESTORE OR $TRANSFER_STATE = $TRANSFER_RESTORE_OFFLOADED OR $TRANSFER_STATE = $TRANSFER_PROGRESS_FAILED) AND"
TRANSFER_PROGRESS_FAILED -> "$TRANSFER_STATE != $TRANSFER_PROGRESS_PERMANENT_FAILURE AND"
else -> ""
}
@@ -1723,6 +1723,50 @@ class AttachmentTable(
}
}
/**
* Replaces the remote pointer fields on [attachmentId] with the values from a primary device's
* backfill response and resets the transfer state to pending so a fresh download can run.
*/
fun updatePointerFromBackfill(attachmentId: AttachmentId, attachment: Attachment): Boolean {
val remoteLocation = attachment.remoteLocation
if (remoteLocation.isNullOrBlank()) {
Log.w(TAG, "[updatePointerFromBackfill] Attachment has no remote location for $attachmentId.")
return false
}
val remoteKey = attachment.remoteKey
if (remoteKey.isNullOrBlank()) {
Log.w(TAG, "[updatePointerFromBackfill] Attachment missing key for $attachmentId.")
return false
}
val remoteDigest = attachment.remoteDigest
if (remoteDigest == null) {
Log.w(TAG, "[updatePointerFromBackfill] Attachment missing digest for $attachmentId.")
return false
}
val values = contentValuesOf(
TRANSFER_STATE to TRANSFER_PROGRESS_PENDING,
CDN_NUMBER to attachment.cdn.cdnNumber,
REMOTE_LOCATION to remoteLocation,
REMOTE_KEY to remoteKey,
REMOTE_DIGEST to remoteDigest,
REMOTE_INCREMENTAL_DIGEST to attachment.incrementalDigest,
REMOTE_INCREMENTAL_DIGEST_CHUNK_SIZE to attachment.incrementalMacChunkSize,
DATA_SIZE to attachment.size,
UPLOAD_TIMESTAMP to attachment.uploadTimestamp
)
val updateCount = writableDatabase
.update(TABLE_NAME)
.values(values)
.where("$ID = ?", attachmentId.id)
.run()
return updateCount > 0
}
/**
* Updates the attachment (and all attachments that share the same data file) with a new length.
*/
@@ -203,7 +203,7 @@ class CallLinkTable(context: Context, databaseHelper: SignalDatabase) : Database
)
insertCallLink(link)
return getCallLinkByRoomId(roomId)!!
getCallLinkByRoomId(roomId)!!
} else {
callLink
}
@@ -287,7 +287,7 @@ class CallLinkTable(context: Context, databaseHelper: SignalDatabase) : Database
deletionTimestamp = 0L
)
insertCallLink(link)
return getCallLinkByRoomId(callLinkRoomId)!!
getCallLinkByRoomId(callLinkRoomId)!!
} else {
callLink
}
@@ -468,7 +468,7 @@ class CallLinkTable(context: Context, databaseHelper: SignalDatabase) : Database
}
}
override fun deserialize(data: ContentValues): CallLink {
override fun deserialize(input: ContentValues): CallLink {
throw UnsupportedOperationException()
}
}
@@ -478,21 +478,21 @@ class CallLinkTable(context: Context, databaseHelper: SignalDatabase) : Database
throw UnsupportedOperationException()
}
override fun deserialize(data: Cursor): CallLink {
override fun deserialize(input: Cursor): CallLink {
return CallLink(
recipientId = data.requireLong(RECIPIENT_ID).let { if (it > 0) RecipientId.from(it) else RecipientId.UNKNOWN },
roomId = CallLinkRoomId.DatabaseSerializer.deserialize(data.requireNonNullString(ROOM_ID)),
credentials = data.requireBlob(ROOT_KEY)?.let { linkKey ->
recipientId = input.requireLong(RECIPIENT_ID).let { if (it > 0) RecipientId.from(it) else RecipientId.UNKNOWN },
roomId = CallLinkRoomId.DatabaseSerializer.deserialize(input.requireNonNullString(ROOM_ID)),
credentials = input.requireBlob(ROOT_KEY)?.let { linkKey ->
CallLinkCredentials(
linkKeyBytes = linkKey,
adminPassBytes = data.requireBlob(ADMIN_KEY)
adminPassBytes = input.requireBlob(ADMIN_KEY)
)
},
state = SignalCallLinkState(
name = data.requireNonNullString(NAME),
restrictions = data.requireInt(RESTRICTIONS).mapToRestrictions(),
revoked = data.requireBoolean(REVOKED),
expiration = data.requireLong(EXPIRATION).let {
name = input.requireNonNullString(NAME),
restrictions = input.requireInt(RESTRICTIONS).mapToRestrictions(),
revoked = input.requireBoolean(REVOKED),
expiration = input.requireLong(EXPIRATION).let {
if (it == -1L) {
Instant.MAX
} else {
@@ -500,7 +500,7 @@ class CallLinkTable(context: Context, databaseHelper: SignalDatabase) : Database
}
}
),
deletionTimestamp = data.requireLong(DELETION_TIMESTAMP)
deletionTimestamp = input.requireLong(DELETION_TIMESTAMP)
)
}
@@ -521,6 +521,7 @@ class CallLinkTable(context: Context, databaseHelper: SignalDatabase) : Database
val deletionTimestamp: Long
) {
val avatarColor: AvatarColor = credentials?.let { AvatarColorHash.forCallLink(it.linkKeyBytes) } ?: AvatarColor.UNKNOWN
val canModify: Boolean = credentials?.adminPassBytes != null
}
override fun remapRecipient(fromId: RecipientId, toId: RecipientId) {

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