Compare commits

..

80 Commits

Author SHA1 Message Date
Greyson Parrelli
8809b8f77c Bump version to 7.56.0 2025-09-04 17:00:43 -04:00
Greyson Parrelli
f8aa3644aa Update translations and other static files. 2025-09-04 17:00:20 -04:00
Greyson Parrelli
e1e41b6f7f Revert "Add logging when registration can't be completed."
This reverts commit 2c47cd2422.
2025-09-04 17:00:20 -04:00
jeffrey-signal
b1f067536b Disable saving PIN to password manager.
We haven't found a reliable way to make the auto-fill framework differentiate between the PIN and backup key, so we are disabling prompts to save Signal PINs to password managers to avoid accidentally overwriting a saved backup key with a PIN value.

Co-authored-by: Michelle Tang <mtang@signal.org>
2025-09-04 17:00:20 -04:00
Cody Henthorne
217a6187c2 Add run restore media job to internal settings. 2025-09-04 17:00:20 -04:00
Cody Henthorne
1d1f0c0b3a Fix wallpaper attachments not being restored. 2025-09-04 17:00:20 -04:00
Greyson Parrelli
ba3c30f768 Enable Signal Secure Backups for external users. 2025-09-04 17:00:20 -04:00
Greyson Parrelli
bc85552ded Fix possible crash during restore. 2025-09-04 16:35:06 -04:00
Greyson Parrelli
ccf1be2359 Add failsafe for clearing abandoned backup progress. 2025-09-04 16:35:06 -04:00
Greyson Parrelli
0d3727f08b Handle thumbnail generation failing more gracefully. 2025-09-04 16:35:06 -04:00
Cody Henthorne
94b464e37c Call mark registration complete in more places. 2025-09-04 16:35:06 -04:00
Greyson Parrelli
ca2cc722d4 Add separate column to track archive thumbnail status. 2025-09-04 16:35:06 -04:00
Alex Hart
2c47cd2422 Add logging when registration can't be completed. 2025-09-04 16:35:06 -04:00
Greyson Parrelli
1c9d68a932 Fix thumbnail job precondition. 2025-09-04 16:35:06 -04:00
Greyson Parrelli
807d10837b Do not include view-once or long text in the media snapshot table. 2025-09-04 16:35:06 -04:00
Cody Henthorne
6e5c569f7e Update various registration related logs as keep-longer. 2025-09-04 16:35:06 -04:00
Greyson Parrelli
4179592ae7 Disable the 'optimize storage' feature for production users. 2025-09-04 16:35:06 -04:00
Cody Henthorne
1f40c7ab7e Improve archive restore progress tracking and UX. 2025-09-04 16:35:06 -04:00
Greyson Parrelli
89a0541574 Ensure we don't archive thumbnails or stories. 2025-09-04 16:35:06 -04:00
Greyson Parrelli
5294bd8a1a Show 'calculating' while loading backup size. 2025-09-04 16:35:06 -04:00
Alex Hart
2d9c572c01 Fix several backup related problems. 2025-09-04 16:35:06 -04:00
Greyson Parrelli
8520108bb2 Limit quote bodies to 2kib on export. 2025-09-04 16:35:06 -04:00
Greyson Parrelli
2572dac8a7 Remove backup frequency setting. 2025-09-04 16:35:06 -04:00
andrew-signal
54b31514ba Bump libsignal to v0.79.1 2025-09-04 16:35:06 -04:00
Greyson Parrelli
1166b99d01 Add backupVersion to RegistrationProvisioning.proto 2025-09-04 16:35:06 -04:00
Greyson Parrelli
b44cd5d4c4 Clear some tooltips after restore, remove some legacy ones entirely. 2025-09-04 16:35:06 -04:00
Greyson Parrelli
08a8c56d5c Only allow archive tier overrides on staging. 2025-09-04 16:35:05 -04:00
Michelle Tang
33645c302b Update backup related strings. 2025-09-04 16:35:05 -04:00
Greyson Parrelli
a7ac138ea3 Add quote reconstruction job for free-tier restores. 2025-09-04 16:35:05 -04:00
Greyson Parrelli
06b85cc3cb Improve quote deletion, remove file deletes from transactions. 2025-09-04 16:35:05 -04:00
Greyson Parrelli
662404d335 Migrate quotes to have a separate quoteTargetContentType. 2025-09-04 16:35:05 -04:00
Greyson Parrelli
631b51baf2 Add a migration to generate thumbnails for existing quotes. 2025-09-04 16:35:05 -04:00
Greyson Parrelli
c29d77d4a5 Do not generate archive thumbnails for quotes. 2025-09-04 16:35:05 -04:00
Greyson Parrelli
d4c1c39179 Generate thumbnails for quote attachments. 2025-09-04 16:35:05 -04:00
Greyson Parrelli
71dd1d9d8b Add backstop for ensuring attachment archive jobs are enqueued. 2025-09-04 16:35:05 -04:00
Michelle Tang
3b715bc461 Catch foreground service not allowed exceptions. 2025-09-04 16:35:05 -04:00
Greyson Parrelli
712616e569 Do not prompt prod users to enable backups. 2025-09-04 16:35:05 -04:00
Michelle Tang
c18cb6a926 Set remote fetch time for remote config v2. 2025-09-04 16:35:05 -04:00
Greyson Parrelli
b975e2ed69 Ensure that memory-only jobs do not get lost due to cache eviction. 2025-09-04 16:35:05 -04:00
Greyson Parrelli
b87a060251 Use more sensible numberings for JobRunner ids. 2025-09-04 16:35:05 -04:00
Greyson Parrelli
c493fc1c4c Fix name of thread dump log sections. 2025-09-04 16:35:05 -04:00
Alex Hart
e083076e40 Add additional information to entitlement level log-line of context job. 2025-09-04 16:35:05 -04:00
Greyson Parrelli
a5c4c3b54a Small JobManager tweaks to scale more often and improve debugging info. 2025-09-04 16:35:05 -04:00
Cody Henthorne
3bcfb5ab61 Fix received stickers for installed packs without a data_hash_end. 2025-08-28 12:04:43 -04:00
Cody Henthorne
8ce17e3e2d Remove set pin after aep reg megaphone. 2025-08-28 11:30:23 -04:00
Greyson Parrelli
460b097a71 Ensure that all SignalValues are having the correct overrides called. 2025-08-28 09:50:02 -04:00
Michelle Tang
8e9dc78957 Bump version to 7.55.0 2025-08-27 16:09:44 -04:00
Michelle Tang
1ee5d32322 Update translations and other static files. 2025-08-27 15:59:54 -04:00
Michelle Tang
42905b5bb8 Remove phantom restore media notification. 2025-08-27 15:55:18 -04:00
Cody Henthorne
b8c25a4d78 Re-enable large image loading. 2025-08-27 15:55:18 -04:00
Cody Henthorne
cdbe2c1c71 Revert "Disable all fixes for large image loading."
This reverts commit a16ac3394c.
2025-08-27 15:55:18 -04:00
Michelle Tang
d4f08e6d46 Update unsupported message UI. 2025-08-27 15:55:18 -04:00
Michelle Tang
8322bf3ecc Choose manual profile over scheduled profile if active. 2025-08-27 15:55:18 -04:00
Cody Henthorne
21363f085e Add sticker specific restore flow and fix archive related sticker bugs. 2025-08-27 15:55:18 -04:00
Greyson Parrelli
9903a664d4 Allow NEEDS_RESTORE attachments to be considered as 'couldBeOnArchiveCdn'. 2025-08-27 15:55:18 -04:00
Greyson Parrelli
1a1ddbfa39 Add additional logging around JobRunners. 2025-08-27 15:55:18 -04:00
Greyson Parrelli
23bbe704ab Add a reserved runner for attachment operations. 2025-08-27 15:55:18 -04:00
Greyson Parrelli
0dda3d54c9 Add SendDeliveryReceiptJob to the reserved runner. 2025-08-27 15:55:18 -04:00
Greyson Parrelli
dde1d9b2c8 Ensure that debuglog search queries are escaped.
Thank you to Aref Alotaibi <arefalotaibi.cs@gmail.com> for discovering
and reporting this issue!
2025-08-27 15:55:18 -04:00
Cody Henthorne
7bb0b513e8 Don't set archive states when importing from no-media backup. 2025-08-27 15:55:18 -04:00
Greyson Parrelli
2046b44fce Clear incrementalMac if we discover it's bad during playback. 2025-08-27 15:55:18 -04:00
Alex Hart
45c64f825d Migrate DataAndStorageFragment to compose. 2025-08-27 15:55:18 -04:00
Jim Gustafson
94ed0650dc Calling: Remove unnecessary hangups 2025-08-27 15:55:18 -04:00
Cody Henthorne
0d390769d4 Add key reuse to create keys operation in backup job. 2025-08-27 15:55:18 -04:00
Alex Hart
2872020c1f Convert ChatsSettings screen to compose. 2025-08-27 15:55:18 -04:00
Michelle Tang
8723fd9a24 Check remaining attachment size before launching service. 2025-08-27 15:55:18 -04:00
Greyson Parrelli
9a9661149b Run unit tests in parallel. 2025-08-27 15:55:18 -04:00
Michelle Tang
5dfbfccc08 Add media restore tier to debuglogs. 2025-08-27 15:55:18 -04:00
Alex Hart
a344618c63 Update logging of timestamps in Remote Backups section. 2025-08-27 15:55:18 -04:00
Alex Hart
24b93fb517 Fix IAP insertion timestamp. 2025-08-27 15:55:18 -04:00
Greyson Parrelli
f052b1fd90 Ignore expensive AttachmentCipherTests.
They can take up to 15 seconds to run, and they've already proven
their usefulness. We can un-ignore them temporarily if we ever need
to make changes.
2025-08-27 15:55:18 -04:00
Greyson Parrelli
a234896438 Ensure that we clear incrementalMac's that will be invalidated during archive. 2025-08-27 15:55:18 -04:00
Alex Hart
bed718347c Fix navigation bar gap when opening keyboard in split pane view. 2025-08-27 15:55:18 -04:00
Cody Henthorne
53f2049c48 Use same cipher version for registration and linking provisioning messages. 2025-08-27 15:55:18 -04:00
andrew-signal
00d425356d Bump to libsignal v0.79.0 2025-08-27 15:55:18 -04:00
Cody Henthorne
6c42ce411b Add JUnit-based database consistency test. 2025-08-27 15:55:18 -04:00
Alex Hart
1833248c96 Don't bother with the empty item if we are on the new calling UI. 2025-08-27 15:55:18 -04:00
Cody Henthorne
f5b1857866 Fix various attachment reuse bugs causing archive issues. 2025-08-27 15:55:18 -04:00
Alex Hart
114524adc6 Add support for resizing pane on main activity. 2025-08-25 18:01:22 -04:00
Michelle Tang
47fb0deca4 Add foreground service when restoring backup media. 2025-08-25 18:01:22 -04:00
300 changed files with 13665 additions and 9414 deletions

View File

@@ -21,8 +21,8 @@ plugins {
apply(from = "static-ips.gradle.kts")
val canonicalVersionCode = 1578
val canonicalVersionName = "7.54.1"
val canonicalVersionCode = 1580
val canonicalVersionName = "7.56.0"
val currentHotfixVersion = 0
val maxHotfixVersions = 100
@@ -237,8 +237,8 @@ android {
buildConfigField("String", "STRIPE_BASE_URL", "\"https://api.stripe.com/v1\"")
buildConfigField("String", "STRIPE_PUBLISHABLE_KEY", "\"pk_live_6cmGZopuTsV8novGgJJW9JpC00vLIgtQ1D\"")
buildConfigField("boolean", "TRACING_ENABLED", "false")
buildConfigField("boolean", "MESSAGE_BACKUP_RESTORE_ENABLED", "false")
buildConfigField("boolean", "LINK_DEVICE_UX_ENABLED", "false")
buildConfigField("boolean", "MESSAGE_BACKUP_RESTORE_ENABLED", "true")
buildConfigField("boolean", "LINK_DEVICE_UX_ENABLED", "true")
ndk {
abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64")

View File

@@ -126,7 +126,7 @@ class ConversationItemPreviewer {
SignalDatabase.threads.getOrCreateThreadIdFor(other),
false,
null
)
).messageId
SignalDatabase.attachments.getAttachmentsForMessage(insert).forEachIndexed { index, attachment ->
SignalDatabase.attachments.setTransferProgressPermanentFailure(attachment.attachmentId, insert)

View File

@@ -346,5 +346,7 @@ class V2ConversationItemShapeTest {
override fun onDisplayMediaNoLongerAvailableSheet() = Unit
override fun onShowUnverifiedProfileSheet(forGroup: Boolean) = Unit
override fun onUpdateSignalClicked() = Unit
}
}

View File

@@ -388,6 +388,7 @@ class AttachmentTableTest {
stickerLocator = null,
gif = false,
quote = false,
quoteTargetContentType = null,
uuid = UUID.randomUUID(),
fileName = null
)

View File

@@ -252,7 +252,6 @@ class AttachmentTableTest_deduping {
assertSkipTransform(id2, true)
assertDoesNotHaveRemoteFields(id2)
assertArchiveFieldsMatch(id1, id2)
upload(id2)
@@ -671,7 +670,9 @@ class AttachmentTableTest_deduping {
caption = null,
stickerLocator = null,
blurHash = null,
uuid = UUID.randomUUID()
uuid = UUID.randomUUID(),
quote = false,
quoteTargetContentType = null
)
),
quoteAttachment = emptyList()
@@ -704,7 +705,7 @@ class AttachmentTableTest_deduping {
author = Recipient.self().id,
text = "Some quote text",
isOriginalMissing = false,
attachments = listOf(originalAttachment),
attachment = originalAttachment,
mentions = emptyList(),
type = QuoteModel.Type.NORMAL,
bodyRanges = null
@@ -713,7 +714,7 @@ class AttachmentTableTest_deduping {
threadId = threadId,
forceSms = false,
insertListener = null
)
).messageId
val attachments = SignalDatabase.attachments.getAttachmentsForMessage(messageId)
return attachments[0].attachmentId

View File

@@ -51,6 +51,30 @@ class BackupMediaSnapshotTableTest {
assertThat(count).isEqualTo(countWithThumbnails)
}
@Test
fun givenAnEmptyTable_whenIWriteToTableAndCommitQuotes_thenIExpectFilledTableWithNoThumbnails() {
val inputCount = 100
SignalDatabase.backupMediaSnapshots.writePendingMediaObjects(generateArchiveMediaItemSequence(count = inputCount, quote = true))
SignalDatabase.backupMediaSnapshots.commitPendingRows()
val count = getCountForLatestSnapshot(includeThumbnails = true)
assertThat(count).isEqualTo(inputCount)
}
@Test
fun givenAnEmptyTable_whenIWriteToTableAndCommitNonMedia_thenIExpectFilledTableWithNoThumbnails() {
val inputCount = 100
SignalDatabase.backupMediaSnapshots.writePendingMediaObjects(generateArchiveMediaItemSequence(count = inputCount, contentType = "text/plain"))
SignalDatabase.backupMediaSnapshots.commitPendingRows()
val count = getCountForLatestSnapshot(includeThumbnails = true)
assertThat(count).isEqualTo(inputCount)
}
@Test
fun givenAFilledTable_whenIReinsertObjects_thenIExpectUncommittedOverrides() {
val initialCount = 100
@@ -290,19 +314,21 @@ class BackupMediaSnapshotTableTest {
.readToSingleInt(0)
}
private fun generateArchiveMediaItemSequence(count: Int): Sequence<ArchiveMediaItem> {
private fun generateArchiveMediaItemSequence(count: Int, quote: Boolean = false, contentType: String = "image/jpeg"): Sequence<ArchiveMediaItem> {
return (1..count)
.asSequence()
.map { createArchiveMediaItem(it) }
.map { createArchiveMediaItem(it, quote = quote, contentType = contentType) }
}
private fun createArchiveMediaItem(seed: Int, cdn: Int = 0): ArchiveMediaItem {
private fun createArchiveMediaItem(seed: Int, cdn: Int = 0, quote: Boolean = false, contentType: String = "image/jpeg"): ArchiveMediaItem {
return ArchiveMediaItem(
mediaId = "media_id_$seed",
thumbnailMediaId = "thumbnail_media_id_$seed",
cdn = cdn,
plaintextHash = Util.toByteArray(seed),
remoteKey = Util.toByteArray(seed)
remoteKey = Util.toByteArray(seed),
quote = quote,
contentType = contentType
)
}

View File

@@ -51,7 +51,7 @@ object MmsHelper {
message: OutgoingMessage,
threadId: Long
): Long {
return SignalDatabase.messages.insertMessageOutbox(message, threadId, false, GroupReceiptTable.STATUS_UNKNOWN, null)
return SignalDatabase.messages.insertMessageOutbox(message, threadId, false, GroupReceiptTable.STATUS_UNKNOWN, null).messageId
}
fun insert(

View File

@@ -19,6 +19,7 @@ object UriAttachmentBuilder {
borderless: Boolean = false,
videoGif: Boolean = false,
quote: Boolean = false,
quoteTargetContentType: String? = null,
caption: String? = null,
stickerLocator: StickerLocator? = null,
blurHash: BlurHash? = null,
@@ -39,6 +40,7 @@ object UriAttachmentBuilder {
borderless = borderless,
videoGif = videoGif,
quote = quote,
quoteTargetContentType = quoteTargetContentType,
caption = caption,
stickerLocator = stickerLocator,
blurHash = blurHash,

View File

@@ -124,7 +124,7 @@ class EditMessageSyncProcessorTest {
bodyRanges = content.dataMessage?.bodyRanges.toBodyRangeList()
)
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(toRecipient)
val originalMessageId = SignalDatabase.messages.insertMessageOutbox(originalTextMessage, threadId, false, null)
val originalMessageId = SignalDatabase.messages.insertMessageOutbox(originalTextMessage, threadId, false, null).messageId
SignalDatabase.messages.markAsSent(originalMessageId, true)
if ((content.dataMessage?.expireTimer ?: 0) > 0) {
SignalDatabase.messages.markExpireStarted(originalMessageId, originalTimestamp)
@@ -141,7 +141,7 @@ class EditMessageSyncProcessorTest {
messageToEdit = originalMessageId
)
val editMessageId = SignalDatabase.messages.insertMessageOutbox(editMessage, threadId, false, null)
val editMessageId = SignalDatabase.messages.insertMessageOutbox(editMessage, threadId, false, null).messageId
SignalDatabase.messages.markAsSent(editMessageId, true)
if ((content.dataMessage?.expireTimer ?: 0) > 0) {

View File

@@ -91,7 +91,7 @@ class MessageHelper(private val harness: SignalActivityRule, var startTime: Long
).let { updateMessage?.invoke(it) ?: it }
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(threadRecipient)
val messageId = SignalDatabase.messages.insertMessageOutbox(message, threadId, false, null)
val messageId = SignalDatabase.messages.insertMessageOutbox(message, threadId, false, null).messageId
if (successfulSend) {
SignalDatabase.messages.markAsSent(messageId, true)
@@ -114,7 +114,7 @@ class MessageHelper(private val harness: SignalActivityRule, var startTime: Long
).apply { updateMessage() }
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(threadRecipient)
val messageId = SignalDatabase.messages.insertMessageOutbox(message, threadId, false, null)
val messageId = SignalDatabase.messages.insertMessageOutbox(message, threadId, false, null).messageId
return messageData.copy(messageId = messageId)
}
@@ -151,7 +151,7 @@ class MessageHelper(private val harness: SignalActivityRule, var startTime: Long
val outgoingMessage = OutgoingMessage.groupUpdateMessage(groupRecipient, updateDescription, startTime)
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(groupRecipient)
val messageId = SignalDatabase.messages.insertMessageOutbox(outgoingMessage, threadId, false, null)
val messageId = SignalDatabase.messages.insertMessageOutbox(outgoingMessage, threadId, false, null).messageId
SignalDatabase.messages.markAsSent(messageId, true)
return messageData.copy(messageId = messageId)

View File

@@ -21,6 +21,7 @@ import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.testing.AliceClient
import org.thoughtcrime.securesms.testing.BobClient
import org.thoughtcrime.securesms.testing.Entry
import org.thoughtcrime.securesms.testing.FakeClientHelpers
import org.thoughtcrime.securesms.testing.SignalActivityRule
import org.thoughtcrime.securesms.testing.awaitFor
import org.whispersystems.signalservice.internal.push.Envelope
@@ -54,8 +55,7 @@ class MessageProcessingPerformanceTest {
@Before
fun setup() {
mockkStatic(SealedSenderAccessUtil::class)
// TODO reinstate this for libsignal 0.76.1
// every { SealedSenderAccessUtil.getCertificateValidator() } returns FakeClientHelpers.noOpCertificateValidator
every { SealedSenderAccessUtil.getCertificateValidator() } returns FakeClientHelpers.noOpCertificateValidator
mockkObject(MessageContentProcessor)
every { MessageContentProcessor.create(harness.application) } returns TimingMessageContentProcessor(harness.application)

View File

@@ -705,7 +705,8 @@ class SyncMessageProcessorTest_synchronizeDeleteForMe {
archiveCdn = this.archiveCdn,
thumbnailRestoreState = this.thumbnailRestoreState,
archiveTransferState = this.archiveTransferState,
uuid = uuid
uuid = uuid,
quoteTargetContentType = this.quoteTargetContentType
)
}

View File

@@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.testing
import okio.ByteString.Companion.toByteString
import org.signal.core.util.Base64
import org.signal.libsignal.metadata.certificate.CertificateValidator
import org.signal.libsignal.metadata.certificate.SenderCertificate
import org.signal.libsignal.metadata.certificate.ServerCertificate
import org.signal.libsignal.protocol.ecc.ECKeyPair
@@ -22,10 +23,9 @@ import java.util.UUID
object FakeClientHelpers {
// TODO reinstate this for libsignal 0.76.1
// val noOpCertificateValidator = object : CertificateValidator(ECKeyPair.generate().publicKey) {
// override fun validate(certificate: SenderCertificate, validationTime: Long) = Unit
// }
val noOpCertificateValidator = object : CertificateValidator(ECKeyPair.generate().publicKey) {
override fun validate(certificate: SenderCertificate, validationTime: Long) = Unit
}
fun createCertificateFor(trustRoot: ECKeyPair, uuid: UUID, e164: String, deviceId: Int, identityKey: ECPublicKey, expires: Long): SenderCertificate {
val serverKey: ECKeyPair = ECKeyPair.generate()

View File

@@ -332,5 +332,9 @@ class InternalConversationTestFragment : Fragment(R.layout.conversation_test_fra
override fun onShowUnverifiedProfileSheet(forGroup: Boolean) {
Toast.makeText(requireContext(), "Can't touch this.", Toast.LENGTH_SHORT).show()
}
override fun onUpdateSignalClicked() {
Toast.makeText(requireContext(), "Can't touch this.", Toast.LENGTH_SHORT).show()
}
}
}

View File

@@ -1220,6 +1220,11 @@
android:foregroundServiceType="dataSync"
android:exported="false"/>
<service
android:name=".service.BackupMediaRestoreService"
android:foregroundServiceType="dataSync"
android:exported="false"/>
<service
android:name=".gcm.FcmFetchBackgroundService"
android:exported="false"/>

View File

@@ -69,7 +69,7 @@ class DecryptableStreamLocalUriFetcher extends StreamLocalUriFetcher {
try {
if (PartAuthority.isBlobUri(uri) && BlobProvider.isSingleUseMemoryBlob(uri)) {
return PartAuthority.getAttachmentThumbnailStream(context, uri);
} else if (isSafeSize(PartAuthority.getAttachmentThumbnailStream(context, uri))) {
} else if (isSafeSize(context, uri)) {
return PartAuthority.getAttachmentThumbnailStream(context, uri);
} else {
throw new IOException("File dimensions are too large!");
@@ -80,13 +80,15 @@ class DecryptableStreamLocalUriFetcher extends StreamLocalUriFetcher {
}
}
private boolean isSafeSize(InputStream stream) {
private boolean isSafeSize(Context context, Uri uri) throws IOException {
try {
InputStream stream = PartAuthority.getAttachmentThumbnailStream(context, uri);
Pair<Integer, Integer> dimensions = BitmapUtil.getDimensions(stream);
long totalPixels = (long) dimensions.first * dimensions.second;
return totalPixels < TOTAL_PIXEL_SIZE_LIMIT;
} catch (BitmapDecodingException e) {
return false;
Long size = PartAuthority.getAttachmentSize(context, uri);
return size != null && size < GlideStreamConfig.getMarkReadLimitBytes();
}
}
}

View File

@@ -9,9 +9,11 @@ import org.thoughtcrime.securesms.dependencies.AppDependencies;
import org.thoughtcrime.securesms.jobmanager.JobManager;
import org.thoughtcrime.securesms.jobs.DeleteAbandonedAttachmentsJob;
import org.thoughtcrime.securesms.jobs.EmojiSearchIndexDownloadJob;
import org.thoughtcrime.securesms.jobs.QuoteThumbnailBackfillJob;
import org.thoughtcrime.securesms.jobs.StickerPackDownloadJob;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.migrations.ApplicationMigrations;
import org.thoughtcrime.securesms.migrations.QuoteThumbnailBackfillMigrationJob;
import org.thoughtcrime.securesms.stickers.BlessedPacks;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.Util;
@@ -53,6 +55,8 @@ public final class AppInitialization {
SignalStore.onPostBackupRestore();
SignalStore.onFirstEverAppLaunch();
SignalStore.onboarding().clearAll();
SignalStore.settings().setPassphraseDisabled(true);
SignalStore.notificationProfile().setHasSeenTooltip(true);
TextSecurePreferences.onPostBackupRestore(context);
SignalStore.settings().setPassphraseDisabled(true);
AppDependencies.getJobManager().add(StickerPackDownloadJob.forInstall(BlessedPacks.ZOZO.getPackId(), BlessedPacks.ZOZO.getPackKey(), false));
@@ -62,6 +66,12 @@ public final class AppInitialization {
AppDependencies.getJobManager().add(StickerPackDownloadJob.forReference(BlessedPacks.SWOON_FACES.getPackId(), BlessedPacks.SWOON_FACES.getPackKey()));
EmojiSearchIndexDownloadJob.scheduleImmediately();
DeleteAbandonedAttachmentsJob.enqueue();
if (SignalStore.misc().startedQuoteThumbnailMigration()) {
AppDependencies.getJobManager().add(new QuoteThumbnailBackfillJob());
} else {
AppDependencies.getJobManager().add(new QuoteThumbnailBackfillMigrationJob());
}
}
/**

View File

@@ -205,6 +205,7 @@ public class ApplicationContext extends Application implements AppForegroundObse
.addNonBlocking(AppDependencies::getBillingApi)
.addNonBlocking(this::ensureProfileUploaded)
.addNonBlocking(() -> AppDependencies.getExpireStoriesManager().scheduleIfNecessary())
.addNonBlocking(BackupRepository::maybeFixAnyDanglingUploadProgress)
.addPostRender(() -> AppDependencies.getDeletedCallEventManager().scheduleIfNecessary())
.addPostRender(() -> RateLimitUtil.retryAllRateLimitedMessages(this))
.addPostRender(this::initializeExpiringMessageManager)

View File

@@ -142,5 +142,6 @@ public interface BindableConversationItem extends Unbindable, GiphyMp4Playable,
void onPaymentTombstoneClicked();
void onDisplayMediaNoLongerAvailableSheet();
void onShowUnverifiedProfileSheet(boolean forGroup);
void onUpdateSignalClicked();
}
}

View File

@@ -25,6 +25,7 @@ import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.BoxWithConstraintsScope
@@ -36,10 +37,8 @@ import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
import androidx.compose.material3.adaptive.layout.calculatePaneScaffoldDirective
import androidx.compose.material3.adaptive.layout.rememberPaneExpansionState
import androidx.compose.material3.adaptive.navigation.ThreePaneScaffoldNavigator
import androidx.compose.material3.adaptive.navigation.rememberListDetailPaneScaffoldNavigator
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -63,6 +62,8 @@ import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.distinctUntilChangedBy
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.signal.core.ui.compose.theme.SignalTheme
@@ -70,6 +71,7 @@ import org.signal.core.util.concurrent.LifecycleDisposable
import org.signal.core.util.getSerializableCompat
import org.signal.core.util.logging.Log
import org.signal.donations.StripeApi
import org.thoughtcrime.securesms.backup.v2.ArchiveRestoreProgress
import org.thoughtcrime.securesms.backup.v2.ui.verify.VerifyBackupKeyActivity
import org.thoughtcrime.securesms.calls.YouAreAlreadyInACallSnackbar.show
import org.thoughtcrime.securesms.calls.log.CallLogFilter
@@ -127,6 +129,7 @@ import org.thoughtcrime.securesms.notifications.profiles.NotificationProfile
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfiles
import org.thoughtcrime.securesms.permissions.Permissions
import org.thoughtcrime.securesms.profiles.manage.UsernameEditFragment
import org.thoughtcrime.securesms.service.BackupMediaRestoreService
import org.thoughtcrime.securesms.service.KeyCachingService
import org.thoughtcrime.securesms.stories.Stories
import org.thoughtcrime.securesms.stories.landing.StoriesLandingFragment
@@ -143,8 +146,10 @@ import org.thoughtcrime.securesms.util.SplashScreenUtil
import org.thoughtcrime.securesms.util.TopToastPopup
import org.thoughtcrime.securesms.util.Util
import org.thoughtcrime.securesms.util.viewModel
import org.thoughtcrime.securesms.window.AppPaneDragHandle
import org.thoughtcrime.securesms.window.AppScaffold
import org.thoughtcrime.securesms.window.WindowSizeClass
import org.thoughtcrime.securesms.window.rememberAppScaffoldNavigator
import org.whispersystems.signalservice.api.websocket.WebSocketConnectionState
class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner, MainNavigator.NavigatorProvider, Material3OnScrollHelperBinder, ConversationListFragment.Callback, CallLogFragment.Callback {
@@ -253,6 +258,20 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
}
}
}
launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
ArchiveRestoreProgress
.stateFlow
.distinctUntilChangedBy { it.needRestoreMediaService() }
.filter { it.needRestoreMediaService() }
.collect {
Log.i(TAG, "Still restoring media, launching a service. Remaining restoration size: ${it.remainingRestoreSize} out of ${it.totalRestoreSize} ")
BackupMediaRestoreService.resetTimeout()
BackupMediaRestoreService.start(this@MainActivity, resources.getString(R.string.BackupStatus__restoring_media))
}
}
}
}
val callback = object : OnBackPressedCallback(toolbarViewModel.state.value.mode == MainToolbarMode.ACTION_MODE) {
@@ -310,9 +329,12 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
MainContainer {
val wrappedNavigator = rememberNavigator(windowSizeClass, contentLayoutData, maxWidth)
val paneExpansionState = rememberPaneExpansionState()
val mutableInteractionSource = remember { MutableInteractionSource() }
AppScaffold(
navigator = wrappedNavigator,
paneExpansionState = paneExpansionState,
bottomNavContent = {
if (isNavigationVisible) {
Column(
@@ -371,6 +393,7 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
modifier = Modifier.fillMaxSize()
)
}
MainNavigationListLocation.ARCHIVE -> {
val state = key(destination) { rememberFragmentState() }
AndroidFragment(
@@ -379,6 +402,7 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
modifier = Modifier.fillMaxSize()
)
}
MainNavigationListLocation.CALLS -> {
val state = key(destination) { rememberFragmentState() }
AndroidFragment(
@@ -387,6 +411,7 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
modifier = Modifier.fillMaxSize()
)
}
MainNavigationListLocation.STORIES -> {
val state = key(destination) { rememberFragmentState() }
AndroidFragment(
@@ -440,7 +465,12 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
}
},
paneExpansionDragHandle = if (contentLayoutData.hasDragHandle()) {
{ }
{
AppPaneDragHandle(
paneExpansionState = paneExpansionState,
mutableInteractionSource = mutableInteractionSource
)
}
} else null
)
}
@@ -480,14 +510,10 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
contentLayoutData: MainContentLayoutData,
maxWidth: Dp
): ThreePaneScaffoldNavigator<Any> {
val scaffoldNavigator = rememberListDetailPaneScaffoldNavigator<Any>(
scaffoldDirective = calculatePaneScaffoldDirective(
currentWindowAdaptiveInfo()
).copy(
maxHorizontalPartitions = if (windowSizeClass.isSplitPane()) 2 else 1,
horizontalPartitionSpacerSize = contentLayoutData.partitionWidth,
defaultPanePreferredWidth = contentLayoutData.rememberDefaultPanePreferredWidth(maxWidth)
)
val scaffoldNavigator = rememberAppScaffoldNavigator(
isSplitPane = windowSizeClass.isSplitPane(),
horizontalPartitionSpacerSize = contentLayoutData.partitionWidth,
defaultPanePreferredWidth = contentLayoutData.rememberDefaultPanePreferredWidth(maxWidth)
)
val coroutine = rememberCoroutineScope()
@@ -498,6 +524,7 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
is MainNavigationDetailLocation.Conversation -> {
startActivity(detailLocation.intent)
}
MainNavigationDetailLocation.Empty -> Unit
}
}
@@ -516,7 +543,9 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
}
val modifier = if (windowSizeClass.isSplitPane()) {
Modifier.systemBarsPadding().displayCutoutPadding()
Modifier
.systemBarsPadding()
.displayCutoutPadding()
} else {
Modifier
}

View File

@@ -45,11 +45,13 @@ class ArchivedAttachment : Attachment {
stickerLocator: StickerLocator?,
gif: Boolean,
quote: Boolean,
quoteTargetContentType: String?,
uuid: UUID?,
fileName: String?
) : super(
contentType = contentType ?: "",
quote = quote,
quoteTargetContentType = quoteTargetContentType,
transferState = AttachmentTable.TRANSFER_NEEDS_RESTORE,
size = size,
fileName = fileName,

View File

@@ -59,6 +59,8 @@ abstract class Attachment(
@JvmField
val quote: Boolean,
@JvmField
val quoteTargetContentType: String?,
@JvmField
val uploadTimestamp: Long,
@JvmField
val caption: String?,
@@ -98,6 +100,7 @@ abstract class Attachment(
height = parcel.readInt(),
incrementalMacChunkSize = parcel.readInt(),
quote = ParcelUtil.readBoolean(parcel),
quoteTargetContentType = parcel.readString(),
uploadTimestamp = parcel.readLong(),
caption = parcel.readString(),
stickerLocator = ParcelCompat.readParcelable(parcel, StickerLocator::class.java.classLoader, StickerLocator::class.java),
@@ -126,6 +129,7 @@ abstract class Attachment(
dest.writeInt(height)
dest.writeInt(incrementalMacChunkSize)
ParcelUtil.writeBoolean(dest, quote)
dest.writeString(quoteTargetContentType)
dest.writeLong(uploadTimestamp)
dest.writeString(caption)
dest.writeParcelable(stickerLocator, 0)

View File

@@ -75,7 +75,8 @@ class DatabaseAttachment : Attachment {
archiveCdn: Int?,
thumbnailRestoreState: AttachmentTable.ThumbnailRestoreState,
archiveTransferState: AttachmentTable.ArchiveTransferState,
uuid: UUID?
uuid: UUID?,
quoteTargetContentType: String?
) : super(
contentType = contentType,
transferState = transferProgress,
@@ -93,6 +94,7 @@ class DatabaseAttachment : Attachment {
height = height,
incrementalMacChunkSize = incrementalMacChunkSize,
quote = quote,
quoteTargetContentType = quoteTargetContentType,
uploadTimestamp = uploadTimestamp,
caption = caption,
stickerLocator = stickerLocator,

View File

@@ -0,0 +1,79 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.attachments
import android.net.Uri
import android.os.Parcel
import androidx.core.os.ParcelCompat
import org.thoughtcrime.securesms.database.AttachmentTable
import org.thoughtcrime.securesms.database.model.StickerRecord
import org.thoughtcrime.securesms.mms.StickerSlide
import org.thoughtcrime.securesms.stickers.StickerLocator
import java.security.SecureRandom
/**
* An incoming sticker that is already available locally via an installed sticker pack.
*/
class LocalStickerAttachment : Attachment {
constructor(
stickerRecord: StickerRecord,
stickerLocator: StickerLocator
) : super(
contentType = stickerRecord.contentType,
transferState = AttachmentTable.TRANSFER_PROGRESS_DONE,
size = stickerRecord.size,
fileName = null,
cdn = Cdn.CDN_0,
remoteLocation = null,
remoteKey = null,
remoteDigest = null,
incrementalDigest = null,
fastPreflightId = SecureRandom().nextLong().toString(),
voiceNote = false,
borderless = false,
videoGif = false,
width = StickerSlide.WIDTH,
height = StickerSlide.HEIGHT,
incrementalMacChunkSize = 0,
quote = false,
quoteTargetContentType = null,
uploadTimestamp = 0,
caption = null,
stickerLocator = stickerLocator,
blurHash = null,
audioHash = null,
transformProperties = null,
uuid = null
) {
uri = stickerRecord.uri
}
@Suppress("unused")
constructor(parcel: Parcel) : super(parcel) {
uri = ParcelCompat.readParcelable(parcel, Uri::class.java.classLoader, Uri::class.java)!!
}
override val uri: Uri
override val publicUri: Uri? = null
override val thumbnailUri: Uri? = null
val packId: String = stickerLocator!!.packId
val stickerId: Int = stickerLocator!!.stickerId
override fun writeToParcel(dest: Parcel, flags: Int) {
super.writeToParcel(dest, flags)
dest.writeParcelable(uri, 0)
}
override fun equals(other: Any?): Boolean {
return other != null && other is LocalStickerAttachment && other.uri == uri
}
override fun hashCode(): Int {
return uri.hashCode()
}
}

View File

@@ -38,7 +38,9 @@ class PointerAttachment : Attachment {
caption: String?,
stickerLocator: StickerLocator?,
blurHash: BlurHash?,
uuid: UUID?
uuid: UUID?,
quote: Boolean,
quoteTargetContentType: String? = null
) : super(
contentType = contentType,
transferState = transferState,
@@ -56,7 +58,8 @@ class PointerAttachment : Attachment {
width = width,
height = height,
incrementalMacChunkSize = incrementalMacChunkSize,
quote = false,
quote = quote,
quoteTargetContentType = quoteTargetContentType,
uploadTimestamp = uploadTimestamp,
caption = caption,
stickerLocator = stickerLocator,
@@ -91,7 +94,9 @@ class PointerAttachment : Attachment {
pointer: Optional<SignalServiceAttachment>,
stickerLocator: StickerLocator? = null,
fastPreflightId: String? = null,
transferState: Int = AttachmentTable.TRANSFER_PROGRESS_PENDING
transferState: Int = AttachmentTable.TRANSFER_PROGRESS_PENDING,
quote: Boolean = false,
quoteTargetContentType: String? = null
): Optional<Attachment> {
if (!pointer.isPresent || !pointer.get().isPointer()) {
return Optional.empty()
@@ -122,7 +127,9 @@ class PointerAttachment : Attachment {
caption = pointer.get().asPointer().caption.orElse(null),
stickerLocator = stickerLocator,
blurHash = BlurHash.parseOrNull(pointer.get().asPointer().blurHash.orElse(null)),
uuid = pointer.get().asPointer().uuid
uuid = pointer.get().asPointer().uuid,
quote = quote,
quoteTargetContentType = quoteTargetContentType
)
)
}
@@ -140,7 +147,9 @@ class PointerAttachment : Attachment {
return Optional.of(
PointerAttachment(
contentType = quotedAttachment.contentType!!,
quote = true,
contentType = quotedAttachment.thumbnail?.contentType,
quoteTargetContentType = quotedAttachment.contentType!!,
transferState = AttachmentTable.TRANSFER_PROGRESS_PENDING,
size = (if (thumbnail != null) thumbnail.asPointer().size.orElse(0) else 0).toLong(),
fileName = quotedAttachment.fileName,

View File

@@ -5,6 +5,7 @@ import android.os.Parcel
import org.thoughtcrime.securesms.blurhash.BlurHash
import org.thoughtcrime.securesms.database.AttachmentTable
import org.thoughtcrime.securesms.stickers.StickerLocator
import org.thoughtcrime.securesms.util.MediaUtil
import java.util.UUID
/**
@@ -14,9 +15,21 @@ import java.util.UUID
* quote them and know their contentType even though the media has been deleted.
*/
class TombstoneAttachment : Attachment {
constructor(contentType: String?, quote: Boolean) : super(
companion object {
fun forQuote(): TombstoneAttachment {
return TombstoneAttachment(contentType = null, quote = true, quoteTargetContentType = MediaUtil.VIEW_ONCE)
}
fun forNonQuote(contentType: String?): TombstoneAttachment {
return TombstoneAttachment(contentType = contentType, quote = false, quoteTargetContentType = null)
}
}
constructor(contentType: String?, quote: Boolean, quoteTargetContentType: String?) : super(
contentType = contentType,
quote = quote,
quoteTargetContentType = quoteTargetContentType,
transferState = AttachmentTable.TRANSFER_PROGRESS_DONE,
size = 0,
fileName = null,
@@ -55,10 +68,12 @@ class TombstoneAttachment : Attachment {
gif: Boolean = false,
stickerLocator: StickerLocator? = null,
quote: Boolean,
quoteTargetContentType: String?,
uuid: UUID?
) : super(
contentType = contentType ?: "",
quote = quote,
quoteTargetContentType = quoteTargetContentType,
transferState = AttachmentTable.TRANSFER_PROGRESS_PERMANENT_FAILURE,
size = 0,
fileName = fileName,

View File

@@ -22,6 +22,7 @@ class UriAttachment : Attachment {
borderless: Boolean,
videoGif: Boolean,
quote: Boolean,
quoteTargetContentType: String?,
caption: String?,
stickerLocator: StickerLocator?,
blurHash: BlurHash?,
@@ -40,6 +41,7 @@ class UriAttachment : Attachment {
borderless = borderless,
videoGif = videoGif,
quote = quote,
quoteTargetContentType = quoteTargetContentType,
caption = caption,
stickerLocator = stickerLocator,
blurHash = blurHash,
@@ -61,6 +63,7 @@ class UriAttachment : Attachment {
borderless: Boolean,
videoGif: Boolean,
quote: Boolean,
quoteTargetContentType: String?,
caption: String?,
stickerLocator: StickerLocator?,
blurHash: BlurHash?,
@@ -85,6 +88,7 @@ class UriAttachment : Attachment {
height = height,
incrementalMacChunkSize = 0,
quote = quote,
quoteTargetContentType = quoteTargetContentType,
uploadTimestamp = 0,
caption = caption,
stickerLocator = stickerLocator,

View File

@@ -30,6 +30,7 @@ class WallpaperAttachment() : Attachment(
height = 0,
incrementalMacChunkSize = 0,
quote = false,
quoteTargetContentType = null,
uploadTimestamp = 0,
caption = null,
stickerLocator = null,

View File

@@ -7,12 +7,16 @@ package org.thoughtcrime.securesms.backup
import org.signal.core.util.LongSerializer
enum class RestoreState(val id: Int, val inProgress: Boolean) {
FAILED(-1, false),
enum class RestoreState(private val id: Int, val inProgress: Boolean) {
NONE(0, false),
PENDING(1, true),
RESTORING_DB(2, true),
RESTORING_MEDIA(3, true);
CALCULATING_MEDIA(4, true),
RESTORING_MEDIA(3, true),
CANCELING_MEDIA(5, true);
val isMediaRestoreOperation: Boolean
get() = this == CALCULATING_MEDIA || this == RESTORING_MEDIA || this == CANCELING_MEDIA
companion object {
val serializer: LongSerializer<RestoreState> = Serializer()
@@ -23,14 +27,8 @@ enum class RestoreState(val id: Int, val inProgress: Boolean) {
return data.id.toLong()
}
override fun deserialize(data: Long): RestoreState {
return when (data.toInt()) {
FAILED.id -> FAILED
PENDING.id -> PENDING
RESTORING_DB.id -> RESTORING_DB
RESTORING_MEDIA.id -> RESTORING_MEDIA
else -> NONE
}
override fun deserialize(input: Long): RestoreState {
return entries.firstOrNull { it.id == input.toInt() } ?: throw IllegalStateException()
}
}
}

View File

@@ -5,22 +5,239 @@
package org.thoughtcrime.securesms.backup.v2
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.net.ConnectivityManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.update
import org.signal.core.util.bytes
import org.signal.core.util.concurrent.SignalExecutors
import org.signal.core.util.logging.Log
import org.signal.core.util.throttleLatest
import org.thoughtcrime.securesms.BuildConfig
import org.thoughtcrime.securesms.attachments.AttachmentId
import org.thoughtcrime.securesms.backup.RestoreState
import org.thoughtcrime.securesms.database.DatabaseObserver
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobmanager.impl.BatteryNotLowConstraint
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint
import org.thoughtcrime.securesms.jobmanager.impl.WifiConstraint
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.safeUnregisterReceiver
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.atomic.AtomicLong
import java.util.concurrent.locks.ReentrantLock
import kotlin.concurrent.withLock
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
import kotlin.time.DurationUnit
/**
* A class for tracking restore progress, largely just for debugging purposes. It keeps no state on disk, and is therefore only useful for testing.
* A class for tracking restore progress as a whole, but with a primary focus on managing media restore.
*
* Also provides helpful debugging information for attachment download speeds.
*/
object ArchiveRestoreProgress {
private val TAG = Log.tag(ArchiveRestoreProgress::class.java)
private var listenersRegistered = false
private val listenerLock = ReentrantLock()
private val attachmentObserver = DatabaseObserver.Observer {
update()
}
private val networkChangeReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
update()
}
}
private val store = MutableStateFlow(
ArchiveRestoreProgressState(
restoreState = SignalStore.backup.restoreState,
remainingRestoreSize = SignalStore.backup.totalRestorableAttachmentSize.bytes,
totalRestoreSize = SignalStore.backup.totalRestorableAttachmentSize.bytes,
hasActivelyRestoredThisRun = SignalStore.backup.totalRestorableAttachmentSize > 0,
totalToRestoreThisRun = SignalStore.backup.totalRestorableAttachmentSize.bytes,
restoreStatus = ArchiveRestoreProgressState.RestoreStatus.NONE
)
)
val state: ArchiveRestoreProgressState
get() = store.value
val stateFlow: Flow<ArchiveRestoreProgressState> = store
.throttleLatest(1.seconds)
.distinctUntilChanged()
.flowOn(Dispatchers.IO)
init {
SignalExecutors.BOUNDED.execute { update() }
}
fun onRestorePending() {
Log.i(TAG, "onRestorePending")
SignalStore.backup.restoreState = RestoreState.PENDING
update()
}
fun onStartMediaRestore() {
Log.i(TAG, "onStartMediaRestore")
SignalStore.backup.restoreState = RestoreState.CALCULATING_MEDIA
SignalStore.backup.totalRestorableAttachmentSize = SignalDatabase.attachments.getRemainingRestorableAttachmentSize()
update()
}
fun onRestoringMedia() {
Log.i(TAG, "onRestoringMedia")
SignalStore.backup.restoreState = RestoreState.RESTORING_MEDIA
SignalStore.backup.totalRestorableAttachmentSize = SignalDatabase.attachments.getRemainingRestorableAttachmentSize()
update()
}
fun onRestoringDb() {
Log.i(TAG, "onRestoringDb")
SignalStore.backup.restoreState = RestoreState.RESTORING_DB
update()
}
fun onCancelMediaRestore() {
Log.i(TAG, "onCancelMediaRestore")
SignalStore.backup.restoreState = RestoreState.CANCELING_MEDIA
update()
}
fun allMediaRestored() {
val shouldUpdate = if (SignalStore.backup.restoreState == RestoreState.CANCELING_MEDIA) {
Log.i(TAG, "allMediaCanceled")
store.update { state ->
if (state.restoreState == RestoreState.CANCELING_MEDIA) {
state.copy(
hasActivelyRestoredThisRun = false,
totalToRestoreThisRun = 0.bytes
)
} else {
state
}
}
true
} else if (SignalStore.backup.restoreState != RestoreState.NONE) {
Log.i(TAG, "allMediaRestored")
true
} else {
false
}
if (shouldUpdate) {
SignalStore.backup.totalRestorableAttachmentSize = 0
SignalStore.backup.restoreState = RestoreState.NONE
update()
onProcessEnd()
}
}
@JvmStatic
fun forceUpdate() {
update()
}
fun clearFinishedStatus() {
store.update { state ->
if (state.restoreStatus == ArchiveRestoreProgressState.RestoreStatus.FINISHED) {
state.copy(
restoreStatus = ArchiveRestoreProgressState.RestoreStatus.NONE,
hasActivelyRestoredThisRun = false,
totalToRestoreThisRun = 0.bytes
)
} else {
state
}
}
}
private fun update() {
store.update { state ->
val remainingRestoreSize = SignalDatabase.attachments.getRemainingRestorableAttachmentSize().bytes
var restoreState = SignalStore.backup.restoreState
val status = when {
!WifiConstraint.isMet(AppDependencies.application) && !SignalStore.backup.restoreWithCellular -> ArchiveRestoreProgressState.RestoreStatus.WAITING_FOR_WIFI
!NetworkConstraint.isMet(AppDependencies.application) -> ArchiveRestoreProgressState.RestoreStatus.WAITING_FOR_INTERNET
!BatteryNotLowConstraint.isMet() -> ArchiveRestoreProgressState.RestoreStatus.LOW_BATTERY
restoreState == RestoreState.NONE -> if (state.hasActivelyRestoredThisRun) ArchiveRestoreProgressState.RestoreStatus.FINISHED else ArchiveRestoreProgressState.RestoreStatus.NONE
else -> {
val availableBytes = SignalStore.backup.spaceAvailableOnDiskBytes
if (availableBytes > -1L && remainingRestoreSize > availableBytes.bytes) {
ArchiveRestoreProgressState.RestoreStatus.NOT_ENOUGH_DISK_SPACE
} else {
ArchiveRestoreProgressState.RestoreStatus.RESTORING
}
}
}
if (restoreState.isMediaRestoreOperation) {
if (remainingRestoreSize == 0.bytes && SignalStore.backup.totalRestorableAttachmentSize == 0L) {
restoreState = RestoreState.NONE
SignalStore.backup.restoreState = restoreState
} else {
registerUpdateListeners()
}
} else {
unregisterUpdateListeners()
}
val totalRestoreSize = SignalStore.backup.totalRestorableAttachmentSize.bytes
state.copy(
restoreState = restoreState,
remainingRestoreSize = remainingRestoreSize,
restoreStatus = status,
totalRestoreSize = totalRestoreSize,
hasActivelyRestoredThisRun = state.hasActivelyRestoredThisRun || SignalStore.backup.totalRestorableAttachmentSize > 0,
totalToRestoreThisRun = if (totalRestoreSize > 0.bytes) totalRestoreSize else state.totalToRestoreThisRun
)
}
}
private fun registerUpdateListeners() {
if (!listenersRegistered) {
listenerLock.withLock {
if (!listenersRegistered) {
Log.i(TAG, "Registering progress related listeners")
AppDependencies.databaseObserver.registerAttachmentUpdatedObserver(attachmentObserver)
AppDependencies.application.registerReceiver(networkChangeReceiver, IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION))
AppDependencies.application.registerReceiver(networkChangeReceiver, IntentFilter(Intent.ACTION_BATTERY_CHANGED))
listenersRegistered = true
}
}
}
}
private fun unregisterUpdateListeners() {
if (listenersRegistered) {
listenerLock.withLock {
if (listenersRegistered) {
Log.i(TAG, "Unregistering listeners")
AppDependencies.databaseObserver.unregisterObserver(attachmentObserver)
AppDependencies.application.safeUnregisterReceiver(networkChangeReceiver)
listenersRegistered = false
}
}
}
}
//region Attachment Debug
private var debugAttachmentStartTime: Long = 0
private val debugTotalAttachments: AtomicInteger = AtomicInteger(0)
private val debugTotalBytes: AtomicLong = AtomicLong(0)
@@ -53,7 +270,7 @@ object ArchiveRestoreProgress {
}
}
fun onProcessEnd() {
private fun onProcessEnd() {
if (debugAttachmentStartTime <= 0 || debugTotalAttachments.get() <= 0 || debugTotalBytes.get() <= 0) {
Log.w(TAG, "Insufficient data to print debug stats.")
return
@@ -84,4 +301,6 @@ object ArchiveRestoreProgress {
return "Duration=${System.currentTimeMillis() - startTimeMs}ms, TotalBytes=$totalBytes (${totalBytes.bytes.toUnitString()}), NetworkRate=$networkBytesPerSecond bytes/sec (${networkBytesPerSecond.bytes.toUnitString()}/sec), DiskRate=$diskBytesPerSecond bytes/sec (${diskBytesPerSecond.bytes.toUnitString()}/sec)"
}
}
//endregion Attachment Debug
}

View File

@@ -0,0 +1,70 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2
import org.signal.core.util.ByteSize
import org.signal.core.util.bytes
import org.thoughtcrime.securesms.backup.RestoreState
/**
* In-memory view of the current state of an attachment restore process.
*/
data class ArchiveRestoreProgressState(
val restoreState: RestoreState,
val remainingRestoreSize: ByteSize,
val totalRestoreSize: ByteSize,
val hasActivelyRestoredThisRun: Boolean = false,
val totalToRestoreThisRun: ByteSize = 0.bytes,
val restoreStatus: RestoreStatus
) {
val completedRestoredSize = totalRestoreSize - remainingRestoreSize
val progress: Float? = when (this.restoreState) {
RestoreState.CALCULATING_MEDIA,
RestoreState.CANCELING_MEDIA -> this.completedRestoredSize.percentageOf(this.totalRestoreSize)
RestoreState.RESTORING_MEDIA -> {
when (this.restoreStatus) {
RestoreStatus.NONE -> null
RestoreStatus.FINISHED -> 1f
else -> this.completedRestoredSize.percentageOf(this.totalRestoreSize)
}
}
RestoreState.NONE -> {
if (this.restoreStatus == RestoreStatus.FINISHED) {
1f
} else {
null
}
}
else -> null
}
fun activelyRestoring(): Boolean {
return restoreState.inProgress
}
fun needRestoreMediaService(): Boolean {
return (restoreState == RestoreState.CALCULATING_MEDIA || restoreState == RestoreState.RESTORING_MEDIA) &&
totalRestoreSize > 0.bytes &&
remainingRestoreSize != 0.bytes
}
/**
* Describes the status of an in-progress media download session.
*/
enum class RestoreStatus {
NONE,
RESTORING,
LOW_BATTERY,
WAITING_FOR_INTERNET,
WAITING_FOR_WIFI,
NOT_ENOUGH_DISK_SPACE,
FINISHED
}
}

View File

@@ -41,8 +41,10 @@ import org.signal.core.util.getForeignKeyViolations
import org.signal.core.util.logging.Log
import org.signal.core.util.logging.logW
import org.signal.core.util.money.FiatMoney
import org.signal.core.util.requireBoolean
import org.signal.core.util.requireIntOrNull
import org.signal.core.util.requireNonNullString
import org.signal.core.util.requireString
import org.signal.core.util.stream.NonClosingOutputStream
import org.signal.core.util.urlEncode
import org.signal.core.util.withinTransaction
@@ -57,7 +59,6 @@ import org.thoughtcrime.securesms.attachments.Cdn
import org.thoughtcrime.securesms.attachments.DatabaseAttachment
import org.thoughtcrime.securesms.backup.ArchiveUploadProgress
import org.thoughtcrime.securesms.backup.DeletionState
import org.thoughtcrime.securesms.backup.RestoreState
import org.thoughtcrime.securesms.backup.v2.BackupRepository.copyAttachmentToArchive
import org.thoughtcrime.securesms.backup.v2.BackupRepository.exportForDebugging
import org.thoughtcrime.securesms.backup.v2.importer.ChatItemArchiveImporter
@@ -91,29 +92,31 @@ import org.thoughtcrime.securesms.database.OneTimePreKeyTable
import org.thoughtcrime.securesms.database.SearchTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.SignedPreKeyTable
import org.thoughtcrime.securesms.database.StickerTable
import org.thoughtcrime.securesms.database.ThreadTable
import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.jobmanager.Job
import org.thoughtcrime.securesms.jobmanager.impl.DataRestoreConstraint
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint
import org.thoughtcrime.securesms.jobmanager.impl.WifiConstraint
import org.thoughtcrime.securesms.jobs.ArchiveAttachmentBackfillJob
import org.thoughtcrime.securesms.jobs.AvatarGroupsV2DownloadJob
import org.thoughtcrime.securesms.jobs.BackupDeleteJob
import org.thoughtcrime.securesms.jobs.BackupMessagesJob
import org.thoughtcrime.securesms.jobs.BackupRestoreMediaJob
import org.thoughtcrime.securesms.jobs.CheckRestoreMediaLeftJob
import org.thoughtcrime.securesms.jobs.CancelRestoreMediaJob
import org.thoughtcrime.securesms.jobs.CreateReleaseChannelJob
import org.thoughtcrime.securesms.jobs.LocalBackupJob
import org.thoughtcrime.securesms.jobs.RequestGroupV2InfoJob
import org.thoughtcrime.securesms.jobs.RestoreAttachmentJob
import org.thoughtcrime.securesms.jobs.RestoreOptimizedMediaJob
import org.thoughtcrime.securesms.jobs.RetrieveProfileJob
import org.thoughtcrime.securesms.jobs.StickerPackDownloadJob
import org.thoughtcrime.securesms.jobs.UploadAttachmentToArchiveJob
import org.thoughtcrime.securesms.keyvalue.BackupValues.ArchiveServiceCredentials
import org.thoughtcrime.securesms.keyvalue.KeyValueStore
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.keyvalue.isDecisionPending
import org.thoughtcrime.securesms.keyvalue.protos.ArchiveUploadProgressState
import org.thoughtcrime.securesms.logsubmit.SubmitDebugLogRepository
import org.thoughtcrime.securesms.net.SignalNetwork
import org.thoughtcrime.securesms.notifications.NotificationChannels
@@ -121,6 +124,7 @@ 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
import org.thoughtcrime.securesms.service.BackupProgressService
import org.thoughtcrime.securesms.storage.StorageSyncHelper
import org.thoughtcrime.securesms.util.RemoteConfig
@@ -172,7 +176,7 @@ import kotlin.time.Duration.Companion.seconds
object BackupRepository {
private val TAG = Log.tag(BackupRepository::class.java)
private const val VERSION = 1L
const val VERSION = 1L
private const val REMOTE_MAIN_DB_SNAPSHOT_NAME = "remote-signal-snapshot"
private const val REMOTE_KEYVALUE_DB_SNAPSHOT_NAME = "remote-signal-key-value-snapshot"
private const val LOCAL_MAIN_DB_SNAPSHOT_NAME = "local-signal-snapshot"
@@ -357,11 +361,7 @@ object BackupRepository {
*/
@JvmStatic
fun skipMediaRestore() {
SignalStore.backup.userManuallySkippedMediaRestore = true
RestoreAttachmentJob.Queues.ALL.forEach { AppDependencies.jobManager.cancelAllInQueue(it) }
RestoreAttachmentJob.Queues.ALL.forEach { AppDependencies.jobManager.add(CheckRestoreMediaLeftJob(it)) }
CancelRestoreMediaJob.enqueue()
}
fun markBackupFailure() {
@@ -374,29 +374,6 @@ object BackupRepository {
}
}
fun displayManualBackupNotCreatedInThresholdNotification() {
if (SignalStore.backup.lastBackupTime <= 0) {
return
}
val daysSinceLastBackup = (System.currentTimeMillis().milliseconds - SignalStore.backup.lastBackupTime.milliseconds).inWholeDays.toInt()
val context = AppDependencies.application
val pendingIntent = PendingIntent.getActivity(context, 0, AppSettingsActivity.remoteBackups(context), cancelCurrent())
val notification = NotificationCompat.Builder(context, NotificationChannels.getInstance().APP_ALERTS)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(context.resources.getQuantityString(R.plurals.Notification_no_backup_for_d_days, daysSinceLastBackup, daysSinceLastBackup))
.setContentText(context.resources.getQuantityString(R.plurals.Notification_you_have_not_completed_a_backup, daysSinceLastBackup, daysSinceLastBackup))
.setContentIntent(pendingIntent)
.setAutoCancel(true)
.build()
ServiceUtil.getNotificationManager(context).notify(NotificationIds.MANUAL_BACKUP_NOT_CREATED, notification)
}
fun cancelManualBackupNotCreatedInThresholdNotification() {
ServiceUtil.getNotificationManager(AppDependencies.application).cancel(NotificationIds.MANUAL_BACKUP_NOT_CREATED)
}
@Discouraged("This is only public to allow internal settings to call it directly.")
fun displayInitialBackupFailureNotification() {
val context = AppDependencies.application
@@ -520,45 +497,6 @@ object BackupRepository {
SignalStore.backup.hasBackupAlreadyRedeemedError = false
}
/**
* Whether or not the "No backup" for manual backups should be displayed.
* This should only be displayed after a set threshold has passed and the user
* has set the MANUAL backups frequency.
*/
fun shouldDisplayNoManualBackupForTimeoutSheet(): Boolean {
if (shouldNotDisplayBackupFailedMessaging()) {
return false
}
if (SignalStore.backup.backupFrequency != BackupFrequency.MANUAL) {
return false
}
if (SignalStore.backup.lastBackupTime <= 0) {
return false
}
val isNetworkConstraintMet = if (SignalStore.backup.backupWithCellular) {
NetworkConstraint.isMet(AppDependencies.application)
} else {
WifiConstraint.isMet(AppDependencies.application)
}
if (!isNetworkConstraintMet) {
return false
}
val durationSinceLastBackup = System.currentTimeMillis().milliseconds - SignalStore.backup.lastBackupTime.milliseconds
if (durationSinceLastBackup < MANUAL_BACKUP_NOTIFICATION_THRESHOLD) {
return false
}
val display = !SignalStore.backup.isNoBackupForManualUploadNotified
SignalStore.backup.isNoBackupForManualUploadNotified = false
return display
}
/**
* Updates the watermark for the indicator display.
*/
@@ -610,6 +548,29 @@ object BackupRepository {
SignalStore.backup.snoozeDownloadNotifier()
}
@JvmStatic
fun maybeFixAnyDanglingUploadProgress() {
if (SignalStore.backup.archiveUploadState?.backupPhase == ArchiveUploadProgressState.BackupPhase.Message && AppDependencies.jobManager.find { it.factoryKey == BackupMessagesJob.KEY }.isEmpty()) {
SignalStore.backup.archiveUploadState = null
BackupMessagesJob.enqueue()
return
}
if (!SignalStore.backup.backsUpMedia || !AppDependencies.jobManager.areQueuesEmpty(UploadAttachmentToArchiveJob.QUEUES)) {
return
}
val pendingBytes = SignalDatabase.attachments.getPendingArchiveUploadBytes()
if (pendingBytes == 0L) {
return
}
Log.w(TAG, "There are ${pendingBytes.bytes.toUnitString(maxPlaces = 2)} of attachments that need to be uploaded to the archive, but no jobs for them! Attempting to fix.")
val resetCount = SignalDatabase.attachments.clearArchiveTransferStateForInProgressItems()
Log.w(TAG, "Cleared the archive transfer state of $resetCount attachments.")
AppDependencies.jobManager.add(ArchiveAttachmentBackfillJob())
}
/**
* Whether or not the "Your media will be deleted today" sheet should be displayed.
*/
@@ -1373,6 +1334,17 @@ object BackupRepository {
AppDependencies.recipientCache.warmUp()
SignalDatabase.threads.clearCache()
val stickerJobs = SignalDatabase.stickers.getAllStickerPacks().use { cursor ->
val reader = StickerTable.StickerPackRecordReader(cursor)
reader
.filter { it.isInstalled }
.map {
StickerPackDownloadJob.forInstall(it.packId, it.packKey, false)
}
}
AppDependencies.jobManager.addAll(stickerJobs)
stopwatch.split("sticker-jobs")
val recipientIds = SignalDatabase.threads.getRecentConversationList(
limit = RECENT_RECIPIENTS_MAX,
includeInactiveGroups = false,
@@ -1390,6 +1362,7 @@ object BackupRepository {
}
RetrieveProfileJob.enqueue(recipientIds, skipDebounce = false)
stopwatch.split("profile-jobs")
AppDependencies.jobManager.add(CreateReleaseChannelJob.create())
@@ -1466,6 +1439,7 @@ object BackupRepository {
fun enablePaidBackupTier() {
Log.i(TAG, "Setting backup tier to PAID", true)
resetInitializedStateAndAuthCredentials()
SignalStore.backup.backupTier = MessageBackupTier.PAID
SignalStore.backup.lastCheckInMillis = System.currentTimeMillis()
SignalStore.backup.lastCheckInSnoozeMillis = 0
@@ -2018,7 +1992,7 @@ object BackupRepository {
suspend fun restoreRemoteBackup(): RemoteRestoreResult {
val context = AppDependencies.application
SignalStore.backup.restoreState = RestoreState.PENDING
ArchiveRestoreProgress.onRestorePending()
try {
DataRestoreConstraint.isRestoringData = true
@@ -2033,7 +2007,7 @@ object BackupRepository {
}
private fun restoreRemoteBackup(controller: BackupProgressService.Controller, cancellationSignal: () -> Boolean): RemoteRestoreResult {
SignalStore.backup.restoreState = RestoreState.RESTORING_DB
ArchiveRestoreProgress.onRestoringDb()
val progressListener = object : ProgressListener {
override fun onAttachmentProgress(progress: AttachmentTransferProgress) {
@@ -2125,8 +2099,7 @@ object BackupRepository {
return RemoteRestoreResult.Failure
}
SignalStore.backup.restoreState = RestoreState.RESTORING_MEDIA
BackupMediaRestoreService.resetTimeout()
AppDependencies.jobManager.add(BackupRestoreMediaJob())
Log.i(TAG, "[remoteRestore] Restore successful")
@@ -2135,7 +2108,7 @@ object BackupRepository {
suspend fun restoreLinkAndSyncBackup(response: TransferArchiveResponse, ephemeralBackupKey: MessageBackupKey) {
val context = AppDependencies.application
SignalStore.backup.restoreState = RestoreState.PENDING
ArchiveRestoreProgress.onRestorePending()
try {
DataRestoreConstraint.isRestoringData = true
@@ -2150,7 +2123,7 @@ object BackupRepository {
}
private fun restoreLinkAndSyncBackup(response: TransferArchiveResponse, ephemeralBackupKey: MessageBackupKey, controller: BackupProgressService.Controller, cancellationSignal: () -> Boolean): RemoteRestoreResult {
SignalStore.backup.restoreState = RestoreState.RESTORING_DB
ArchiveRestoreProgress.onRestoringDb()
val progressListener = object : ProgressListener {
override fun onAttachmentProgress(progress: AttachmentTransferProgress) {
@@ -2201,8 +2174,7 @@ object BackupRepository {
return RemoteRestoreResult.Failure
}
SignalStore.backup.restoreState = RestoreState.RESTORING_MEDIA
BackupMediaRestoreService.resetTimeout()
AppDependencies.jobManager.add(BackupRestoreMediaJob())
Log.i(TAG, "[restoreLinkAndSyncBackup] Restore successful")
@@ -2357,11 +2329,22 @@ class ArchiveMediaItemIterator(private val cursor: Cursor) : Iterator<ArchiveMed
val plaintextHash = cursor.requireNonNullString(AttachmentTable.DATA_HASH_END).decodeBase64OrThrow()
val remoteKey = cursor.requireNonNullString(AttachmentTable.REMOTE_KEY).decodeBase64OrThrow()
val cdn = cursor.requireIntOrNull(AttachmentTable.ARCHIVE_CDN)
val quote = cursor.requireBoolean(AttachmentTable.QUOTE)
val contentType = cursor.requireString(AttachmentTable.CONTENT_TYPE)
val mediaId = MediaName.fromPlaintextHashAndRemoteKey(plaintextHash, remoteKey).toMediaId(SignalStore.backup.mediaRootBackupKey).encode()
val thumbnailMediaId = MediaName.fromPlaintextHashAndRemoteKeyForThumbnail(plaintextHash, remoteKey).toMediaId(SignalStore.backup.mediaRootBackupKey).encode()
cursor.moveToNext()
return ArchiveMediaItem(mediaId, thumbnailMediaId, cdn, plaintextHash, remoteKey)
return ArchiveMediaItem(
mediaId = mediaId,
thumbnailMediaId = thumbnailMediaId,
cdn = cdn,
plaintextHash = plaintextHash,
remoteKey = remoteKey,
quote = quote,
contentType = contentType
)
}
}

View File

@@ -113,6 +113,7 @@ import org.thoughtcrime.securesms.backup.v2.proto.GiftBadge as BackupGiftBadge
private val TAG = Log.tag(ChatItemArchiveExporter::class.java)
private val MAX_INLINED_BODY_SIZE = 128.kibiBytes.bytes.toInt()
private val MAX_INLINED_BODY_SIZE_WITH_LONG_ATTACHMENT_POINTER = 2.kibiBytes.bytes.toInt()
private val MAX_INLINED_QUOTE_BODY_SIZE = 2.kibiBytes.bytes.toInt()
/**
* An iterator for chat items with a clever performance twist: rather than do the extra queries one at a time (for reactions,
@@ -460,7 +461,7 @@ class ChatItemArchiveExporter(
val attachmentsFuture = executor.submitTyped {
extraDataTimer.timeEvent("attachments") {
db.attachmentTable.getAttachmentsForMessages(messageIds)
db.attachmentTable.getAttachmentsForMessages(messageIds, excludeTranscodingQuotes = true)
}
}
@@ -1071,7 +1072,7 @@ private fun BackupMessageRecord.toRemoteQuote(exportState: ExportState, attachme
val localType = QuoteModel.Type.fromCode(this.quoteType)
val remoteType = when (localType) {
QuoteModel.Type.NORMAL -> {
if (attachments?.any { it.contentType == MediaUtil.VIEW_ONCE } == true) {
if (attachments?.any { it.quoteTargetContentType == MediaUtil.VIEW_ONCE } == true) {
Quote.Type.VIEW_ONCE
} else {
Quote.Type.NORMAL
@@ -1081,7 +1082,8 @@ private fun BackupMessageRecord.toRemoteQuote(exportState: ExportState, attachme
}
val bodyRanges = this.quoteBodyRanges?.toRemoteBodyRanges(dateSent) ?: emptyList()
val body = this.quoteBody?.takeUnless { it.isBlank() }?.let { body ->
val trimmedQuoteBody = StringUtil.trimToFit(this.quoteBody, MAX_INLINED_QUOTE_BODY_SIZE)
val body = trimmedQuoteBody.takeUnless { it.isBlank() }?.let { body ->
Text(
body = body,
bodyRanges = bodyRanges
@@ -1158,11 +1160,11 @@ private fun DatabaseAttachment.toRemoteStickerMessage(sentTimestamp: Long, react
private fun List<DatabaseAttachment>.toRemoteQuoteAttachments(): List<Quote.QuotedAttachment> {
return this.map { attachment ->
Quote.QuotedAttachment(
contentType = attachment.contentType,
contentType = attachment.quoteTargetContentType,
fileName = attachment.fileName,
thumbnail = attachment.toRemoteMessageAttachment(
flagOverride = MessageAttachment.Flag.NONE,
contentTypeOverride = "image/jpeg"
contentTypeOverride = attachment.contentType
)
)
}

View File

@@ -76,7 +76,6 @@ import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.stickers.StickerLocator
import org.thoughtcrime.securesms.util.JsonUtils
import org.thoughtcrime.securesms.util.MediaUtil
import org.thoughtcrime.securesms.util.MessageUtil
import org.whispersystems.signalservice.api.payments.Money
import org.whispersystems.signalservice.api.push.ServiceId
@@ -1059,8 +1058,8 @@ class ChatItemArchiveImporter(
else -> null
}
},
start = bodyRange.start ?: 0,
length = bodyRange.length ?: 0
start = bodyRange.start,
length = bodyRange.length
)
}
)
@@ -1090,11 +1089,11 @@ class ChatItemArchiveImporter(
private fun Quote.toLocalAttachments(): List<Attachment> {
if (this.type == Quote.Type.VIEW_ONCE) {
return listOf(TombstoneAttachment(contentType = MediaUtil.VIEW_ONCE, quote = true))
return listOf(TombstoneAttachment.forQuote())
}
return attachments.mapNotNull { attachment ->
val thumbnail = attachment.thumbnail?.toLocalAttachment(quote = true)
return this.attachments.mapNotNull { attachment ->
val thumbnail = attachment.thumbnail?.toLocalAttachment(quote = true, quoteTargetContentType = attachment.contentType)
if (thumbnail != null) {
return@mapNotNull thumbnail
@@ -1141,7 +1140,7 @@ class ChatItemArchiveImporter(
)
}
private fun MessageAttachment.toLocalAttachment(quote: Boolean = false, contentType: String? = pointer?.contentType): Attachment? {
private fun MessageAttachment.toLocalAttachment(quote: Boolean = false, quoteTargetContentType: String? = null, contentType: String? = pointer?.contentType): Attachment? {
return pointer?.toLocalAttachment(
voiceNote = flag == MessageAttachment.Flag.VOICE_MESSAGE,
borderless = flag == MessageAttachment.Flag.BORDERLESS,
@@ -1150,7 +1149,8 @@ class ChatItemArchiveImporter(
contentType = contentType,
fileName = pointer.fileName,
uuid = clientUuid,
quote = quote
quote = quote,
quoteTargetContentType = quoteTargetContentType
)
}

View File

@@ -18,8 +18,6 @@ import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.StickerTable
import org.thoughtcrime.securesms.database.StickerTable.StickerPackRecordReader
import org.thoughtcrime.securesms.database.model.StickerPackRecord
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobs.StickerPackDownloadJob
import java.io.IOException
private val TAG = Log.tag(StickerArchiveProcessor::class)
@@ -55,14 +53,6 @@ object StickerArchiveProcessor {
StickerTable.FILE_PATH to ""
)
.run(SQLiteDatabase.CONFLICT_IGNORE)
AppDependencies.jobManager.add(
StickerPackDownloadJob.forInstall(
Hex.toStringCondensed(stickerPack.packId.toByteArray()),
Hex.toStringCondensed(stickerPack.packKey.toByteArray()),
false
)
)
}
}

View File

@@ -38,10 +38,6 @@ object BackupAlertDelegate {
} else if (BackupRepository.shouldDisplayBackupExpiredAndDowngradedSheet()) {
Log.d(TAG, "Displaying ExpiredAndDowngraded sheet.")
BackupAlertBottomSheet.create(BackupAlert.ExpiredAndDowngraded).show(fragmentManager, FRAGMENT_TAG)
} else if (BackupRepository.shouldDisplayNoManualBackupForTimeoutSheet()) {
Log.d(TAG, "Displaying NoManualBackupBottomSheet.")
NoManualBackupBottomSheet().show(fragmentManager, FRAGMENT_TAG)
BackupRepository.displayManualBackupNotCreatedInThresholdNotification()
} else if (BackupRepository.shouldDisplayOutOfRemoteStorageSpaceSheet()) {
Log.d(TAG, "Displaying NoRemoteStorageSpaceAvailableBottomSheet.")
NoRemoteStorageSpaceAvailableBottomSheet().show(fragmentManager, FRAGMENT_TAG)

View File

@@ -0,0 +1,129 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2.ui.status
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.InlineTextContent
import androidx.compose.foundation.text.appendInlineContent
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.Placeholder
import androidx.compose.ui.text.PlaceholderVerticalAlign
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextLinkStyles
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.withLink
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.SignalPreview
import org.thoughtcrime.securesms.R
import org.signal.core.ui.R as CoreUiR
private val YELLOW_DOT = Color(0xFFFFCC00)
/**
* Show backup creation failures as a settings row.
*/
@Composable
fun BackupCreateErrorRow(
showCouldNotComplete: Boolean,
showBackupFailed: Boolean,
onLearnMoreClick: () -> Unit = {}
) {
if (showBackupFailed) {
val inlineContentMap = mapOf(
"yellow_bullet" to InlineTextContent(
Placeholder(20.sp, 12.sp, PlaceholderVerticalAlign.TextCenter)
) {
Box(
modifier = Modifier
.size(12.dp)
.background(color = YELLOW_DOT, shape = CircleShape)
)
}
)
BackupAlertText(
text = buildAnnotatedString {
appendInlineContent("yellow_bullet")
append(" ")
append(stringResource(R.string.BackupStatusRow__your_last_backup_latest_version))
append(" ")
withLink(
LinkAnnotation.Clickable(
stringResource(R.string.BackupStatusRow__learn_more),
styles = TextLinkStyles(style = SpanStyle(color = MaterialTheme.colorScheme.primary))
) {
onLearnMoreClick()
}
) {
append(stringResource(R.string.BackupStatusRow__learn_more))
}
},
inlineContent = inlineContentMap
)
} else if (showCouldNotComplete) {
val inlineContentMap = mapOf(
"yellow_bullet" to InlineTextContent(
Placeholder(20.sp, 12.sp, PlaceholderVerticalAlign.TextCenter)
) {
Box(
modifier = Modifier
.size(12.dp)
.background(color = YELLOW_DOT, shape = CircleShape)
)
}
)
BackupAlertText(
text = buildAnnotatedString {
appendInlineContent("yellow_bullet")
append(" ")
append(stringResource(R.string.BackupStatusRow__your_last_backup))
},
inlineContent = inlineContentMap
)
}
}
@Composable
private fun BackupAlertText(text: AnnotatedString, inlineContent: Map<String, InlineTextContent>) {
Text(
text = text,
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(horizontal = dimensionResource(CoreUiR.dimen.gutter)),
inlineContent = inlineContent
)
}
@SignalPreview
@Composable
fun BackupStatusRowCouldNotCompleteBackupPreview() {
Previews.Preview {
BackupCreateErrorRow(showCouldNotComplete = true, showBackupFailed = false)
}
}
@SignalPreview
@Composable
fun BackupStatusRowBackupFailedPreview() {
Previews.Preview {
BackupCreateErrorRow(showCouldNotComplete = false, showBackupFailed = true)
}
}

View File

@@ -5,8 +5,6 @@
package org.thoughtcrime.securesms.backup.v2.ui.status
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
@@ -31,6 +29,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
@@ -38,14 +37,12 @@ import androidx.compose.ui.unit.dp
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.SignalPreview
import org.signal.core.util.ByteSize
import org.signal.core.util.bytes
import org.signal.core.util.kibiBytes
import org.signal.core.util.mebiBytes
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.backup.RestoreState
import org.thoughtcrime.securesms.backup.v2.ArchiveRestoreProgressState
import org.thoughtcrime.securesms.backup.v2.ArchiveRestoreProgressState.RestoreStatus
import org.thoughtcrime.securesms.backup.v2.ui.BackupsIconColors
import kotlin.math.max
import kotlin.math.min
private const val NONE = -1
@@ -56,12 +53,16 @@ private const val NONE = -1
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun BackupStatusBanner(
data: BackupStatusData,
data: ArchiveRestoreProgressState,
onBannerClick: () -> Unit = {},
onActionClick: (BackupStatusData) -> Unit = {},
onActionClick: (ArchiveRestoreProgressState) -> Unit = {},
onDismissClick: () -> Unit = {},
contentPadding: PaddingValues = PaddingValues(horizontal = 12.dp, vertical = 8.dp)
) {
if (!data.restoreState.isMediaRestoreOperation && data.restoreStatus != RestoreStatus.FINISHED) {
return
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
@@ -73,9 +74,9 @@ fun BackupStatusBanner(
.padding(12.dp)
) {
Icon(
painter = painterResource(id = data.iconRes),
painter = painterResource(id = data.iconResource()),
contentDescription = null,
tint = data.iconColors.foreground,
tint = data.iconColor(),
modifier = Modifier
.padding(start = 4.dp)
.size(24.dp)
@@ -89,7 +90,7 @@ fun BackupStatusBanner(
.weight(1f)
) {
Text(
text = data.title,
text = data.title(),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier
@@ -97,7 +98,7 @@ fun BackupStatusBanner(
.align(Alignment.CenterVertically)
)
data.status?.let { status ->
data.status()?.let { status ->
Text(
text = status,
style = MaterialTheme.typography.bodySmall,
@@ -109,9 +110,12 @@ fun BackupStatusBanner(
}
}
if (data.progress >= 0f) {
if (data.restoreState == RestoreState.CALCULATING_MEDIA ||
data.restoreState == RestoreState.CANCELING_MEDIA ||
(data.restoreState == RestoreState.RESTORING_MEDIA && data.restoreStatus == RestoreStatus.RESTORING)
) {
CircularProgressIndicator(
progress = { data.progress },
progress = { data.progress!! },
strokeWidth = 3.dp,
strokeCap = StrokeCap.Round,
modifier = Modifier
@@ -119,16 +123,16 @@ fun BackupStatusBanner(
)
}
if (data.actionRes != NONE) {
if (data.actionResource() != NONE) {
Buttons.Small(
onClick = { onActionClick(data) },
modifier = Modifier.padding(start = 8.dp)
) {
Text(text = stringResource(id = data.actionRes))
Text(text = stringResource(id = data.actionResource()))
}
}
if (data.showDismissAction) {
if (data.restoreStatus == RestoreStatus.FINISHED) {
val interactionSource = remember { MutableInteractionSource() }
Icon(
@@ -147,195 +151,208 @@ fun BackupStatusBanner(
}
}
private fun ArchiveRestoreProgressState.iconResource(): Int {
return when (this.restoreState) {
RestoreState.CALCULATING_MEDIA,
RestoreState.CANCELING_MEDIA -> R.drawable.symbol_backup_light
RestoreState.RESTORING_MEDIA -> {
when (this.restoreStatus) {
RestoreStatus.RESTORING,
RestoreStatus.WAITING_FOR_INTERNET,
RestoreStatus.WAITING_FOR_WIFI,
RestoreStatus.LOW_BATTERY -> R.drawable.symbol_backup_light
RestoreStatus.NOT_ENOUGH_DISK_SPACE -> R.drawable.symbol_backup_error_24
RestoreStatus.FINISHED -> R.drawable.symbol_check_circle_24
RestoreStatus.NONE -> throw IllegalStateException()
}
}
RestoreState.NONE -> {
if (this.restoreStatus == RestoreStatus.FINISHED) {
R.drawable.symbol_check_circle_24
} else {
throw IllegalStateException()
}
}
RestoreState.PENDING,
RestoreState.RESTORING_DB -> throw IllegalStateException()
}
}
@Composable
private fun ArchiveRestoreProgressState.iconColor(): Color {
return when (this.restoreState) {
RestoreState.CALCULATING_MEDIA,
RestoreState.CANCELING_MEDIA -> BackupsIconColors.Normal.foreground
RestoreState.RESTORING_MEDIA -> {
when (this.restoreStatus) {
RestoreStatus.RESTORING -> BackupsIconColors.Normal.foreground
RestoreStatus.WAITING_FOR_INTERNET,
RestoreStatus.WAITING_FOR_WIFI,
RestoreStatus.LOW_BATTERY,
RestoreStatus.NOT_ENOUGH_DISK_SPACE -> BackupsIconColors.Warning.foreground
RestoreStatus.FINISHED -> BackupsIconColors.Success.foreground
RestoreStatus.NONE -> throw IllegalStateException()
}
}
RestoreState.NONE -> {
if (this.restoreStatus == RestoreStatus.FINISHED) {
BackupsIconColors.Success.foreground
} else {
throw IllegalStateException()
}
}
RestoreState.PENDING,
RestoreState.RESTORING_DB -> throw IllegalStateException()
}
}
@Composable
private fun ArchiveRestoreProgressState.title(): String {
return when (this.restoreState) {
RestoreState.CALCULATING_MEDIA -> stringResource(R.string.BackupStatus__restoring_media)
RestoreState.CANCELING_MEDIA -> stringResource(R.string.BackupStatus__cancel_restore_media)
RestoreState.RESTORING_MEDIA -> {
when (this.restoreStatus) {
RestoreStatus.RESTORING -> stringResource(R.string.BackupStatus__restoring_media)
RestoreStatus.WAITING_FOR_INTERNET,
RestoreStatus.WAITING_FOR_WIFI,
RestoreStatus.LOW_BATTERY -> stringResource(R.string.BackupStatus__restore_paused)
RestoreStatus.NOT_ENOUGH_DISK_SPACE -> {
stringResource(R.string.BackupStatus__free_up_s_of_space_to_download_your_media, this.remainingRestoreSize.toUnitString())
}
RestoreStatus.FINISHED -> stringResource(R.string.BackupStatus__restore_complete)
RestoreStatus.NONE -> throw IllegalStateException()
}
}
RestoreState.NONE -> {
if (this.restoreStatus == RestoreStatus.FINISHED) {
stringResource(R.string.BackupStatus__restore_complete)
} else {
throw IllegalStateException()
}
}
RestoreState.PENDING,
RestoreState.RESTORING_DB -> throw IllegalStateException()
}
}
@Composable
private fun ArchiveRestoreProgressState.status(): String? {
return when (this.restoreState) {
RestoreState.CALCULATING_MEDIA -> {
stringResource(
R.string.BackupStatus__status_size_of_size,
this.completedRestoredSize.toUnitString(),
this.totalRestoreSize.toUnitString()
)
}
RestoreState.CANCELING_MEDIA -> null
RestoreState.RESTORING_MEDIA -> {
when (this.restoreStatus) {
RestoreStatus.RESTORING -> {
stringResource(
R.string.BackupStatus__status_size_of_size,
this.completedRestoredSize.toUnitString(),
this.totalRestoreSize.toUnitString()
)
}
RestoreStatus.WAITING_FOR_INTERNET -> stringResource(R.string.BackupStatus__status_no_internet)
RestoreStatus.WAITING_FOR_WIFI -> stringResource(R.string.BackupStatus__status_waiting_for_wifi)
RestoreStatus.LOW_BATTERY -> stringResource(R.string.BackupStatus__status_device_has_low_battery)
RestoreStatus.NOT_ENOUGH_DISK_SPACE -> null
RestoreStatus.FINISHED -> this.totalToRestoreThisRun.toUnitString()
RestoreStatus.NONE -> throw IllegalStateException()
}
}
RestoreState.NONE -> {
if (this.restoreStatus == RestoreStatus.FINISHED) {
this.totalToRestoreThisRun.toUnitString()
} else {
throw IllegalStateException()
}
}
RestoreState.PENDING,
RestoreState.RESTORING_DB -> throw IllegalStateException()
}
}
private fun ArchiveRestoreProgressState.actionResource(): Int {
return when (this.restoreState) {
RestoreState.CALCULATING_MEDIA,
RestoreState.CANCELING_MEDIA -> NONE
RestoreState.RESTORING_MEDIA -> {
when (this.restoreStatus) {
RestoreStatus.NOT_ENOUGH_DISK_SPACE -> R.string.BackupStatus__details
RestoreStatus.WAITING_FOR_WIFI -> R.string.BackupStatus__resume
else -> NONE
}
}
else -> NONE
}
}
@SignalPreview
@Composable
fun BackupStatusBannerPreview() {
Previews.Preview {
Column {
BackupStatusBanner(
data = BackupStatusData.RestoringMedia(5755000.bytes, 1253.mebiBytes)
data = ArchiveRestoreProgressState(restoreState = RestoreState.RESTORING_MEDIA, restoreStatus = RestoreStatus.RESTORING, remainingRestoreSize = 800.mebiBytes, totalRestoreSize = 1024.mebiBytes)
)
HorizontalDivider()
BackupStatusBanner(
data = BackupStatusData.RestoringMedia(
bytesDownloaded = 55000.bytes,
bytesTotal = 1253.mebiBytes,
restoreStatus = BackupStatusData.RestoreStatus.WAITING_FOR_WIFI
)
data = ArchiveRestoreProgressState(restoreState = RestoreState.CALCULATING_MEDIA, restoreStatus = RestoreStatus.RESTORING, remainingRestoreSize = 1024.mebiBytes, totalRestoreSize = 1024.mebiBytes)
)
HorizontalDivider()
BackupStatusBanner(
data = BackupStatusData.RestoringMedia(
bytesDownloaded = 55000.bytes,
bytesTotal = 1253.mebiBytes,
restoreStatus = BackupStatusData.RestoreStatus.WAITING_FOR_INTERNET
)
data = ArchiveRestoreProgressState(restoreState = RestoreState.CANCELING_MEDIA, restoreStatus = RestoreStatus.RESTORING, remainingRestoreSize = 200.mebiBytes, totalRestoreSize = 1024.mebiBytes)
)
HorizontalDivider()
BackupStatusBanner(
data = BackupStatusData.RestoringMedia(
bytesDownloaded = 55000.bytes,
bytesTotal = 1253.mebiBytes,
restoreStatus = BackupStatusData.RestoreStatus.FINISHED
)
data = ArchiveRestoreProgressState(restoreState = RestoreState.RESTORING_MEDIA, restoreStatus = RestoreStatus.WAITING_FOR_WIFI, remainingRestoreSize = 800.mebiBytes, totalRestoreSize = 1024.mebiBytes)
)
HorizontalDivider()
BackupStatusBanner(
data = BackupStatusData.NotEnoughFreeSpace(40900.kibiBytes)
data = ArchiveRestoreProgressState(restoreState = RestoreState.RESTORING_MEDIA, restoreStatus = RestoreStatus.WAITING_FOR_INTERNET, remainingRestoreSize = 800.mebiBytes, totalRestoreSize = 1024.mebiBytes)
)
HorizontalDivider()
BackupStatusBanner(
data = BackupStatusData.CouldNotCompleteBackup
data = ArchiveRestoreProgressState(restoreState = RestoreState.NONE, restoreStatus = RestoreStatus.FINISHED, remainingRestoreSize = 0.mebiBytes, totalRestoreSize = 0.mebiBytes, totalToRestoreThisRun = 1024.mebiBytes)
)
HorizontalDivider()
BackupStatusBanner(
data = BackupStatusData.BackupFailed
data = ArchiveRestoreProgressState(restoreState = RestoreState.RESTORING_MEDIA, restoreStatus = RestoreStatus.NOT_ENOUGH_DISK_SPACE, remainingRestoreSize = 500.mebiBytes, totalRestoreSize = 1024.mebiBytes)
)
}
}
}
/**
* Sealed interface describing status data to display in BackupStatus widget.
*/
sealed interface BackupStatusData {
@get:DrawableRes
val iconRes: Int
@get:Composable
val title: String
val iconColors: BackupsIconColors
@get:StringRes
val actionRes: Int get() = NONE
@get:Composable
val status: String? get() = null
val progress: Float get() = NONE.toFloat()
val showDismissAction: Boolean get() = false
/**
* Generic failure
*/
data object CouldNotCompleteBackup : BackupStatusData {
override val iconRes: Int = R.drawable.symbol_backup_error_24
override val title: String
@Composable
get() = stringResource(androidx.biometric.R.string.default_error_msg)
override val iconColors: BackupsIconColors = BackupsIconColors.Warning
}
/**
* Initial backup creation failure
*/
data object BackupFailed : BackupStatusData {
override val iconRes: Int = R.drawable.symbol_backup_error_24
override val title: String
@Composable
get() = stringResource(androidx.biometric.R.string.default_error_msg)
override val iconColors: BackupsIconColors = BackupsIconColors.Warning
}
/**
* User does not have enough space on their device to complete backup restoration
*/
class NotEnoughFreeSpace(
requiredSpace: ByteSize
) : BackupStatusData {
val requiredSpace = requiredSpace.toUnitString()
override val iconRes: Int = R.drawable.symbol_backup_error_24
override val title: String
@Composable
get() = stringResource(R.string.BackupStatus__free_up_s_of_space_to_download_your_media, requiredSpace)
override val iconColors: BackupsIconColors = BackupsIconColors.Warning
override val actionRes: Int = R.string.BackupStatus__details
}
/**
* Restoring media, finished, and paused states.
*/
data class RestoringMedia(
val bytesDownloaded: ByteSize = 0.bytes,
val bytesTotal: ByteSize = 0.bytes,
val restoreStatus: RestoreStatus = RestoreStatus.NORMAL
) : BackupStatusData {
override val iconRes: Int = if (restoreStatus == RestoreStatus.FINISHED) R.drawable.symbol_check_circle_24 else R.drawable.symbol_backup_light
override val iconColors: BackupsIconColors = when (restoreStatus) {
RestoreStatus.FINISHED -> BackupsIconColors.Success
RestoreStatus.NORMAL -> BackupsIconColors.Normal
RestoreStatus.LOW_BATTERY,
RestoreStatus.WAITING_FOR_INTERNET,
RestoreStatus.WAITING_FOR_WIFI -> BackupsIconColors.Warning
}
override val showDismissAction: Boolean = restoreStatus == RestoreStatus.FINISHED
override val actionRes: Int = when (restoreStatus) {
RestoreStatus.WAITING_FOR_WIFI -> R.string.BackupStatus__resume
else -> NONE
}
override val title: String
@Composable get() = stringResource(
when (restoreStatus) {
RestoreStatus.NORMAL -> R.string.BackupStatus__restoring_media
RestoreStatus.LOW_BATTERY -> R.string.BackupStatus__restore_paused
RestoreStatus.WAITING_FOR_INTERNET -> R.string.BackupStatus__restore_paused
RestoreStatus.WAITING_FOR_WIFI -> R.string.BackupStatus__restore_paused
RestoreStatus.FINISHED -> R.string.BackupStatus__restore_complete
}
)
override val status: String
@Composable get() = when (restoreStatus) {
RestoreStatus.NORMAL -> stringResource(
R.string.BackupStatus__status_size_of_size,
bytesDownloaded.toUnitString(),
bytesTotal.toUnitString()
)
RestoreStatus.LOW_BATTERY -> stringResource(R.string.BackupStatus__status_device_has_low_battery)
RestoreStatus.WAITING_FOR_INTERNET -> stringResource(R.string.BackupStatus__status_no_internet)
RestoreStatus.WAITING_FOR_WIFI -> stringResource(R.string.BackupStatus__status_waiting_for_wifi)
RestoreStatus.FINISHED -> bytesTotal.toUnitString()
}
override val progress: Float = if (bytesTotal.bytes > 0 && restoreStatus == RestoreStatus.NORMAL) {
min(1f, max(0f, bytesDownloaded.bytes.toFloat() / bytesTotal.bytes.toFloat()))
} else {
NONE.toFloat()
}
}
/**
* Describes the status of an in-progress media download session.
*/
enum class RestoreStatus {
NORMAL,
LOW_BATTERY,
WAITING_FOR_INTERNET,
WAITING_FOR_WIFI,
FINISHED
}
}

View File

@@ -5,48 +5,34 @@
package org.thoughtcrime.securesms.backup.v2.ui.status
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.InlineTextContent
import androidx.compose.foundation.text.appendInlineContent
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.Placeholder
import androidx.compose.ui.text.PlaceholderVerticalAlign
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextLinkStyles
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.withLink
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.Rows
import org.signal.core.ui.compose.SignalPreview
import org.signal.core.util.ByteSize
import org.signal.core.util.mebiBytes
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.backup.RestoreState
import org.thoughtcrime.securesms.backup.v2.ArchiveRestoreProgressState
import org.thoughtcrime.securesms.backup.v2.ArchiveRestoreProgressState.RestoreStatus
import org.thoughtcrime.securesms.backup.v2.ui.BackupsIconColors
import kotlin.math.roundToInt
import org.signal.core.ui.R as CoreUiR
private val YELLOW_DOT = Color(0xFFFFCC00)
/**
* Specifies what kind of restore this is. Slightly different messaging
* is utilized for downloads.
@@ -69,11 +55,10 @@ enum class RestoreType {
*/
@Composable
fun BackupStatusRow(
backupStatusData: BackupStatusData,
backupStatusData: ArchiveRestoreProgressState,
restoreType: RestoreType = RestoreType.RESTORE,
onSkipClick: () -> Unit = {},
onCancelClick: (() -> Unit)? = null,
onLearnMoreClick: () -> Unit = {}
onCancelClick: (() -> Unit)? = null
) {
val endPad = if (onCancelClick == null) {
dimensionResource(CoreUiR.dimen.gutter)
@@ -84,183 +69,155 @@ fun BackupStatusRow(
Column(
modifier = Modifier.padding(top = 8.dp, bottom = 12.dp)
) {
if (backupStatusData !is BackupStatusData.CouldNotCompleteBackup &&
backupStatusData !is BackupStatusData.BackupFailed
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(
start = dimensionResource(CoreUiR.dimen.gutter),
end = endPad
)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(
start = dimensionResource(CoreUiR.dimen.gutter),
end = endPad
)
) {
LinearProgressIndicator(
color = progressColor(backupStatusData),
progress = { backupStatusData.progress },
modifier = Modifier.weight(1f).padding(vertical = 12.dp),
gapSize = 0.dp,
drawStopIndicator = {}
)
LinearProgressIndicator(
color = progressColor(backupStatusData),
progress = { backupStatusData.progress ?: 0f },
modifier = Modifier.weight(1f).padding(vertical = 12.dp),
gapSize = 0.dp,
drawStopIndicator = {}
)
val isFinished = backupStatusData is BackupStatusData.RestoringMedia && backupStatusData.restoreStatus == BackupStatusData.RestoreStatus.FINISHED
if (onCancelClick != null && !isFinished) {
IconButton(
onClick = onCancelClick
) {
Icon(
painter = painterResource(R.drawable.symbol_x_24),
contentDescription = stringResource(R.string.BackupStatusRow__cancel_download)
)
}
if (onCancelClick != null) {
IconButton(
onClick = onCancelClick
) {
Icon(
painter = painterResource(R.drawable.symbol_x_24),
contentDescription = stringResource(R.string.BackupStatusRow__cancel_download)
)
}
}
}
when (backupStatusData) {
is BackupStatusData.RestoringMedia -> {
val string = when (restoreType) {
RestoreType.RESTORE -> getRestoringMediaString(backupStatusData)
RestoreType.DOWNLOAD -> getDownloadingMediaString(backupStatusData)
}
if (backupStatusData.restoreStatus == RestoreStatus.NOT_ENOUGH_DISK_SPACE) {
BackupAlertText(
text = stringResource(R.string.BackupStatusRow__not_enough_space, backupStatusData.remainingRestoreSize)
)
BackupAlertText(text = string)
Rows.TextRow(
text = stringResource(R.string.BackupStatusRow__skip_download),
onClick = onSkipClick
)
} else {
val string = when (restoreType) {
RestoreType.RESTORE -> getRestoringMediaString(backupStatusData)
RestoreType.DOWNLOAD -> getDownloadingMediaString(backupStatusData)
}
is BackupStatusData.NotEnoughFreeSpace -> {
BackupAlertText(
text = stringResource(
R.string.BackupStatusRow__not_enough_space,
backupStatusData.requiredSpace,
"%d".format((backupStatusData.progress * 100).roundToInt())
)
)
Rows.TextRow(
text = stringResource(R.string.BackupStatusRow__skip_download),
onClick = onSkipClick
)
}
BackupStatusData.CouldNotCompleteBackup -> {
val inlineContentMap = mapOf(
"yellow_bullet" to InlineTextContent(
Placeholder(20.sp, 12.sp, PlaceholderVerticalAlign.TextCenter)
) {
Box(
modifier = Modifier
.size(12.dp)
.background(color = YELLOW_DOT, shape = CircleShape)
)
}
)
BackupAlertText(
text = buildAnnotatedString {
appendInlineContent("yellow_bullet")
append(" ")
append(stringResource(R.string.BackupStatusRow__your_last_backup))
},
inlineContent = inlineContentMap
)
}
BackupStatusData.BackupFailed -> {
val inlineContentMap = mapOf(
"yellow_bullet" to InlineTextContent(
Placeholder(20.sp, 12.sp, PlaceholderVerticalAlign.TextCenter)
) {
Box(
modifier = Modifier
.size(12.dp)
.background(color = YELLOW_DOT, shape = CircleShape)
)
}
)
BackupAlertText(
text = buildAnnotatedString {
appendInlineContent("yellow_bullet")
append(" ")
append(stringResource(R.string.BackupStatusRow__your_last_backup_latest_version))
append(" ")
withLink(
LinkAnnotation.Clickable(
stringResource(R.string.BackupStatusRow__learn_more),
styles = TextLinkStyles(style = SpanStyle(color = MaterialTheme.colorScheme.primary))
) {
onLearnMoreClick()
}
) {
append(stringResource(R.string.BackupStatusRow__learn_more))
}
},
inlineContent = inlineContentMap
)
}
BackupAlertText(text = string)
}
}
}
@Composable
private fun BackupAlertText(text: String) {
BackupAlertText(
text = remember(text) { AnnotatedString(text) },
inlineContent = emptyMap()
)
}
@Composable
private fun BackupAlertText(text: AnnotatedString, inlineContent: Map<String, InlineTextContent>) {
Text(
text = text,
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(horizontal = dimensionResource(CoreUiR.dimen.gutter)),
inlineContent = inlineContent
modifier = Modifier.padding(horizontal = dimensionResource(CoreUiR.dimen.gutter))
)
}
@Composable
private fun getRestoringMediaString(backupStatusData: BackupStatusData.RestoringMedia): String {
return when (backupStatusData.restoreStatus) {
BackupStatusData.RestoreStatus.NORMAL -> {
private fun getRestoringMediaString(backupStatusData: ArchiveRestoreProgressState): String {
return when (backupStatusData.restoreState) {
RestoreState.CALCULATING_MEDIA -> {
stringResource(
R.string.BackupStatusRow__restoring_s_of_s_s,
backupStatusData.bytesDownloaded.toUnitString(2),
backupStatusData.bytesTotal.toUnitString(2),
"%d".format((backupStatusData.progress * 100).roundToInt())
backupStatusData.completedRestoredSize.toUnitString(2),
backupStatusData.totalRestoreSize.toUnitString(2),
"%d".format(((backupStatusData.progress ?: 0f) * 100).roundToInt())
)
}
BackupStatusData.RestoreStatus.LOW_BATTERY -> stringResource(R.string.BackupStatusRow__restore_device_has_low_battery)
BackupStatusData.RestoreStatus.WAITING_FOR_INTERNET -> stringResource(R.string.BackupStatusRow__restore_no_internet)
BackupStatusData.RestoreStatus.WAITING_FOR_WIFI -> stringResource(R.string.BackupStatusRow__restore_waiting_for_wifi)
BackupStatusData.RestoreStatus.FINISHED -> stringResource(R.string.BackupStatus__restore_complete)
RestoreState.CANCELING_MEDIA -> stringResource(R.string.BackupStatus__cancel_restore_media)
RestoreState.RESTORING_MEDIA -> {
when (backupStatusData.restoreStatus) {
RestoreStatus.RESTORING -> {
stringResource(
R.string.BackupStatusRow__restoring_s_of_s_s,
backupStatusData.completedRestoredSize.toUnitString(2),
backupStatusData.totalRestoreSize.toUnitString(2),
"%d".format(((backupStatusData.progress ?: 0f) * 100).roundToInt())
)
}
RestoreStatus.WAITING_FOR_INTERNET -> stringResource(R.string.BackupStatusRow__restore_no_internet)
RestoreStatus.WAITING_FOR_WIFI -> stringResource(R.string.BackupStatusRow__restore_waiting_for_wifi)
RestoreStatus.LOW_BATTERY -> stringResource(R.string.BackupStatusRow__restore_device_has_low_battery)
RestoreStatus.FINISHED -> stringResource(R.string.BackupStatus__restore_complete)
else -> throw IllegalStateException()
}
}
RestoreState.NONE -> {
if (backupStatusData.restoreStatus == RestoreStatus.FINISHED) {
stringResource(R.string.BackupStatus__restore_complete)
} else {
throw IllegalStateException()
}
}
RestoreState.PENDING,
RestoreState.RESTORING_DB -> throw IllegalStateException()
}
}
@Composable
private fun getDownloadingMediaString(backupStatusData: BackupStatusData.RestoringMedia): String {
return when (backupStatusData.restoreStatus) {
BackupStatusData.RestoreStatus.NORMAL -> {
private fun getDownloadingMediaString(backupStatusData: ArchiveRestoreProgressState): String {
return when (backupStatusData.restoreState) {
RestoreState.CALCULATING_MEDIA -> {
stringResource(
R.string.BackupStatusRow__downloading_s_of_s_s,
backupStatusData.bytesDownloaded.toUnitString(2),
backupStatusData.bytesTotal.toUnitString(2),
"%d".format((backupStatusData.progress * 100).roundToInt())
backupStatusData.completedRestoredSize.toUnitString(2),
backupStatusData.totalRestoreSize.toUnitString(2),
"%d".format(((backupStatusData.progress ?: 0f) * 100).roundToInt())
)
}
BackupStatusData.RestoreStatus.LOW_BATTERY -> stringResource(R.string.BackupStatusRow__download_device_has_low_battery)
BackupStatusData.RestoreStatus.WAITING_FOR_INTERNET -> stringResource(R.string.BackupStatusRow__download_no_internet)
BackupStatusData.RestoreStatus.WAITING_FOR_WIFI -> stringResource(R.string.BackupStatusRow__download_waiting_for_wifi)
BackupStatusData.RestoreStatus.FINISHED -> stringResource(R.string.BackupStatus__restore_complete)
RestoreState.CANCELING_MEDIA -> stringResource(R.string.BackupStatus__cancel_restore_media)
RestoreState.RESTORING_MEDIA -> {
when (backupStatusData.restoreStatus) {
RestoreStatus.RESTORING -> {
stringResource(
R.string.BackupStatusRow__downloading_s_of_s_s,
backupStatusData.completedRestoredSize.toUnitString(2),
backupStatusData.totalRestoreSize.toUnitString(2),
"%d".format(((backupStatusData.progress ?: 0f) * 100).roundToInt())
)
}
RestoreStatus.WAITING_FOR_INTERNET -> stringResource(R.string.BackupStatusRow__download_no_internet)
RestoreStatus.WAITING_FOR_WIFI -> stringResource(R.string.BackupStatusRow__download_waiting_for_wifi)
RestoreStatus.LOW_BATTERY -> stringResource(R.string.BackupStatusRow__download_device_has_low_battery)
RestoreStatus.FINISHED -> stringResource(R.string.BackupStatus__restore_complete)
else -> throw IllegalStateException()
}
}
RestoreState.NONE -> {
if (backupStatusData.restoreStatus == RestoreStatus.FINISHED) {
stringResource(R.string.BackupStatus__restore_complete)
} else {
throw IllegalStateException()
}
}
RestoreState.PENDING,
RestoreState.RESTORING_DB -> throw IllegalStateException()
}
}
@Composable
private fun progressColor(backupStatusData: BackupStatusData): Color {
return if (backupStatusData is BackupStatusData.RestoringMedia && backupStatusData.restoreStatus == BackupStatusData.RestoreStatus.NORMAL) {
MaterialTheme.colorScheme.primary
} else {
backupStatusData.iconColors.foreground
private fun progressColor(backupStatusData: ArchiveRestoreProgressState): Color {
return when (backupStatusData.restoreStatus) {
RestoreStatus.RESTORING -> MaterialTheme.colorScheme.primary
RestoreStatus.WAITING_FOR_INTERNET,
RestoreStatus.WAITING_FOR_WIFI,
RestoreStatus.LOW_BATTERY,
RestoreStatus.NOT_ENOUGH_DISK_SPACE -> BackupsIconColors.Warning.foreground
RestoreStatus.FINISHED -> BackupsIconColors.Success.foreground
RestoreStatus.NONE -> BackupsIconColors.Normal.foreground
}
}
@@ -269,11 +226,7 @@ private fun progressColor(backupStatusData: BackupStatusData): Color {
fun BackupStatusRowNormalPreview() {
Previews.Preview {
BackupStatusRow(
backupStatusData = BackupStatusData.RestoringMedia(
bytesTotal = ByteSize(100),
bytesDownloaded = ByteSize(50),
restoreStatus = BackupStatusData.RestoreStatus.NORMAL
),
backupStatusData = ArchiveRestoreProgressState(restoreState = RestoreState.RESTORING_MEDIA, restoreStatus = RestoreStatus.RESTORING, remainingRestoreSize = 800.mebiBytes, totalRestoreSize = 1024.mebiBytes),
onCancelClick = {}
)
}
@@ -284,11 +237,7 @@ fun BackupStatusRowNormalPreview() {
fun BackupStatusRowWaitingForWifiPreview() {
Previews.Preview {
BackupStatusRow(
backupStatusData = BackupStatusData.RestoringMedia(
bytesTotal = ByteSize(100),
bytesDownloaded = ByteSize(50),
restoreStatus = BackupStatusData.RestoreStatus.WAITING_FOR_WIFI
)
backupStatusData = ArchiveRestoreProgressState(restoreState = RestoreState.RESTORING_MEDIA, restoreStatus = RestoreStatus.WAITING_FOR_WIFI, remainingRestoreSize = 800.mebiBytes, totalRestoreSize = 1024.mebiBytes)
)
}
}
@@ -298,11 +247,7 @@ fun BackupStatusRowWaitingForWifiPreview() {
fun BackupStatusRowWaitingForInternetPreview() {
Previews.Preview {
BackupStatusRow(
backupStatusData = BackupStatusData.RestoringMedia(
bytesTotal = ByteSize(100),
bytesDownloaded = ByteSize(50),
restoreStatus = BackupStatusData.RestoreStatus.WAITING_FOR_INTERNET
)
backupStatusData = ArchiveRestoreProgressState(restoreState = RestoreState.RESTORING_MEDIA, restoreStatus = RestoreStatus.WAITING_FOR_INTERNET, remainingRestoreSize = 800.mebiBytes, totalRestoreSize = 1024.mebiBytes)
)
}
}
@@ -312,11 +257,7 @@ fun BackupStatusRowWaitingForInternetPreview() {
fun BackupStatusRowLowBatteryPreview() {
Previews.Preview {
BackupStatusRow(
backupStatusData = BackupStatusData.RestoringMedia(
bytesTotal = ByteSize(100),
bytesDownloaded = ByteSize(50),
restoreStatus = BackupStatusData.RestoreStatus.LOW_BATTERY
)
backupStatusData = ArchiveRestoreProgressState(restoreState = RestoreState.RESTORING_MEDIA, restoreStatus = RestoreStatus.LOW_BATTERY, remainingRestoreSize = 800.mebiBytes, totalRestoreSize = 1024.mebiBytes)
)
}
}
@@ -326,11 +267,8 @@ fun BackupStatusRowLowBatteryPreview() {
fun BackupStatusRowFinishedPreview() {
Previews.Preview {
BackupStatusRow(
backupStatusData = BackupStatusData.RestoringMedia(
bytesTotal = ByteSize(100),
bytesDownloaded = ByteSize(50),
restoreStatus = BackupStatusData.RestoreStatus.FINISHED
)
backupStatusData = ArchiveRestoreProgressState(restoreState = RestoreState.NONE, restoreStatus = RestoreStatus.FINISHED, remainingRestoreSize = 0.mebiBytes, totalRestoreSize = 0.mebiBytes, totalToRestoreThisRun = 1024.mebiBytes),
onCancelClick = {}
)
}
}
@@ -340,29 +278,7 @@ fun BackupStatusRowFinishedPreview() {
fun BackupStatusRowNotEnoughFreeSpacePreview() {
Previews.Preview {
BackupStatusRow(
backupStatusData = BackupStatusData.NotEnoughFreeSpace(
requiredSpace = ByteSize(50)
)
)
}
}
@SignalPreview
@Composable
fun BackupStatusRowCouldNotCompleteBackupPreview() {
Previews.Preview {
BackupStatusRow(
backupStatusData = BackupStatusData.CouldNotCompleteBackup
)
}
}
@SignalPreview
@Composable
fun BackupStatusRowBackupFailedPreview() {
Previews.Preview {
BackupStatusRow(
backupStatusData = BackupStatusData.BackupFailed
backupStatusData = ArchiveRestoreProgressState(restoreState = RestoreState.RESTORING_MEDIA, restoreStatus = RestoreStatus.NOT_ENOUGH_DISK_SPACE, remainingRestoreSize = 500.mebiBytes, totalRestoreSize = 1024.mebiBytes)
)
}
}

View File

@@ -39,7 +39,8 @@ fun FilePointer?.toLocalAttachment(
contentType: String? = this?.contentType,
fileName: String? = this?.fileName,
uuid: ByteString? = null,
quote: Boolean = false
quote: Boolean = false,
quoteTargetContentType: String? = null
): Attachment? {
if (this == null || this.locatorInfo == null) return null
@@ -71,6 +72,7 @@ fun FilePointer?.toLocalAttachment(
stickerLocator = stickerLocator,
gif = gif,
quote = quote,
quoteTargetContentType = quoteTargetContentType,
uuid = UuidUtil.fromByteStringOrNull(uuid),
fileName = fileName
)
@@ -100,7 +102,9 @@ fun FilePointer?.toLocalAttachment(
PointerAttachment.forPointer(
pointer = Optional.of(signalAttachmentPointer),
stickerLocator = stickerLocator,
transferState = if (wasDownloaded) AttachmentTable.TRANSFER_NEEDS_RESTORE else AttachmentTable.TRANSFER_PROGRESS_PENDING
transferState = if (wasDownloaded) AttachmentTable.TRANSFER_NEEDS_RESTORE else AttachmentTable.TRANSFER_PROGRESS_PENDING,
quote = quote,
quoteTargetContentType = quoteTargetContentType
).orNull()
}
AttachmentType.INVALID -> {
@@ -117,6 +121,7 @@ fun FilePointer?.toLocalAttachment(
borderless = borderless,
gif = gif,
quote = quote,
quoteTargetContentType = quoteTargetContentType,
stickerLocator = stickerLocator,
uuid = UuidUtil.fromByteStringOrNull(uuid)
)
@@ -227,7 +232,7 @@ private fun DatabaseAttachment.toRemoteAttachmentType(): AttachmentType {
}
val activelyOnArchiveCdn = this.archiveTransferState == AttachmentTable.ArchiveTransferState.FINISHED
val couldBeOnArchiveCdn = this.transferState == AttachmentTable.TRANSFER_PROGRESS_DONE && this.archiveTransferState != AttachmentTable.ArchiveTransferState.PERMANENT_FAILURE
val couldBeOnArchiveCdn = (this.transferState == AttachmentTable.TRANSFER_PROGRESS_DONE || this.transferState == AttachmentTable.TRANSFER_NEEDS_RESTORE) && this.archiveTransferState != AttachmentTable.ArchiveTransferState.PERMANENT_FAILURE
if (this.dataHash != null && (activelyOnArchiveCdn || couldBeOnArchiveCdn)) {
return AttachmentType.ARCHIVE

View File

@@ -5,143 +5,53 @@
package org.thoughtcrime.securesms.banner.banners
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.net.ConnectivityManager
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.runtime.Composable
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import org.signal.core.util.bytes
import org.signal.core.util.throttleLatest
import kotlinx.coroutines.flow.filter
import org.thoughtcrime.securesms.backup.v2.ArchiveRestoreProgress
import org.thoughtcrime.securesms.backup.v2.ArchiveRestoreProgressState
import org.thoughtcrime.securesms.backup.v2.ArchiveRestoreProgressState.RestoreStatus
import org.thoughtcrime.securesms.backup.v2.ui.status.BackupStatusBanner
import org.thoughtcrime.securesms.backup.v2.ui.status.BackupStatusData
import org.thoughtcrime.securesms.banner.Banner
import org.thoughtcrime.securesms.database.DatabaseObserver
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobmanager.impl.BatteryNotLowConstraint
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint
import org.thoughtcrime.securesms.jobmanager.impl.WifiConstraint
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.safeUnregisterReceiver
import kotlin.time.Duration.Companion.seconds
@OptIn(ExperimentalCoroutinesApi::class)
class MediaRestoreProgressBanner(private val listener: RestoreProgressBannerListener = EmptyListener) : Banner<BackupStatusData>() {
private var totalRestoredSize: Long = 0
class MediaRestoreProgressBanner(private val listener: RestoreProgressBannerListener = EmptyListener) : Banner<ArchiveRestoreProgressState>() {
override val enabled: Boolean
get() = SignalStore.backup.isMediaRestoreInProgress || totalRestoredSize > 0
get() = ArchiveRestoreProgress.state.let { it.restoreState.isMediaRestoreOperation || it.restoreStatus == RestoreStatus.FINISHED }
override val dataFlow: Flow<BackupStatusData> by lazy {
SignalStore
.backup
.totalRestorableAttachmentSizeFlow
.flatMapLatest { size ->
when {
size > 0 -> {
totalRestoredSize = size
getActiveRestoreFlow()
}
totalRestoredSize > 0 -> {
flowOf(
BackupStatusData.RestoringMedia(
bytesTotal = totalRestoredSize.bytes,
restoreStatus = BackupStatusData.RestoreStatus.FINISHED
)
)
}
else -> flowOf(BackupStatusData.RestoringMedia())
}
override val dataFlow: Flow<ArchiveRestoreProgressState> by lazy {
ArchiveRestoreProgress
.stateFlow
.filter {
it.restoreStatus != RestoreStatus.NONE && (it.restoreState.isMediaRestoreOperation || it.restoreStatus == RestoreStatus.FINISHED)
}
}
@Composable
override fun DisplayBanner(model: BackupStatusData, contentPadding: PaddingValues) {
override fun DisplayBanner(model: ArchiveRestoreProgressState, contentPadding: PaddingValues) {
BackupStatusBanner(
data = model,
onBannerClick = listener::onBannerClick,
onActionClick = listener::onActionClick,
onDismissClick = {
totalRestoredSize = 0
ArchiveRestoreProgress.clearFinishedStatus()
listener.onDismissComplete()
}
)
}
private fun getActiveRestoreFlow(): Flow<BackupStatusData> {
val flow: Flow<Unit> = callbackFlow {
val onChange = { trySend(Unit) }
val observer = DatabaseObserver.Observer {
onChange()
}
val receiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
onChange()
}
}
onChange()
AppDependencies.databaseObserver.registerAttachmentUpdatedObserver(observer)
AppDependencies.application.registerReceiver(receiver, IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION))
AppDependencies.application.registerReceiver(receiver, IntentFilter(Intent.ACTION_BATTERY_CHANGED))
awaitClose {
AppDependencies.databaseObserver.unregisterObserver(observer)
AppDependencies.application.safeUnregisterReceiver(receiver)
}
}
return flow
.throttleLatest(1.seconds)
.map {
val totalRestoreSize = SignalStore.backup.totalRestorableAttachmentSize
val remainingAttachmentSize = SignalDatabase.attachments.getRemainingRestorableAttachmentSize()
val completedBytes = totalRestoreSize - remainingAttachmentSize
when {
!WifiConstraint.isMet(AppDependencies.application) && !SignalStore.backup.restoreWithCellular -> BackupStatusData.RestoringMedia(completedBytes.bytes, totalRestoreSize.bytes, BackupStatusData.RestoreStatus.WAITING_FOR_WIFI)
!NetworkConstraint.isMet(AppDependencies.application) -> BackupStatusData.RestoringMedia(completedBytes.bytes, totalRestoreSize.bytes, BackupStatusData.RestoreStatus.WAITING_FOR_INTERNET)
!BatteryNotLowConstraint.isMet() -> BackupStatusData.RestoringMedia(completedBytes.bytes, totalRestoreSize.bytes, BackupStatusData.RestoreStatus.LOW_BATTERY)
else -> {
val availableBytes = SignalStore.backup.spaceAvailableOnDiskBytes
if (availableBytes > -1L && remainingAttachmentSize > availableBytes) {
BackupStatusData.NotEnoughFreeSpace(requiredSpace = remainingAttachmentSize.bytes)
} else {
BackupStatusData.RestoringMedia(completedBytes.bytes, totalRestoreSize.bytes)
}
}
}
}
.flowOn(Dispatchers.IO)
}
interface RestoreProgressBannerListener {
fun onBannerClick()
fun onActionClick(data: BackupStatusData)
fun onActionClick(data: ArchiveRestoreProgressState)
fun onDismissComplete()
}
private object EmptyListener : RestoreProgressBannerListener {
override fun onBannerClick() = Unit
override fun onActionClick(data: BackupStatusData) = Unit
override fun onActionClick(data: ArchiveRestoreProgressState) = Unit
override fun onDismissComplete() = Unit
}
}

View File

@@ -219,7 +219,7 @@ public class InputPanel extends ConstraintLayout
@NonNull SlideDeck attachments,
@NonNull QuoteModel.Type quoteType)
{
this.quoteView.setQuote(requestManager, id, author, body, false, attachments, null, quoteType);
this.quoteView.setQuote(requestManager, id, author, body, false, attachments, null, quoteType, true);
if (listener != null) {
this.quoteView.setOnClickListener(v -> listener.onQuoteClicked(id, author.getId()));
}
@@ -309,7 +309,7 @@ public class InputPanel extends ConstraintLayout
quoteView.getAuthor().getId(),
quoteView.getBody().toString(),
false,
quoteView.getAttachments(),
quoteView.getAttachment(),
quoteView.getMentions(),
quoteView.getQuoteType(),
quoteView.getBodyRanges()));

View File

@@ -1,10 +1,7 @@
package org.thoughtcrime.securesms.components
import android.content.Context
import android.os.Build
import android.util.AttributeSet
import android.util.DisplayMetrics
import android.view.Surface
import android.view.View
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.constraintlayout.widget.Guideline
@@ -16,8 +13,8 @@ import androidx.core.view.WindowInsetsCompat
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.ServiceUtil
import org.thoughtcrime.securesms.util.ViewUtil
import org.thoughtcrime.securesms.window.WindowSizeClass.Companion.getWindowSizeClass
/**
* A specialized [ConstraintLayout] that sets guidelines based on the window insets provided
@@ -61,7 +58,6 @@ open class InsetAwareConstraintLayout @JvmOverloads constructor(
private val windowInsetsListeners: MutableSet<WindowInsetsListener> = mutableSetOf()
private val keyboardStateListeners: MutableSet<KeyboardStateListener> = mutableSetOf()
private val keyboardAnimator = KeyboardInsetAnimator()
private val displayMetrics = DisplayMetrics()
private var overridingKeyboard: Boolean = false
private var previousKeyboardHeight: Int = 0
private var applyRootInsets: Boolean = false
@@ -104,6 +100,16 @@ open class InsetAwareConstraintLayout @JvmOverloads constructor(
private fun insetTarget(): View = if (applyRootInsets) rootView else this
fun setApplyRootInsets(useRootInsets: Boolean) {
if (applyRootInsets == useRootInsets) {
return
}
ViewCompat.setOnApplyWindowInsetsListener(insetTarget(), null)
applyRootInsets = useRootInsets
ViewCompat.setOnApplyWindowInsetsListener(insetTarget(), windowInsetsListener)
}
/**
* Specifies whether or not window insets should be accounted for when applying
* insets. This is useful when choosing whether to display the content in this
@@ -222,23 +228,7 @@ open class InsetAwareConstraintLayout @JvmOverloads constructor(
}
private fun isLandscape(): Boolean {
val rotation = getDeviceRotation()
return rotation == Surface.ROTATION_90
}
@Suppress("DEPRECATION")
private fun getDeviceRotation(): Int {
if (isInEditMode) {
return Surface.ROTATION_0
}
if (Build.VERSION.SDK_INT >= 30) {
context.display?.getRealMetrics(displayMetrics)
} else {
ServiceUtil.getWindowManager(context).defaultDisplay.getRealMetrics(displayMetrics)
}
return if (displayMetrics.widthPixels > displayMetrics.heightPixels) Surface.ROTATION_90 else Surface.ROTATION_0
return resources.getWindowSizeClass().isLandscape()
}
private val Guideline?.guidelineEnd: Int

View File

@@ -47,6 +47,7 @@ import org.thoughtcrime.securesms.util.views.Stub;
import java.io.IOException;
import java.util.List;
import java.util.Optional;
public class QuoteView extends ConstraintLayout implements RecipientForeverObserver {
@@ -200,7 +201,8 @@ public class QuoteView extends ConstraintLayout implements RecipientForeverObser
boolean originalMissing,
@NonNull SlideDeck attachments,
@Nullable String storyReaction,
@NonNull QuoteModel.Type quoteType)
@NonNull QuoteModel.Type quoteType,
boolean composeMode)
{
if (this.author != null) this.author.removeForeverObserver(this);
@@ -211,9 +213,19 @@ public class QuoteView extends ConstraintLayout implements RecipientForeverObser
this.quoteType = quoteType;
this.author.observeForever(this);
Slide slide = attachments.getFirstSlide();
String quoteTargetContentType;
if (composeMode) {
quoteTargetContentType = Optional.ofNullable(slide).map(Slide::getContentType).orElse(null);
} else {
quoteTargetContentType = Optional.ofNullable(slide).map(Slide::getQuoteTargetContentType).orElse(null);
}
setQuoteAuthor(author);
setQuoteText(resolveBody(body, quoteType), attachments, originalMissing, storyReaction);
setQuoteAttachment(requestManager, body, attachments, originalMissing);
setQuoteText(resolveBody(body, quoteType), slide, originalMissing, storyReaction, quoteTargetContentType);
setQuoteAttachment(requestManager, body, slide, originalMissing, quoteTargetContentType);
setQuoteMissingFooter(originalMissing);
applyColorTheme();
}
@@ -267,9 +279,10 @@ public class QuoteView extends ConstraintLayout implements RecipientForeverObser
}
private void setQuoteText(@Nullable CharSequence body,
@NonNull SlideDeck attachments,
@Nullable Slide slide,
boolean originalMissing,
@Nullable String storyReaction)
@Nullable String storyReaction,
@Nullable String quoteTargetContentType)
{
if (originalMissing && isStoryReply()) {
bodyView.setVisibility(GONE);
@@ -316,40 +329,37 @@ public class QuoteView extends ConstraintLayout implements RecipientForeverObser
bodyView.setVisibility(GONE);
mediaDescriptionText.setVisibility(VISIBLE);
Slide audioSlide = attachments.getSlides().stream().filter(Slide::hasAudio).findFirst().orElse(null);
Slide documentSlide = attachments.getSlides().stream().filter(Slide::hasDocument).findFirst().orElse(null);
Slide imageSlide = attachments.getSlides().stream().filter(Slide::hasImage).findFirst().orElse(null);
Slide videoSlide = attachments.getSlides().stream().filter(Slide::hasVideo).findFirst().orElse(null);
Slide stickerSlide = attachments.getSlides().stream().filter(Slide::hasSticker).findFirst().orElse(null);
Slide viewOnceSlide = attachments.getSlides().stream().filter(Slide::hasViewOnce).findFirst().orElse(null);
// Given that most types have images, we specifically check images last
if (viewOnceSlide != null) {
if (MediaUtil.isViewOnceType(quoteTargetContentType)) {
mediaDescriptionText.setPadding(0, mediaDescriptionText.getPaddingTop(), 0, (int) DimensionUnit.DP.toPixels(8));
mediaDescriptionText.setText(R.string.QuoteView_view_once_media);
} else if (audioSlide != null) {
} else if (MediaUtil.isAudioType(quoteTargetContentType)) {
mediaDescriptionText.setPadding(0, mediaDescriptionText.getPaddingTop(), 0, (int) DimensionUnit.DP.toPixels(8));
mediaDescriptionText.setText(R.string.QuoteView_audio);
} else if (documentSlide != null) {
mediaDescriptionText.setVisibility(GONE);
} else if (videoSlide != null) {
if (videoSlide.isVideoGif()) {
} else if (MediaUtil.isVideoType(quoteTargetContentType)) {
if (slide != null && slide.isVideoGif()) {
mediaDescriptionText.setText(R.string.QuoteView_gif);
} else {
mediaDescriptionText.setText(R.string.QuoteView_video);
}
} else if (stickerSlide != null) {
} else if (slide != null && slide.hasSticker()) {
mediaDescriptionText.setText(R.string.QuoteView_sticker);
} else if (imageSlide != null) {
if (MediaUtil.isGif(imageSlide.getContentType())) {
} else if (MediaUtil.isImageType(quoteTargetContentType)) {
if (MediaUtil.isGif(quoteTargetContentType)) {
mediaDescriptionText.setText(R.string.QuoteView_gif);
} else {
mediaDescriptionText.setText(R.string.QuoteView_photo);
}
} else {
mediaDescriptionText.setVisibility(GONE);
}
}
private void setQuoteAttachment(@NonNull RequestManager requestManager, @NonNull CharSequence body, @NonNull SlideDeck slideDeck, boolean originalMissing) {
private void setQuoteAttachment(@NonNull RequestManager requestManager,
@NonNull CharSequence body,
@NonNull Slide slide,
boolean originalMissing,
@Nullable String quoteTargetContentType)
{
boolean outgoing = messageType != MessageType.INCOMING && messageType != MessageType.STORY_REPLY_INCOMING;
boolean preview = messageType == MessageType.PREVIEW || messageType == MessageType.STORY_REPLY_PREVIEW;
@@ -394,41 +404,49 @@ public class QuoteView extends ConstraintLayout implements RecipientForeverObser
return;
}
Slide imageVideoSlide = slideDeck.getSlides().stream().filter(s -> s.hasImage() || s.hasVideo() || s.hasSticker()).findFirst().orElse(null);
Slide documentSlide = slideDeck.getSlides().stream().filter(Slide::hasDocument).findFirst().orElse(null);
Slide viewOnceSlide = slideDeck.getSlides().stream().filter(Slide::hasViewOnce).findFirst().orElse(null);
attachmentVideoOVerlayStub.setVisibility(GONE);
if (viewOnceSlide != null) {
thumbnailView.setVisibility(GONE);
attachmentNameViewStub.setVisibility(GONE);
} else if (imageVideoSlide != null && imageVideoSlide.getUri() != null) {
thumbnailView.setVisibility(VISIBLE);
attachmentNameViewStub.setVisibility(GONE);
if (dismissStub.resolved()) {
dismissStub.get().setBackgroundResource(R.drawable.dismiss_background);
}
if (imageVideoSlide.hasVideo() && !imageVideoSlide.isVideoGif()) {
attachmentVideoOVerlayStub.setVisibility(VISIBLE);
}
requestManager.load(new DecryptableUri(imageVideoSlide.getUri()))
.centerCrop()
.override(thumbWidth, thumbHeight)
.diskCacheStrategy(DiskCacheStrategy.RESOURCE)
.into(thumbnailView);
} else if (documentSlide != null){
thumbnailView.setVisibility(GONE);
attachmentNameViewStub.setVisibility(VISIBLE);
attachmentNameViewStub.get().setText(documentSlide.getFileName().orElse(""));
} else {
if (TextUtils.isEmpty(quoteTargetContentType) || slide == null || slide.getUri() == null) {
thumbnailView.setVisibility(GONE);
attachmentNameViewStub.setVisibility(GONE);
if (dismissStub.resolved()) {
dismissStub.get().setBackground(null);
}
return;
}
attachmentVideoOVerlayStub.setVisibility(GONE);
if (MediaUtil.isViewOnceType(quoteTargetContentType)) {
thumbnailView.setVisibility(GONE);
attachmentNameViewStub.setVisibility(GONE);
} else if (MediaUtil.isImageOrVideoType(quoteTargetContentType)) {
thumbnailView.setVisibility(VISIBLE);
attachmentNameViewStub.setVisibility(GONE);
if (dismissStub.resolved()) {
dismissStub.get().setBackgroundResource(R.drawable.dismiss_background);
}
if (MediaUtil.isVideoType(quoteTargetContentType) && !slide.isVideoGif()) {
attachmentVideoOVerlayStub.setVisibility(VISIBLE);
}
requestManager.load(new DecryptableUri(slide.getUri()))
.centerCrop()
.override(thumbWidth, thumbHeight)
.diskCacheStrategy(DiskCacheStrategy.RESOURCE)
.into(thumbnailView);
} else if (MediaUtil.isAudioType(quoteTargetContentType)) {
thumbnailView.setVisibility(GONE);
attachmentNameViewStub.setVisibility(GONE);
if (dismissStub.resolved()) {
dismissStub.get().setBackground(null);
}
} else {
thumbnailView.setVisibility(GONE);
attachmentNameViewStub.setVisibility(VISIBLE);
attachmentNameViewStub.get().setText(slide.getFileName().orElse(""));
}
}
@@ -464,8 +482,13 @@ public class QuoteView extends ConstraintLayout implements RecipientForeverObser
return body;
}
public List<Attachment> getAttachments() {
return attachments.asAttachments();
public @Nullable Attachment getAttachment() {
List<Attachment> converted = attachments.asAttachments();
if (converted.size() > 0) {
return converted.get(0);
} else {
return null;
}
}
public @NonNull QuoteModel.Type getQuoteType() {

View File

@@ -30,7 +30,6 @@ 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.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.res.colorResource
@@ -72,7 +71,7 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.BadgeImag
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.completed.InAppPaymentsBottomSheetDelegate
import org.thoughtcrime.securesms.compose.ComposeFragment
import org.thoughtcrime.securesms.compose.StatusBarColorNestedScrollConnection
import org.thoughtcrime.securesms.compose.rememberStatusBarColorNestedScrollModifier
import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord
import org.thoughtcrime.securesms.profiles.ProfileName
import org.thoughtcrime.securesms.recipients.Recipient
@@ -107,16 +106,11 @@ class AppSettingsFragment : ComposeFragment(), Callbacks {
)
}
val nestedScrollConnection = remember {
StatusBarColorNestedScrollConnection(requireActivity())
}
AppSettingsContent(
self = self!!,
state = state!!,
bannerManager = bannerManager,
callbacks = this,
lazyColumnModifier = Modifier.nestedScroll(nestedScrollConnection)
callbacks = this
)
}
@@ -176,8 +170,7 @@ private fun AppSettingsContent(
self: BioRecipientState,
state: AppSettingsState,
bannerManager: BannerManager,
callbacks: Callbacks,
lazyColumnModifier: Modifier = Modifier
callbacks: Callbacks
) {
val isRegisteredAndUpToDate by rememberUpdatedState(state.isRegisteredAndUpToDate())
@@ -193,7 +186,7 @@ private fun AppSettingsContent(
bannerManager.Banner()
LazyColumn(
modifier = lazyColumnModifier
modifier = rememberStatusBarColorNestedScrollModifier()
) {
item {
BioRow(

View File

@@ -4,6 +4,7 @@ import android.content.Context
import android.content.Intent
import android.graphics.Typeface
import android.text.InputType
import android.text.method.PasswordTransformationMethod
import android.util.DisplayMetrics
import android.view.ViewGroup
import android.widget.EditText
@@ -13,7 +14,6 @@ import androidx.annotation.ColorRes
import androidx.annotation.StringRes
import androidx.annotation.VisibleForTesting
import androidx.appcompat.app.AlertDialog
import androidx.autofill.HintConstants
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.MaterialTheme
@@ -31,7 +31,6 @@ import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.core.app.DialogCompat
import androidx.core.view.ViewCompat
import androidx.fragment.app.viewModels
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.fragment.findNavController
@@ -47,6 +46,7 @@ import org.signal.core.ui.compose.SignalPreview
import org.signal.core.ui.compose.Texts
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.compose.ComposeFragment
import org.thoughtcrime.securesms.compose.rememberStatusBarColorNestedScrollModifier
import org.thoughtcrime.securesms.contactshare.SimpleTextWatcher
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore
@@ -116,10 +116,10 @@ class AccountSettingsFragment : ComposeFragment() {
changeKeyboard.setOnClickListener {
if (pinEditText.inputType and InputType.TYPE_CLASS_NUMBER == 0) {
pinEditText.inputType = InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_VARIATION_PASSWORD
pinEditText.inputType = InputType.TYPE_CLASS_NUMBER
changeKeyboard.setIconResource(PinKeyboardType.ALPHA_NUMERIC.iconResource)
} else {
pinEditText.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
pinEditText.inputType = InputType.TYPE_CLASS_TEXT
changeKeyboard.setIconResource(PinKeyboardType.NUMERIC.iconResource)
}
pinEditText.typeface = Typeface.DEFAULT
@@ -129,20 +129,19 @@ class AccountSettingsFragment : ComposeFragment() {
ViewUtil.focusAndShowKeyboard(pinEditText)
}
ViewCompat.setAutofillHints(pinEditText, HintConstants.AUTOFILL_HINT_PASSWORD)
when (SignalStore.pin.keyboardType) {
PinKeyboardType.NUMERIC -> {
pinEditText.inputType = InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_VARIATION_PASSWORD
pinEditText.inputType = InputType.TYPE_CLASS_NUMBER
changeKeyboard.setIconResource(PinKeyboardType.ALPHA_NUMERIC.iconResource)
}
PinKeyboardType.ALPHA_NUMERIC -> {
pinEditText.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
pinEditText.inputType = InputType.TYPE_CLASS_TEXT
changeKeyboard.setIconResource(PinKeyboardType.NUMERIC.iconResource)
}
}
pinEditText.transformationMethod = PasswordTransformationMethod.getInstance()
pinEditText.addTextChangedListener(object : SimpleTextWatcher() {
override fun onTextChanged(text: String) {
turnOffButton.isEnabled = text.length >= SvrConstants.MINIMUM_PIN_LENGTH
@@ -278,7 +277,10 @@ fun AccountSettingsScreen(
navigationIcon = ImageVector.vectorResource(R.drawable.ic_arrow_left_24)
) { contentPadding ->
LazyColumn(
modifier = Modifier.padding(contentPadding).testTag(AccountSettingsTestTags.SCROLLER)
modifier = Modifier
.padding(contentPadding)
.then(rememberStatusBarColorNestedScrollModifier())
.testTag(AccountSettingsTestTags.SCROLLER)
) {
item {
Texts.SectionHeader(

View File

@@ -24,6 +24,7 @@ import org.signal.core.ui.compose.SignalPreview
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.app.appearance.navbar.ChooseNavigationBarStyleFragment
import org.thoughtcrime.securesms.compose.ComposeFragment
import org.thoughtcrime.securesms.compose.rememberStatusBarColorNestedScrollModifier
import org.thoughtcrime.securesms.keyvalue.SettingsValues
import org.thoughtcrime.securesms.util.navigation.safeNavigate
@@ -107,7 +108,9 @@ private fun AppearanceSettingsScreen(
navigationIcon = ImageVector.vectorResource(R.drawable.symbol_arrow_start_24)
) { paddingValues ->
LazyColumn(
modifier = Modifier.padding(paddingValues)
modifier = Modifier
.padding(paddingValues)
.then(rememberStatusBarColorNestedScrollModifier())
) {
item {
Rows.RadioListRow(

View File

@@ -63,9 +63,9 @@ class BackupStateObserver(
private val backupTierChangedNotifier = MutableSharedFlow<Unit>()
/**
* Called when the value returned by [SignalStore.backup.backupTier] changes.
* Called when the backup state likely changed.
*/
fun notifyBackupTierChanged(scope: CoroutineScope = staticScope) {
fun notifyBackupStateChanged(scope: CoroutineScope = staticScope) {
Log.d(TAG, "Notifier got a change")
scope.launch {
backupTierChangedNotifier.emit(Unit)

View File

@@ -20,7 +20,6 @@ import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.storage.StorageSyncHelper
import org.thoughtcrime.securesms.util.Environment
import org.thoughtcrime.securesms.util.RemoteConfig
import kotlin.time.Duration.Companion.milliseconds
class BackupsSettingsViewModel : ViewModel() {
@@ -44,7 +43,7 @@ class BackupsSettingsViewModel : ViewModel() {
it.copy(
backupState = enabledState,
lastBackupAt = SignalStore.backup.lastBackupTime.milliseconds,
showBackupTierInternalOverride = RemoteConfig.internalUser || Environment.IS_STAGING,
showBackupTierInternalOverride = Environment.IS_STAGING,
backupTierInternalOverride = SignalStore.backup.backupTierInternalOverride
)
}
@@ -60,6 +59,6 @@ class BackupsSettingsViewModel : ViewModel() {
StorageSyncHelper.scheduleSyncForDataChange()
}
BackupStateObserver.notifyBackupTierChanged(scope = viewModelScope)
BackupStateObserver.notifyBackupStateChanged(scope = viewModelScope)
}
}

View File

@@ -5,17 +5,13 @@
package org.thoughtcrime.securesms.components.settings.app.backups.remote
import org.thoughtcrime.securesms.backup.v2.ui.status.BackupStatusData
import org.thoughtcrime.securesms.backup.v2.ArchiveRestoreProgressState
/**
* State container for BackupStatusData, including the enabled state.
*/
sealed interface BackupRestoreState {
data object None : BackupRestoreState
data class Ready(
val bytes: String
) : BackupRestoreState
data class FromBackupStatusData(
val backupStatusData: BackupStatusData
) : BackupRestoreState
data class Ready(val bytes: String) : BackupRestoreState
data class Restoring(val state: ArchiveRestoreProgressState) : BackupRestoreState
}

View File

@@ -16,7 +16,6 @@ import androidx.compose.animation.core.tween
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement.spacedBy
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -30,7 +29,6 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.BasicAlertDialog
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -40,7 +38,6 @@ import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBarDefaults
@@ -93,17 +90,19 @@ import org.thoughtcrime.securesms.DevicePinAuthEducationSheet
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.backup.ArchiveUploadProgress
import org.thoughtcrime.securesms.backup.DeletionState
import org.thoughtcrime.securesms.backup.RestoreState
import org.thoughtcrime.securesms.backup.v2.ArchiveRestoreProgressState
import org.thoughtcrime.securesms.backup.v2.ArchiveRestoreProgressState.RestoreStatus
import org.thoughtcrime.securesms.backup.v2.BackupFrequency
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
import org.thoughtcrime.securesms.backup.v2.ui.BackupAlert
import org.thoughtcrime.securesms.backup.v2.ui.BackupAlertBottomSheet
import org.thoughtcrime.securesms.backup.v2.ui.status.BackupStatusData
import org.thoughtcrime.securesms.backup.v2.ui.status.BackupCreateErrorRow
import org.thoughtcrime.securesms.backup.v2.ui.status.BackupStatusRow
import org.thoughtcrime.securesms.backup.v2.ui.status.RestoreType
import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsType
import org.thoughtcrime.securesms.billing.launchManageBackupsSubscription
import org.thoughtcrime.securesms.components.compose.BetaHeader
import org.thoughtcrime.securesms.components.compose.TextWithBetaLabel
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
import org.thoughtcrime.securesms.components.settings.app.backups.BackupState
import org.thoughtcrime.securesms.components.settings.app.subscription.MessageBackupsCheckoutLauncher.createBackupsCheckoutLauncher
@@ -192,7 +191,7 @@ class RemoteBackupsSettingsFragment : ComposeFragment() {
viewModel.requestDialog(RemoteBackupsSettingsState.Dialog.TURN_OFF_AND_DELETE_BACKUPS)
}
override fun onChangeBackupFrequencyClick() {
override fun onBackupFrequencyClick() {
viewModel.requestDialog(RemoteBackupsSettingsState.Dialog.BACKUP_FREQUENCY)
}
@@ -204,10 +203,6 @@ class RemoteBackupsSettingsFragment : ComposeFragment() {
viewModel.requestSnackbar(RemoteBackupsSettingsState.Snackbar.NONE)
}
override fun onSelectBackupsFrequencyChange(newFrequency: BackupFrequency) {
viewModel.setBackupsFrequency(newFrequency)
}
override fun onTurnOffAndDeleteBackupsConfirm() {
viewModel.turnOffAndDeleteBackups()
}
@@ -230,7 +225,7 @@ class RemoteBackupsSettingsFragment : ComposeFragment() {
}
override fun onCancelMediaRestore() {
viewModel.requestDialog(RemoteBackupsSettingsState.Dialog.CANCEL_MEDIA_RESTORE_PROTECTION)
viewModel.cancelMediaRestore()
}
override fun onDisplaySkipMediaRestoreProtectionDialog() {
@@ -366,10 +361,9 @@ private interface ContentCallbacks {
fun onBackupNowClick() = Unit
fun onCancelUploadClick() = Unit
fun onTurnOffAndDeleteBackupsClick() = Unit
fun onChangeBackupFrequencyClick() = Unit
fun onBackupFrequencyClick() = Unit
fun onDialogDismissed() = Unit
fun onSnackbarDismissed() = Unit
fun onSelectBackupsFrequencyChange(newFrequency: BackupFrequency) = Unit
fun onTurnOffAndDeleteBackupsConfirm() = Unit
fun onViewBackupKeyClick() = Unit
fun onStartMediaRestore() = Unit
@@ -420,9 +414,9 @@ private fun RemoteBackupsSettingsContent(
Scaffold(
topBar = {
Scaffolds.DefaultTopAppBar(
title = stringResource(R.string.RemoteBackupsSettingsFragment__signal_backups),
title = stringResource(R.string.RemoteBackupsSettingsFragment__secure_backups),
titleContent = { _, title ->
TextWithBetaLabel(text = title, textStyle = MaterialTheme.typography.titleLarge)
Text(text = title, style = MaterialTheme.typography.titleLarge)
},
onNavigationClick = contentCallbacks::onNavigationClick,
navigationIcon = ImageVector.vectorResource(R.drawable.symbol_arrow_start_24),
@@ -521,29 +515,28 @@ private fun RemoteBackupsSettingsContent(
)
} else if (state.backupsEnabled) {
appendBackupDetailsItems(
backupState = state.backupState,
canViewBackupKey = state.canViewBackupKey,
state = state,
backupRestoreState = backupRestoreState,
backupProgress = backupProgress,
canBackupMessagesRun = state.canBackupMessagesJobRun,
lastBackupTimestamp = state.lastBackupTimestamp,
backupMediaSize = state.backupMediaSize,
backupsFrequency = state.backupsFrequency,
canBackUpUsingCellular = state.canBackUpUsingCellular,
canRestoreUsingCellular = state.canRestoreUsingCellular,
canBackUpNow = !state.isOutOfStorageSpace,
includeDebuglog = state.includeDebuglog,
backupMediaDetails = state.backupMediaDetails,
contentCallbacks = contentCallbacks
)
} else {
if (backupRestoreState is BackupRestoreState.FromBackupStatusData) {
if (state.showBackupCreateFailedError || state.showBackupCreateCouldNotCompleteError) {
item {
BackupCreateErrorRow(
showCouldNotComplete = state.showBackupCreateCouldNotCompleteError,
showBackupFailed = state.showBackupCreateFailedError,
onLearnMoreClick = contentCallbacks::onLearnMoreAboutBackupFailure
)
}
}
if (backupRestoreState is BackupRestoreState.Restoring) {
item {
BackupStatusRow(
backupStatusData = backupRestoreState.backupStatusData,
backupStatusData = backupRestoreState.state,
onCancelClick = contentCallbacks::onCancelMediaRestore,
onSkipClick = contentCallbacks::onSkipMediaRestore,
onLearnMoreClick = contentCallbacks::onLearnMoreAboutBackupFailure
onSkipClick = contentCallbacks::onSkipMediaRestore
)
}
}
@@ -582,10 +575,11 @@ private fun RemoteBackupsSettingsContent(
}
RemoteBackupsSettingsState.Dialog.BACKUP_FREQUENCY -> {
BackupFrequencyDialog(
selected = state.backupsFrequency,
onSelected = contentCallbacks::onSelectBackupsFrequencyChange,
onDismiss = contentCallbacks::onDialogDismissed
Dialogs.SimpleAlertDialog(
title = stringResource(R.string.RemoteBackupsSettingsFragment__backup_frequency),
body = stringResource(R.string.RemoteBackupsSettingsFragment__backup_frequency_dialog_body),
confirm = stringResource(android.R.string.ok),
onConfirm = contentCallbacks::onDialogDismissed
)
}
@@ -674,24 +668,24 @@ private fun ReenableBackupsButton(contentCallbacks: ContentCallbacks) {
}
private fun LazyListScope.appendRestoreFromBackupStatusData(
backupRestoreState: BackupRestoreState.FromBackupStatusData,
backupRestoreState: BackupRestoreState.Restoring,
canRestoreUsingCellular: Boolean,
contentCallbacks: ContentCallbacks,
isCancelable: Boolean = true
) {
item {
BackupStatusRow(
backupStatusData = backupRestoreState.backupStatusData,
backupStatusData = backupRestoreState.state,
restoreType = if (isCancelable) RestoreType.DOWNLOAD else RestoreType.RESTORE,
onCancelClick = if (isCancelable) contentCallbacks::onCancelMediaRestore else null,
onSkipClick = contentCallbacks::onDisplaySkipMediaRestoreProtectionDialog,
onLearnMoreClick = contentCallbacks::onLearnMoreAboutBackupFailure
onSkipClick = contentCallbacks::onDisplaySkipMediaRestoreProtectionDialog
)
}
val displayResumeButton = when (val data = backupRestoreState.backupStatusData) {
is BackupStatusData.RestoringMedia -> !canRestoreUsingCellular && data.restoreStatus == BackupStatusData.RestoreStatus.WAITING_FOR_WIFI
else -> false
val displayResumeButton = if (backupRestoreState.state.restoreState == RestoreState.RESTORING_MEDIA) {
!canRestoreUsingCellular && backupRestoreState.state.restoreStatus == RestoreStatus.WAITING_FOR_WIFI
} else {
false
}
if (displayResumeButton) {
@@ -753,7 +747,7 @@ private fun LazyListScope.appendBackupDeletionItems(
)
}
if (backupRestoreState is BackupRestoreState.FromBackupStatusData) {
if (backupRestoreState is BackupRestoreState.Restoring) {
appendRestoreFromBackupStatusData(
backupRestoreState = backupRestoreState,
canRestoreUsingCellular = canRestoreUsingCellular,
@@ -763,7 +757,9 @@ private fun LazyListScope.appendBackupDeletionItems(
} else {
item {
LinearProgressIndicator(
modifier = Modifier.horizontalGutters().fillMaxWidth()
modifier = Modifier
.horizontalGutters()
.fillMaxWidth()
)
}
}
@@ -828,19 +824,9 @@ private fun DescriptionText(
}
private fun LazyListScope.appendBackupDetailsItems(
backupState: BackupState,
canViewBackupKey: Boolean,
state: RemoteBackupsSettingsState,
backupRestoreState: BackupRestoreState,
backupProgress: ArchiveUploadProgressState?,
canBackupMessagesRun: Boolean,
lastBackupTimestamp: Long,
backupMediaSize: Long,
backupsFrequency: BackupFrequency,
canBackUpUsingCellular: Boolean,
canRestoreUsingCellular: Boolean,
canBackUpNow: Boolean,
includeDebuglog: Boolean?,
backupMediaDetails: RemoteBackupsSettingsState.BackupMediaDetails?,
contentCallbacks: ContentCallbacks
) {
item {
@@ -851,45 +837,55 @@ private fun LazyListScope.appendBackupDetailsItems(
Texts.SectionHeader(text = stringResource(id = R.string.RemoteBackupsSettingsFragment__backup_details))
}
if (backupMediaDetails != null) {
if (state.backupMediaDetails != null) {
item {
Column(modifier = Modifier.horizontalGutters()) {
Text("[Internal Only] Backup Media Details")
Text("Awaiting Restore: ${backupMediaDetails.awaitingRestore.toUnitString()}")
Text("Offloaded: ${backupMediaDetails.offloaded.toUnitString()}")
Text("Awaiting Restore: ${state.backupMediaDetails.awaitingRestore.toUnitString()}")
Text("Offloaded: ${state.backupMediaDetails.offloaded.toUnitString()}")
}
}
}
if (state.showBackupCreateFailedError || state.showBackupCreateCouldNotCompleteError) {
item {
BackupCreateErrorRow(
showCouldNotComplete = state.showBackupCreateCouldNotCompleteError,
showBackupFailed = state.showBackupCreateFailedError,
onLearnMoreClick = contentCallbacks::onLearnMoreAboutBackupFailure
)
}
}
if (backupRestoreState !is BackupRestoreState.None) {
if (backupRestoreState is BackupRestoreState.FromBackupStatusData) {
if (backupRestoreState is BackupRestoreState.Restoring) {
appendRestoreFromBackupStatusData(
backupRestoreState = backupRestoreState,
canRestoreUsingCellular = canRestoreUsingCellular,
canRestoreUsingCellular = state.canRestoreUsingCellular,
contentCallbacks = contentCallbacks
)
} else if (backupRestoreState is BackupRestoreState.Ready) {
item {
BackupReadyToDownloadRow(
ready = backupRestoreState,
backupState = backupState,
backupState = state.backupState,
onDownloadClick = contentCallbacks::onStartMediaRestore
)
}
}
}
if (includeDebuglog != null) {
if (state.includeDebuglog != null) {
item {
IncludeDebuglogRow(includeDebuglog) { contentCallbacks.onIncludeDebuglogClick(it) }
IncludeDebuglogRow(state.includeDebuglog) { contentCallbacks.onIncludeDebuglogClick(it) }
}
}
if (backupProgress == null || backupProgress.state == ArchiveUploadProgressState.State.None || backupProgress.state == ArchiveUploadProgressState.State.UserCanceled) {
item {
LastBackupRow(
lastBackupTimestamp = lastBackupTimestamp,
enabled = canBackUpNow,
lastBackupTimestamp = state.lastBackupTimestamp,
enabled = !state.isOutOfStorageSpace,
onBackupNowClick = contentCallbacks::onBackupNowClick
)
}
@@ -897,19 +893,19 @@ private fun LazyListScope.appendBackupDetailsItems(
item {
InProgressBackupRow(
archiveUploadProgressState = backupProgress,
canBackupMessagesRun = canBackupMessagesRun,
canBackupUsingCellular = canBackUpUsingCellular,
canBackupMessagesRun = state.canBackupMessagesJobRun,
canBackupUsingCellular = state.canBackUpUsingCellular,
cancelArchiveUpload = contentCallbacks::onCancelUploadClick
)
}
}
if (backupState !is BackupState.ActiveFree) {
if (state.backupState !is BackupState.ActiveFree) {
item {
val sizeText = if (backupMediaSize < 0L) {
stringResource(R.string.RemoteBackupsSettingsFragment__loading)
val sizeText = if (state.backupMediaSize < 0L) {
stringResource(R.string.RemoteBackupsSettingsFragment__calculating)
} else {
backupMediaSize.bytes.toUnitString()
state.backupMediaSize.bytes.toUnitString()
}
Rows.TextRow(text = {
@@ -920,7 +916,7 @@ private fun LazyListScope.appendBackupDetailsItems(
color = MaterialTheme.colorScheme.onSurface
)
Text(
text = backupMediaSize.bytes.toUnitString(),
text = sizeText,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -939,19 +935,19 @@ private fun LazyListScope.appendBackupDetailsItems(
color = MaterialTheme.colorScheme.onSurface
)
Text(
text = getTextForFrequency(backupsFrequency = backupsFrequency),
text = stringResource(id = R.string.RemoteBackupsSettingsFragment__daily),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
},
onClick = contentCallbacks::onChangeBackupFrequencyClick
onClick = contentCallbacks::onBackupFrequencyClick
)
}
item {
Rows.ToggleRow(
checked = canBackUpUsingCellular,
checked = state.canBackUpUsingCellular,
text = stringResource(id = R.string.RemoteBackupsSettingsFragment__back_up_using_cellular),
onCheckChanged = contentCallbacks::onBackUpUsingCellularClick
)
@@ -961,7 +957,7 @@ private fun LazyListScope.appendBackupDetailsItems(
Rows.TextRow(
text = stringResource(R.string.RemoteBackupsSettingsFragment__view_backup_key),
onClick = contentCallbacks::onViewBackupKeyClick,
enabled = canViewBackupKey
enabled = state.canViewBackupKey
)
}
@@ -1122,13 +1118,17 @@ private fun OutOfStorageSpaceBlock(
Dividers.Default()
Row(
modifier = Modifier.horizontalGutters().padding(vertical = 12.dp)
modifier = Modifier
.horizontalGutters()
.padding(vertical = 12.dp)
) {
Icon(
imageVector = ImageVector.vectorResource(R.drawable.symbol_error_circle_fill_24),
tint = MaterialTheme.colorScheme.error,
contentDescription = null,
modifier = Modifier.padding(top = 4.dp, end = 4.dp, start = 2.dp).size(20.dp)
modifier = Modifier
.padding(top = 4.dp, end = 4.dp, start = 2.dp)
.size(20.dp)
)
Column {
@@ -1659,64 +1659,6 @@ private fun ResumeRestoreOverCellularDialog(
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun BackupFrequencyDialog(
selected: BackupFrequency,
onSelected: (BackupFrequency) -> Unit,
onDismiss: () -> Unit
) {
BasicAlertDialog(
onDismissRequest = onDismiss
) {
Surface(
color = Dialogs.Defaults.containerColor,
shape = Dialogs.Defaults.shape,
shadowElevation = Dialogs.Defaults.TonalElevation
) {
Column(
modifier = Modifier.fillMaxWidth()
) {
Text(
text = stringResource(id = R.string.RemoteBackupsSettingsFragment__backup_frequency),
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(24.dp)
)
BackupFrequency.entries.forEach {
Rows.RadioRow(
selected = selected == it,
text = getTextForFrequency(backupsFrequency = it),
label = when (it) {
BackupFrequency.MANUAL -> stringResource(id = R.string.RemoteBackupsSettingsFragment__by_tapping_back_up_now)
else -> null
},
modifier = Modifier
.padding(end = 24.dp)
.clickable(onClick = {
onSelected(it)
onDismiss()
})
)
}
Box(
contentAlignment = Alignment.CenterEnd,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp)
.padding(bottom = 24.dp)
) {
TextButton(onClick = onDismiss) {
Text(text = stringResource(id = android.R.string.cancel))
}
}
}
}
}
}
@Composable
private fun BackupReadyToDownloadRow(
ready: BackupRestoreState.Ready,
@@ -1773,7 +1715,6 @@ private fun RemoteBackupsSettingsContentPreview() {
lastBackupTimestamp = -1,
canBackUpUsingCellular = false,
canRestoreUsingCellular = false,
backupsFrequency = BackupFrequency.MANUAL,
dialog = RemoteBackupsSettingsState.Dialog.NONE,
snackbar = RemoteBackupsSettingsState.Snackbar.NONE,
backupMediaSize = 2300000,
@@ -1785,7 +1726,7 @@ private fun RemoteBackupsSettingsContentPreview() {
),
statusBarColorNestedScrollConnection = null,
backupDeleteState = DeletionState.NONE,
backupRestoreState = BackupRestoreState.FromBackupStatusData(BackupStatusData.CouldNotCompleteBackup),
backupRestoreState = BackupRestoreState.None,
contentCallbacks = ContentCallbacks.Empty,
backupProgress = null
)
@@ -1802,7 +1743,6 @@ private fun RemoteBackupsSettingsInternalUserContentPreview() {
lastBackupTimestamp = -1,
canBackUpUsingCellular = false,
canRestoreUsingCellular = false,
backupsFrequency = BackupFrequency.MANUAL,
dialog = RemoteBackupsSettingsState.Dialog.NONE,
snackbar = RemoteBackupsSettingsState.Snackbar.NONE,
backupMediaSize = 2300000,
@@ -1815,7 +1755,7 @@ private fun RemoteBackupsSettingsInternalUserContentPreview() {
),
statusBarColorNestedScrollConnection = null,
backupDeleteState = DeletionState.NONE,
backupRestoreState = BackupRestoreState.FromBackupStatusData(BackupStatusData.CouldNotCompleteBackup),
backupRestoreState = BackupRestoreState.None,
contentCallbacks = ContentCallbacks.Empty,
backupProgress = null
)
@@ -2109,18 +2049,6 @@ private fun SkipDownloadDialogPreview() {
}
}
@SignalPreview
@Composable
private fun BackupFrequencyDialogPreview() {
Previews.Preview {
BackupFrequencyDialog(
selected = BackupFrequency.DAILY,
onSelected = {},
onDismiss = {}
)
}
}
@SignalPreview
@Composable
private fun BackupDeletionCardPreview() {
@@ -2129,11 +2057,8 @@ private fun BackupDeletionCardPreview() {
for (state in DeletionState.entries.filter { it.hasUx() }) {
appendBackupDeletionItems(
backupDeleteState = state,
backupRestoreState = BackupRestoreState.FromBackupStatusData(
backupStatusData = BackupStatusData.RestoringMedia(
bytesDownloaded = 80.mebiBytes,
bytesTotal = 3.gibiBytes
)
backupRestoreState = BackupRestoreState.Restoring(
state = ArchiveRestoreProgressState(restoreState = RestoreState.RESTORING_MEDIA, restoreStatus = RestoreStatus.RESTORING, remainingRestoreSize = 800.mebiBytes, totalRestoreSize = 1024.mebiBytes)
),
contentCallbacks = ContentCallbacks.Empty,
canRestoreUsingCellular = true

View File

@@ -6,7 +6,6 @@
package org.thoughtcrime.securesms.components.settings.app.backups.remote
import org.signal.core.util.ByteSize
import org.thoughtcrime.securesms.backup.v2.BackupFrequency
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
import org.thoughtcrime.securesms.components.settings.app.backups.BackupState
@@ -24,13 +23,14 @@ data class RemoteBackupsSettingsState(
val totalAllowedStorageSpace: String = "",
val backupState: BackupState,
val backupMediaSize: Long = -1L,
val backupsFrequency: BackupFrequency = BackupFrequency.DAILY,
val lastBackupTimestamp: Long = 0,
val dialog: Dialog = Dialog.NONE,
val snackbar: Snackbar = Snackbar.NONE,
val includeDebuglog: Boolean? = null,
val canBackupMessagesJobRun: Boolean = false,
val backupMediaDetails: BackupMediaDetails? = null
val backupMediaDetails: BackupMediaDetails? = null,
val showBackupCreateFailedError: Boolean = false,
val showBackupCreateCouldNotCompleteError: Boolean = false
) {
data class BackupMediaDetails(

View File

@@ -30,11 +30,10 @@ import org.signal.core.util.throttleLatest
import org.signal.donations.InAppPaymentType
import org.thoughtcrime.securesms.backup.ArchiveUploadProgress
import org.thoughtcrime.securesms.backup.DeletionState
import org.thoughtcrime.securesms.backup.v2.BackupFrequency
import org.thoughtcrime.securesms.backup.v2.ArchiveRestoreProgress
import org.thoughtcrime.securesms.backup.v2.ArchiveRestoreProgressState.RestoreStatus
import org.thoughtcrime.securesms.backup.v2.BackupRepository
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
import org.thoughtcrime.securesms.backup.v2.ui.status.BackupStatusData
import org.thoughtcrime.securesms.banner.banners.MediaRestoreProgressBanner
import org.thoughtcrime.securesms.components.settings.app.backups.BackupStateObserver
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository
import org.thoughtcrime.securesms.database.InAppPaymentTable
@@ -45,7 +44,6 @@ import org.thoughtcrime.securesms.jobmanager.impl.BackupMessagesConstraint
import org.thoughtcrime.securesms.jobs.BackupMessagesJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.keyvalue.protos.ArchiveUploadProgressState
import org.thoughtcrime.securesms.service.MessageBackupListener
import org.thoughtcrime.securesms.util.Environment
import org.thoughtcrime.securesms.util.RemoteConfig
import org.thoughtcrime.securesms.util.TextSecurePreferences
@@ -70,10 +68,11 @@ class RemoteBackupsSettingsViewModel : ViewModel() {
canBackupMessagesJobRun = BackupMessagesConstraint.isMet(AppDependencies.application),
canViewBackupKey = !TextSecurePreferences.isUnauthorizedReceived(AppDependencies.application),
lastBackupTimestamp = SignalStore.backup.lastBackupTime,
backupsFrequency = SignalStore.backup.backupFrequency,
canBackUpUsingCellular = SignalStore.backup.backupWithCellular,
canRestoreUsingCellular = SignalStore.backup.restoreWithCellular,
includeDebuglog = SignalStore.internal.includeDebuglogInBackup.takeIf { RemoteConfig.internalUser }
includeDebuglog = SignalStore.internal.includeDebuglogInBackup.takeIf { RemoteConfig.internalUser },
showBackupCreateFailedError = BackupRepository.shouldDisplayBackupFailedSettingsRow(),
showBackupCreateCouldNotCompleteError = BackupRepository.shouldDisplayCouldNotCompleteBackupSettingsRow()
)
)
@@ -114,16 +113,14 @@ class RemoteBackupsSettingsViewModel : ViewModel() {
}
viewModelScope.launch(Dispatchers.IO) {
val restoreProgress = MediaRestoreProgressBanner()
var optimizedRemainingBytes = 0L
while (isActive) {
if (restoreProgress.enabled) {
if (ArchiveRestoreProgress.state.let { it.restoreState.isMediaRestoreOperation || it.restoreStatus == RestoreStatus.FINISHED }) {
Log.d(TAG, "Backup is being restored. Collecting updates.")
restoreProgress
.dataFlow
.onEach { latest -> _restoreState.update { BackupRestoreState.FromBackupStatusData(latest) } }
.takeWhile { it !is BackupStatusData.RestoringMedia || it.restoreStatus != BackupStatusData.RestoreStatus.FINISHED }
ArchiveRestoreProgress
.stateFlow
.takeWhile { it.restoreState.isMediaRestoreOperation || it.restoreStatus == RestoreStatus.FINISHED }
.onEach { latest -> _restoreState.update { BackupRestoreState.Restoring(latest) } }
.collect()
} else if (
!SignalStore.backup.optimizeStorage &&
@@ -133,10 +130,6 @@ class RemoteBackupsSettingsViewModel : ViewModel() {
_restoreState.update { BackupRestoreState.Ready(optimizedRemainingBytes.bytes.toUnitString()) }
} else if (SignalStore.backup.totalRestorableAttachmentSize > 0L) {
_restoreState.update { BackupRestoreState.Ready(SignalStore.backup.totalRestorableAttachmentSize.bytes.toUnitString()) }
} else if (BackupRepository.shouldDisplayBackupFailedSettingsRow()) {
_restoreState.update { BackupRestoreState.FromBackupStatusData(BackupStatusData.BackupFailed) }
} else if (BackupRepository.shouldDisplayCouldNotCompleteBackupSettingsRow()) {
_restoreState.update { BackupRestoreState.FromBackupStatusData(BackupStatusData.CouldNotCompleteBackup) }
} else {
_restoreState.update { BackupRestoreState.None }
}
@@ -164,6 +157,10 @@ class RemoteBackupsSettingsViewModel : ViewModel() {
}
}
}
viewModelScope.launch(Dispatchers.IO) {
BackupRepository.maybeFixAnyDanglingUploadProgress()
}
}
fun setCanBackUpUsingCellular(canBackUpUsingCellular: Boolean) {
@@ -181,17 +178,18 @@ class RemoteBackupsSettingsViewModel : ViewModel() {
_state.update { it.copy(canRestoreUsingCellular = true) }
}
fun setBackupsFrequency(backupsFrequency: BackupFrequency) {
SignalStore.backup.backupFrequency = backupsFrequency
_state.update { it.copy(backupsFrequency = backupsFrequency) }
MessageBackupListener.setNextBackupTimeToIntervalFromNow()
MessageBackupListener.schedule(AppDependencies.application)
}
fun beginMediaRestore() {
BackupRepository.resumeMediaRestore()
}
fun cancelMediaRestore() {
if (ArchiveRestoreProgress.state.restoreStatus == RestoreStatus.FINISHED) {
ArchiveRestoreProgress.clearFinishedStatus()
} else {
requestDialog(RemoteBackupsSettingsState.Dialog.CANCEL_MEDIA_RESTORE_PROTECTION)
}
}
fun skipMediaRestore() {
BackupRepository.skipMediaRestore()
@@ -298,11 +296,12 @@ class RemoteBackupsSettingsViewModel : ViewModel() {
lastBackupTimestamp = SignalStore.backup.lastBackupTime,
canBackupMessagesJobRun = BackupMessagesConstraint.isMet(AppDependencies.application),
backupMediaSize = getBackupMediaSize(),
backupsFrequency = SignalStore.backup.backupFrequency,
canBackUpUsingCellular = SignalStore.backup.backupWithCellular,
canRestoreUsingCellular = SignalStore.backup.restoreWithCellular,
isOutOfStorageSpace = BackupRepository.shouldDisplayOutOfRemoteStorageSpaceUx(),
hasRedemptionError = lastPurchase?.data?.error?.data_ == "409"
hasRedemptionError = lastPurchase?.data?.error?.data_ == "409",
showBackupCreateFailedError = BackupRepository.shouldDisplayBackupFailedSettingsRow(),
showBackupCreateCouldNotCompleteError = BackupRepository.shouldDisplayCouldNotCompleteBackupSettingsRow()
)
}
}

View File

@@ -7,6 +7,7 @@ package org.thoughtcrime.securesms.components.settings.app.changenumber
import android.os.Bundle
import android.text.InputType
import android.text.method.PasswordTransformationMethod
import android.view.KeyEvent
import android.view.View
import android.view.inputmethod.EditorInfo
@@ -287,11 +288,12 @@ class ChangeNumberRegistrationLockFragment : LoggingFragment(R.layout.fragment_c
val isAlphaNumeric = keyboard == PinKeyboardType.ALPHA_NUMERIC
binding.kbsLockPinInput.setInputType(
if (isAlphaNumeric) InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
else InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_VARIATION_PASSWORD
if (isAlphaNumeric) InputType.TYPE_CLASS_TEXT
else InputType.TYPE_CLASS_NUMBER
)
binding.kbsLockPinInput.getText().clear()
binding.kbsLockPinInput.transformationMethod = PasswordTransformationMethod.getInstance()
}
private fun navigateToAccountLocked() {

View File

@@ -1,125 +1,247 @@
package org.thoughtcrime.securesms.components.settings.app.chats
import androidx.lifecycle.ViewModelProvider
import androidx.navigation.Navigation
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.fragment.app.viewModels
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.fragment.findNavController
import org.signal.core.ui.compose.Dividers
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.Rows
import org.signal.core.ui.compose.Scaffolds
import org.signal.core.ui.compose.SignalPreview
import org.signal.core.ui.compose.Texts
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.compose.ComposeFragment
import org.thoughtcrime.securesms.compose.rememberStatusBarColorNestedScrollModifier
import org.thoughtcrime.securesms.util.RemoteConfig
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import org.thoughtcrime.securesms.util.navigation.safeNavigate
class ChatsSettingsFragment : DSLSettingsFragment(R.string.preferences_chats__chats) {
/**
* Displays a list of chats settings options to the user, including
* generating link previews and keeping muted chats archived.
*/
class ChatsSettingsFragment : ComposeFragment() {
private lateinit var viewModel: ChatsSettingsViewModel
private val viewModel: ChatsSettingsViewModel by viewModels()
override fun onResume() {
super.onResume()
viewModel.refresh()
}
@Suppress("ReplaceGetOrSet")
override fun bindAdapter(adapter: MappingAdapter) {
viewModel = ViewModelProvider(this).get(ChatsSettingsViewModel::class.java)
@Composable
override fun FragmentContent() {
val state by viewModel.state.collectAsStateWithLifecycle()
val callbacks = remember { Callbacks() }
viewModel.state.observe(viewLifecycleOwner) {
adapter.submitList(getConfiguration(it).toMappingModelList())
}
ChatsSettingsScreen(
state = state,
callbacks = callbacks,
isRemoteBackupsAvailable = RemoteConfig.messageBackups
)
}
private fun getConfiguration(state: ChatsSettingsState): DSLConfiguration {
return configure {
switchPref(
title = DSLSettingsText.from(R.string.preferences__generate_link_previews),
summary = DSLSettingsText.from(R.string.preferences__retrieve_link_previews_from_websites_for_messages),
isEnabled = state.isRegisteredAndUpToDate(),
isChecked = state.generateLinkPreviews,
onClick = {
viewModel.setGenerateLinkPreviewsEnabled(!state.generateLinkPreviews)
}
)
private inner class Callbacks : ChatsSettingsCallbacks {
override fun onNavigationClick() {
requireActivity().onBackPressedDispatcher.onBackPressed()
}
switchPref(
title = DSLSettingsText.from(R.string.preferences__pref_use_address_book_photos),
summary = DSLSettingsText.from(R.string.preferences__display_contact_photos_from_your_address_book_if_available),
isEnabled = state.isRegisteredAndUpToDate(),
isChecked = state.useAddressBook,
onClick = {
viewModel.setUseAddressBook(!state.useAddressBook)
}
)
override fun onGenerateLinkPreviewsChanged(enabled: Boolean) {
viewModel.setGenerateLinkPreviewsEnabled(enabled)
}
switchPref(
title = DSLSettingsText.from(R.string.preferences__pref_keep_muted_chats_archived),
summary = DSLSettingsText.from(R.string.preferences__muted_chats_that_are_archived_will_remain_archived),
isEnabled = state.isRegisteredAndUpToDate(),
isChecked = state.keepMutedChatsArchived,
onClick = {
viewModel.setKeepMutedChatsArchived(!state.keepMutedChatsArchived)
}
)
override fun onUseAddressBookChanged(enabled: Boolean) {
viewModel.setUseAddressBook(enabled)
}
dividerPref()
override fun onKeepMutedChatsArchivedChanged(enabled: Boolean) {
viewModel.setKeepMutedChatsArchived(enabled)
}
sectionHeaderPref(R.string.ChatsSettingsFragment__chat_folders)
override fun onAddAChatFolderClick() {
findNavController().safeNavigate(R.id.action_chatsSettingsFragment_to_chatFoldersFragment)
}
if (state.folderCount == 1) {
clickPref(
title = DSLSettingsText.from(R.string.ChatsSettingsFragment__add_chat_folder),
isEnabled = state.isRegisteredAndUpToDate(),
onClick = {
Navigation.findNavController(requireView()).safeNavigate(R.id.action_chatsSettingsFragment_to_chatFoldersFragment)
}
)
} else {
clickPref(
title = DSLSettingsText.from(R.string.ChatsSettingsFragment__add_edit_chat_folder),
summary = DSLSettingsText.from(resources.getQuantityString(R.plurals.ChatsSettingsFragment__d_folder, state.folderCount, state.folderCount)),
isEnabled = state.isRegisteredAndUpToDate(),
onClick = {
Navigation.findNavController(requireView()).safeNavigate(R.id.action_chatsSettingsFragment_to_chatFoldersFragment)
}
override fun onAddOrEditFoldersClick() {
findNavController().safeNavigate(R.id.action_chatsSettingsFragment_to_chatFoldersFragment)
}
override fun onUseSystemEmojiChanged(enabled: Boolean) {
viewModel.setUseSystemEmoji(enabled)
}
override fun onEnterKeySendsChanged(enabled: Boolean) {
viewModel.setEnterKeySends(enabled)
}
override fun onChatBackupsClick() {
findNavController().safeNavigate(R.id.action_chatsSettingsFragment_to_backupsPreferenceFragment)
}
}
}
private interface ChatsSettingsCallbacks {
fun onNavigationClick() = Unit
fun onGenerateLinkPreviewsChanged(enabled: Boolean) = Unit
fun onUseAddressBookChanged(enabled: Boolean) = Unit
fun onKeepMutedChatsArchivedChanged(enabled: Boolean) = Unit
fun onAddAChatFolderClick() = Unit
fun onAddOrEditFoldersClick() = Unit
fun onUseSystemEmojiChanged(enabled: Boolean) = Unit
fun onEnterKeySendsChanged(enabled: Boolean) = Unit
fun onChatBackupsClick() = Unit
object Empty : ChatsSettingsCallbacks
}
@Composable
private fun ChatsSettingsScreen(
isRemoteBackupsAvailable: Boolean,
state: ChatsSettingsState,
callbacks: ChatsSettingsCallbacks
) {
Scaffolds.Settings(
title = stringResource(R.string.preferences_chats__chats),
onNavigationClick = callbacks::onNavigationClick,
navigationIcon = ImageVector.vectorResource(R.drawable.symbol_arrow_start_24)
) { paddingValues ->
LazyColumn(
modifier = Modifier
.padding(paddingValues)
.then(rememberStatusBarColorNestedScrollModifier())
) {
item {
Rows.ToggleRow(
text = stringResource(R.string.preferences__generate_link_previews),
label = stringResource(R.string.preferences__retrieve_link_previews_from_websites_for_messages),
enabled = state.isRegisteredAndUpToDate(),
checked = state.generateLinkPreviews,
onCheckChanged = callbacks::onGenerateLinkPreviewsChanged
)
}
dividerPref()
sectionHeaderPref(R.string.ChatsSettingsFragment__keyboard)
switchPref(
title = DSLSettingsText.from(R.string.preferences_advanced__use_system_emoji),
isEnabled = state.isRegisteredAndUpToDate(),
isChecked = state.useSystemEmoji,
onClick = {
viewModel.setUseSystemEmoji(!state.useSystemEmoji)
}
)
switchPref(
title = DSLSettingsText.from(R.string.ChatsSettingsFragment__send_with_enter),
isEnabled = state.isRegisteredAndUpToDate(),
isChecked = state.enterKeySends,
onClick = {
viewModel.setEnterKeySends(!state.enterKeySends)
}
)
if (!RemoteConfig.messageBackups) {
dividerPref()
sectionHeaderPref(R.string.preferences_chats__backups)
clickPref(
title = DSLSettingsText.from(R.string.preferences_chats__chat_backups),
summary = DSLSettingsText.from(if (state.localBackupsEnabled) R.string.arrays__enabled else R.string.arrays__disabled),
isEnabled = state.localBackupsEnabled || state.isRegisteredAndUpToDate(),
onClick = {
Navigation.findNavController(requireView()).safeNavigate(R.id.action_chatsSettingsFragment_to_backupsPreferenceFragment)
}
item {
Rows.ToggleRow(
text = stringResource(R.string.preferences__pref_use_address_book_photos),
label = stringResource(R.string.preferences__display_contact_photos_from_your_address_book_if_available),
enabled = state.isRegisteredAndUpToDate(),
checked = state.useAddressBook,
onCheckChanged = callbacks::onUseAddressBookChanged
)
}
item {
Rows.ToggleRow(
text = stringResource(R.string.preferences__pref_keep_muted_chats_archived),
label = stringResource(R.string.preferences__muted_chats_that_are_archived_will_remain_archived),
enabled = state.isRegisteredAndUpToDate(),
checked = state.keepMutedChatsArchived,
onCheckChanged = callbacks::onKeepMutedChatsArchivedChanged
)
}
item {
Dividers.Default()
}
item {
Texts.SectionHeader(stringResource(R.string.ChatsSettingsFragment__chat_folders))
}
if (state.folderCount == 1) {
item {
Rows.TextRow(
text = stringResource(R.string.ChatsSettingsFragment__add_chat_folder),
enabled = state.isRegisteredAndUpToDate(),
onClick = callbacks::onAddAChatFolderClick
)
}
} else {
item {
Rows.TextRow(
text = stringResource(R.string.ChatsSettingsFragment__add_edit_chat_folder),
label = pluralStringResource(R.plurals.ChatsSettingsFragment__d_folder, state.folderCount, state.folderCount),
enabled = state.isRegisteredAndUpToDate(),
onClick = callbacks::onAddOrEditFoldersClick
)
}
}
item {
Dividers.Default()
}
item {
Texts.SectionHeader(stringResource(R.string.ChatsSettingsFragment__keyboard))
}
item {
Rows.ToggleRow(
text = stringResource(R.string.preferences_advanced__use_system_emoji),
enabled = state.isRegisteredAndUpToDate(),
checked = state.useSystemEmoji,
onCheckChanged = callbacks::onUseSystemEmojiChanged
)
}
item {
Rows.ToggleRow(
text = stringResource(R.string.ChatsSettingsFragment__send_with_enter),
enabled = state.isRegisteredAndUpToDate(),
checked = state.enterKeySends,
onCheckChanged = callbacks::onEnterKeySendsChanged
)
}
if (!isRemoteBackupsAvailable) {
item {
Dividers.Default()
}
item {
Texts.SectionHeader(stringResource(R.string.preferences_chats__backups))
}
item {
Rows.TextRow(
text = stringResource(R.string.preferences_chats__chat_backups),
label = stringResource(if (state.localBackupsEnabled) R.string.arrays__enabled else R.string.arrays__disabled),
enabled = state.localBackupsEnabled || state.isRegisteredAndUpToDate(),
onClick = callbacks::onChatBackupsClick
)
}
}
}
}
}
@SignalPreview
@Composable
private fun ChatsSettingsScreenPreview() {
Previews.Preview {
ChatsSettingsScreen(
state = ChatsSettingsState(
generateLinkPreviews = true,
useAddressBook = true,
keepMutedChatsArchived = true,
useSystemEmoji = false,
enterKeySends = false,
localBackupsEnabled = true,
folderCount = 1,
userUnregistered = false,
clientDeprecated = false
),
callbacks = ChatsSettingsCallbacks.Empty,
isRemoteBackupsAvailable = false
)
}
}

View File

@@ -1,9 +1,11 @@
package org.thoughtcrime.securesms.components.settings.app.chats
import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.thoughtcrime.securesms.components.settings.app.chats.folders.ChatFoldersRepository
import org.thoughtcrime.securesms.dependencies.AppDependencies
@@ -12,7 +14,6 @@ import org.thoughtcrime.securesms.util.BackupUtil
import org.thoughtcrime.securesms.util.ConversationUtil
import org.thoughtcrime.securesms.util.TextSecurePreferences
import org.thoughtcrime.securesms.util.ThrottledDebouncer
import org.thoughtcrime.securesms.util.livedata.Store
class ChatsSettingsViewModel @JvmOverloads constructor(
private val repository: ChatsSettingsRepository = ChatsSettingsRepository()
@@ -20,7 +21,7 @@ class ChatsSettingsViewModel @JvmOverloads constructor(
private val refreshDebouncer = ThrottledDebouncer(500L)
private val store: Store<ChatsSettingsState> = Store(
private val store = MutableStateFlow(
ChatsSettingsState(
generateLinkPreviews = SignalStore.settings.isLinkPreviewsEnabled,
useAddressBook = SignalStore.settings.isPreferSystemContactPhotos,
@@ -34,7 +35,7 @@ class ChatsSettingsViewModel @JvmOverloads constructor(
)
)
val state: LiveData<ChatsSettingsState> = store.stateLiveData
val state: StateFlow<ChatsSettingsState> = store
fun setGenerateLinkPreviewsEnabled(enabled: Boolean) {
store.update { it.copy(generateLinkPreviews = enabled) }
@@ -70,7 +71,7 @@ class ChatsSettingsViewModel @JvmOverloads constructor(
val count = ChatFoldersRepository.getFolderCount()
val backupsEnabled = SignalStore.settings.isBackupEnabled && BackupUtil.canUserAccessBackupDirectory(AppDependencies.application)
if (store.state.localBackupsEnabled != backupsEnabled) {
if (store.value.localBackupsEnabled != backupsEnabled) {
store.update {
it.copy(
folderCount = count,

View File

@@ -1,134 +1,269 @@
package org.thoughtcrime.securesms.components.settings.app.data
import androidx.lifecycle.ViewModelProvider
import androidx.navigation.Navigation
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringArrayResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.fragment.app.viewModels
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.fragment.findNavController
import androidx.preference.PreferenceManager
import org.signal.core.ui.compose.Dividers
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.Rows
import org.signal.core.ui.compose.Scaffolds
import org.signal.core.ui.compose.SignalPreview
import org.signal.core.ui.compose.Texts
import org.signal.core.util.bytes
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.compose.ComposeFragment
import org.thoughtcrime.securesms.compose.rememberStatusBarColorNestedScrollModifier
import org.thoughtcrime.securesms.mms.SentMediaQuality
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import org.thoughtcrime.securesms.util.navigation.safeNavigate
import org.thoughtcrime.securesms.webrtc.CallDataMode
import kotlin.math.abs
class DataAndStorageSettingsFragment : DSLSettingsFragment(R.string.preferences__data_and_storage) {
class DataAndStorageSettingsFragment : ComposeFragment() {
private val autoDownloadValues by lazy { resources.getStringArray(R.array.pref_media_download_entries) }
private val autoDownloadLabels by lazy { resources.getStringArray(R.array.pref_media_download_values) }
private val sentMediaQualityLabels by lazy { SentMediaQuality.getLabels(requireContext()) }
private val callDataModeLabels by lazy { resources.getStringArray(R.array.pref_data_and_storage_call_data_mode_values) }
private lateinit var viewModel: DataAndStorageSettingsViewModel
private val viewModel: DataAndStorageSettingsViewModel by viewModels(
factoryProducer = {
val preferences = PreferenceManager.getDefaultSharedPreferences(requireContext())
val repository = DataAndStorageSettingsRepository()
DataAndStorageSettingsViewModel.Factory(preferences, repository)
}
)
override fun onResume() {
super.onResume()
viewModel.refresh()
}
override fun bindAdapter(adapter: MappingAdapter) {
val preferences = PreferenceManager.getDefaultSharedPreferences(requireContext())
val repository = DataAndStorageSettingsRepository()
val factory = DataAndStorageSettingsViewModel.Factory(preferences, repository)
viewModel = ViewModelProvider(this, factory)[DataAndStorageSettingsViewModel::class.java]
@Composable
override fun FragmentContent() {
val state by viewModel.state.collectAsStateWithLifecycle()
val callbacks = remember { Callbacks() }
viewModel.state.observe(viewLifecycleOwner) {
adapter.submitList(getConfiguration(it).toMappingModelList())
}
DataAndStorageSettingsScreen(
state = state,
callbacks = callbacks
)
}
fun getConfiguration(state: DataAndStorageSettingsState): DSLConfiguration {
return configure {
clickPref(
title = DSLSettingsText.from(R.string.preferences_data_and_storage__manage_storage),
summary = DSLSettingsText.from(state.totalStorageUse.bytes.toUnitString()),
onClick = {
Navigation.findNavController(requireView()).safeNavigate(R.id.action_dataAndStorageSettingsFragment_to_storagePreferenceFragment)
}
)
private inner class Callbacks : DataAndStorageSettingsCallbacks {
override fun onNavigationClick() {
requireActivity().onBackPressedDispatcher.onBackPressed()
}
dividerPref()
override fun onManageStorageClick() {
findNavController().safeNavigate(R.id.action_dataAndStorageSettingsFragment_to_storagePreferenceFragment)
}
sectionHeaderPref(R.string.preferences_chats__media_auto_download)
override fun onSentMediaQualitySelected(code: String) {
viewModel.setSentMediaQuality(SentMediaQuality.fromCode(code.toInt()))
}
multiSelectPref(
title = DSLSettingsText.from(R.string.preferences_chats__when_using_mobile_data),
listItems = autoDownloadLabels,
selected = autoDownloadValues.map { state.mobileAutoDownloadValues.contains(it) }.toBooleanArray(),
onSelected = {
val resultSet = it.mapIndexed { index, selected -> if (selected) autoDownloadValues[index] else null }.filterNotNull().toSet()
viewModel.setMobileAutoDownloadValues(resultSet)
}
)
override fun onCallDataModeSelected(code: String) {
viewModel.setCallDataMode(CallDataMode.fromCode(abs(code.toInt() - 2)))
}
multiSelectPref(
title = DSLSettingsText.from(R.string.preferences_chats__when_using_wifi),
listItems = autoDownloadLabels,
selected = autoDownloadValues.map { state.wifiAutoDownloadValues.contains(it) }.toBooleanArray(),
onSelected = {
val resultSet = it.mapIndexed { index, selected -> if (selected) autoDownloadValues[index] else null }.filterNotNull().toSet()
viewModel.setWifiAutoDownloadValues(resultSet)
}
)
override fun onUseProxyClick() {
findNavController().safeNavigate(R.id.action_dataAndStorageSettingsFragment_to_editProxyFragment)
}
multiSelectPref(
title = DSLSettingsText.from(R.string.preferences_chats__when_roaming),
listItems = autoDownloadLabels,
selected = autoDownloadValues.map { state.roamingAutoDownloadValues.contains(it) }.toBooleanArray(),
onSelected = {
val resultSet = it.mapIndexed { index, selected -> if (selected) autoDownloadValues[index] else null }.filterNotNull().toSet()
viewModel.setRoamingAutoDownloadValues(resultSet)
}
)
override fun onMobileDataAutoDownloadSelectionChanged(selection: Array<String>) {
viewModel.setMobileAutoDownloadValues(selection.toSet())
}
dividerPref()
override fun onWifiDataAutoDownloadSelectionChanged(selection: Array<String>) {
viewModel.setWifiAutoDownloadValues(selection.toSet())
}
sectionHeaderPref(R.string.DataAndStorageSettingsFragment__media_quality)
radioListPref(
title = DSLSettingsText.from(R.string.DataAndStorageSettingsFragment__sent_media_quality),
listItems = sentMediaQualityLabels,
selected = SentMediaQuality.entries.indexOf(state.sentMediaQuality),
onSelected = { viewModel.setSentMediaQuality(SentMediaQuality.entries[it]) }
)
textPref(
summary = DSLSettingsText.from(R.string.DataAndStorageSettingsFragment__sending_high_quality_media_will_use_more_data)
)
dividerPref()
sectionHeaderPref(R.string.DataAndStorageSettingsFragment__calls)
radioListPref(
title = DSLSettingsText.from(R.string.preferences_data_and_storage__use_less_data_for_calls),
listItems = callDataModeLabels,
selected = abs(state.callDataMode.code - 2),
onSelected = {
viewModel.setCallDataMode(CallDataMode.fromCode(abs(it - 2)))
}
)
textPref(
summary = DSLSettingsText.from(R.string.preference_data_and_storage__using_less_data_may_improve_calls_on_bad_networks)
)
dividerPref()
sectionHeaderPref(R.string.preferences_proxy)
clickPref(
title = DSLSettingsText.from(R.string.preferences_use_proxy),
summary = DSLSettingsText.from(if (state.isProxyEnabled) R.string.preferences_on else R.string.preferences_off),
onClick = {
Navigation.findNavController(requireView()).safeNavigate(R.id.action_dataAndStorageSettingsFragment_to_editProxyFragment)
}
)
override fun onRoamingDataAutoDownloadSelectionChanged(selection: Array<String>) {
viewModel.setRoamingAutoDownloadValues(selection.toSet())
}
}
}
private interface DataAndStorageSettingsCallbacks {
fun onNavigationClick() = Unit
fun onManageStorageClick() = Unit
fun onSentMediaQualitySelected(code: String) = Unit
fun onCallDataModeSelected(code: String) = Unit
fun onUseProxyClick() = Unit
fun onMobileDataAutoDownloadSelectionChanged(selection: Array<String>) = Unit
fun onWifiDataAutoDownloadSelectionChanged(selection: Array<String>) = Unit
fun onRoamingDataAutoDownloadSelectionChanged(selection: Array<String>) = Unit
object Empty : DataAndStorageSettingsCallbacks
}
@Composable
private fun DataAndStorageSettingsScreen(
state: DataAndStorageSettingsState,
callbacks: DataAndStorageSettingsCallbacks
) {
Scaffolds.Settings(
title = stringResource(R.string.preferences__data_and_storage),
onNavigationClick = callbacks::onNavigationClick,
navigationIcon = ImageVector.vectorResource(R.drawable.symbol_arrow_start_24)
) { paddingValues ->
LazyColumn(
modifier = Modifier
.padding(paddingValues)
.then(rememberStatusBarColorNestedScrollModifier())
) {
item {
Rows.TextRow(
text = stringResource(R.string.preferences_data_and_storage__manage_storage),
label = state.totalStorageUse.bytes.toUnitString(),
onClick = callbacks::onManageStorageClick
)
}
item {
Dividers.Default()
}
item {
Texts.SectionHeader(stringResource(R.string.preferences_chats__media_auto_download))
}
item {
Rows.MultiSelectRow(
text = stringResource(R.string.preferences_chats__when_using_mobile_data),
labels = stringArrayResource(R.array.pref_media_download_entries),
values = stringArrayResource(R.array.pref_media_download_values),
selection = state.mobileAutoDownloadValues.toTypedArray(),
onSelectionChanged = callbacks::onMobileDataAutoDownloadSelectionChanged
)
}
item {
Rows.MultiSelectRow(
text = stringResource(R.string.preferences_chats__when_using_wifi),
labels = stringArrayResource(R.array.pref_media_download_entries),
values = stringArrayResource(R.array.pref_media_download_values),
selection = state.wifiAutoDownloadValues.toTypedArray(),
onSelectionChanged = callbacks::onWifiDataAutoDownloadSelectionChanged
)
}
item {
Rows.MultiSelectRow(
text = stringResource(R.string.preferences_chats__when_roaming),
labels = stringArrayResource(R.array.pref_media_download_entries),
values = stringArrayResource(R.array.pref_media_download_values),
selection = state.roamingAutoDownloadValues.toTypedArray(),
onSelectionChanged = callbacks::onRoamingDataAutoDownloadSelectionChanged
)
}
item {
Dividers.Default()
}
item {
Texts.SectionHeader(stringResource(R.string.DataAndStorageSettingsFragment__media_quality))
}
item {
val context = LocalContext.current
val labels = remember { SentMediaQuality.getLabels(context) }
Rows.RadioListRow(
text = stringResource(R.string.DataAndStorageSettingsFragment__sent_media_quality),
labels = labels,
values = SentMediaQuality.entries.map { it.code.toString() }.toTypedArray(),
selectedValue = state.sentMediaQuality.code.toString(),
onSelected = callbacks::onSentMediaQualitySelected
)
}
item {
Rows.TextRow(
text = {
Text(
text = stringResource(R.string.DataAndStorageSettingsFragment__sending_high_quality_media_will_use_more_data),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
)
}
item {
Dividers.Default()
}
item {
Texts.SectionHeader(stringResource(R.string.DataAndStorageSettingsFragment__calls))
}
item {
Rows.RadioListRow(
text = stringResource(R.string.preferences_data_and_storage__use_less_data_for_calls),
labels = stringArrayResource(R.array.pref_data_and_storage_call_data_mode_values),
values = CallDataMode.entries.map { it.code.toString() }.toTypedArray(),
selectedValue = abs(state.callDataMode.code - 2).toString(),
onSelected = callbacks::onCallDataModeSelected
)
}
item {
Rows.TextRow(
text = {
Text(
text = stringResource(R.string.preference_data_and_storage__using_less_data_may_improve_calls_on_bad_networks),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
)
}
item {
Dividers.Default()
}
item {
Texts.SectionHeader(stringResource(R.string.preferences_proxy))
}
item {
Rows.TextRow(
text = stringResource(R.string.preferences_use_proxy),
label = stringResource(if (state.isProxyEnabled) R.string.preferences_on else R.string.preferences_off),
onClick = callbacks::onUseProxyClick
)
}
}
}
}
@SignalPreview
@Composable
private fun DataAndStorageSettingsScreenPreview() {
Previews.Preview {
DataAndStorageSettingsScreen(
state = DataAndStorageSettingsState(
totalStorageUse = 100_000,
mobileAutoDownloadValues = setOf(),
wifiAutoDownloadValues = setOf(),
roamingAutoDownloadValues = setOf(),
callDataMode = CallDataMode.HIGH_ALWAYS,
isProxyEnabled = false,
sentMediaQuality = SentMediaQuality.STANDARD
),
callbacks = DataAndStorageSettingsCallbacks.Empty
)
}
}

View File

@@ -1,14 +1,15 @@
package org.thoughtcrime.securesms.components.settings.app.data
import android.content.SharedPreferences
import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.mms.SentMediaQuality
import org.thoughtcrime.securesms.util.TextSecurePreferences
import org.thoughtcrime.securesms.util.livedata.Store
import org.thoughtcrime.securesms.webrtc.CallDataMode
class DataAndStorageSettingsViewModel(
@@ -16,9 +17,9 @@ class DataAndStorageSettingsViewModel(
private val repository: DataAndStorageSettingsRepository
) : ViewModel() {
private val store = Store(getState())
private val store = MutableStateFlow(getState())
val state: LiveData<DataAndStorageSettingsState> = store.stateLiveData
val state: StateFlow<DataAndStorageSettingsState> = store
fun refresh() {
repository.getTotalStorageUse { totalStorageUse ->

View File

@@ -68,6 +68,7 @@ import org.signal.core.ui.compose.TextFields.TextField
import org.signal.core.util.Base64
import org.signal.core.util.Hex
import org.signal.core.util.getLength
import org.thoughtcrime.securesms.MainActivity
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.backup.v2.BackupRepository
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
@@ -78,6 +79,7 @@ import org.thoughtcrime.securesms.components.settings.app.internal.backup.Intern
import org.thoughtcrime.securesms.compose.ComposeFragment
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobs.ArchiveAttachmentReconciliationJob
import org.thoughtcrime.securesms.jobs.BackupRestoreMediaJob
import org.thoughtcrime.securesms.jobs.LocalBackupJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.Util
@@ -153,6 +155,7 @@ class InternalBackupPlaygroundFragment : ComposeFragment() {
onCheckRemoteBackupStateClicked = { viewModel.checkRemoteBackupState() },
onEnqueueRemoteBackupClicked = { viewModel.triggerBackupJob() },
onEnqueueReconciliationClicked = { AppDependencies.jobManager.add(ArchiveAttachmentReconciliationJob(forced = true)) },
onEnqueueMediaRestoreClicked = { AppDependencies.jobManager.add(BackupRestoreMediaJob()) },
onHaltAllBackupJobsClicked = { viewModel.haltAllJobs() },
onValidateBackupClicked = { viewModel.validateBackup() },
onSaveEncryptedBackupToDiskClicked = {
@@ -190,7 +193,12 @@ class InternalBackupPlaygroundFragment : ComposeFragment() {
MaterialAlertDialogBuilder(context)
.setTitle("Are you sure?")
.setMessage("This will delete all of your chats! Make sure you've finished a backup first, we don't check for you. Only do this on a test device!")
.setPositiveButton("Wipe and restore") { _, _ -> viewModel.wipeAllDataAndRestoreFromRemote() }
.setPositiveButton("Wipe and restore") { _, _ ->
Toast.makeText(this@InternalBackupPlaygroundFragment.requireContext(), "Restoring backup...", Toast.LENGTH_SHORT).show()
viewModel.wipeAllDataAndRestoreFromRemote {
startActivity(MainActivity.clearTop(this@InternalBackupPlaygroundFragment.requireActivity()))
}
}
.show()
},
onImportEncryptedBackupFromDiskClicked = {
@@ -329,6 +337,7 @@ fun Screen(
onCheckRemoteBackupStateClicked: () -> Unit = {},
onEnqueueRemoteBackupClicked: () -> Unit = {},
onEnqueueReconciliationClicked: () -> Unit = {},
onEnqueueMediaRestoreClicked: () -> Unit = {},
onWipeDataAndRestoreFromRemoteClicked: () -> Unit = {},
onHaltAllBackupJobsClicked: () -> Unit = {},
onSavePlaintextCopyOfRemoteBackupClicked: () -> Unit = {},
@@ -400,6 +409,12 @@ fun Screen(
onClick = onEnqueueReconciliationClicked
)
Rows.TextRow(
text = "Enqueue media restore job",
label = "Schedules a job that will restore any NEEDS_RESTORE media.",
onClick = onEnqueueMediaRestoreClicked
)
Rows.TextRow(
text = "Halt all backup jobs",
label = "Stops all backup-related jobs to the best of our ability.",

View File

@@ -24,6 +24,7 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.signal.core.util.Hex
import org.signal.core.util.ThreadUtil
import org.signal.core.util.bytes
import org.signal.core.util.concurrent.SignalExecutors
import org.signal.core.util.copyTo
@@ -305,10 +306,10 @@ class InternalBackupPlaygroundViewModel : ViewModel() {
}
}
fun wipeAllDataAndRestoreFromRemote() {
fun wipeAllDataAndRestoreFromRemote(afterDbRestoreCallback: () -> Unit) {
SignalExecutors.BOUNDED_IO.execute {
SignalStore.backup.restoreWithCellular = false
restoreFromRemote()
restoreFromRemote(afterDbRestoreCallback)
}
}
@@ -352,12 +353,15 @@ class InternalBackupPlaygroundViewModel : ViewModel() {
_state.value = _state.value.copy(dialog = DialogState.None)
}
private fun restoreFromRemote() {
private fun restoreFromRemote(afterDbRestoreCallback: () -> Unit) {
_state.value = _state.value.copy(statusMessage = "Importing from remote...")
viewModelScope.launch {
when (val result = BackupRepository.restoreRemoteBackup()) {
RemoteRestoreResult.Success -> _state.value = _state.value.copy(statusMessage = "Import complete!")
RemoteRestoreResult.Success -> {
_state.value = _state.value.copy(statusMessage = "Import complete!")
ThreadUtil.runOnMain { afterDbRestoreCallback() }
}
RemoteRestoreResult.Canceled,
RemoteRestoreResult.Failure,
RemoteRestoreResult.PermanentSvrBFailure,

View File

@@ -17,6 +17,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import org.signal.core.ui.compose.Dividers
import org.signal.core.ui.compose.Rows
import org.signal.core.ui.compose.Texts
import org.signal.core.util.bytes
@@ -24,49 +25,65 @@ import org.signal.core.util.bytes
fun InternalBackupStatsTab(stats: InternalBackupPlaygroundViewModel.StatsState, callbacks: StatsCallbacks) {
val scrollState = rememberScrollState()
Column(modifier = Modifier.verticalScroll(scrollState)) {
Texts.SectionHeader(text = "Local Attachment State")
if (stats.attachmentStats != null) {
Text(text = "Attachment Count: ${stats.attachmentStats.attachmentCount}")
Texts.SectionHeader(text = "Local Attachments")
Text(text = "Transit Download State:")
stats.attachmentStats.transferStateCounts.forEach { (state, count) ->
if (count > 0) {
Text(text = "$state: $count")
}
}
Rows.TextRow(
text = "Total attachment rows",
label = "${stats.attachmentStats.totalAttachmentRows}"
)
Text(text = "Valid for archive Transit Download State:")
stats.attachmentStats.validForArchiveTransferStateCounts.forEach { (state, count) ->
if (count > 0) {
Text(text = "$state: $count")
}
}
Rows.TextRow(
text = "Total unique data files",
label = "${stats.attachmentStats.totalUniqueDataFiles}"
)
Spacer(modifier = Modifier.size(4.dp))
Rows.TextRow(
text = "Total unique media names",
label = "${stats.attachmentStats.totalUniqueMediaNames}"
)
Text(text = "Archive State:")
stats.attachmentStats.archiveStateCounts.forEach { (state, count) ->
if (count > 0) {
Text(text = "$state: $count")
}
}
Rows.TextRow(
text = "Total eligible for upload rows",
label = "${stats.attachmentStats.totalEligibleForUploadRows}"
)
Rows.TextRow(
text = "Total unique media names eligible for upload ⭐",
label = "${stats.attachmentStats.totalUniqueMediaNamesEligibleForUpload}"
)
Rows.TextRow(
text = "Eligible attachments by status ⭐",
label = stats.attachmentStats.archiveStatusMediaNameCounts.entries.joinToString("\n") { (status, count) -> "$status: $count" }
)
Rows.TextRow(
text = "Total media names with thumbnails",
label = "${stats.attachmentStats.mediaNamesWithThumbnailsCount}"
)
Rows.TextRow(
text = "Eligible thumbnails by status ⭐",
label = stats.attachmentStats.archiveStatusMediaNameThumbnailCounts.entries.joinToString("\n") { (status, count) -> "$status: $count" }
)
Rows.TextRow(
text = "Pending attachment upload bytes ⭐",
label = "${stats.attachmentStats.pendingAttachmentUploadBytes} (~${stats.attachmentStats.pendingAttachmentUploadBytes.bytes.toUnitString()})"
)
Rows.TextRow(
text = "Uploaded attachment bytes ⭐",
label = "${stats.attachmentStats.uploadedAttachmentBytes} (~${stats.attachmentStats.uploadedAttachmentBytes.bytes.toUnitString()})"
)
Rows.TextRow(
text = "Uploaded thumbnail bytes (estimated)",
label = "${stats.attachmentStats.uploadedThumbnailBytes} (~${stats.attachmentStats.uploadedThumbnailBytes.bytes.toUnitString()})"
)
Spacer(modifier = Modifier.size(16.dp))
Text(text = "Unique/archived data files: ${stats.attachmentStats.attachmentFileCount}/${stats.attachmentStats.finishedAttachmentFileCount}")
Text(text = "Unique/archived verified plaintextHash count: ${stats.attachmentStats.attachmentPlaintextHashAndKeyCount}/${stats.attachmentStats.finishedAttachmentPlaintextHashAndKeyCount}")
Text(text = "Unique/expected thumbnail files: ${stats.attachmentStats.thumbnailFileCount}/${stats.attachmentStats.estimatedThumbnailCount}")
Text(text = "Local Total: ${stats.attachmentStats.attachmentFileCount + stats.attachmentStats.thumbnailFileCount}")
Text(text = "Expected remote total: ${stats.attachmentStats.estimatedThumbnailCount + stats.attachmentStats.finishedAttachmentPlaintextHashAndKeyCount}")
Spacer(modifier = Modifier.size(16.dp))
Text(text = "Pending upload: ${stats.attachmentStats.pendingUploadBytes} (~${stats.attachmentStats.pendingUploadBytes.bytes.toUnitString()})")
Text(text = "Est uploaded attachments: ${stats.attachmentStats.uploadedAttachmentBytes} (~${stats.attachmentStats.uploadedAttachmentBytes.bytes.toUnitString()})")
Text(text = "Est uploaded thumbnails: ${stats.attachmentStats.thumbnailBytes} (~${stats.attachmentStats.thumbnailBytes.bytes.toUnitString()})")
val total = stats.attachmentStats.thumbnailBytes + stats.attachmentStats.uploadedAttachmentBytes
Text(text = "Est total: $total (~${total.bytes.toUnitString()})")
} else {
CircularProgressIndicator()
}
@@ -79,28 +96,43 @@ fun InternalBackupStatsTab(stats: InternalBackupPlaygroundViewModel.StatsState,
Button(onClick = callbacks::loadRemoteState) {
Text(text = "Load remote stats (expensive and long)")
}
} else if (stats.remoteFailureMsg != null) {
Text(text = stats.remoteFailureMsg)
} else if (stats.loadingRemoteStats) {
CircularProgressIndicator()
} else if (stats.remoteState != null) {
Rows.TextRow(
"Total media items ⭐",
label = "${stats.remoteState.mediaCount}"
)
Rows.TextRow(
"Total media size ⭐",
label = "${stats.remoteState.mediaSize} (~${stats.remoteState.mediaSize.bytes.toUnitString()})"
)
Rows.TextRow(
text = "Server estimated used size",
label = "${stats.remoteState.usedSpace} (~${stats.remoteState.usedSpace.bytes.toUnitString()})"
)
}
Dividers.Default()
Texts.SectionHeader(text = "Expected vs Actual")
if (stats.attachmentStats != null && stats.remoteState != null) {
Rows.TextRow(
text = "Counts ⭐",
label = "Local: ${stats.attachmentStats.totalUploadCount}\nRemote: ${stats.remoteState.mediaCount}"
)
Rows.TextRow(
text = "Bytes ⭐",
label = "Local: ${stats.attachmentStats.totalUploadBytes} (~${stats.attachmentStats.totalUploadBytes.bytes.toUnitString()}, thumbnails are estimated)\nRemote: ${stats.remoteState.mediaSize} (~${stats.remoteState.mediaSize.bytes.toUnitString()})"
)
} else {
if (stats.loadingRemoteStats) {
CircularProgressIndicator()
} else if (stats.remoteState != null) {
Text(text = "Media item count: ${stats.remoteState.mediaCount}")
Text(text = "Media items sum size: ${stats.remoteState.mediaSize} (~${stats.remoteState.mediaSize.bytes.toUnitString()})")
Text(text = "Server estimated used size: ${stats.remoteState.usedSpace} (~${stats.remoteState.usedSpace.bytes.toUnitString()})")
} else if (stats.remoteFailureMsg != null) {
Text(text = stats.remoteFailureMsg)
}
Dividers.Default()
Texts.SectionHeader(text = "Expected vs Actual")
if (stats.attachmentStats != null && stats.remoteState != null) {
val finished = stats.attachmentStats.finishedAttachmentFileCount
val thumbnails = stats.attachmentStats.thumbnailFileCount
Text(text = "Expected Count/Actual Remote Count: ${finished + thumbnails} / ${stats.remoteState.mediaCount}")
} else {
CircularProgressIndicator()
}
CircularProgressIndicator()
}
}
}

View File

@@ -40,6 +40,7 @@ import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.emoji.EmojiStrings
import org.thoughtcrime.securesms.components.settings.app.notifications.profiles.AddAllowedMembersViewModel.NotificationProfileAndRecipients
import org.thoughtcrime.securesms.compose.ComposeFragment
import org.thoughtcrime.securesms.compose.rememberStatusBarColorNestedScrollModifier
import org.thoughtcrime.securesms.database.RecipientTable
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfile
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfileId
@@ -166,7 +167,9 @@ private fun AddAllowedMembersContent(
modifier = Modifier.padding(contentPadding)
) {
LazyColumn(
modifier = Modifier.weight(1f)
modifier = Modifier
.weight(1f)
.then(rememberStatusBarColorNestedScrollModifier())
) {
item {
Text(

View File

@@ -42,6 +42,7 @@ import org.signal.core.ui.compose.SignalPreview
import org.signal.core.ui.compose.Texts
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.compose.ComposeFragment
import org.thoughtcrime.securesms.compose.rememberStatusBarColorNestedScrollModifier
import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.viewModel
@@ -176,7 +177,9 @@ private fun AdvancedPrivacySettingsScreen(
navigationIcon = ImageVector.vectorResource(R.drawable.symbol_arrow_start_24)
) { paddingValues ->
LazyColumn(
modifier = Modifier.padding(paddingValues)
modifier = Modifier
.padding(paddingValues)
.then(rememberStatusBarColorNestedScrollModifier())
) {
item {
Rows.ToggleRow(

View File

@@ -27,6 +27,7 @@ import org.thoughtcrime.securesms.jobs.OptimizeMediaJob
import org.thoughtcrime.securesms.jobs.RestoreOptimizedMediaJob
import org.thoughtcrime.securesms.keyvalue.KeepMessagesDuration
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.Environment
import org.thoughtcrime.securesms.util.RemoteConfig
class ManageStorageSettingsViewModel : ViewModel() {
@@ -134,7 +135,7 @@ class ManageStorageSettingsViewModel : ViewModel() {
private suspend fun getOnDeviceStorageOptimizationState(): OnDeviceStorageOptimizationState {
return when {
!RemoteConfig.messageBackups || !SignalStore.backup.areBackupsEnabled || !AppDependencies.billingApi.isApiAvailable() -> OnDeviceStorageOptimizationState.FEATURE_NOT_AVAILABLE
!RemoteConfig.messageBackups || !SignalStore.backup.areBackupsEnabled || !AppDependencies.billingApi.isApiAvailable() || (!RemoteConfig.internalUser && !Environment.IS_STAGING) -> OnDeviceStorageOptimizationState.FEATURE_NOT_AVAILABLE
SignalStore.backup.backupTier != MessageBackupTier.PAID -> OnDeviceStorageOptimizationState.REQUIRES_PAID_TIER
SignalStore.backup.optimizeStorage -> OnDeviceStorageOptimizationState.ENABLED
else -> OnDeviceStorageOptimizationState.DISABLED

View File

@@ -23,6 +23,7 @@ import org.signal.core.ui.compose.Scaffolds
import org.signal.core.ui.compose.SignalPreview
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.compose.ComposeFragment
import org.thoughtcrime.securesms.compose.rememberStatusBarColorNestedScrollModifier
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobs.ApkUpdateJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
@@ -90,7 +91,9 @@ private fun AppUpdatesSettingsScreen(
) { paddingValues ->
LazyColumn(
modifier = Modifier.padding(paddingValues)
modifier = Modifier
.padding(paddingValues)
.then(rememberStatusBarColorNestedScrollModifier())
) {
if (Build.VERSION.SDK_INT >= 31) {
item {

View File

@@ -98,6 +98,7 @@ class InternalConversationSettingsFragment : ComposeFragment(), InternalConversa
borderless = false,
videoGif = false,
quote = false,
quoteTargetContentType = null,
caption = null,
stickerLocator = null,
blurHash = null,
@@ -185,7 +186,7 @@ class InternalConversationSettingsFragment : ComposeFragment(), InternalConversa
val id = SignalDatabase.messages.insertMessageOutbox(
message = OutgoingMessage(threadRecipient = recipient, sentTimeMillis = time, body = "Outgoing: $i"),
threadId = targetThread
)
).messageId
SignalDatabase.messages.markAsSent(id, true)
} else {
SignalDatabase.messages.insertMessageInbox(
@@ -215,7 +216,7 @@ class InternalConversationSettingsFragment : ComposeFragment(), InternalConversa
val id = SignalDatabase.messages.insertMessageOutbox(
message = OutgoingMessage(threadRecipient = recipient, sentTimeMillis = time, body = "Outgoing: $i", attachments = listOf(attachment)),
threadId = targetThread
)
).messageId
SignalDatabase.messages.markAsSent(id, true)
SignalDatabase.attachments.getAttachmentsForMessage(id).forEach {
SignalDatabase.attachments.debugMakeValidForArchive(it.attachmentId)
@@ -249,7 +250,7 @@ class InternalConversationSettingsFragment : ComposeFragment(), InternalConversa
splitThreadId,
false,
null
)
).messageId
SignalDatabase.messages.markAsSent(messageId, true)
SignalDatabase.threads.update(splitThreadId, true)

View File

@@ -14,6 +14,7 @@ import org.thoughtcrime.securesms.events.GroupCallRaiseHandEvent
import org.thoughtcrime.securesms.events.GroupCallReactionEvent
import org.thoughtcrime.securesms.events.WebRtcViewModel
import org.thoughtcrime.securesms.groups.ui.GroupMemberEntry
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.ringrtc.CameraState
import org.thoughtcrime.securesms.service.webrtc.collections.ParticipantCollection
@@ -78,7 +79,7 @@ data class CallParticipantsState(
} else {
listParticipants.addAll(remoteParticipants.listParticipants)
}
if (foldableState.isFlat) {
if (foldableState.isFlat && !SignalStore.internal.newCallingUi) {
listParticipants.add(CallParticipant.EMPTY)
}
listParticipants.reverse()

View File

@@ -2,9 +2,15 @@ package org.thoughtcrime.securesms.compose
import android.animation.ValueAnimator
import android.app.Activity
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.Velocity
import androidx.core.content.ContextCompat
import com.google.android.material.animation.ArgbEvaluatorCompat
@@ -14,10 +20,14 @@ import kotlin.math.abs
/**
* Controls status-bar color based off a nested scroll
*
* Recommended to use this with [rememberStatusBarColorNestedScrollModifier] since it'll prevent you from having to wire through
* an activity or the connection to subcomponents.
*/
class StatusBarColorNestedScrollConnection(
private val activity: Activity
) : NestedScrollConnection {
private var animator: ValueAnimator? = null
private val normalColor = ContextCompat.getColor(activity, R.color.signal_colorBackground)
@@ -78,3 +88,21 @@ class StatusBarColorNestedScrollConnection(
private fun Float.isNearZero(): Boolean = abs(this) < 0.001
}
/**
* Remembers the nested scroll modifier to ensure the proper status bar coloring behavior.
*
* This is only required if the screen you are modifying does not utilize edgeToEdge.
*/
@Composable
fun rememberStatusBarColorNestedScrollModifier(): Modifier {
val activity = LocalContext.current as? AppCompatActivity
return remember {
if (activity != null) {
Modifier.nestedScroll(StatusBarColorNestedScrollConnection(activity))
} else {
Modifier
}
}
}

View File

@@ -650,7 +650,7 @@ public class Contact implements Parcelable {
private static Attachment attachmentFromUri(@Nullable Uri uri) {
if (uri == null) return null;
return new UriAttachment(uri, MediaUtil.IMAGE_JPEG, AttachmentTable.TRANSFER_PROGRESS_DONE, 0, null, false, false, false, false, null, null, null, null, null);
return new UriAttachment(uri, MediaUtil.IMAGE_JPEG, AttachmentTable.TRANSFER_PROGRESS_DONE, 0, null, false, false, false, false, null, null, null, null, null, null);
}
@Override

View File

@@ -1649,7 +1649,8 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
quote.isOriginalMissing(),
quote.getAttachment(),
isStoryReaction(current) ? current.getBody() : null,
quote.getQuoteType());
quote.getQuoteType(),
false);
quoteView.setWallpaperEnabled(hasWallpaper);
quoteView.setVisibility(View.VISIBLE);

View File

@@ -634,6 +634,16 @@ public final class ConversationUpdateItem extends FrameLayout
passthroughClickListener.onClick(v);
}
});
} else if (conversationMessage.getMessageRecord().isUnsupported()) {
actionButton.setText(R.string.ConversationFragment__update_build);
actionButton.setVisibility(VISIBLE);
actionButton.setOnClickListener(v -> {
if (batchSelected.isEmpty() && eventListener != null) {
eventListener.onUpdateSignalClicked();
} else {
passthroughClickListener.onClick(v);
}
});
} else {
actionButton.setVisibility(GONE);
actionButton.setOnClickListener(null);

View File

@@ -594,6 +594,7 @@ class ConversationFragment :
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
binding.toolbar.isBackInvokedCallbackEnabled = false
binding.root.setApplyRootInsets(!resources.getWindowSizeClass().isSplitPane())
binding.root.setUseWindowTypes(!resources.getWindowSizeClass().isSplitPane())
disposables.bindTo(viewLifecycleOwner)
@@ -1651,11 +1652,6 @@ class ConversationFragment :
return
}
if (SignalStore.uiHints.hasNotSeenEditMessageBetaAlert()) {
Dialogs.showEditMessageBetaDialog(requireContext()) { handleSendEditMessage() }
return
}
val editMessage = inputPanel.editMessage
if (editMessage == null) {
Log.w(TAG, "No edit message found, forcing exit")
@@ -1964,13 +1960,6 @@ class ConversationFragment :
return
}
if (SignalStore.uiHints.hasNotSeenTextFormattingAlert() && bodyRanges != null && bodyRanges.ranges.isNotEmpty()) {
Dialogs.showFormattedTextDialog(requireContext()) {
sendMessage(body, mentions, bodyRanges, messageToEdit, quote, scheduledDate, slideDeck, contacts, clearCompose, linkPreviews, preUploadResults, bypassPreSendSafetyNumberCheck, isViewOnce, afterSendComplete)
}
return
}
if (inputPanel.isRecordingInLockedMode) {
inputPanel.releaseRecordingLockAndSend()
return
@@ -3039,6 +3028,10 @@ class ConversationFragment :
UnverifiedProfileNameBottomSheet.show(parentFragmentManager, forGroup)
}
override fun onUpdateSignalClicked() {
PlayStoreUtil.openPlayStoreOrOurApkDownloadPage(requireContext())
}
override fun onJoinGroupCallClicked() {
val activity = activity ?: return
val recipient = viewModel.recipientSnapshot ?: return

View File

@@ -505,7 +505,7 @@ class ConversationRepository(
}
if (messageRecord.isViewOnceMessage()) {
val attachment = TombstoneAttachment(MediaUtil.VIEW_ONCE, true)
val attachment = TombstoneAttachment.forQuote()
slideDeck = SlideDeck()
slideDeck.addSlide(MediaUtil.getSlideForAttachment(attachment))
}

View File

@@ -10,6 +10,7 @@ import org.signal.core.util.Stopwatch
import org.signal.core.util.logging.Log
import org.signal.core.util.toInt
import org.signal.paging.PagedDataSource
import org.thoughtcrime.securesms.backup.v2.ArchiveRestoreProgress
import org.thoughtcrime.securesms.backup.v2.BackupRestoreManager
import org.thoughtcrime.securesms.conversation.ConversationData
import org.thoughtcrime.securesms.conversation.ConversationMessage
@@ -21,7 +22,6 @@ import org.thoughtcrime.securesms.database.model.InMemoryMessageRecord.Universal
import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.messagerequests.MessageRequestRepository
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.RemoteConfig
@@ -125,7 +125,7 @@ class ConversationDataSource(
records = MessageDataFetcher.updateModelsWithData(records, extraData).toMutableList()
stopwatch.split("models")
if (RemoteConfig.messageBackups && SignalStore.backup.restoreState.inProgress) {
if (RemoteConfig.messageBackups && ArchiveRestoreProgress.state.activelyRestoring()) {
BackupRestoreManager.prioritizeAttachmentsIfNeeded(records)
stopwatch.split("restore")
}

View File

@@ -88,7 +88,8 @@ class V2ConversationItemMediaViewHolder<Model : MappingModel<Model>>(
quote.isOriginalMissing,
quote.attachment,
if (conversationMessage.messageRecord.isStoryReaction()) conversationMessage.messageRecord.body else null,
quote.quoteType
quote.quoteType,
false
)
quoteView.setMessageType(

View File

@@ -76,10 +76,12 @@ import org.thoughtcrime.securesms.MainFragment;
import org.thoughtcrime.securesms.MainNavigator;
import org.thoughtcrime.securesms.MuteDialog;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.backup.RestoreState;
import org.thoughtcrime.securesms.backup.v2.ArchiveRestoreProgress;
import org.thoughtcrime.securesms.backup.v2.ArchiveRestoreProgressState;
import org.thoughtcrime.securesms.backup.v2.ui.BackupAlert;
import org.thoughtcrime.securesms.backup.v2.ui.BackupAlertBottomSheet;
import org.thoughtcrime.securesms.backup.v2.ui.BackupAlertDelegate;
import org.thoughtcrime.securesms.backup.v2.ui.status.BackupStatusData;
import org.thoughtcrime.securesms.badges.models.Badge;
import org.thoughtcrime.securesms.badges.self.expired.ExpiredOneTimeBadgeBottomSheetDialogFragment;
import org.thoughtcrime.securesms.badges.self.expired.MonthlyDonationCanceledBottomSheetDialogFragment;
@@ -94,9 +96,9 @@ import org.thoughtcrime.securesms.banner.banners.OutdatedBuildBanner;
import org.thoughtcrime.securesms.banner.banners.ServiceOutageBanner;
import org.thoughtcrime.securesms.banner.banners.UnauthorizedBanner;
import org.thoughtcrime.securesms.banner.banners.UsernameOutOfSyncBanner;
import org.thoughtcrime.securesms.components.compose.DeleteSyncEducationDialog;
import org.thoughtcrime.securesms.components.RatingManager;
import org.thoughtcrime.securesms.components.SignalProgressDialog;
import org.thoughtcrime.securesms.components.compose.DeleteSyncEducationDialog;
import org.thoughtcrime.securesms.components.menu.ActionItem;
import org.thoughtcrime.securesms.components.menu.SignalBottomActionBar;
import org.thoughtcrime.securesms.components.menu.SignalContextMenu;
@@ -746,16 +748,20 @@ public class ConversationListFragment extends MainFragment implements Conversati
}
@Override
public void onActionClick(@NonNull BackupStatusData backupStatusData) {
if (backupStatusData instanceof BackupStatusData.NotEnoughFreeSpace) {
BackupAlertBottomSheet.create(new BackupAlert.DiskFull(((BackupStatusData.NotEnoughFreeSpace) backupStatusData).getRequiredSpace()))
.show(getParentFragmentManager(), null);
} else if (backupStatusData instanceof BackupStatusData.RestoringMedia && ((BackupStatusData.RestoringMedia) backupStatusData).getRestoreStatus() == BackupStatusData.RestoreStatus.WAITING_FOR_WIFI) {
public void onActionClick(@NonNull ArchiveRestoreProgressState data) {
if (data.getRestoreStatus() == ArchiveRestoreProgressState.RestoreStatus.NOT_ENOUGH_DISK_SPACE) {
BackupAlertBottomSheet.create(new BackupAlert.DiskFull(data.getRemainingRestoreSize().toUnitString())).show(getParentFragmentManager(), null);
} else if (data.getRestoreState() == RestoreState.RESTORING_MEDIA && data.getRestoreStatus() == ArchiveRestoreProgressState.RestoreStatus.WAITING_FOR_WIFI) {
new MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.ResumeRestoreCellular_resume_using_cellular_title)
.setMessage(R.string.ResumeRestoreCellular_resume_using_cellular_message)
.setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(R.string.BackupStatus__resume, (d, w) -> SignalStore.backup().setRestoreWithCellular(true))
.setPositiveButton(R.string.BackupStatus__resume, (d, w) -> {
SignalExecutors.BOUNDED.execute(() -> {
SignalStore.backup().setRestoreWithCellular(true);
ArchiveRestoreProgress.forceUpdate();
});
})
.show();
}
}

View File

@@ -24,6 +24,7 @@ import org.signal.core.util.toInt
import org.signal.core.util.update
import org.signal.core.util.withinTransaction
import org.thoughtcrime.securesms.backup.v2.ArchivedMediaObject
import org.thoughtcrime.securesms.util.MediaUtil
/**
* When we delete attachments locally, we can't immediately delete them from the archive CDN. This is because there is still a backup that exists that
@@ -135,12 +136,19 @@ class BackupMediaSnapshotTable(context: Context, database: SignalDatabase) : Dat
mediaObjects
.chunked(SqlUtil.MAX_QUERY_ARGS)
.forEach { chunk ->
// Full attachment
writePendingMediaObjectsChunk(
chunk.map { MediaEntry(it.mediaId, it.cdn, it.plaintextHash, it.remoteKey, isThumbnail = false) }
chunk
.filterNot { MediaUtil.isViewOnceType(it.contentType) || MediaUtil.isLongTextType(it.contentType) }
.map { MediaEntry(it.mediaId, it.cdn, it.plaintextHash, it.remoteKey, isThumbnail = false) }
)
// Thumbnail
writePendingMediaObjectsChunk(
chunk.map { MediaEntry(it.thumbnailMediaId, it.cdn, it.plaintextHash, it.remoteKey, isThumbnail = true) }
chunk
.filterNot { it.quote }
.filter { MediaUtil.isImageOrVideoType(it.contentType) }
.map { MediaEntry(it.thumbnailMediaId, it.cdn, it.plaintextHash, it.remoteKey, isThumbnail = true) }
)
}
}
@@ -291,6 +299,10 @@ class BackupMediaSnapshotTable(context: Context, database: SignalDatabase) : Dat
}
private fun writePendingMediaObjectsChunk(chunk: List<MediaEntry>) {
if (chunk.isEmpty()) {
return
}
val values = chunk.map {
contentValuesOf(
MEDIA_ID to it.mediaId,
@@ -324,7 +336,9 @@ class BackupMediaSnapshotTable(context: Context, database: SignalDatabase) : Dat
val thumbnailMediaId: String,
val cdn: Int?,
val plaintextHash: ByteArray,
val remoteKey: ByteArray
val remoteKey: ByteArray,
val quote: Boolean,
val contentType: String?
)
class CdnMismatchResult(

View File

@@ -135,7 +135,7 @@ class InAppPaymentTable(context: Context, databaseHelper: SignalDatabase) : Data
endOfPeriod: Duration?,
inAppPaymentData: InAppPaymentData
): InAppPaymentId {
val now = System.currentTimeMillis()
val now = System.currentTimeMillis().milliseconds
validateInAppPayment(state, inAppPaymentData)
@@ -143,8 +143,8 @@ class InAppPaymentTable(context: Context, databaseHelper: SignalDatabase) : Data
.values(
TYPE to type.code,
STATE to state.code,
INSERTED_AT to now,
UPDATED_AT to now,
INSERTED_AT to now.inWholeMilliseconds,
UPDATED_AT to now.inWholeSeconds,
SUBSCRIBER_ID to subscriberId?.serialize(),
END_OF_PERIOD to (endOfPeriod?.inWholeSeconds ?: 0L),
DATA to InAppPaymentData.ADAPTER.encode(inAppPaymentData),
@@ -410,7 +410,7 @@ class InAppPaymentTable(context: Context, databaseHelper: SignalDatabase) : Data
ID to data.id.serialize(),
TYPE to data.type.apply { check(this != InAppPaymentType.UNKNOWN) }.code,
STATE to data.state.code,
INSERTED_AT to data.insertedAt.inWholeSeconds,
INSERTED_AT to data.insertedAt.inWholeMilliseconds,
UPDATED_AT to data.updatedAt.inWholeSeconds,
NOTIFIED to data.notified,
SUBSCRIBER_ID to data.subscriberId?.serialize(),
@@ -424,7 +424,7 @@ class InAppPaymentTable(context: Context, databaseHelper: SignalDatabase) : Data
id = InAppPaymentId(input.requireLong(ID)),
type = InAppPaymentType.deserialize(input.requireInt(TYPE)),
state = State.deserialize(input.requireInt(STATE)),
insertedAt = input.requireLong(INSERTED_AT).seconds,
insertedAt = input.requireLong(INSERTED_AT).milliseconds,
updatedAt = input.requireLong(UPDATED_AT).seconds,
notified = input.requireBoolean(NOTIFIED),
subscriberId = input.requireString(SUBSCRIBER_ID)?.let { SubscriberId.deserialize(it) },

View File

@@ -39,6 +39,7 @@ class MediaTable internal constructor(context: Context?, databaseHelper: SignalD
${AttachmentTable.TABLE_NAME}.${AttachmentTable.WIDTH},
${AttachmentTable.TABLE_NAME}.${AttachmentTable.HEIGHT},
${AttachmentTable.TABLE_NAME}.${AttachmentTable.QUOTE},
${AttachmentTable.TABLE_NAME}.${AttachmentTable.QUOTE_TARGET_CONTENT_TYPE},
${AttachmentTable.TABLE_NAME}.${AttachmentTable.STICKER_PACK_ID},
${AttachmentTable.TABLE_NAME}.${AttachmentTable.STICKER_PACK_KEY},
${AttachmentTable.TABLE_NAME}.${AttachmentTable.STICKER_ID},

View File

@@ -396,7 +396,8 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
'${AttachmentTable.ARCHIVE_CDN}', ${AttachmentTable.TABLE_NAME}.${AttachmentTable.ARCHIVE_CDN},
'${AttachmentTable.THUMBNAIL_RESTORE_STATE}', ${AttachmentTable.TABLE_NAME}.${AttachmentTable.THUMBNAIL_RESTORE_STATE},
'${AttachmentTable.ARCHIVE_TRANSFER_STATE}', ${AttachmentTable.TABLE_NAME}.${AttachmentTable.ARCHIVE_TRANSFER_STATE},
'${AttachmentTable.ATTACHMENT_UUID}', ${AttachmentTable.TABLE_NAME}.${AttachmentTable.ATTACHMENT_UUID}
'${AttachmentTable.ATTACHMENT_UUID}', ${AttachmentTable.TABLE_NAME}.${AttachmentTable.ATTACHMENT_UUID},
'${AttachmentTable.QUOTE_TARGET_CONTENT_TYPE}', ${AttachmentTable.TABLE_NAME}.${AttachmentTable.QUOTE_TARGET_CONTENT_TYPE}
)
) AS ${AttachmentTable.ATTACHMENT_JSON_ALIAS}
""".toSingleLine()
@@ -2548,11 +2549,11 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
val quoteText = cursor.requireString(QUOTE_BODY)
val quoteType = cursor.requireInt(QUOTE_TYPE)
val quoteMissing = cursor.requireBoolean(QUOTE_MISSING)
val quoteAttachments: List<Attachment> = associatedAttachments.filter { it.quote }.toList()
val quoteAttachment: Attachment? = associatedAttachments.filter { it.quote }.firstOrNull()
val quoteMentions: List<Mention> = parseQuoteMentions(cursor)
val quoteBodyRanges: BodyRangeList? = parseQuoteBodyRanges(cursor)
val quote: QuoteModel? = if (quoteId != QUOTE_NOT_PRESENT_ID && quoteAuthor > 0 && (!TextUtils.isEmpty(quoteText) || quoteAttachments.isNotEmpty())) {
QuoteModel(quoteId, RecipientId.from(quoteAuthor), quoteText ?: "", quoteMissing, quoteAttachments, quoteMentions, QuoteModel.Type.fromCode(quoteType), quoteBodyRanges)
val quote: QuoteModel? = if (quoteId != QUOTE_NOT_PRESENT_ID && quoteAuthor > 0 && (!TextUtils.isEmpty(quoteText) || quoteAttachment != null)) {
QuoteModel(quoteId, RecipientId.from(quoteAuthor), quoteText ?: "", quoteMissing, quoteAttachment, quoteMentions, QuoteModel.Type.fromCode(quoteType), quoteBodyRanges)
} else {
null
}
@@ -2776,7 +2777,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
contentValues.put(QUOTE_BODY_RANGES, quoteBodyRanges.build().encode())
}
quoteAttachments += retrieved.quote.attachments
retrieved.quote.attachment?.let { quoteAttachments += it }
} else {
contentValues.put(QUOTE_ID, 0)
contentValues.put(QUOTE_AUTHOR, 0)
@@ -2869,7 +2870,8 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
messageId = messageId,
threadId = threadId,
threadWasNewlyCreated = threadIdResult.newlyCreated,
insertedAttachments = insertedAttachments
insertedAttachments = insertedAttachments,
quoteAttachmentId = quoteAttachments.firstOrNull()?.let { insertedAttachments?.get(it) }
)
)
}
@@ -2982,7 +2984,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
threadId: Long,
forceSms: Boolean = false,
insertListener: InsertListener? = null
): Long {
): InsertResult {
return insertMessageOutbox(
message = message,
threadId = threadId,
@@ -2999,7 +3001,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
forceSms: Boolean,
defaultReceiptStatus: Int,
insertListener: InsertListener?
): Long {
): InsertResult {
var type = MessageTypes.BASE_SENDING_TYPE
var hasSpecialType = false
@@ -3218,7 +3220,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
}
if (editedMessage == null) {
quoteAttachments += message.outgoingQuote.attachments
message.outgoingQuote.attachment?.let { quoteAttachments += it }
}
} else {
contentValues.put(QUOTE_ID, 0)
@@ -3320,7 +3322,13 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
TrimThreadJob.enqueueAsync(threadId)
return messageId
return InsertResult(
messageId = messageId,
threadId = threadId,
threadWasNewlyCreated = false,
insertedAttachments = insertedAttachments,
quoteAttachmentId = quoteAttachments.firstOrNull()?.let { insertedAttachments?.get(it) }
)
}
private fun hasAudioAttachment(attachments: List<Attachment>): Boolean {
@@ -5255,7 +5263,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
timetamp = this.requireLong(DATE_SENT)
),
expirationInfo = null,
storyType = StoryType.fromCode(this.requireInt(STORY_TYPE)),
storyType = fromCode(this.requireInt(STORY_TYPE)),
dateReceived = this.requireLong(DATE_RECEIVED)
)
}
@@ -5406,7 +5414,8 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
val messageId: Long,
val threadId: Long,
val threadWasNewlyCreated: Boolean,
val insertedAttachments: Map<Attachment, AttachmentId>? = null
val insertedAttachments: Map<Attachment, AttachmentId>? = null,
val quoteAttachmentId: AttachmentId? = null
)
data class MessageReceiptUpdate(

View File

@@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.database
import android.content.Context
import android.database.Cursor
import androidx.core.content.contentValuesOf
import org.greenrobot.eventbus.EventBus
import org.signal.core.util.StreamUtil
import org.signal.core.util.delete
@@ -98,23 +99,37 @@ class StickerTable(
fun insertSticker(sticker: IncomingSticker, dataStream: InputStream, notify: Boolean) {
val fileInfo: FileInfo = saveStickerImage(dataStream)
writableDatabase
.insertInto(TABLE_NAME)
.values(
PACK_ID to sticker.packId,
PACK_KEY to sticker.packKey,
PACK_TITLE to sticker.packTitle,
PACK_AUTHOR to sticker.packAuthor,
STICKER_ID to sticker.stickerId,
EMOJI to sticker.emoji,
CONTENT_TYPE to sticker.contentType,
COVER to if (sticker.isCover) 1 else 0,
INSTALLED to if (sticker.isInstalled) 1 else 0,
FILE_PATH to fileInfo.file.absolutePath,
FILE_LENGTH to fileInfo.length,
FILE_RANDOM to fileInfo.random
)
.run(SQLiteDatabase.CONFLICT_REPLACE)
val values = contentValuesOf(
PACK_ID to sticker.packId,
PACK_KEY to sticker.packKey,
PACK_TITLE to sticker.packTitle,
PACK_AUTHOR to sticker.packAuthor,
STICKER_ID to sticker.stickerId,
EMOJI to sticker.emoji,
CONTENT_TYPE to sticker.contentType,
COVER to if (sticker.isCover) 1 else 0,
INSTALLED to if (sticker.isInstalled) 1 else 0,
FILE_PATH to fileInfo.file.absolutePath,
FILE_LENGTH to fileInfo.length,
FILE_RANDOM to fileInfo.random
)
var updated = false
if (sticker.isCover) {
// Archive restore inserts cover rows without a sticker id, try to update first on a reduced uniqueness constraint
updated = writableDatabase
.update(TABLE_NAME)
.values(values)
.where("$PACK_ID = ? AND $COVER = 1", sticker.packId)
.run() > 0
}
if (!updated) {
writableDatabase
.insertInto(TABLE_NAME)
.values(values)
.run(SQLiteDatabase.CONFLICT_REPLACE)
}
notifyStickerListeners()
@@ -454,7 +469,7 @@ class StickerTable(
}
}
class StickerPackRecordReader(private val cursor: Cursor) : Closeable {
class StickerPackRecordReader(private val cursor: Cursor) : Closeable, Iterable<StickerPackRecord> {
fun getNext(): StickerPackRecord? {
if (!cursor.moveToNext()) {
@@ -486,5 +501,19 @@ class StickerTable(
override fun close() {
cursor.close()
}
override fun iterator(): Iterator<StickerPackRecord> {
return ReaderIterator()
}
private inner class ReaderIterator : Iterator<StickerPackRecord> {
override fun hasNext(): Boolean {
return cursor.count != 0 && !cursor.isLast
}
override fun next(): StickerPackRecord {
return getNext() ?: throw NoSuchElementException()
}
}
}
}

View File

@@ -141,6 +141,10 @@ import org.thoughtcrime.securesms.database.helpers.migration.V283_ViewOnceRemote
import org.thoughtcrime.securesms.database.helpers.migration.V284_SetPlaceholderGroupFlag
import org.thoughtcrime.securesms.database.helpers.migration.V285_AddEpochToCallLinksTable
import org.thoughtcrime.securesms.database.helpers.migration.V286_FixRemoteKeyEncoding
import org.thoughtcrime.securesms.database.helpers.migration.V287_FixInvalidArchiveState
import org.thoughtcrime.securesms.database.helpers.migration.V288_CopyStickerDataHashStartToEnd
import org.thoughtcrime.securesms.database.helpers.migration.V289_AddQuoteTargetContentTypeColumn
import org.thoughtcrime.securesms.database.helpers.migration.V290_AddArchiveThumbnailTransferStateColumn
import org.thoughtcrime.securesms.database.SQLiteDatabase as SignalSqliteDatabase
/**
@@ -287,48 +291,52 @@ object SignalDatabaseMigrations {
283 to V283_ViewOnceRemoteDataCleanup,
284 to V284_SetPlaceholderGroupFlag,
285 to V285_AddEpochToCallLinksTable,
286 to V286_FixRemoteKeyEncoding
286 to V286_FixRemoteKeyEncoding,
287 to V287_FixInvalidArchiveState,
288 to V288_CopyStickerDataHashStartToEnd,
289 to V289_AddQuoteTargetContentTypeColumn,
290 to V290_AddArchiveThumbnailTransferStateColumn
)
const val DATABASE_VERSION = 286
const val DATABASE_VERSION = 290
@JvmStatic
fun migrate(context: Application, db: SignalSqliteDatabase, oldVersion: Int, newVersion: Int) {
val initialForeignKeyState = db.areForeignKeyConstraintsEnabled()
for (migrationData in migrations) {
val eligibleMigrations = migrations.filter { (version, _) -> version > oldVersion && version <= newVersion }
for (migrationData in eligibleMigrations) {
val (version, migration) = migrationData
if (oldVersion < version) {
Log.i(TAG, "Running migration for version $version: ${migration.javaClass.simpleName}. Foreign keys: ${migration.enableForeignKeys}")
val startTime = System.currentTimeMillis()
Log.i(TAG, "Running migration for version $version: ${migration.javaClass.simpleName}. Foreign keys: ${migration.enableForeignKeys}")
val startTime = System.currentTimeMillis()
var ftsException: SQLiteException? = null
var ftsException: SQLiteException? = null
db.setForeignKeyConstraintsEnabled(migration.enableForeignKeys)
db.beginTransaction()
try {
migration.migrate(context, db, oldVersion, newVersion)
db.version = version
db.setTransactionSuccessful()
} catch (e: SQLiteException) {
if (e.message?.contains("invalid fts5 file format") == true || e.message?.contains("vtable constructor failed") == true) {
ftsException = e
} else {
throw e
}
} finally {
db.endTransaction()
db.setForeignKeyConstraintsEnabled(migration.enableForeignKeys)
db.beginTransaction()
try {
migration.migrate(context, db, oldVersion, newVersion)
db.version = version
db.setTransactionSuccessful()
} catch (e: SQLiteException) {
if (e.message?.contains("invalid fts5 file format") == true || e.message?.contains("vtable constructor failed") == true) {
ftsException = e
} else {
throw e
}
if (ftsException != null) {
Log.w(TAG, "Encountered FTS format issue! Attempting to repair.", ftsException)
SignalDatabase.messageSearch.fullyResetTables(db)
throw ftsException
}
Log.i(TAG, "Successfully completed migration for version $version in ${System.currentTimeMillis() - startTime} ms")
} finally {
db.endTransaction()
}
if (ftsException != null) {
Log.w(TAG, "Encountered FTS format issue! Attempting to repair.", ftsException)
SignalDatabase.messageSearch.fullyResetTables(db)
throw ftsException
}
Log.i(TAG, "Successfully completed migration for version $version in ${System.currentTimeMillis() - startTime} ms")
}
db.setForeignKeyConstraintsEnabled(initialForeignKeyState)

View File

@@ -0,0 +1,19 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.database.helpers.migration
import android.app.Application
import org.thoughtcrime.securesms.database.SQLiteDatabase
/**
* Ensure archive_transfer_state is clear if an attachment is missing a remote_key.
*/
@Suppress("ClassName")
object V287_FixInvalidArchiveState : SignalDatabaseMigration {
override fun migrate(context: Application, db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
db.execSQL("UPDATE attachment SET archive_cdn = null, archive_transfer_state = 0 WHERE remote_key IS NULL AND archive_transfer_state = 3")
}
}

View File

@@ -0,0 +1,21 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.database.helpers.migration
import android.app.Application
import org.thoughtcrime.securesms.database.SQLiteDatabase
/**
* Copy data_hash_start to data_hash_end for sticker attachments that have completed transfer.
*/
@Suppress("ClassName")
object V288_CopyStickerDataHashStartToEnd : SignalDatabaseMigration {
override fun migrate(context: Application, db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
db.execSQL(
"UPDATE attachment SET data_hash_end = data_hash_start WHERE sticker_pack_id IS NOT NULL AND data_hash_start IS NOT NULL AND data_hash_end IS NULL AND transfer_state = 0"
)
}
}

View File

@@ -0,0 +1,22 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.database.helpers.migration
import android.app.Application
import org.thoughtcrime.securesms.database.SQLiteDatabase
/**
* Adds the quote_target_content_type column to attachments and migrates existing quote attachments
* to populate this field with their current content_type.
*/
@Suppress("ClassName")
object V289_AddQuoteTargetContentTypeColumn : SignalDatabaseMigration {
override fun migrate(context: Application, db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
db.execSQL("ALTER TABLE attachment ADD COLUMN quote_target_content_type TEXT DEFAULT NULL;")
db.execSQL("UPDATE attachment SET quote_target_content_type = content_type WHERE quote != 0;")
}
}

View File

@@ -0,0 +1,20 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.database.helpers.migration
import android.app.Application
import org.thoughtcrime.securesms.database.SQLiteDatabase
/**
* We need to keep track of a transfer state for thumbnails too.
*/
@Suppress("ClassName")
object V290_AddArchiveThumbnailTransferStateColumn : SignalDatabaseMigration {
override fun migrate(context: Application, db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
db.execSQL("ALTER TABLE attachment ADD COLUMN archive_thumbnail_transfer_state INTEGER DEFAULT 0;")
}
}

View File

@@ -256,4 +256,8 @@ public abstract class DisplayRecord {
public boolean isUnblocked() {
return MessageTypes.isUnblocked(type);
}
public boolean isUnsupported() {
return MessageTypes.isUnsupportedMessageType(type);
}
}

View File

@@ -291,6 +291,8 @@ public abstract class MessageRecord extends DisplayRecord {
return staticUpdateDescription(context.getString(isGroupV2() ? R.string.MessageRecord_you_blocked_this_group : R.string.MessageRecord_you_blocked_this_person), Glyph.BLOCK);
} else if (isUnblocked()) {
return staticUpdateDescription(context.getString(isGroupV2() ? R.string.MessageRecord_you_unblocked_this_group : R.string.MessageRecord_you_unblocked_this_person) , Glyph.THREAD);
} else if (isUnsupported()) {
return staticUpdateDescription(context.getString(R.string.MessageRecord_unsupported_feature, getFromRecipient().getDisplayName(context)), Glyph.ERROR);
}
return null;
@@ -730,7 +732,7 @@ public abstract class MessageRecord extends DisplayRecord {
isProfileChange() || isGroupV1MigrationEvent() || isChatSessionRefresh() || isBadDecryptType() ||
isChangeNumber() || isReleaseChannelDonationRequest() || isThreadMergeEventType() || isSmsExportType() || isSessionSwitchoverEventType() ||
isPaymentsRequestToActivate() || isPaymentsActivated() || isReportedSpam() || isMessageRequestAccepted() ||
isBlocked() || isUnblocked();
isBlocked() || isUnblocked() || isUnsupported();
}
public boolean isMediaPending() {

View File

@@ -35,6 +35,8 @@ import org.thoughtcrime.securesms.database.PendingRetryReceiptCache;
import org.thoughtcrime.securesms.jobmanager.JobManager;
import org.thoughtcrime.securesms.jobmanager.JobMigrator;
import org.thoughtcrime.securesms.jobmanager.impl.FactoryJobPredicate;
import org.thoughtcrime.securesms.jobs.AttachmentCompressionJob;
import org.thoughtcrime.securesms.jobs.AttachmentUploadJob;
import org.thoughtcrime.securesms.jobs.FastJobStorage;
import org.thoughtcrime.securesms.jobs.GroupCallUpdateSendJob;
import org.thoughtcrime.securesms.jobs.IndividualSendJob;
@@ -44,6 +46,7 @@ import org.thoughtcrime.securesms.jobs.PreKeysSyncJob;
import org.thoughtcrime.securesms.jobs.PushGroupSendJob;
import org.thoughtcrime.securesms.jobs.PushProcessMessageJob;
import org.thoughtcrime.securesms.jobs.ReactionSendJob;
import org.thoughtcrime.securesms.jobs.SendDeliveryReceiptJob;
import org.thoughtcrime.securesms.jobs.TypingSendJob;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.megaphone.MegaphoneRepository;
@@ -200,7 +203,15 @@ public class ApplicationDependencyProvider implements AppDependencies.Provider {
.setJobStorage(new FastJobStorage(JobDatabase.getInstance(context)))
.setJobMigrator(new JobMigrator(TextSecurePreferences.getJobManagerVersion(context), JobManager.CURRENT_VERSION, JobManagerFactories.getJobMigrations(context)))
.addReservedJobRunner(new FactoryJobPredicate(PushProcessMessageJob.KEY, MarkerJob.KEY))
.addReservedJobRunner(new FactoryJobPredicate(IndividualSendJob.KEY, PushGroupSendJob.KEY, ReactionSendJob.KEY, TypingSendJob.KEY, GroupCallUpdateSendJob.KEY))
.addReservedJobRunner(new FactoryJobPredicate(AttachmentUploadJob.KEY, AttachmentCompressionJob.KEY))
.addReservedJobRunner(new FactoryJobPredicate(
IndividualSendJob.KEY,
PushGroupSendJob.KEY,
ReactionSendJob.KEY,
TypingSendJob.KEY,
GroupCallUpdateSendJob.KEY,
SendDeliveryReceiptJob.KEY
))
.build();
return new JobManager(context, config);
}

View File

@@ -1,86 +0,0 @@
package org.thoughtcrime.securesms.glide;
import android.content.ContentResolver;
import android.content.Context;
import android.graphics.Bitmap;
import android.net.Uri;
import android.util.Pair;
import com.bumptech.glide.load.data.StreamLocalUriFetcher;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.attachments.AttachmentId;
import org.thoughtcrime.securesms.mms.PartAuthority;
import org.thoughtcrime.securesms.providers.BlobProvider;
import org.thoughtcrime.securesms.util.BitmapDecodingException;
import org.thoughtcrime.securesms.util.BitmapUtil;
import org.thoughtcrime.securesms.util.MediaUtil;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
class DecryptableStreamLocalUriFetcher extends StreamLocalUriFetcher {
private static final String TAG = Log.tag(DecryptableStreamLocalUriFetcher.class);
private static final int DIMENSION_LIMIT = 12_000;
private Context context;
DecryptableStreamLocalUriFetcher(Context context, Uri uri) {
super(context.getContentResolver(), uri);
this.context = context;
}
@Override
protected InputStream loadResource(Uri uri, ContentResolver contentResolver) throws FileNotFoundException {
if (MediaUtil.hasVideoThumbnail(context, uri)) {
Bitmap thumbnail = MediaUtil.getVideoThumbnail(context, uri, 1000);
if (thumbnail != null) {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
thumbnail.compress(Bitmap.CompressFormat.JPEG, 100, baos);
ByteArrayInputStream thumbnailStream = new ByteArrayInputStream(baos.toByteArray());
thumbnail.recycle();
return thumbnailStream;
}
if (PartAuthority.isAttachmentUri(uri) && MediaUtil.isVideoType(PartAuthority.getAttachmentContentType(context, uri))) {
try {
AttachmentId attachmentId = PartAuthority.requireAttachmentId(uri);
Uri thumbnailUri = PartAuthority.getAttachmentThumbnailUri(attachmentId);
InputStream thumbStream = PartAuthority.getAttachmentThumbnailStream(context, thumbnailUri);
if (thumbStream != null) {
return thumbStream;
}
} catch (IOException e) {
Log.i(TAG, "Failed to fetch thumbnail", e);
}
}
}
try {
if (PartAuthority.isBlobUri(uri) && BlobProvider.isSingleUseMemoryBlob(uri)) {
return PartAuthority.getAttachmentThumbnailStream(context, uri);
} else if (isSafeSize(PartAuthority.getAttachmentThumbnailStream(context, uri))) {
return PartAuthority.getAttachmentThumbnailStream(context, uri);
} else {
throw new IOException("File dimensions are too large!");
}
} catch (IOException ioe) {
Log.w(TAG, ioe);
throw new FileNotFoundException("PartAuthority couldn't load Uri resource.");
}
}
private boolean isSafeSize(InputStream stream) {
try {
Pair<Integer, Integer> size = BitmapUtil.getDimensions(stream);
return size.first < DIMENSION_LIMIT && size.second < DIMENSION_LIMIT;
} catch (BitmapDecodingException e) {
return false;
}
}
}

View File

@@ -1,55 +0,0 @@
package org.thoughtcrime.securesms.glide;
import android.content.Context;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.bumptech.glide.load.Options;
import com.bumptech.glide.load.model.ModelLoader;
import com.bumptech.glide.load.model.ModelLoaderFactory;
import com.bumptech.glide.load.model.MultiModelLoaderFactory;
import org.thoughtcrime.securesms.mms.DecryptableUri;
import java.io.InputStream;
public class DecryptableStreamUriLoader implements ModelLoader<DecryptableUri, InputStream> {
private final Context context;
private DecryptableStreamUriLoader(Context context) {
this.context = context;
}
@Nullable
@Override
public LoadData<InputStream> buildLoadData(@NonNull DecryptableUri decryptableUri, int width, int height, @NonNull Options options) {
return new LoadData<>(decryptableUri, new DecryptableStreamLocalUriFetcher(context, decryptableUri.getUri()));
}
@Override
public boolean handles(@NonNull DecryptableUri decryptableUri) {
return true;
}
public static class Factory implements ModelLoaderFactory<DecryptableUri, InputStream> {
private final Context context;
public Factory(Context context) {
this.context = context.getApplicationContext();
}
@Override
public @NonNull ModelLoader<DecryptableUri, InputStream> build(@NonNull MultiModelLoaderFactory multiFactory) {
return new DecryptableStreamUriLoader(context);
}
@Override
public void teardown() {
// Do nothing.
}
}
}

View File

@@ -12,12 +12,11 @@ import com.bumptech.glide.Registry;
import com.bumptech.glide.load.model.GlideUrl;
import com.bumptech.glide.load.model.UnitModelLoader;
import com.bumptech.glide.load.resource.bitmap.BitmapDrawableEncoder;
import com.bumptech.glide.load.resource.bitmap.Downsampler;
import com.bumptech.glide.load.resource.bitmap.StreamBitmapDecoder;
import com.bumptech.glide.load.resource.gif.ByteBufferGifDecoder;
import com.bumptech.glide.load.resource.gif.GifDrawable;
import com.bumptech.glide.load.resource.gif.StreamGifDecoder;
import org.signal.glide.common.io.InputStreamFactory;
import org.signal.glide.load.resource.apng.decode.APNGDecoder;
import org.thoughtcrime.securesms.badges.load.BadgeLoader;
import org.thoughtcrime.securesms.badges.load.GiftBadgeModel;
@@ -37,9 +36,14 @@ import org.thoughtcrime.securesms.glide.cache.EncryptedBitmapResourceEncoder;
import org.thoughtcrime.securesms.glide.cache.EncryptedCacheDecoder;
import org.thoughtcrime.securesms.glide.cache.EncryptedCacheEncoder;
import org.thoughtcrime.securesms.glide.cache.EncryptedGifDrawableResourceEncoder;
import org.thoughtcrime.securesms.glide.cache.InputStreamFactoryBitmapDecoder;
import org.thoughtcrime.securesms.glide.cache.StreamApngDecoder;
import org.thoughtcrime.securesms.glide.cache.StreamBitmapDecoder;
import org.thoughtcrime.securesms.glide.cache.StreamFactoryApngDecoder;
import org.thoughtcrime.securesms.glide.cache.StreamFactoryGifDecoder;
import org.thoughtcrime.securesms.glide.cache.WebpSanDecoder;
import org.thoughtcrime.securesms.mms.DecryptableUri;
import org.thoughtcrime.securesms.mms.DecryptableUriStreamLoader;
import org.thoughtcrime.securesms.mms.RegisterGlideComponents;
import org.thoughtcrime.securesms.mms.SignalGlideModule;
import org.thoughtcrime.securesms.stickers.StickerRemoteUri;
@@ -68,10 +72,12 @@ public class SignalGlideComponents implements RegisterGlideComponents {
registry.prepend(InputStream.class, new EncryptedCacheEncoder(secret, glide.getArrayPool()));
registry.prepend(File.class, Bitmap.class, new EncryptedCacheDecoder<>(secret, new StreamBitmapDecoder(new Downsampler(registry.getImageHeaderParsers(), context.getResources().getDisplayMetrics(), glide.getBitmapPool(), glide.getArrayPool()), glide.getArrayPool())));
registry.prepend(File.class, Bitmap.class, new EncryptedCacheDecoder<>(secret, new StreamBitmapDecoder(context, glide, registry)));
StreamGifDecoder streamGifDecoder = new StreamGifDecoder(registry.getImageHeaderParsers(), new ByteBufferGifDecoder(context, registry.getImageHeaderParsers(), glide.getBitmapPool(), glide.getArrayPool()), glide.getArrayPool());
StreamGifDecoder streamGifDecoder = new StreamGifDecoder(registry.getImageHeaderParsers(), new ByteBufferGifDecoder(context, registry.getImageHeaderParsers(), glide.getBitmapPool(), glide.getArrayPool()), glide.getArrayPool());
StreamFactoryGifDecoder streamFactoryGifDecoder = new StreamFactoryGifDecoder(streamGifDecoder);
registry.prepend(InputStream.class, GifDrawable.class, streamGifDecoder);
registry.prepend(InputStreamFactory.class, GifDrawable.class, streamFactoryGifDecoder);
registry.prepend(GifDrawable.class, new EncryptedGifDrawableResourceEncoder(secret));
registry.prepend(File.class, GifDrawable.class, new EncryptedCacheDecoder<>(secret, streamGifDecoder));
@@ -79,13 +85,15 @@ public class SignalGlideComponents implements RegisterGlideComponents {
registry.prepend(Bitmap.class, new EncryptedBitmapResourceEncoder(secret));
registry.prepend(BitmapDrawable.class, new BitmapDrawableEncoder(glide.getBitmapPool(), encryptedBitmapResourceEncoder));
ByteBufferApngDecoder apngBufferCacheDecoder = new ByteBufferApngDecoder();
StreamApngDecoder apngStreamCacheDecoder = new StreamApngDecoder(apngBufferCacheDecoder);
ByteBufferApngDecoder byteBufferApngDecoder = new ByteBufferApngDecoder();
StreamApngDecoder streamApngDecoder = new StreamApngDecoder(byteBufferApngDecoder);
StreamFactoryApngDecoder streamFactoryApngDecoder = new StreamFactoryApngDecoder(byteBufferApngDecoder, glide, registry);
registry.prepend(InputStream.class, APNGDecoder.class, apngStreamCacheDecoder);
registry.prepend(ByteBuffer.class, APNGDecoder.class, apngBufferCacheDecoder);
registry.prepend(InputStream.class, APNGDecoder.class, streamApngDecoder);
registry.prepend(InputStreamFactory.class, APNGDecoder.class, streamFactoryApngDecoder);
registry.prepend(ByteBuffer.class, APNGDecoder.class, byteBufferApngDecoder);
registry.prepend(APNGDecoder.class, new EncryptedApngCacheEncoder(secret));
registry.prepend(File.class, APNGDecoder.class, new EncryptedCacheDecoder<>(secret, apngStreamCacheDecoder));
registry.prepend(File.class, APNGDecoder.class, new EncryptedCacheDecoder<>(secret, streamApngDecoder));
registry.register(APNGDecoder.class, Drawable.class, new ApngFrameDrawableTranscoder());
registry.prepend(BlurHash.class, Bitmap.class, new BlurHashResourceDecoder());
@@ -94,7 +102,8 @@ public class SignalGlideComponents implements RegisterGlideComponents {
registry.append(StoryTextPostModel.class, StoryTextPostModel.class, UnitModelLoader.Factory.getInstance());
registry.append(ConversationShortcutPhoto.class, Bitmap.class, new ConversationShortcutPhoto.Loader.Factory(context));
registry.append(ContactPhoto.class, InputStream.class, new ContactPhotoLoader.Factory(context));
registry.append(DecryptableUri.class, InputStream.class, new DecryptableStreamUriLoader.Factory(context));
registry.append(DecryptableUri.class, InputStreamFactory.class, new DecryptableUriStreamLoader.Factory(context));
registry.append(InputStreamFactory.class, Bitmap.class, new InputStreamFactoryBitmapDecoder(context, glide, registry));
registry.append(ChunkedImageUrl.class, InputStream.class, new ChunkedImageUrlLoader.Factory());
registry.append(StickerRemoteUri.class, InputStream.class, new StickerRemoteUriLoader.Factory());
registry.append(BlurHash.class, BlurHash.class, new BlurHashModelLoader.Factory());

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