mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-05-11 18:50:15 +01:00
Compare commits
104 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| aeb9054a63 | |||
| bb33945a93 | |||
| 3d2ceef47f | |||
| 892e6bd853 | |||
| 78e1a407a6 | |||
| 48d766ecff | |||
| d6d3226fcd | |||
| ed4944f806 | |||
| eb2dfb3fb6 | |||
| 265f71dff3 | |||
| 01d1769e4c | |||
| 97d099c7f1 | |||
| 0a957bc97c | |||
| 5df7552506 | |||
| 75334abe0f | |||
| 8524d20de5 | |||
| 495e2e043e | |||
| dec9eb613e | |||
| d6e7030dd0 | |||
| 6e43e931b2 | |||
| 430a55f89f | |||
| d717aad03d | |||
| efd86ad2fc | |||
| b284835545 | |||
| 4dd30f4ec3 | |||
| a48938f3d8 | |||
| 01989ad6e7 | |||
| f37f67c6c0 | |||
| 36f7c60a99 | |||
| 3f067654d9 | |||
| 0ce3eab3cd | |||
| b0f7c36cc2 | |||
| 966e208be5 | |||
| a80d353e04 | |||
| 080fa88bfb | |||
| 172e3d129e | |||
| 52d5947c0a | |||
| 7334ebfce1 | |||
| 2c98bbaf7e | |||
| 5a91dba56e | |||
| 535c5a1574 | |||
| b61c54c0e2 | |||
| 5ac5d45fc6 | |||
| 79ba929e70 | |||
| 3e9146a6f5 | |||
| 0c4c280a50 | |||
| ebea499a5a | |||
| d6b39e9f0a | |||
| 787eaee6a0 | |||
| 5ecb3d8832 | |||
| b2e8666c9f | |||
| 8af41e4b2c | |||
| 5eaf1000c8 | |||
| 4ed6773983 | |||
| 0de0441f65 | |||
| 9e1b4a9a8c | |||
| bf28b90e89 | |||
| a0a962a94f | |||
| abe0b2ebca | |||
| 7b4fe7ff40 | |||
| 1ba9793943 | |||
| 14d4228e86 | |||
| 3d2c51c14b | |||
| 72d75e9cd5 | |||
| e125fa6bfb | |||
| 57574126bb | |||
| 833c81a99e | |||
| 5ca17dfe52 | |||
| 5e058bb655 | |||
| ce87b50a07 | |||
| 2ad14800d1 | |||
| f04a0533cb | |||
| 5ae51f844e | |||
| 4ce2c6ef73 | |||
| 4442f26f53 | |||
| 849fce5a89 | |||
| 482fce6a25 | |||
| e7e69ab064 | |||
| 4b768419da | |||
| 2cca01d30f | |||
| e0c69dc485 | |||
| 1dd79efdb2 | |||
| dbb3c8def9 | |||
| 562185f46d | |||
| f6c7c6de73 | |||
| 1ca3a9ca73 | |||
| c76c3f65f2 | |||
| 59c27797d6 | |||
| c5c720b1c9 | |||
| caa09c82d0 | |||
| d45f80f25d | |||
| 6a248f617a | |||
| 2959e05ea7 | |||
| 17faf56388 | |||
| f533ad1533 | |||
| 25452fefa5 | |||
| 9702728c19 | |||
| 43f19d14d8 | |||
| 467c154ea6 | |||
| d72c742ab6 | |||
| 567bf0facc | |||
| d5329d0794 | |||
| ff04e5c5c3 | |||
| e529fbd1bc |
@@ -1,23 +0,0 @@
|
||||
# Number of days of inactivity before an Issue or Pull Request becomes stale
|
||||
daysUntilStale: 60
|
||||
|
||||
# Number of days of inactivity before an Issue or Pull Request with the stale label is closed.
|
||||
# Set to false to disable. If disabled, issues still need to be closed manually, but will remain marked as stale.
|
||||
daysUntilClose: 7
|
||||
|
||||
issues:
|
||||
exemptLabels:
|
||||
- acknowledged
|
||||
|
||||
# Comment to post when marking as stale. Set to `false` to disable
|
||||
markComment: >
|
||||
This issue has been automatically marked as stale because it has not had
|
||||
recent activity. It will be closed if no further activity occurs. Thank you
|
||||
for your contributions.
|
||||
|
||||
# Comment to post when closing a stale Issue or Pull Request.
|
||||
closeComment: >
|
||||
This issue has been closed due to inactivity.
|
||||
|
||||
# Limit the number of actions per hour, from 1-30. Default is 30
|
||||
limitPerRun: 1
|
||||
@@ -0,0 +1,37 @@
|
||||
name: Mark stale issues and PRs
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 2 * * *' # daily at 02:00 UTC
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
actions: write
|
||||
|
||||
steps:
|
||||
- uses: actions/stale@v10
|
||||
with:
|
||||
days-before-stale: 60
|
||||
days-before-close: 7
|
||||
exempt-issue-labels: 'acknowledged'
|
||||
exempt-pr-labels: 'acknowledged'
|
||||
stale-issue-label: 'wontfix'
|
||||
stale-pr-label: 'wontfix'
|
||||
stale-issue-message: >
|
||||
This issue has been automatically marked as stale because it has not had
|
||||
recent activity. It will be closed if no further activity occurs. Thank you
|
||||
for your contributions!
|
||||
stale-pr-message: >
|
||||
This pull request has been automatically marked as stale because it has not had
|
||||
recent activity. It will be closed if no further activity occurs. Thank you
|
||||
for your contributions!
|
||||
close-issue-message: >
|
||||
This issue has been closed due to inactivity.
|
||||
close-pr-message: >
|
||||
This pull request has been closed due to inactivity.
|
||||
operations-per-run: 150
|
||||
@@ -24,8 +24,8 @@ plugins {
|
||||
|
||||
apply(from = "static-ips.gradle.kts")
|
||||
|
||||
val canonicalVersionCode = 1671
|
||||
val canonicalVersionName = "8.5.0"
|
||||
val canonicalVersionCode = 1675
|
||||
val canonicalVersionName = "8.6.2"
|
||||
val currentHotfixVersion = 0
|
||||
val maxHotfixVersions = 100
|
||||
|
||||
|
||||
+2
-2
@@ -359,8 +359,8 @@ class V2ConversationItemShapeTest {
|
||||
|
||||
override fun onViewPinnedMessage(messageId: Long) = Unit
|
||||
|
||||
override fun onExpandEvents(messageId: Long) = Unit
|
||||
override fun onExpandEvents(messageId: Long, itemView: View, collapsedSize: Int) = Unit
|
||||
|
||||
override fun onCollapseEvents(messageId: Long) = Unit
|
||||
override fun onCollapseEvents(messageId: Long, itemView: View, collapsedSize: Int) = Unit
|
||||
}
|
||||
}
|
||||
|
||||
+22
@@ -3,7 +3,9 @@ package org.thoughtcrime.securesms.database
|
||||
import androidx.core.content.contentValuesOf
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import io.mockk.every
|
||||
import io.mockk.mockkObject
|
||||
import io.mockk.mockkStatic
|
||||
import io.mockk.unmockkObject
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
@@ -298,4 +300,24 @@ class CollapsingMessagesTests {
|
||||
assertEquals(CollapsedState.COLLAPSED, msgCall4.collapsedState)
|
||||
assertEquals(call3.messageId, msgCall4.collapsedHeadId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenMaxCollapsedSet_whenIAddAnotherEvent_thenIExpectANewHead() {
|
||||
mockkObject(CollapsibleEvents)
|
||||
every { CollapsibleEvents.MAX_SIZE } returns 2
|
||||
|
||||
val messageId1 = message.insertCallLog(alice, MessageTypes.INCOMING_AUDIO_CALL_TYPE, 1000L, false).messageId
|
||||
val messageId2 = message.insertCallLog(alice, MessageTypes.INCOMING_AUDIO_CALL_TYPE, 2000L, false).messageId
|
||||
val messageId3 = message.insertCallLog(alice, MessageTypes.INCOMING_AUDIO_CALL_TYPE, 3000L, false).messageId
|
||||
|
||||
val msg1 = message.getMessageRecord(messageId1)
|
||||
val msg2 = message.getMessageRecord(messageId2)
|
||||
val msg3 = message.getMessageRecord(messageId3)
|
||||
|
||||
assertEquals(CollapsedState.HEAD_COLLAPSED, msg1.collapsedState)
|
||||
assertEquals(CollapsedState.PENDING_COLLAPSED, msg2.collapsedState)
|
||||
assertEquals(CollapsedState.HEAD_COLLAPSED, msg3.collapsedState)
|
||||
assertEquals(messageId3, msg3.collapsedHeadId)
|
||||
unmockkObject(CollapsibleEvents)
|
||||
}
|
||||
}
|
||||
|
||||
+29
-14
@@ -29,24 +29,39 @@ class ThreadTableTest_recents {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenARecentRecipient_whenIBlockAndGetRecents_thenIDoNotExpectToSeeThatRecipient() {
|
||||
// GIVEN
|
||||
fun getRecentConversationList_excludes_blocked_recipients() {
|
||||
createActiveThreadFor(recipient)
|
||||
|
||||
SignalDatabase.recipients.setBlocked(recipient.id, true)
|
||||
|
||||
assertFalse(recipient.id in getRecentConversationRecipients(limit = 10))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getRecentConversationList_excludes_hidden_recipients() {
|
||||
createActiveThreadFor(recipient)
|
||||
|
||||
SignalDatabase.recipients.markHidden(recipient.id)
|
||||
|
||||
assertFalse(recipient.id in getRecentConversationRecipients(limit = 10))
|
||||
}
|
||||
|
||||
private fun createActiveThreadFor(recipient: Recipient) {
|
||||
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(recipient)
|
||||
MmsHelper.insert(recipient = recipient, threadId = threadId)
|
||||
SignalDatabase.threads.update(threadId, true)
|
||||
}
|
||||
|
||||
// WHEN
|
||||
SignalDatabase.recipients.setBlocked(recipient.id, true)
|
||||
val results: MutableList<RecipientId> = SignalDatabase.threads.getRecentConversationList(10, false, false, false, false, false, false).use { cursor ->
|
||||
val ids = mutableListOf<RecipientId>()
|
||||
while (cursor.moveToNext()) {
|
||||
ids.add(RecipientId.from(CursorUtil.requireLong(cursor, ThreadTable.RECIPIENT_ID)))
|
||||
@Suppress("SameParameterValue")
|
||||
private fun getRecentConversationRecipients(limit: Int = 10): Set<RecipientId> {
|
||||
return SignalDatabase.threads
|
||||
.getRecentConversationList(limit = limit, includeInactiveGroups = false, individualsOnly = false, groupsOnly = false, hideV1Groups = false, hideSms = false, hideSelf = false)
|
||||
.use { cursor ->
|
||||
buildSet {
|
||||
while (cursor.moveToNext()) {
|
||||
add(RecipientId.from(CursorUtil.requireLong(cursor, ThreadTable.RECIPIENT_ID)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ids
|
||||
}
|
||||
|
||||
// THEN
|
||||
assertFalse(recipient.id in results)
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -316,7 +316,7 @@ class DataMessageProcessorTest_polls {
|
||||
|
||||
private fun insertPoll(allowMultiple: Boolean = true): Long {
|
||||
val envelope = MessageContentFuzzer.envelope(100)
|
||||
val pollMessage = IncomingMessage(type = MessageType.NORMAL, from = alice.id, sentTimeMillis = envelope.timestamp!!, serverTimeMillis = envelope.serverTimestamp!!, receivedTimeMillis = 0, groupId = groupId)
|
||||
val pollMessage = IncomingMessage(type = MessageType.NORMAL, from = alice.id, sentTimeMillis = envelope.clientTimestamp!!, serverTimeMillis = envelope.serverTimestamp!!, receivedTimeMillis = 0, groupId = groupId)
|
||||
val messageId = SignalDatabase.messages.insertMessageInbox(pollMessage).get()
|
||||
SignalDatabase.polls.insertPoll("question?", allowMultiple, listOf("a", "b", "c"), alice.id.toLong(), messageId.messageId)
|
||||
return messageId.messageId
|
||||
|
||||
@@ -43,7 +43,7 @@ object MessageContentFuzzer {
|
||||
*/
|
||||
fun envelope(timestamp: Long, serverGuid: UUID = UUID.randomUUID()): Envelope {
|
||||
return Envelope.Builder()
|
||||
.timestamp(timestamp)
|
||||
.clientTimestamp(timestamp)
|
||||
.serverTimestamp(timestamp + 5)
|
||||
.serverGuidBinary(serverGuid.toByteArray().toByteString())
|
||||
.build()
|
||||
@@ -292,7 +292,7 @@ object MessageContentFuzzer {
|
||||
body = string()
|
||||
val quoted = quoteAble.random(random)
|
||||
quote = DataMessage.Quote.Builder().buildWith {
|
||||
id = quoted.envelope.timestamp
|
||||
id = quoted.envelope.clientTimestamp
|
||||
authorAciBinary = quoted.metadata.sourceServiceId.toByteString()
|
||||
text = quoted.content.dataMessage?.body
|
||||
attachments(quoted.content.dataMessage?.attachments ?: emptyList())
|
||||
@@ -304,7 +304,7 @@ object MessageContentFuzzer {
|
||||
if (random.nextFloat() < 0.1 && quoteAble.isNotEmpty()) {
|
||||
val quoted = quoteAble.random(random)
|
||||
quote = DataMessage.Quote.Builder().buildWith {
|
||||
id = random.nextLong(quoted.envelope.timestamp!! - 1000000, quoted.envelope.timestamp!!)
|
||||
id = random.nextLong(quoted.envelope.clientTimestamp!! - 1000000, quoted.envelope.clientTimestamp!!)
|
||||
authorAciBinary = quoted.metadata.sourceServiceId.toByteString()
|
||||
text = quoted.content.dataMessage?.body
|
||||
}
|
||||
@@ -333,7 +333,7 @@ object MessageContentFuzzer {
|
||||
emoji = emojis.random(random)
|
||||
remove = false
|
||||
targetAuthorAciBinary = reactTo.metadata.sourceServiceId.toByteString()
|
||||
targetSentTimestamp = reactTo.envelope.timestamp
|
||||
targetSentTimestamp = reactTo.envelope.clientTimestamp
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,7 +212,7 @@ class BenchmarkCommandReceiver : BroadcastReceiver() {
|
||||
verb = "PUT",
|
||||
path = "/api/v1/message",
|
||||
id = Random.nextLong(),
|
||||
headers = listOf("X-Signal-Timestamp: ${this.timestamp}"),
|
||||
headers = listOf("X-Signal-Timestamp: ${this.serverTimestamp}"),
|
||||
body = this.encodeByteString()
|
||||
)
|
||||
}
|
||||
|
||||
@@ -70,8 +70,8 @@ object Generator {
|
||||
val serverGuid = UUID.randomUUID()
|
||||
return Envelope.Builder()
|
||||
.type(Envelope.Type.fromValue(this.type))
|
||||
.sourceDevice(1)
|
||||
.timestamp(timestamp)
|
||||
.sourceDeviceId(1)
|
||||
.clientTimestamp(timestamp)
|
||||
.serverTimestamp(timestamp + 1)
|
||||
.destinationServiceId(destination.toString())
|
||||
.destinationServiceIdBinary(destination.toByteString())
|
||||
|
||||
+2
-2
@@ -354,11 +354,11 @@ class InternalConversationTestFragment : Fragment(R.layout.conversation_test_fra
|
||||
Toast.makeText(requireContext(), "Can't touch this.", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
override fun onExpandEvents(messageId: Long) {
|
||||
override fun onExpandEvents(messageId: Long, itemView: View, collapsedSize: Int) {
|
||||
Toast.makeText(requireContext(), "Can't touch this.", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
override fun onCollapseEvents(messageId: Long) {
|
||||
override fun onCollapseEvents(messageId: Long, itemView: View, collapsedSize: Int) {
|
||||
Toast.makeText(requireContext(), "Can't touch this.", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -214,7 +214,6 @@ public class ApplicationContext extends Application implements AppForegroundObse
|
||||
.addNonBlocking(this::ensureProfileUploaded)
|
||||
.addNonBlocking(() -> AppDependencies.getExpireStoriesManager().scheduleIfNecessary())
|
||||
.addNonBlocking(BackupRepository::maybeFixAnyDanglingUploadProgress)
|
||||
.addNonBlocking(BackupRepository::maybeFixAnyDanglingLocalExportProgress)
|
||||
.addPostRender(() -> AppDependencies.getDeletedCallEventManager().scheduleIfNecessary())
|
||||
.addPostRender(() -> RateLimitUtil.retryAllRateLimitedMessages(this))
|
||||
.addPostRender(this::initializeExpiringMessageManager)
|
||||
|
||||
@@ -148,7 +148,7 @@ public interface BindableConversationItem extends Unbindable, GiphyMp4Playable,
|
||||
void onViewPollClicked(long messageId);
|
||||
void onToggleVote(@NonNull PollRecord poll, @NonNull PollOption pollOption, Boolean isChecked);
|
||||
void onViewPinnedMessage(long messageId);
|
||||
void onExpandEvents(long messageId);
|
||||
void onCollapseEvents(long messageId);
|
||||
void onExpandEvents(long messageId, @NonNull View itemView, int collapsedSize);
|
||||
void onCollapseEvents(long messageId, @NonNull View itemView, int collapsedSize);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -365,9 +365,7 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
@Override
|
||||
public void onDismissFindContactsBannerClicked() {
|
||||
SignalStore.uiHints().markDismissedContactsPermissionBanner();
|
||||
if (onRefreshListener != null) {
|
||||
onRefreshListener.onRefresh();
|
||||
}
|
||||
contactSearchMediator.refresh();
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -96,6 +96,8 @@ import org.signal.core.util.logging.Log
|
||||
import org.signal.donations.StripeApi
|
||||
import org.signal.mediasend.MediaSendActivityContract
|
||||
import org.thoughtcrime.securesms.backup.v2.ArchiveRestoreProgress
|
||||
import org.thoughtcrime.securesms.backup.v2.ArchiveRestoreProgressState
|
||||
import org.thoughtcrime.securesms.backup.v2.ui.CouldNotCompleteBackupRestoreSheet
|
||||
import org.thoughtcrime.securesms.backup.v2.ui.verify.VerifyBackupKeyActivity
|
||||
import org.thoughtcrime.securesms.calls.YouAreAlreadyInACallSnackbar.show
|
||||
import org.thoughtcrime.securesms.calls.log.CallLogFilter
|
||||
@@ -159,7 +161,7 @@ import org.thoughtcrime.securesms.main.navigateToDetailLocation
|
||||
import org.thoughtcrime.securesms.main.rememberDetailNavHostController
|
||||
import org.thoughtcrime.securesms.main.rememberFocusRequester
|
||||
import org.thoughtcrime.securesms.main.storiesNavGraphBuilder
|
||||
import org.thoughtcrime.securesms.mediasend.camerax.CameraXUtil
|
||||
import org.thoughtcrime.securesms.mediasend.camerax.CameraXRemoteConfig
|
||||
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionActivity
|
||||
import org.thoughtcrime.securesms.megaphone.Megaphone
|
||||
import org.thoughtcrime.securesms.megaphone.MegaphoneActionController
|
||||
@@ -342,6 +344,19 @@ class MainActivity :
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
launch {
|
||||
repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
ArchiveRestoreProgress
|
||||
.stateFlow
|
||||
.filter { it.restoreStatus == ArchiveRestoreProgressState.RestoreStatus.LOCAL_RESTORE_DIRECTORY_UNAVAILABLE }
|
||||
.collect {
|
||||
ArchiveRestoreProgress.clearLocalRestoreDirectoryError()
|
||||
CouldNotCompleteBackupRestoreSheet().show(supportFragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
|
||||
Log.i(TAG, "Local restore directory became unavailable.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
supportFragmentManager.setFragmentResultListener(
|
||||
@@ -1125,7 +1140,7 @@ class MainActivity :
|
||||
}
|
||||
}
|
||||
|
||||
if (CameraXUtil.isSupported()) {
|
||||
if (CameraXRemoteConfig.isSupported()) {
|
||||
onGranted()
|
||||
} else {
|
||||
Permissions.with(this@MainActivity)
|
||||
|
||||
@@ -8,16 +8,19 @@ package org.thoughtcrime.securesms.attachments
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import org.signal.blurhash.BlurHashEncoder
|
||||
import org.signal.core.util.Base64
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.core.util.mebiBytes
|
||||
import org.signal.protos.resumableuploads.ResumableUpload
|
||||
import org.thoughtcrime.securesms.mms.PartAuthority
|
||||
import org.thoughtcrime.securesms.util.MediaUtil
|
||||
import org.whispersystems.signalservice.api.crypto.AttachmentCipherStreamUtil
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceAttachment
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceAttachment.ProgressListener
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentStream
|
||||
import org.whispersystems.signalservice.internal.push.http.ResumableUploadSpec
|
||||
import org.whispersystems.signalservice.internal.crypto.PaddingInputStream
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.security.MessageDigest
|
||||
import java.util.Objects
|
||||
|
||||
/**
|
||||
@@ -32,6 +35,29 @@ object AttachmentUploadUtil {
|
||||
*/
|
||||
val FOREGROUND_LIMIT_BYTES: Long = 10.mebiBytes.inWholeBytes
|
||||
|
||||
/**
|
||||
* Computes the base64-encoded SHA-256 checksum of the ciphertext that would result from encrypting [plaintextStream]
|
||||
* with the given [key] and [iv], including padding, IV prefix, and HMAC suffix.
|
||||
*/
|
||||
fun computeCiphertextChecksum(key: ByteArray, iv: ByteArray, plaintextStream: InputStream, plaintextSize: Long): String {
|
||||
val paddedStream = PaddingInputStream(plaintextStream, plaintextSize)
|
||||
return Base64.encodeWithPadding(AttachmentCipherStreamUtil.computeCiphertextSha256(key, iv, paddedStream))
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the base64-encoded SHA-256 checksum of the raw bytes in [inputStream].
|
||||
* Used for pre-encrypted uploads where the data is already in its final form.
|
||||
*/
|
||||
fun computeRawChecksum(inputStream: InputStream): String {
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
val buffer = ByteArray(16 * 1024)
|
||||
var read: Int
|
||||
while (inputStream.read(buffer).also { read = it } != -1) {
|
||||
digest.update(buffer, 0, read)
|
||||
}
|
||||
return Base64.encodeWithPadding(digest.digest())
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a [SignalServiceAttachmentStream] from the provided data, which can then be provided to various upload methods.
|
||||
*/
|
||||
@@ -39,7 +65,6 @@ object AttachmentUploadUtil {
|
||||
fun buildSignalServiceAttachmentStream(
|
||||
context: Context,
|
||||
attachment: Attachment,
|
||||
uploadSpec: ResumableUpload,
|
||||
cancellationSignal: (() -> Boolean)? = null,
|
||||
progressListener: ProgressListener? = null
|
||||
): SignalServiceAttachmentStream {
|
||||
@@ -57,7 +82,6 @@ object AttachmentUploadUtil {
|
||||
.withHeight(attachment.height)
|
||||
.withUploadTimestamp(System.currentTimeMillis())
|
||||
.withCaption(attachment.caption)
|
||||
.withResumableUploadSpec(ResumableUploadSpec.from(uploadSpec))
|
||||
.withCancelationSignal(cancellationSignal)
|
||||
.withListener(progressListener)
|
||||
.withUuid(attachment.uuid)
|
||||
|
||||
@@ -46,9 +46,9 @@ sealed interface FallbackAvatar {
|
||||
fun getIconBySize(size: Size): Int
|
||||
|
||||
/**
|
||||
* Local user
|
||||
* Note to Self / local user
|
||||
*/
|
||||
data class Local(override val color: AvatarColor) : Resource {
|
||||
data class NoteToSelf(override val color: AvatarColor) : Resource {
|
||||
override fun getIconBySize(size: Size): Int {
|
||||
return when (size) {
|
||||
Size.SMALL -> R.drawable.symbol_note_compact_16
|
||||
|
||||
@@ -30,7 +30,7 @@ import org.thoughtcrime.securesms.avatar.vector.VectorAvatarCreationFragment
|
||||
import org.thoughtcrime.securesms.components.ButtonStripItemView
|
||||
import org.thoughtcrime.securesms.components.recyclerview.GridDividerDecoration
|
||||
import org.thoughtcrime.securesms.mediasend.AvatarSelectionActivity
|
||||
import org.thoughtcrime.securesms.mediasend.camerax.CameraXUtil
|
||||
import org.thoughtcrime.securesms.mediasend.camerax.CameraXRemoteConfig
|
||||
import org.thoughtcrime.securesms.util.ViewUtil
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
|
||||
import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
||||
@@ -223,7 +223,7 @@ class AvatarPickerFragment : Fragment(R.layout.avatar_picker_fragment) {
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
private fun openCameraCapture() {
|
||||
if (CameraXUtil.isSupported()) {
|
||||
if (CameraXRemoteConfig.isSupported()) {
|
||||
val intent = AvatarSelectionActivity.getIntentForCameraCapture(requireContext())
|
||||
startActivityForResult(intent, REQUEST_CODE_SELECT_IMAGE)
|
||||
} else {
|
||||
|
||||
@@ -8,7 +8,7 @@ package org.thoughtcrime.securesms.backup
|
||||
import org.thoughtcrime.securesms.keyvalue.protos.LocalBackupCreationProgress
|
||||
|
||||
val LocalBackupCreationProgress.isIdle: Boolean
|
||||
get() = idle != null || (exporting == null && transferring == null && canceled == null)
|
||||
get() = idle != null || succeeded != null || failed != null || canceled != null || (exporting == null && transferring == null)
|
||||
|
||||
fun LocalBackupCreationProgress.exportProgress(): Float {
|
||||
val exporting = exporting ?: return 0f
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.backup
|
||||
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import org.thoughtcrime.securesms.keyvalue.protos.LocalBackupCreationProgress
|
||||
|
||||
object LocalExportProgress {
|
||||
val internalEncryptedProgress = MutableStateFlow(LocalBackupCreationProgress())
|
||||
val internalPlaintextProgress = MutableStateFlow(LocalBackupCreationProgress())
|
||||
|
||||
val encryptedProgress: StateFlow<LocalBackupCreationProgress> = internalEncryptedProgress
|
||||
val plaintextProgress: StateFlow<LocalBackupCreationProgress> = internalPlaintextProgress
|
||||
|
||||
fun setEncryptedProgress(progress: LocalBackupCreationProgress) {
|
||||
internalEncryptedProgress.value = progress
|
||||
}
|
||||
|
||||
fun setPlaintextProgress(progress: LocalBackupCreationProgress) {
|
||||
internalPlaintextProgress.value = progress
|
||||
}
|
||||
}
|
||||
@@ -194,6 +194,10 @@ object ExportOddities {
|
||||
return log(sentTimestamp, "Revisions for this message contained items of a different type than the parent item. Ignoring mismatched revisions.")
|
||||
}
|
||||
|
||||
fun mismatchedRevisionAuthor(sentTimestamp: Long): String {
|
||||
return log(sentTimestamp, "Revisions for this message contained items with a different author than the parent item. Ignoring mismatched revisions.")
|
||||
}
|
||||
|
||||
fun outgoingMessageWasSentButTimerNotStarted(sentTimestamp: Long): String {
|
||||
return log(sentTimestamp, "Outgoing expiring message was sent, but the timer wasn't started. Setting expireStartDate to dateReceived.")
|
||||
}
|
||||
|
||||
@@ -157,6 +157,11 @@ object ArchiveRestoreProgress {
|
||||
update()
|
||||
}
|
||||
|
||||
fun clearLocalRestoreDirectoryError() {
|
||||
SignalStore.backup.localRestoreDirectoryError = false
|
||||
update()
|
||||
}
|
||||
|
||||
fun clearFinishedStatus() {
|
||||
store.update { state ->
|
||||
if (state.restoreStatus == ArchiveRestoreProgressState.RestoreStatus.FINISHED) {
|
||||
@@ -193,7 +198,11 @@ object ArchiveRestoreProgress {
|
||||
!NetworkConstraint.isMet(AppDependencies.application) -> ArchiveRestoreProgressState.RestoreStatus.WAITING_FOR_INTERNET
|
||||
!BatteryNotLowConstraint.isMet() -> ArchiveRestoreProgressState.RestoreStatus.LOW_BATTERY
|
||||
!DiskSpaceNotLowConstraint.isMet() -> ArchiveRestoreProgressState.RestoreStatus.NOT_ENOUGH_DISK_SPACE
|
||||
restoreState == RestoreState.NONE -> if (state.hasActivelyRestoredThisRun) ArchiveRestoreProgressState.RestoreStatus.FINISHED else ArchiveRestoreProgressState.RestoreStatus.NONE
|
||||
restoreState == RestoreState.NONE -> when {
|
||||
SignalStore.backup.localRestoreDirectoryError -> ArchiveRestoreProgressState.RestoreStatus.LOCAL_RESTORE_DIRECTORY_UNAVAILABLE
|
||||
state.hasActivelyRestoredThisRun -> ArchiveRestoreProgressState.RestoreStatus.FINISHED
|
||||
else -> ArchiveRestoreProgressState.RestoreStatus.NONE
|
||||
}
|
||||
else -> {
|
||||
val availableBytes = SignalStore.backup.spaceAvailableOnDiskBytes
|
||||
|
||||
|
||||
+2
-1
@@ -69,6 +69,7 @@ data class ArchiveRestoreProgressState(
|
||||
WAITING_FOR_INTERNET,
|
||||
WAITING_FOR_WIFI,
|
||||
NOT_ENOUGH_DISK_SPACE,
|
||||
FINISHED
|
||||
FINISHED,
|
||||
LOCAL_RESTORE_DIRECTORY_UNAVAILABLE
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,13 +67,11 @@ import org.signal.libsignal.zkgroup.VerificationFailedException
|
||||
import org.signal.libsignal.zkgroup.backups.BackupLevel
|
||||
import org.signal.libsignal.zkgroup.profiles.ProfileKey
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.attachments.Attachment
|
||||
import org.thoughtcrime.securesms.attachments.AttachmentId
|
||||
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.isIdle
|
||||
import org.thoughtcrime.securesms.backup.v2.BackupRepository.copyAttachmentToArchive
|
||||
import org.thoughtcrime.securesms.backup.v2.BackupRepository.exportForDebugging
|
||||
import org.thoughtcrime.securesms.backup.v2.importer.ChatItemArchiveImporter
|
||||
@@ -116,7 +114,6 @@ import org.thoughtcrime.securesms.jobs.BackupMessagesJob
|
||||
import org.thoughtcrime.securesms.jobs.BackupRestoreMediaJob
|
||||
import org.thoughtcrime.securesms.jobs.CancelRestoreMediaJob
|
||||
import org.thoughtcrime.securesms.jobs.CreateReleaseChannelJob
|
||||
import org.thoughtcrime.securesms.jobs.LocalArchiveJob
|
||||
import org.thoughtcrime.securesms.jobs.LocalBackupJob
|
||||
import org.thoughtcrime.securesms.jobs.MultiDeviceKeysUpdateJob
|
||||
import org.thoughtcrime.securesms.jobs.RequestGroupV2InfoJob
|
||||
@@ -133,7 +130,6 @@ 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.keyvalue.protos.LocalBackupCreationProgress
|
||||
import org.thoughtcrime.securesms.logsubmit.SubmitDebugLogRepository
|
||||
import org.thoughtcrime.securesms.net.SignalNetwork
|
||||
import org.thoughtcrime.securesms.notifications.NotificationChannels
|
||||
@@ -593,14 +589,6 @@ object BackupRepository {
|
||||
SignalStore.backup.snoozeDownloadNotifier()
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun maybeFixAnyDanglingLocalExportProgress() {
|
||||
if (!SignalStore.backup.newLocalBackupProgress.isIdle && AppDependencies.jobManager.find { it.factoryKey == LocalArchiveJob.KEY }.isEmpty()) {
|
||||
Log.w(TAG, "Found stale local backup progress with no active job. Resetting to idle.")
|
||||
SignalStore.backup.newLocalBackupProgress = LocalBackupCreationProgress(idle = LocalBackupCreationProgress.Idle())
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun maybeFixAnyDanglingUploadProgress() {
|
||||
if (SignalStore.account.isLinkedDevice) {
|
||||
@@ -1649,6 +1637,13 @@ object BackupRepository {
|
||||
}
|
||||
}
|
||||
|
||||
fun getMessageBackupUploadForm(backupFileSize: Long): NetworkResult<AttachmentUploadForm> {
|
||||
return initBackupAndFetchAuth()
|
||||
.then { credential ->
|
||||
SignalNetwork.archive.getMessageBackupUploadForm(SignalStore.account.requireAci(), credential.messageBackupAccess, backupFileSize)
|
||||
}
|
||||
}
|
||||
|
||||
fun downloadBackupFile(destination: File, listener: ProgressListener? = null): NetworkResult<Unit> {
|
||||
return initBackupAndFetchAuth()
|
||||
.then { credential ->
|
||||
@@ -1688,7 +1683,6 @@ object BackupRepository {
|
||||
|
||||
/**
|
||||
* Retrieves an [AttachmentUploadForm] that can be used to upload an attachment to the transit cdn.
|
||||
* To continue the upload, use [org.whispersystems.signalservice.api.attachment.AttachmentApi.getResumableUploadSpec].
|
||||
*
|
||||
* It's important to note that in order to get this to the archive cdn, you still need to use [copyAttachmentToArchive].
|
||||
*/
|
||||
@@ -1726,10 +1720,10 @@ object BackupRepository {
|
||||
/**
|
||||
* Copies a thumbnail that has been uploaded to the transit cdn to the archive cdn.
|
||||
*/
|
||||
fun copyThumbnailToArchive(thumbnailAttachment: Attachment, parentAttachment: DatabaseAttachment): NetworkResult<ArchiveMediaResponse> {
|
||||
fun copyThumbnailToArchive(thumbnail: UploadedThumbnailInfo, parentAttachment: DatabaseAttachment): NetworkResult<ArchiveMediaResponse> {
|
||||
return initBackupAndFetchAuth()
|
||||
.then { credential ->
|
||||
val request = thumbnailAttachment.toArchiveMediaRequest(parentAttachment.requireThumbnailMediaName(), credential.mediaBackupAccess.backupKey)
|
||||
val request = buildArchiveMediaRequest(thumbnail.cdnNumber, thumbnail.remoteLocation, thumbnail.size, parentAttachment.requireThumbnailMediaName(), credential.mediaBackupAccess.backupKey)
|
||||
|
||||
SignalNetwork.archive.copyAttachmentToArchive(
|
||||
aci = SignalStore.account.requireAci(),
|
||||
@@ -1746,7 +1740,7 @@ object BackupRepository {
|
||||
return initBackupAndFetchAuth()
|
||||
.then { credential ->
|
||||
val mediaName = attachment.requireMediaName()
|
||||
val request = attachment.toArchiveMediaRequest(mediaName, credential.mediaBackupAccess.backupKey)
|
||||
val request = buildArchiveMediaRequest(attachment.cdn.cdnNumber, attachment.remoteLocation!!, attachment.size, mediaName, credential.mediaBackupAccess.backupKey)
|
||||
SignalNetwork.archive
|
||||
.copyAttachmentToArchive(
|
||||
aci = SignalStore.account.requireAci(),
|
||||
@@ -2197,15 +2191,15 @@ object BackupRepository {
|
||||
val profileKey: ProfileKey
|
||||
)
|
||||
|
||||
private fun Attachment.toArchiveMediaRequest(mediaName: MediaName, mediaRootBackupKey: MediaRootBackupKey): ArchiveMediaRequest {
|
||||
private fun buildArchiveMediaRequest(cdnNumber: Int, remoteLocation: String, plaintextSize: Long, mediaName: MediaName, mediaRootBackupKey: MediaRootBackupKey): ArchiveMediaRequest {
|
||||
val mediaSecrets = mediaRootBackupKey.deriveMediaSecrets(mediaName)
|
||||
|
||||
return ArchiveMediaRequest(
|
||||
sourceAttachment = ArchiveMediaRequest.SourceAttachment(
|
||||
cdn = cdn.cdnNumber,
|
||||
key = remoteLocation!!
|
||||
cdn = cdnNumber,
|
||||
key = remoteLocation
|
||||
),
|
||||
objectLength = AttachmentCipherStreamUtil.getCiphertextLength(PaddingInputStream.getPaddedSize(size)).toInt(),
|
||||
objectLength = AttachmentCipherStreamUtil.getCiphertextLength(PaddingInputStream.getPaddedSize(plaintextSize)).toInt(),
|
||||
mediaId = mediaSecrets.id.encode(),
|
||||
hmacKey = Base64.encodeWithPadding(mediaSecrets.macKey),
|
||||
encryptionKey = Base64.encodeWithPadding(mediaSecrets.aesKey)
|
||||
@@ -2618,3 +2612,9 @@ class ArchiveMediaItemIterator(private val cursor: Cursor) : Iterator<ArchiveMed
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
data class UploadedThumbnailInfo(
|
||||
val cdnNumber: Int,
|
||||
val remoteLocation: String,
|
||||
val size: Long
|
||||
)
|
||||
|
||||
+9
-4
@@ -1741,19 +1741,24 @@ private fun ChatUpdateMessage.canOnlyBeAuthoredBySelf(): Boolean {
|
||||
}
|
||||
|
||||
private fun List<ChatItem>.repairRevisions(current: ChatItem.Builder): List<ChatItem> {
|
||||
val authorFiltered = this.filter { it.authorId == current.authorId }
|
||||
if (authorFiltered.size != this.size) {
|
||||
Log.w(TAG, ExportOddities.mismatchedRevisionAuthor(current.dateSent))
|
||||
}
|
||||
|
||||
return if (current.standardMessage != null) {
|
||||
val filtered = this
|
||||
val filtered = authorFiltered
|
||||
.filter { it.standardMessage != null }
|
||||
.map { it.withDowngradeVoiceNotes() }
|
||||
|
||||
if (this.size != filtered.size) {
|
||||
if (authorFiltered.size != filtered.size) {
|
||||
Log.w(TAG, ExportOddities.mismatchedRevisionHistory(current.dateSent))
|
||||
}
|
||||
|
||||
filtered
|
||||
} else if (current.directStoryReplyMessage != null) {
|
||||
val filtered = this.filter { it.directStoryReplyMessage != null }
|
||||
if (this.size != filtered.size) {
|
||||
val filtered = authorFiltered.filter { it.directStoryReplyMessage != null }
|
||||
if (authorFiltered.size != filtered.size) {
|
||||
Log.w(TAG, ExportOddities.mismatchedRevisionHistory(current.dateSent))
|
||||
}
|
||||
filtered
|
||||
|
||||
+1
-1
@@ -133,7 +133,7 @@ private fun DecryptedMember.toRemote(): Group.Member {
|
||||
userId = aciBytes,
|
||||
role = role.toRemote(),
|
||||
joinedAtVersion = joinedAtRevision,
|
||||
labelEmoji = labelEmoji,
|
||||
labelEmoji = if (labelString.isNotBlank()) labelEmoji else "",
|
||||
labelString = labelString
|
||||
)
|
||||
}
|
||||
|
||||
+2
@@ -472,6 +472,7 @@ class ChatItemArchiveImporter(
|
||||
val ids = SignalDatabase.attachments.insertAttachmentsForMessage(messageRowId, listOf(longTextAttachment), emptyList())
|
||||
ids.values.firstOrNull()?.let { attachmentId ->
|
||||
SignalDatabase.attachments.setTransferState(messageRowId, attachmentId, AttachmentTable.TRANSFER_PROGRESS_DONE)
|
||||
SignalDatabase.attachments.createRemoteKeyIfNecessary(attachmentId)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -511,6 +512,7 @@ class ChatItemArchiveImporter(
|
||||
if (longTextAttachment != null) {
|
||||
attachmentMap[longTextAttachment]?.let { attachmentId ->
|
||||
SignalDatabase.attachments.setTransferState(messageRowId, attachmentId, AttachmentTable.TRANSFER_PROGRESS_DONE)
|
||||
SignalDatabase.attachments.createRemoteKeyIfNecessary(attachmentId)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ package org.thoughtcrime.securesms.backup.v2.local
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
@@ -17,6 +18,8 @@ import org.signal.archive.local.ArchivedFilesReader
|
||||
import org.signal.core.models.backup.MediaName
|
||||
import org.signal.core.util.Stopwatch
|
||||
import org.signal.core.util.androidx.DocumentFileInfo
|
||||
import org.signal.core.util.androidx.DocumentFileUtil
|
||||
import org.signal.core.util.androidx.DocumentFileUtil.OperationResult
|
||||
import org.signal.core.util.androidx.DocumentFileUtil.delete
|
||||
import org.signal.core.util.androidx.DocumentFileUtil.hasFile
|
||||
import org.signal.core.util.androidx.DocumentFileUtil.inputStream
|
||||
@@ -26,7 +29,6 @@ import org.signal.core.util.androidx.DocumentFileUtil.newFile
|
||||
import org.signal.core.util.androidx.DocumentFileUtil.outputStream
|
||||
import org.signal.core.util.androidx.DocumentFileUtil.renameTo
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.R
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
@@ -59,9 +61,18 @@ class ArchiveFileSystem private constructor(private val context: Context, root:
|
||||
* Should likely only be called on API29+
|
||||
*/
|
||||
fun fromUri(context: Context, uri: Uri): ArchiveFileSystem? {
|
||||
val root = DocumentFile.fromTreeUri(context, uri)
|
||||
val root = DocumentFile.fromTreeUri(context, uri) ?: return null
|
||||
|
||||
if (root == null || !root.canWrite()) {
|
||||
val result = DocumentFileUtil.retryDocumentFileOperation<Unit> { attempt, maxAttempts ->
|
||||
Log.d(TAG, "canWrite() check attempt ${attempt + 1}/$maxAttempts")
|
||||
if (root.canWrite()) {
|
||||
OperationResult.Success(true)
|
||||
} else {
|
||||
OperationResult.Retry
|
||||
}
|
||||
}
|
||||
|
||||
if (!result.isSuccess()) {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -77,15 +88,28 @@ class ArchiveFileSystem private constructor(private val context: Context, root:
|
||||
fun openForRestore(context: Context, uri: Uri): ArchiveFileSystem? {
|
||||
val root = DocumentFile.fromTreeUri(context, uri) ?: return null
|
||||
if (!root.canRead()) return null
|
||||
if (root.findFile(MAIN_DIRECTORY_NAME) == null) return null
|
||||
return openForRestore(context, root)
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
fun openForRestore(context: Context, root: DocumentFile): ArchiveFileSystem? {
|
||||
if (root.findFile(MAIN_DIRECTORY_NAME) == null && !looksLikeSignalBackupsDirectory(root)) return null
|
||||
return try {
|
||||
ArchiveFileSystem(context, root, readOnly = true)
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "Unable to open backup directory for restore: $uri", e)
|
||||
Log.w(TAG, "Unable to open backup directory for restore", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if [dir] appears to be a SignalBackups directory based on its name and
|
||||
* expected internal structure (presence of the "files" subdirectory).
|
||||
*/
|
||||
private fun looksLikeSignalBackupsDirectory(dir: DocumentFile): Boolean {
|
||||
return dir.name == MAIN_DIRECTORY_NAME && dir.findFile("files") != null
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to create an [ArchiveFileSystem] from a regular [File].
|
||||
*
|
||||
@@ -105,22 +129,31 @@ class ArchiveFileSystem private constructor(private val context: Context, root:
|
||||
/** File access to shared super-set of archive related files (e.g., media + attachments) */
|
||||
val filesFileSystem: FilesFileSystem
|
||||
|
||||
/**
|
||||
* True if this file system was opened directly from the SignalBackups directory itself (rather than its parent).
|
||||
* In this case, the URI cannot be reused as a backup destination since we lack access to the parent directory.
|
||||
*/
|
||||
val isRootedAtSignalBackups: Boolean
|
||||
|
||||
init {
|
||||
if (readOnly) {
|
||||
signalBackups = root.findFile(MAIN_DIRECTORY_NAME) ?: throw IOException("SignalBackups directory not found in $root")
|
||||
val child = root.findFile(MAIN_DIRECTORY_NAME)
|
||||
if (child != null) {
|
||||
signalBackups = child
|
||||
isRootedAtSignalBackups = false
|
||||
} else if (looksLikeSignalBackupsDirectory(root)) {
|
||||
signalBackups = root
|
||||
isRootedAtSignalBackups = true
|
||||
} else {
|
||||
throw IOException("SignalBackups directory not found in $root")
|
||||
}
|
||||
val filesDirectory = signalBackups.findFile("files") ?: throw IOException("files directory not found in $signalBackups")
|
||||
filesFileSystem = FilesFileSystem(context, filesDirectory, readOnly = true)
|
||||
} else {
|
||||
isRootedAtSignalBackups = false
|
||||
signalBackups = root.mkdirp(MAIN_DIRECTORY_NAME) ?: throw IOException("Unable to create main backups directory")
|
||||
val filesDirectory = signalBackups.mkdirp("files") ?: throw IOException("Unable to create files directory")
|
||||
filesFileSystem = FilesFileSystem(context, filesDirectory)
|
||||
|
||||
val hintFileName = context.getString(R.string.ArchiveFileSystem__select_this_folder_hint_name)
|
||||
if (!root.hasFile(hintFileName)) {
|
||||
root.createFile("text/plain", hintFileName)
|
||||
?.outputStream(context)
|
||||
?.use { out -> out.write(context.getString(R.string.ArchiveFileSystem__select_this_folder_hint_body).toByteArray()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,9 @@
|
||||
|
||||
package org.thoughtcrime.securesms.backup.v2.local
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.webkit.MimeTypeMap
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import org.signal.archive.local.ArchivedFilesWriter
|
||||
import org.signal.archive.local.proto.FilesFrame
|
||||
@@ -20,7 +22,9 @@ import org.signal.core.util.Util
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.core.util.readFully
|
||||
import org.signal.core.util.toJson
|
||||
import org.signal.libsignal.crypto.Aes256Ctr32
|
||||
import org.thoughtcrime.securesms.attachments.AttachmentId
|
||||
import org.thoughtcrime.securesms.backup.LocalExportProgress
|
||||
import org.thoughtcrime.securesms.backup.v2.BackupRepository
|
||||
import org.thoughtcrime.securesms.database.AttachmentTable
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
@@ -33,11 +37,6 @@ import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
import java.util.Collections
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipOutputStream
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.spec.IvParameterSpec
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
|
||||
typealias ArchiveResult = org.signal.core.util.Result<LocalArchiver.ArchiveSuccess, LocalArchiver.ArchiveFailure>
|
||||
typealias RestoreResult = org.signal.core.util.Result<LocalArchiver.RestoreSuccess, LocalArchiver.RestoreFailure>
|
||||
@@ -74,10 +73,10 @@ object LocalArchiver {
|
||||
|
||||
Log.i(TAG, "Listing all current files")
|
||||
val allFiles = filesFileSystem.allFiles { completed, total ->
|
||||
SignalStore.backup.newLocalBackupProgress = LocalBackupCreationProgress(exporting = LocalBackupCreationProgress.Exporting(phase = LocalBackupCreationProgress.ExportPhase.INITIALIZING, frameExportCount = completed.toLong(), frameTotalCount = total.toLong()))
|
||||
LocalExportProgress.setEncryptedProgress(LocalBackupCreationProgress(exporting = LocalBackupCreationProgress.Exporting(phase = LocalBackupCreationProgress.ExportPhase.INITIALIZING, frameExportCount = completed.toLong(), frameTotalCount = total.toLong())))
|
||||
}
|
||||
stopwatch.split("files-list")
|
||||
SignalStore.backup.newLocalBackupProgress = LocalBackupCreationProgress(exporting = LocalBackupCreationProgress.Exporting(phase = LocalBackupCreationProgress.ExportPhase.INITIALIZING))
|
||||
LocalExportProgress.setEncryptedProgress(LocalBackupCreationProgress(exporting = LocalBackupCreationProgress.Exporting(phase = LocalBackupCreationProgress.ExportPhase.INITIALIZING)))
|
||||
|
||||
val mediaNames: MutableSet<MediaName> = Collections.synchronizedSet(HashSet())
|
||||
|
||||
@@ -146,36 +145,44 @@ object LocalArchiver {
|
||||
}
|
||||
|
||||
/**
|
||||
* Export a plaintext archive to the provided [zipOutputStream].
|
||||
* Export a plaintext archive to the provided [directory].
|
||||
*/
|
||||
fun exportPlaintext(
|
||||
zipOutputStream: ZipOutputStream,
|
||||
directory: DocumentFile,
|
||||
contentResolver: ContentResolver,
|
||||
includeMedia: Boolean,
|
||||
stopwatch: Stopwatch,
|
||||
cancellationSignal: () -> Boolean = { false }
|
||||
): ArchiveResult {
|
||||
try {
|
||||
zipOutputStream.putNextEntry(ZipEntry("metadata.json"))
|
||||
zipOutputStream.write(Metadata(version = VERSION, backupId = getEncryptedBackupId()).toJson().toByteArray())
|
||||
zipOutputStream.closeEntry()
|
||||
val metadataFile = directory.createFile("application/octet-stream", "metadata.json")
|
||||
?: return ArchiveResult.failure(ArchiveFailure.MetadataStream)
|
||||
contentResolver.openOutputStream(metadataFile.uri)?.use { out ->
|
||||
out.write(Metadata(version = VERSION, backupId = getEncryptedBackupId()).toJson().toByteArray())
|
||||
} ?: return ArchiveResult.failure(ArchiveFailure.MetadataStream)
|
||||
stopwatch.split("metadata")
|
||||
|
||||
zipOutputStream.putNextEntry(ZipEntry("main.jsonl"))
|
||||
val mainFile = directory.createFile("application/octet-stream", "main.jsonl")
|
||||
?: return ArchiveResult.failure(ArchiveFailure.MainStream)
|
||||
val progressListener = LocalPlaintextExportProgressListener()
|
||||
val attachments = BackupRepository.exportForLocalPlaintextArchive(
|
||||
outputStream = zipOutputStream,
|
||||
progressEmitter = progressListener,
|
||||
cancellationSignal = cancellationSignal,
|
||||
includeMedia = includeMedia
|
||||
)
|
||||
zipOutputStream.closeEntry()
|
||||
val attachments = contentResolver.openOutputStream(mainFile.uri)?.use { mainStream ->
|
||||
BackupRepository.exportForLocalPlaintextArchive(
|
||||
outputStream = mainStream,
|
||||
progressEmitter = progressListener,
|
||||
cancellationSignal = cancellationSignal,
|
||||
includeMedia = includeMedia
|
||||
)
|
||||
} ?: return ArchiveResult.failure(ArchiveFailure.MainStream)
|
||||
stopwatch.split("frames")
|
||||
|
||||
if (includeMedia) {
|
||||
val filesDir = directory.createDirectory("files")
|
||||
?: return ArchiveResult.failure(ArchiveFailure.FilesStream)
|
||||
val total = attachments.size.toLong()
|
||||
var completed = 0L
|
||||
progressListener.onAttachment(0, total)
|
||||
val writtenEntries = HashSet<String>()
|
||||
val prefixDirs = HashMap<String, DocumentFile>()
|
||||
for (attachment in attachments) {
|
||||
if (cancellationSignal()) break
|
||||
val mediaName = MediaName.forLocalBackupFilename(attachment.plaintextHash, attachment.localBackupKey.key)
|
||||
@@ -186,13 +193,21 @@ object LocalArchiver {
|
||||
?.let { ".$it" }
|
||||
?: ""
|
||||
val prefix = mediaName.name.substring(0..1)
|
||||
val entryName = "files/$prefix/${mediaName.name}$ext"
|
||||
val entryName = "$prefix/${mediaName.name}$ext"
|
||||
if (!writtenEntries.add(entryName)) continue
|
||||
zipOutputStream.putNextEntry(ZipEntry(entryName))
|
||||
SignalDatabase.attachments.getAttachmentStream(attachment).use { input ->
|
||||
StreamUtil.copy(input, zipOutputStream, false, false)
|
||||
val prefixDir = prefixDirs[prefix]
|
||||
?: filesDir.createDirectory(prefix)?.also { prefixDirs[prefix] = it }
|
||||
?: run {
|
||||
Log.w(TAG, "Unable to create prefix directory $prefix, skipping attachment ${attachment.attachmentId}")
|
||||
progressListener.onAttachment(++completed, total)
|
||||
continue
|
||||
}
|
||||
val mediaFile = prefixDir.createFile("application/octet-stream", "${mediaName.name}$ext") ?: continue
|
||||
contentResolver.openOutputStream(mediaFile.uri)?.use { out ->
|
||||
SignalDatabase.attachments.getAttachmentStream(attachment).use { input ->
|
||||
StreamUtil.copy(input, out, false, false)
|
||||
}
|
||||
}
|
||||
zipOutputStream.closeEntry()
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "Unable to export attachment ${attachment.attachmentId}, skipping", e)
|
||||
}
|
||||
@@ -216,14 +231,19 @@ object LocalArchiver {
|
||||
val metadataKey = SignalStore.backup.messageBackupKey.deriveLocalBackupMetadataKey()
|
||||
val iv = Util.getSecretBytes(12)
|
||||
val backupId = SignalStore.backup.messageBackupKey.deriveBackupId(SignalStore.account.requireAci())
|
||||
|
||||
val cipher = Cipher.getInstance("AES/CTR/NoPadding")
|
||||
cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(metadataKey, "AES"), IvParameterSpec(iv))
|
||||
val cipherText = cipher.doFinal(backupId.value)
|
||||
val cipherText = applyCipher(backupId.value, metadataKey, iv)
|
||||
|
||||
return Metadata.EncryptedBackupId(iv = iv.toByteString(), encryptedId = cipherText.toByteString())
|
||||
}
|
||||
|
||||
private fun applyCipher(input: ByteArray, metadataKey: ByteArray, iv: ByteArray): ByteArray {
|
||||
val data = input.copyOf()
|
||||
val cipher = Aes256Ctr32(metadataKey, iv, 0)
|
||||
cipher.process(data)
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Import archive data from a folder on the system. Does not restore attachments.
|
||||
*/
|
||||
@@ -300,10 +320,7 @@ object LocalArchiver {
|
||||
val metadataKey = messageBackupKey.deriveLocalBackupMetadataKey()
|
||||
val iv = encryptedBackupId.iv.toByteArray()
|
||||
val backupIdCipher = encryptedBackupId.encryptedId.toByteArray()
|
||||
|
||||
val cipher = Cipher.getInstance("AES/CTR/NoPadding")
|
||||
cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(metadataKey, "AES"), IvParameterSpec(iv))
|
||||
val plaintext = cipher.doFinal(backupIdCipher)
|
||||
val plaintext = applyCipher(backupIdCipher, metadataKey, iv)
|
||||
|
||||
return BackupId(plaintext)
|
||||
}
|
||||
@@ -387,7 +404,7 @@ object LocalArchiver {
|
||||
}
|
||||
|
||||
private fun post(progress: LocalBackupCreationProgress) {
|
||||
SignalStore.backup.newLocalBackupProgress = progress
|
||||
LocalExportProgress.setEncryptedProgress(progress)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -442,7 +459,7 @@ object LocalArchiver {
|
||||
}
|
||||
|
||||
private fun post(progress: LocalBackupCreationProgress) {
|
||||
SignalStore.backup.newLocalPlaintextBackupProgress = progress
|
||||
LocalExportProgress.setPlaintextProgress(progress)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.backup.v2.ui
|
||||
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import org.signal.core.ui.compose.ComposeBottomSheetDialogFragment
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.util.CommunicationActions
|
||||
|
||||
/**
|
||||
* Sheet displayed when the user's backup restoration failed during media import. Generally due
|
||||
* to the files no longer being available.
|
||||
*/
|
||||
class CouldNotCompleteBackupRestoreSheet : ComposeBottomSheetDialogFragment() {
|
||||
@Composable
|
||||
override fun SheetContent() {
|
||||
CouldNotCompleteBackupRestoreSheetContent(
|
||||
onOkClick = { dismiss() },
|
||||
onLearnMoreClick = {
|
||||
dismiss()
|
||||
CommunicationActions.openBrowserLink(requireContext(), getString(R.string.backup_support_url))
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CouldNotCompleteBackupRestoreSheetContent(
|
||||
onOkClick: () -> Unit = {},
|
||||
onLearnMoreClick: () -> Unit = {}
|
||||
) {
|
||||
val ok = stringResource(android.R.string.ok)
|
||||
val primaryActionButtonState: BackupAlertActionButtonState = remember(ok, onOkClick) {
|
||||
BackupAlertActionButtonState(
|
||||
label = ok,
|
||||
callback = onOkClick
|
||||
)
|
||||
}
|
||||
|
||||
val learnMore = stringResource(R.string.preferences__app_icon_learn_more)
|
||||
val secondaryActionButtonState: BackupAlertActionButtonState = remember(learnMore, onLearnMoreClick) {
|
||||
BackupAlertActionButtonState(
|
||||
label = learnMore,
|
||||
callback = onLearnMoreClick
|
||||
)
|
||||
}
|
||||
|
||||
BackupAlertBottomSheetContainer(
|
||||
icon = {
|
||||
BackupAlertIcon(iconColors = BackupsIconColors.Error)
|
||||
},
|
||||
title = stringResource(R.string.CouldNotCompleteBackupRestoreSheet__title),
|
||||
primaryActionButtonState = primaryActionButtonState,
|
||||
secondaryActionButtonState = secondaryActionButtonState
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.CouldNotCompleteBackupRestoreSheet__body_error)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.CouldNotCompleteBackupRestoreSheet__body_retry)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun CouldNotCompleteBackupRestoreSheetContentPreview() {
|
||||
Previews.BottomSheetContentPreview {
|
||||
CouldNotCompleteBackupRestoreSheetContent()
|
||||
}
|
||||
}
|
||||
+8
-5
@@ -164,10 +164,10 @@ private fun ArchiveRestoreProgressState.iconResource(): Int {
|
||||
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 -> CoreUiR.drawable.symbol_check_circle_24
|
||||
RestoreStatus.NONE -> throw IllegalStateException()
|
||||
RestoreStatus.NONE,
|
||||
RestoreStatus.LOCAL_RESTORE_DIRECTORY_UNAVAILABLE -> throw IllegalStateException()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -199,7 +199,8 @@ private fun ArchiveRestoreProgressState.iconColor(): Color {
|
||||
RestoreStatus.NOT_ENOUGH_DISK_SPACE -> BackupsIconColors.Warning.foreground
|
||||
|
||||
RestoreStatus.FINISHED -> BackupsIconColors.Success.foreground
|
||||
RestoreStatus.NONE -> throw IllegalStateException()
|
||||
RestoreStatus.NONE,
|
||||
RestoreStatus.LOCAL_RESTORE_DIRECTORY_UNAVAILABLE -> throw IllegalStateException()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,7 +234,8 @@ private fun ArchiveRestoreProgressState.title(): String {
|
||||
}
|
||||
|
||||
RestoreStatus.FINISHED -> stringResource(R.string.BackupStatus__restore_complete)
|
||||
RestoreStatus.NONE -> throw IllegalStateException()
|
||||
RestoreStatus.NONE,
|
||||
RestoreStatus.LOCAL_RESTORE_DIRECTORY_UNAVAILABLE -> throw IllegalStateException()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -277,7 +279,8 @@ private fun ArchiveRestoreProgressState.status(): String? {
|
||||
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()
|
||||
RestoreStatus.NONE,
|
||||
RestoreStatus.LOCAL_RESTORE_DIRECTORY_UNAVAILABLE -> throw IllegalStateException()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+19
-4
@@ -10,6 +10,8 @@ import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
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
|
||||
@@ -22,6 +24,7 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.SignalIcons
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.backup.exportProgress
|
||||
import org.thoughtcrime.securesms.backup.transferProgress
|
||||
@@ -32,7 +35,8 @@ import org.signal.core.ui.R as CoreUiR
|
||||
fun BackupCreationProgressRow(
|
||||
progress: LocalBackupCreationProgress,
|
||||
isRemote: Boolean,
|
||||
modifier: Modifier = Modifier
|
||||
modifier: Modifier = Modifier,
|
||||
onCancel: (() -> Unit)? = null
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
@@ -42,7 +46,7 @@ fun BackupCreationProgressRow(
|
||||
Column(
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
BackupCreationProgressIndicator(progress = progress)
|
||||
BackupCreationProgressIndicator(progress = progress, onCancel = onCancel)
|
||||
|
||||
Text(
|
||||
text = getProgressMessage(progress, isRemote),
|
||||
@@ -55,7 +59,8 @@ fun BackupCreationProgressRow(
|
||||
|
||||
@Composable
|
||||
private fun BackupCreationProgressIndicator(
|
||||
progress: LocalBackupCreationProgress
|
||||
progress: LocalBackupCreationProgress,
|
||||
onCancel: (() -> Unit)? = null
|
||||
) {
|
||||
val exporting = progress.exporting
|
||||
val transferring = progress.transferring
|
||||
@@ -93,6 +98,15 @@ private fun BackupCreationProgressIndicator(
|
||||
.padding(vertical = 12.dp)
|
||||
)
|
||||
}
|
||||
|
||||
if (onCancel != null) {
|
||||
IconButton(onClick = onCancel) {
|
||||
Icon(
|
||||
imageVector = SignalIcons.X.imageVector,
|
||||
contentDescription = "Cancel"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -224,7 +238,8 @@ private fun TransferringRemotePreview() {
|
||||
mediaPhase = true
|
||||
)
|
||||
),
|
||||
isRemote = true
|
||||
isRemote = true,
|
||||
onCancel = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -217,7 +217,8 @@ private fun progressColor(backupStatusData: ArchiveRestoreProgressState): Color
|
||||
RestoreStatus.LOW_BATTERY,
|
||||
RestoreStatus.NOT_ENOUGH_DISK_SPACE -> BackupsIconColors.Warning.foreground
|
||||
RestoreStatus.FINISHED -> BackupsIconColors.Success.foreground
|
||||
RestoreStatus.NONE -> BackupsIconColors.Normal.foreground
|
||||
RestoreStatus.NONE,
|
||||
RestoreStatus.LOCAL_RESTORE_DIRECTORY_UNAVAILABLE -> BackupsIconColors.Normal.foreground
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+6
-1
@@ -142,7 +142,12 @@ class MessageBackupsFlowFragment : ComposeFragment(), InAppPaymentCheckoutDelega
|
||||
composable(route = MessageBackupsStage.Route.BACKUP_KEY_EDUCATION.name) {
|
||||
MessageBackupsKeyEducationScreen(
|
||||
onNavigationClick = viewModel::goToPreviousStage,
|
||||
onNextClick = viewModel::goToNextStage
|
||||
onNextClick = viewModel::goToNextStage,
|
||||
mode = if (SignalStore.backup.newLocalBackupsEnabled) {
|
||||
MessageBackupsKeyEducationScreenMode.REMOTE_WITH_LOCAL_ENABLED
|
||||
} else {
|
||||
MessageBackupsKeyEducationScreenMode.DEFAULT
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+57
-13
@@ -50,9 +50,9 @@ import org.signal.core.ui.R as CoreUiR
|
||||
|
||||
enum class MessageBackupsKeyEducationScreenMode {
|
||||
/**
|
||||
* Displayed when the user is enabling remote backups and does not have unified local backups enabled
|
||||
* Displayed when the user is enabling remote backups, or local backups without remote enabled.
|
||||
*/
|
||||
REMOTE_BACKUP_WITH_LOCAL_DISABLED,
|
||||
DEFAULT,
|
||||
|
||||
/**
|
||||
* Displayed when the user is upgrading legacy to unified local backup
|
||||
@@ -60,9 +60,14 @@ enum class MessageBackupsKeyEducationScreenMode {
|
||||
LOCAL_BACKUP_UPGRADE,
|
||||
|
||||
/**
|
||||
* Displayed when the user has unified local backup and is enabling remote backups
|
||||
* Displayed when the user has remote backups enabled and is enabling local backups
|
||||
*/
|
||||
REMOTE_BACKUP_WITH_LOCAL_ENABLED
|
||||
LOCAL_WITH_REMOTE_ENABLED,
|
||||
|
||||
/**
|
||||
* Displayed when the user has local backups enabled and is enabling remote backups
|
||||
*/
|
||||
REMOTE_WITH_LOCAL_ENABLED
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -72,7 +77,7 @@ enum class MessageBackupsKeyEducationScreenMode {
|
||||
fun MessageBackupsKeyEducationScreen(
|
||||
onNavigationClick: () -> Unit = {},
|
||||
onNextClick: () -> Unit = {},
|
||||
mode: MessageBackupsKeyEducationScreenMode = MessageBackupsKeyEducationScreenMode.REMOTE_BACKUP_WITH_LOCAL_DISABLED
|
||||
mode: MessageBackupsKeyEducationScreenMode = MessageBackupsKeyEducationScreenMode.DEFAULT
|
||||
) {
|
||||
val scrollState = rememberScrollState()
|
||||
|
||||
@@ -105,14 +110,19 @@ fun MessageBackupsKeyEducationScreen(
|
||||
)
|
||||
|
||||
when (mode) {
|
||||
MessageBackupsKeyEducationScreenMode.REMOTE_BACKUP_WITH_LOCAL_DISABLED -> {
|
||||
MessageBackupsKeyEducationScreenMode.DEFAULT -> {
|
||||
RemoteBackupWithLocalDisabledInfo()
|
||||
}
|
||||
|
||||
MessageBackupsKeyEducationScreenMode.LOCAL_BACKUP_UPGRADE -> {
|
||||
LocalBackupUpgradeInfo()
|
||||
}
|
||||
MessageBackupsKeyEducationScreenMode.REMOTE_BACKUP_WITH_LOCAL_ENABLED -> {
|
||||
|
||||
MessageBackupsKeyEducationScreenMode.LOCAL_WITH_REMOTE_ENABLED -> {
|
||||
LocalBackupWithRemoteEnabledInfo()
|
||||
}
|
||||
|
||||
MessageBackupsKeyEducationScreenMode.REMOTE_WITH_LOCAL_ENABLED -> {
|
||||
RemoteBackupWithLocalEnabledInfo()
|
||||
}
|
||||
}
|
||||
@@ -145,9 +155,8 @@ fun MessageBackupsKeyEducationScreen(
|
||||
@Composable
|
||||
private fun getTitleText(mode: MessageBackupsKeyEducationScreenMode): String {
|
||||
return when (mode) {
|
||||
MessageBackupsKeyEducationScreenMode.REMOTE_BACKUP_WITH_LOCAL_DISABLED -> stringResource(R.string.MessageBackupsKeyEducationScreen__your_backup_key)
|
||||
MessageBackupsKeyEducationScreenMode.LOCAL_BACKUP_UPGRADE -> stringResource(R.string.MessageBackupsKeyEducationScreen__your_new_recovery_key)
|
||||
MessageBackupsKeyEducationScreenMode.REMOTE_BACKUP_WITH_LOCAL_ENABLED -> stringResource(R.string.MessageBackupsKeyEducationScreen__your_recovery_key)
|
||||
else -> stringResource(R.string.MessageBackupsKeyEducationScreen__your_recovery_key)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,6 +185,31 @@ private fun LocalBackupUpgradeInfo() {
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LocalBackupWithRemoteEnabledInfo() {
|
||||
val normalText = stringResource(R.string.MessageBackupsKeyEducationScreen__remote_backup_with_local_enabled_description)
|
||||
val boldText = stringResource(R.string.MessageBackupsKeyEducationScreen__local_backup_with_remote_enabled_description_bold)
|
||||
|
||||
DescriptionText(
|
||||
normalText = normalText,
|
||||
boldText = boldText
|
||||
)
|
||||
|
||||
UseThisKeyToContainer {
|
||||
UseThisKeyToRow(
|
||||
icon = ImageVector.vectorResource(R.drawable.symbol_folder_24),
|
||||
text = stringResource(R.string.MessageBackupsKeyEducationScreen__restore_on_device_backup)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.padding(vertical = 16.dp))
|
||||
|
||||
UseThisKeyToRow(
|
||||
icon = ImageVector.vectorResource(CoreUiR.drawable.symbol_backup_24),
|
||||
text = stringResource(R.string.MessageBackupsKeyEducationScreen__restore_your_signal_secure_backup)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RemoteBackupWithLocalEnabledInfo() {
|
||||
val normalText = stringResource(R.string.MessageBackupsKeyEducationScreen__remote_backup_with_local_enabled_description)
|
||||
@@ -313,10 +347,10 @@ private fun InfoRow(@DrawableRes iconId: Int, @StringRes textId: Int) {
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun MessageBackupsKeyEducationScreenRemoteBackupWithLocalDisabledPreview() {
|
||||
private fun MessageBackupsKeyEducationScreenDefaultPreview() {
|
||||
Previews.Preview {
|
||||
MessageBackupsKeyEducationScreen(
|
||||
mode = MessageBackupsKeyEducationScreenMode.REMOTE_BACKUP_WITH_LOCAL_DISABLED
|
||||
mode = MessageBackupsKeyEducationScreenMode.DEFAULT
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -333,10 +367,20 @@ private fun MessageBackupsKeyEducationScreenLocalBackupUpgradePreview() {
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun MessageBackupsKeyEducationScreenRemoteBackupWithLocalEnabledPreview() {
|
||||
private fun MessageBackupsKeyEducationScreenLocalBackupWithRemoteEnabledPreview() {
|
||||
Previews.Preview {
|
||||
MessageBackupsKeyEducationScreen(
|
||||
mode = MessageBackupsKeyEducationScreenMode.REMOTE_BACKUP_WITH_LOCAL_ENABLED
|
||||
mode = MessageBackupsKeyEducationScreenMode.LOCAL_WITH_REMOTE_ENABLED
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun MessageBackupsKeyEducationScreenRemoteBackupWithLocalEnabledPreview() {
|
||||
Previews.Preview {
|
||||
MessageBackupsKeyEducationScreen(
|
||||
mode = MessageBackupsKeyEducationScreenMode.REMOTE_WITH_LOCAL_ENABLED
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -257,7 +257,7 @@ private fun Wallpaper.LinearGradient.toRemoteWallpaperPreset(): ChatStyle.Wallpa
|
||||
|
||||
private fun Wallpaper.File.toFilePointer(db: SignalDatabase, backupMode: BackupMode): FilePointer? {
|
||||
val attachmentId: AttachmentId = UriUtil.parseOrNull(this.uri)?.let { PartUriParser(it).partId } ?: return null
|
||||
val attachment = db.attachmentTable.getAttachment(attachmentId)
|
||||
val attachment = db.attachmentTable.getAttachmentWithMetadata(attachmentId)
|
||||
return attachment?.toRemoteFilePointer(backupMode = backupMode)
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ import androidx.annotation.Nullable;
|
||||
import androidx.annotation.Px;
|
||||
import androidx.annotation.UiThread;
|
||||
import androidx.appcompat.widget.AppCompatImageView;
|
||||
import com.google.android.material.color.MaterialColors;
|
||||
|
||||
import com.bumptech.glide.RequestBuilder;
|
||||
import com.bumptech.glide.RequestManager;
|
||||
@@ -347,6 +348,7 @@ public class ThumbnailView extends FrameLayout {
|
||||
|
||||
transferControlViewStub.setVisibility(View.GONE);
|
||||
playOverlay.setVisibility(View.GONE);
|
||||
setBackgroundColor(Color.TRANSPARENT);
|
||||
|
||||
requestManager.clear(blurHash);
|
||||
blurHash.setImageDrawable(null);
|
||||
@@ -407,6 +409,8 @@ public class ThumbnailView extends FrameLayout {
|
||||
}
|
||||
|
||||
if (this.slide != null && this.slide.getFastPreflightId() != null &&
|
||||
this.slide.isInProgress() == slide.isInProgress() &&
|
||||
image.getDrawable() != null &&
|
||||
(!slide.hasVideo() || Util.equals(this.slide.getUri(), slide.getUri())) &&
|
||||
Util.equals(this.slide.getFastPreflightId(), slide.getFastPreflightId()))
|
||||
{
|
||||
@@ -486,6 +490,12 @@ public class ThumbnailView extends FrameLayout {
|
||||
image.setImageDrawable(null);
|
||||
}
|
||||
|
||||
if (slide.getTransferState() == AttachmentTable.TRANSFER_RESTORE_OFFLOADED && slide.getDisplayUri() == null) {
|
||||
setBackgroundColor(MaterialColors.getColor(this, com.google.android.material.R.attr.colorSurfaceVariant, Color.GRAY));
|
||||
} else {
|
||||
setBackgroundColor(Color.TRANSPARENT);
|
||||
}
|
||||
|
||||
if (!resultHandled) {
|
||||
result.set(false);
|
||||
}
|
||||
|
||||
+4
@@ -82,6 +82,7 @@ class AppSettingsActivity : DSLSettingsActivity(), GooglePayComponent {
|
||||
appSettingsRoute.threadIds
|
||||
)
|
||||
|
||||
AppSettingsRoute.ChatsRoute.Chats -> AppSettingsFragmentDirections.actionDirectToChatsSettingsFragment()
|
||||
AppSettingsRoute.BackupsRoute.Backups -> AppSettingsFragmentDirections.actionDirectToBackupsSettingsFragment()
|
||||
AppSettingsRoute.Invite -> AppSettingsFragmentDirections.actionDirectToInviteFragment()
|
||||
AppSettingsRoute.DataAndStorageRoute.DataAndStorage -> AppSettingsFragmentDirections.actionDirectToStoragePreferenceFragment()
|
||||
@@ -214,6 +215,9 @@ class AppSettingsActivity : DSLSettingsActivity(), GooglePayComponent {
|
||||
@JvmOverloads
|
||||
fun remoteBackups(context: Context, forQuickRestore: Boolean = false): Intent = getIntentForStartLocation(context, AppSettingsRoute.BackupsRoute.Remote(forQuickRestore = forQuickRestore))
|
||||
|
||||
@JvmStatic
|
||||
fun chats(context: Context): Intent = getIntentForStartLocation(context, AppSettingsRoute.ChatsRoute.Chats)
|
||||
|
||||
@JvmStatic
|
||||
fun chatFolders(context: Context): Intent = getIntentForStartLocation(context, AppSettingsRoute.ChatFoldersRoute.ChatFolders)
|
||||
|
||||
|
||||
+2
-13
@@ -414,19 +414,8 @@ private fun AppSettingsContent(
|
||||
if (state.isPrimaryDevice) {
|
||||
item {
|
||||
Rows.TextRow(
|
||||
text = {
|
||||
Text(
|
||||
text = stringResource(R.string.preferences_chats__backups),
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
},
|
||||
icon = {
|
||||
Icon(
|
||||
imageVector = SignalIcons.Backup.imageVector,
|
||||
contentDescription = stringResource(R.string.preferences_chats__backups),
|
||||
tint = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
},
|
||||
icon = SignalIcons.Backup.imageVector,
|
||||
text = stringResource(R.string.preferences_chats__backups),
|
||||
onClick = {
|
||||
callbacks.navigate(AppSettingsRoute.BackupsRoute.Backups)
|
||||
},
|
||||
|
||||
+7
-1
@@ -110,7 +110,13 @@ class LocalBackupsFragment : ComposeFragment() {
|
||||
MessageBackupsKeyEducationScreen(
|
||||
onNavigationClick = { backPressedDispatcher?.onBackPressedDispatcher?.onBackPressed() },
|
||||
onNextClick = { backstack.add(LocalBackupsNavKey.RECORD_RECOVERY_KEY) },
|
||||
mode = MessageBackupsKeyEducationScreenMode.LOCAL_BACKUP_UPGRADE
|
||||
mode = if (args.triggerUpdateFlow) {
|
||||
MessageBackupsKeyEducationScreenMode.LOCAL_BACKUP_UPGRADE
|
||||
} else if (SignalStore.backup.areBackupsEnabled) {
|
||||
MessageBackupsKeyEducationScreenMode.LOCAL_WITH_REMOTE_ENABLED
|
||||
} else {
|
||||
MessageBackupsKeyEducationScreenMode.DEFAULT
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+13
-11
@@ -141,12 +141,14 @@ internal fun LocalBackupsSettingsScreen(
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
|
||||
Text(
|
||||
text = state.lastBackupLabel.orEmpty(),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 4.dp)
|
||||
)
|
||||
if (state.lastBackupLabel != null) {
|
||||
Text(
|
||||
text = stringResource(R.string.BackupsSettingsFragment_last_backup_s, state.lastBackupLabel),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 4.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
onClick = callback::onCreateBackupClick
|
||||
@@ -270,7 +272,7 @@ private fun LocalBackupsSettingsEnabledIdlePreview() {
|
||||
LocalBackupsSettingsScreen(
|
||||
state = LocalBackupsSettingsState(
|
||||
backupsEnabled = true,
|
||||
lastBackupLabel = "Last backup: 1 hour ago",
|
||||
lastBackupLabel = "1 hour ago",
|
||||
folderDisplayName = "/storage/emulated/0/Signal/Backups",
|
||||
scheduleTimeLabel = "1:00 AM",
|
||||
progress = LocalBackupCreationProgress(idle = LocalBackupCreationProgress.Idle())
|
||||
@@ -287,7 +289,7 @@ private fun LocalBackupsSettingsEnabledExportingIndeterminatePreview() {
|
||||
LocalBackupsSettingsScreen(
|
||||
state = LocalBackupsSettingsState(
|
||||
backupsEnabled = true,
|
||||
lastBackupLabel = "Last backup: 1 hour ago",
|
||||
lastBackupLabel = "1 hour ago",
|
||||
folderDisplayName = "/storage/emulated/0/Signal/Backups",
|
||||
scheduleTimeLabel = "1:00 AM",
|
||||
progress = LocalBackupCreationProgress(
|
||||
@@ -306,7 +308,7 @@ private fun LocalBackupsSettingsEnabledExportingMessagesPreview() {
|
||||
LocalBackupsSettingsScreen(
|
||||
state = LocalBackupsSettingsState(
|
||||
backupsEnabled = true,
|
||||
lastBackupLabel = "Last backup: 1 hour ago",
|
||||
lastBackupLabel = "1 hour ago",
|
||||
folderDisplayName = "/storage/emulated/0/Signal/Backups",
|
||||
scheduleTimeLabel = "1:00 AM",
|
||||
progress = LocalBackupCreationProgress(
|
||||
@@ -329,7 +331,7 @@ private fun LocalBackupsSettingsEnabledTransferringPreview() {
|
||||
LocalBackupsSettingsScreen(
|
||||
state = LocalBackupsSettingsState(
|
||||
backupsEnabled = true,
|
||||
lastBackupLabel = "Last backup: 1 hour ago",
|
||||
lastBackupLabel = "1 hour ago",
|
||||
folderDisplayName = "/storage/emulated/0/Signal/Backups",
|
||||
scheduleTimeLabel = "1:00 AM",
|
||||
progress = LocalBackupCreationProgress(
|
||||
@@ -352,7 +354,7 @@ private fun LocalBackupsSettingsEnabledNonLegacyPreview() {
|
||||
LocalBackupsSettingsScreen(
|
||||
state = LocalBackupsSettingsState(
|
||||
backupsEnabled = true,
|
||||
lastBackupLabel = "Last backup: 1 hour ago",
|
||||
lastBackupLabel = "1 hour ago",
|
||||
folderDisplayName = "Signal Backups",
|
||||
scheduleTimeLabel = "1:00 AM",
|
||||
progress = LocalBackupCreationProgress(idle = LocalBackupCreationProgress.Idle())
|
||||
|
||||
+3
-2
@@ -18,6 +18,7 @@ import org.signal.core.ui.util.StorageUtil
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.backup.BackupPassphrase
|
||||
import org.thoughtcrime.securesms.backup.LocalExportProgress
|
||||
import org.thoughtcrime.securesms.components.settings.app.backups.remote.BackupKeyCredentialManagerHandler
|
||||
import org.thoughtcrime.securesms.components.settings.app.backups.remote.BackupKeySaveState
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
@@ -74,7 +75,7 @@ class LocalBackupsViewModel : ViewModel(), BackupKeyCredentialManagerHandler {
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
SignalStore.backup.newLocalBackupProgressFlow.collect { progress ->
|
||||
LocalExportProgress.encryptedProgress.collect { progress ->
|
||||
internalSettingsState.update { it.copy(progress = progress) }
|
||||
}
|
||||
}
|
||||
@@ -108,7 +109,7 @@ class LocalBackupsViewModel : ViewModel(), BackupKeyCredentialManagerHandler {
|
||||
}
|
||||
|
||||
fun onBackupStarted() {
|
||||
SignalStore.backup.newLocalBackupProgress = LocalBackupCreationProgress(exporting = LocalBackupCreationProgress.Exporting(phase = LocalBackupCreationProgress.ExportPhase.NONE))
|
||||
LocalExportProgress.setEncryptedProgress(LocalBackupCreationProgress(exporting = LocalBackupCreationProgress.Exporting(phase = LocalBackupCreationProgress.ExportPhase.NONE)))
|
||||
}
|
||||
|
||||
fun turnOffAndDelete(context: Context) {
|
||||
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.settings.app.chats
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import org.signal.core.ui.compose.Dialogs
|
||||
import org.signal.core.ui.compose.Launchers
|
||||
import org.thoughtcrime.securesms.R
|
||||
|
||||
/**
|
||||
* Dialogs displayed while processing a user's decrypted chat export.
|
||||
*
|
||||
* Displayed *after* the user has confirmed via phone auth.
|
||||
*/
|
||||
@Composable
|
||||
fun ChatExportDialogs(state: ChatExportState, callbacks: ChatExportCallbacks) {
|
||||
val folderPicker = Launchers.rememberOpenDocumentTreeLauncher {
|
||||
if (it != null) {
|
||||
callbacks.onFolderSelected(it)
|
||||
} else {
|
||||
callbacks.onCancelStartExport()
|
||||
}
|
||||
}
|
||||
|
||||
when (state) {
|
||||
ChatExportState.None -> Unit
|
||||
ChatExportState.ConfirmExport -> ConfirmExportDialog(
|
||||
onConfirmExport = callbacks::onConfirmExport,
|
||||
onCancel = callbacks::onCancelStartExport
|
||||
)
|
||||
|
||||
ChatExportState.ChooseAFolder -> ChooseAFolderDialog(
|
||||
onChooseAFolder = { folderPicker.launch(null) },
|
||||
onCancel = callbacks::onCancelStartExport
|
||||
)
|
||||
|
||||
ChatExportState.Canceling -> Dialogs.IndeterminateProgressDialog(message = stringResource(R.string.ChatExportDialogs__canceling_export))
|
||||
|
||||
ChatExportState.Success -> CompleteDialog(
|
||||
onOK = callbacks::onCompletionConfirmed
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ConfirmExportDialog(
|
||||
onConfirmExport: (withMedia: Boolean) -> Unit,
|
||||
onCancel: () -> Unit
|
||||
) {
|
||||
val body = buildAnnotatedString {
|
||||
withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) {
|
||||
append(stringResource(R.string.ChatExportDialogs__be_careful_warning))
|
||||
}
|
||||
|
||||
append(" ")
|
||||
append(stringResource(R.string.ChatExportDialogs__export_confirm_body))
|
||||
}
|
||||
|
||||
Dialogs.AdvancedAlertDialog(
|
||||
title = AnnotatedString(stringResource(R.string.ChatExportDialogs__export_chat_history_title)),
|
||||
body = body,
|
||||
positive = AnnotatedString(stringResource(R.string.ChatExportDialogs__export_with_media)),
|
||||
neutral = AnnotatedString(stringResource(R.string.ChatExportDialogs__export_without_media)),
|
||||
negative = AnnotatedString(stringResource(android.R.string.cancel)),
|
||||
onPositive = { onConfirmExport(true) },
|
||||
onNeutral = { onConfirmExport(false) },
|
||||
onNegative = onCancel
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChooseAFolderDialog(
|
||||
onChooseAFolder: () -> Unit,
|
||||
onCancel: () -> Unit
|
||||
) {
|
||||
Dialogs.SimpleAlertDialog(
|
||||
title = stringResource(R.string.ChatExportDialogs__choose_a_folder_title),
|
||||
body = stringResource(R.string.ChatExportDialogs__choose_a_folder_body),
|
||||
confirm = stringResource(R.string.ChatExportDialogs__choose_folder_button),
|
||||
dismiss = stringResource(android.R.string.cancel),
|
||||
onConfirm = onChooseAFolder,
|
||||
onDeny = onCancel
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CompleteDialog(
|
||||
onOK: () -> Unit
|
||||
) {
|
||||
val body = buildAnnotatedString {
|
||||
withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) {
|
||||
append(stringResource(R.string.ChatExportDialogs__be_careful))
|
||||
}
|
||||
|
||||
append(" ")
|
||||
append(stringResource(R.string.ChatExportDialogs__complete_body))
|
||||
}
|
||||
|
||||
Dialogs.SimpleAlertDialog(
|
||||
title = AnnotatedString(stringResource(R.string.ChatExportDialogs__complete_title)),
|
||||
body = body,
|
||||
confirm = AnnotatedString(stringResource(android.R.string.ok)),
|
||||
onConfirm = onOK
|
||||
)
|
||||
}
|
||||
|
||||
enum class ChatExportState {
|
||||
None,
|
||||
ConfirmExport,
|
||||
ChooseAFolder,
|
||||
Canceling,
|
||||
Success
|
||||
}
|
||||
|
||||
interface ChatExportCallbacks {
|
||||
fun onConfirmExport(withMedia: Boolean)
|
||||
fun onFolderSelected(uri: Uri)
|
||||
fun onCancelStartExport()
|
||||
fun onCompletionConfirmed()
|
||||
|
||||
object Empty : ChatExportCallbacks {
|
||||
override fun onConfirmExport(withMedia: Boolean) = Unit
|
||||
override fun onFolderSelected(uri: Uri) = Unit
|
||||
override fun onCancelStartExport() = Unit
|
||||
override fun onCompletionConfirmed() = Unit
|
||||
}
|
||||
}
|
||||
+97
-4
@@ -1,16 +1,20 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.chats
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import kotlinx.coroutines.launch
|
||||
import org.signal.core.ui.compose.ComposeFragment
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Dividers
|
||||
@@ -18,9 +22,14 @@ 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.SignalIcons
|
||||
import org.signal.core.ui.compose.Snackbars
|
||||
import org.signal.core.ui.compose.Texts
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.backup.isIdle
|
||||
import org.thoughtcrime.securesms.backup.v2.ui.status.BackupCreationProgressRow
|
||||
import org.thoughtcrime.securesms.components.compose.rememberBiometricsAuthentication
|
||||
import org.thoughtcrime.securesms.compose.rememberStatusBarColorNestedScrollModifier
|
||||
import org.thoughtcrime.securesms.keyvalue.protos.LocalBackupCreationProgress
|
||||
import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
||||
|
||||
/**
|
||||
@@ -79,10 +88,38 @@ class ChatsSettingsFragment : ComposeFragment() {
|
||||
override fun onEnterKeySendsChanged(enabled: Boolean) {
|
||||
viewModel.setEnterKeySends(enabled)
|
||||
}
|
||||
|
||||
override fun onExportPlaintextChatHistoryClick() {
|
||||
viewModel.requestChatExportType()
|
||||
}
|
||||
|
||||
override fun onCancelInFlightExport() {
|
||||
viewModel.cancelChatExport()
|
||||
}
|
||||
|
||||
// region ChatExportCallback
|
||||
|
||||
override fun onConfirmExport(withMedia: Boolean) {
|
||||
viewModel.setExportTypeAndGoToSelectFolder(withMedia)
|
||||
}
|
||||
|
||||
override fun onFolderSelected(uri: Uri) {
|
||||
viewModel.startChatExportToFolder(uri)
|
||||
}
|
||||
|
||||
override fun onCancelStartExport() {
|
||||
viewModel.clearChatExportFlow()
|
||||
}
|
||||
|
||||
override fun onCompletionConfirmed() {
|
||||
viewModel.clearChatExportFlow()
|
||||
}
|
||||
|
||||
// endregion
|
||||
}
|
||||
}
|
||||
|
||||
private interface ChatsSettingsCallbacks {
|
||||
private interface ChatsSettingsCallbacks : ChatExportCallbacks {
|
||||
fun onNavigationClick() = Unit
|
||||
fun onGenerateLinkPreviewsChanged(enabled: Boolean) = Unit
|
||||
fun onUseAddressBookChanged(enabled: Boolean) = Unit
|
||||
@@ -91,8 +128,10 @@ private interface ChatsSettingsCallbacks {
|
||||
fun onAddOrEditFoldersClick() = Unit
|
||||
fun onUseSystemEmojiChanged(enabled: Boolean) = Unit
|
||||
fun onEnterKeySendsChanged(enabled: Boolean) = Unit
|
||||
fun onExportPlaintextChatHistoryClick() = Unit
|
||||
fun onCancelInFlightExport() = Unit
|
||||
|
||||
object Empty : ChatsSettingsCallbacks
|
||||
object Empty : ChatsSettingsCallbacks, ChatExportCallbacks by ChatExportCallbacks.Empty
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -100,10 +139,25 @@ private fun ChatsSettingsScreen(
|
||||
state: ChatsSettingsState,
|
||||
callbacks: ChatsSettingsCallbacks
|
||||
) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
val authenticationFailedMessage = stringResource(R.string.ChatsSettingsFragment__authentication_failed)
|
||||
val plaintextBiometricsAuthentication = rememberBiometricsAuthentication(
|
||||
promptTitle = stringResource(R.string.ChatsSettingsFragment__unlock_to_export_chat_history),
|
||||
onAuthenticationFailed = {
|
||||
coroutineScope.launch {
|
||||
snackbarHostState.showSnackbar(authenticationFailedMessage)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
Scaffolds.Settings(
|
||||
title = stringResource(R.string.preferences_chats__chats),
|
||||
onNavigationClick = callbacks::onNavigationClick,
|
||||
navigationIcon = SignalIcons.ArrowStart.imageVector
|
||||
navigationIcon = SignalIcons.ArrowStart.imageVector,
|
||||
snackbarHost = {
|
||||
Snackbars.Host(snackbarHostState)
|
||||
}
|
||||
) { paddingValues ->
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
@@ -167,6 +221,36 @@ private fun ChatsSettingsScreen(
|
||||
}
|
||||
}
|
||||
|
||||
if (state.isPlaintextExportEnabled) {
|
||||
item {
|
||||
Dividers.Default()
|
||||
}
|
||||
|
||||
if (state.plaintextExportProgress.isIdle) {
|
||||
item(key = "export_chat_history_row") {
|
||||
Rows.TextRow(
|
||||
modifier = Modifier.animateItem(),
|
||||
text = stringResource(R.string.ChatsSettingsFragment__export_chat_history),
|
||||
label = stringResource(R.string.ChatsSettingsFragment__export_chat_history_label),
|
||||
onClick = {
|
||||
plaintextBiometricsAuthentication.withBiometricsAuthentication {
|
||||
callbacks.onExportPlaintextChatHistoryClick()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
} else {
|
||||
item(key = "export_chat_history_progress") {
|
||||
BackupCreationProgressRow(
|
||||
modifier = Modifier.animateItem(),
|
||||
progress = state.plaintextExportProgress,
|
||||
isRemote = false,
|
||||
onCancel = callbacks::onCancelInFlightExport
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Dividers.Default()
|
||||
}
|
||||
@@ -194,6 +278,13 @@ private fun ChatsSettingsScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (state.isPlaintextExportEnabled) {
|
||||
ChatExportDialogs(
|
||||
state = state.chatExportState,
|
||||
callbacks = callbacks
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@@ -210,7 +301,9 @@ private fun ChatsSettingsScreenPreview() {
|
||||
localBackupsEnabled = true,
|
||||
folderCount = 1,
|
||||
userUnregistered = false,
|
||||
clientDeprecated = false
|
||||
clientDeprecated = false,
|
||||
isPlaintextExportEnabled = true,
|
||||
plaintextExportProgress = LocalBackupCreationProgress(idle = LocalBackupCreationProgress.Idle())
|
||||
),
|
||||
callbacks = ChatsSettingsCallbacks.Empty
|
||||
)
|
||||
|
||||
+8
-1
@@ -1,5 +1,8 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.chats
|
||||
|
||||
import org.thoughtcrime.securesms.backup.LocalExportProgress
|
||||
import org.thoughtcrime.securesms.keyvalue.protos.LocalBackupCreationProgress
|
||||
|
||||
data class ChatsSettingsState(
|
||||
val generateLinkPreviews: Boolean,
|
||||
val useAddressBook: Boolean,
|
||||
@@ -9,7 +12,11 @@ data class ChatsSettingsState(
|
||||
val localBackupsEnabled: Boolean,
|
||||
val folderCount: Int,
|
||||
val userUnregistered: Boolean,
|
||||
val clientDeprecated: Boolean
|
||||
val clientDeprecated: Boolean,
|
||||
val isPlaintextExportEnabled: Boolean,
|
||||
val plaintextExportProgress: LocalBackupCreationProgress = LocalExportProgress.plaintextProgress.value,
|
||||
val chatExportState: ChatExportState = ChatExportState.None,
|
||||
val includeMediaInExport: Boolean = false
|
||||
) {
|
||||
fun isRegisteredAndUpToDate(): Boolean {
|
||||
return !userUnregistered && !clientDeprecated
|
||||
|
||||
+46
-1
@@ -1,5 +1,6 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.chats
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -7,11 +8,14 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import org.thoughtcrime.securesms.backup.LocalExportProgress
|
||||
import org.thoughtcrime.securesms.components.settings.app.chats.folders.ChatFoldersRepository
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.jobs.LocalBackupJob
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.util.BackupUtil
|
||||
import org.thoughtcrime.securesms.util.ConversationUtil
|
||||
import org.thoughtcrime.securesms.util.RemoteConfig
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences
|
||||
import org.thoughtcrime.securesms.util.ThrottledDebouncer
|
||||
|
||||
@@ -31,12 +35,53 @@ class ChatsSettingsViewModel @JvmOverloads constructor(
|
||||
localBackupsEnabled = SignalStore.settings.isBackupEnabled && BackupUtil.canUserAccessBackupDirectory(AppDependencies.application),
|
||||
folderCount = 0,
|
||||
userUnregistered = TextSecurePreferences.isUnauthorizedReceived(AppDependencies.application) || !SignalStore.account.isRegistered,
|
||||
clientDeprecated = SignalStore.misc.isClientDeprecated
|
||||
clientDeprecated = SignalStore.misc.isClientDeprecated,
|
||||
isPlaintextExportEnabled = RemoteConfig.localPlaintextExport,
|
||||
chatExportState = ChatExportState.None
|
||||
)
|
||||
)
|
||||
|
||||
val state: StateFlow<ChatsSettingsState> = store
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
LocalExportProgress.plaintextProgress.collect { progress ->
|
||||
store.update {
|
||||
it.copy(
|
||||
plaintextExportProgress = progress,
|
||||
chatExportState = when {
|
||||
progress.succeeded != null && it.plaintextExportProgress.succeeded == null -> ChatExportState.Success
|
||||
progress.canceled != null -> ChatExportState.None
|
||||
else -> it.chatExportState
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun requestChatExportType() {
|
||||
store.update { it.copy(chatExportState = ChatExportState.ConfirmExport) }
|
||||
}
|
||||
|
||||
fun setExportTypeAndGoToSelectFolder(includeMediaInExport: Boolean) {
|
||||
store.update { it.copy(chatExportState = ChatExportState.ChooseAFolder, includeMediaInExport = includeMediaInExport) }
|
||||
}
|
||||
|
||||
fun startChatExportToFolder(uri: Uri) {
|
||||
store.update { it.copy(chatExportState = ChatExportState.None) }
|
||||
LocalBackupJob.enqueuePlaintextArchive(uri.toString(), store.value.includeMediaInExport)
|
||||
}
|
||||
|
||||
fun clearChatExportFlow() {
|
||||
store.update { it.copy(chatExportState = ChatExportState.None, includeMediaInExport = false) }
|
||||
}
|
||||
|
||||
fun cancelChatExport() {
|
||||
store.update { it.copy(chatExportState = ChatExportState.Canceling) }
|
||||
AppDependencies.jobManager.cancelAllInQueue(LocalBackupJob.PLAINTEXT_ARCHIVE_QUEUE)
|
||||
}
|
||||
|
||||
fun setGenerateLinkPreviewsEnabled(enabled: Boolean) {
|
||||
store.update { it.copy(generateLinkPreviews = enabled) }
|
||||
SignalStore.settings.isLinkPreviewsEnabled = enabled
|
||||
|
||||
+1
@@ -235,6 +235,7 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
|
||||
title = DSLSettingsText.from("Collapse chat updates"),
|
||||
summary = DSLSettingsText.from("Collapses certain consecutive chat updates - cannot be undone."),
|
||||
onClick = {
|
||||
SignalStore.misc.completedCollapsedEventsMigration = false
|
||||
AppDependencies.jobManager.add(BackfillCollapsedMessageJob())
|
||||
}
|
||||
)
|
||||
|
||||
+12
@@ -73,6 +73,7 @@ import org.signal.core.util.Util
|
||||
import org.signal.core.util.getLength
|
||||
import org.thoughtcrime.securesms.MainActivity
|
||||
import org.thoughtcrime.securesms.backup.isIdle
|
||||
import org.thoughtcrime.securesms.backup.v2.ArchiveRestoreProgress
|
||||
import org.thoughtcrime.securesms.backup.v2.BackupRepository
|
||||
import org.thoughtcrime.securesms.backup.v2.ui.BackupAlert
|
||||
import org.thoughtcrime.securesms.backup.v2.ui.BackupAlertBottomSheet
|
||||
@@ -270,6 +271,10 @@ class InternalBackupPlaygroundFragment : ComposeFragment() {
|
||||
.setNegativeButton("Cancel", null)
|
||||
.show()
|
||||
},
|
||||
onTriggerLocalRestoreDirectoryError = {
|
||||
SignalStore.backup.localRestoreDirectoryError = true
|
||||
ArchiveRestoreProgress.forceUpdate()
|
||||
},
|
||||
onDisplayInitialBackupFailureSheet = {
|
||||
BackupRepository.displayInitialBackupFailureNotification()
|
||||
BackupAlertBottomSheet
|
||||
@@ -366,6 +371,7 @@ fun Screen(
|
||||
onImportEncryptedBackupFromDiskConfirmed: (aci: String, backupKey: String) -> Unit = { _, _ -> },
|
||||
onClearLocalMediaBackupState: () -> Unit = {},
|
||||
onDeleteRemoteBackup: () -> Unit = {},
|
||||
onTriggerLocalRestoreDirectoryError: () -> Unit = {},
|
||||
onDisplayInitialBackupFailureSheet: () -> Unit = {}
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
@@ -584,6 +590,12 @@ fun Screen(
|
||||
onClick = onClearLocalMediaBackupState
|
||||
)
|
||||
|
||||
Rows.TextRow(
|
||||
text = "Trigger local restore directory error",
|
||||
label = "Simulates the restore directory becoming inaccessible during a local backup restore.",
|
||||
onClick = onTriggerLocalRestoreDirectoryError
|
||||
)
|
||||
|
||||
Dividers.Default()
|
||||
|
||||
Rows.TextRow(
|
||||
|
||||
+2
-1
@@ -40,6 +40,7 @@ import org.signal.libsignal.zkgroup.profiles.ProfileKey
|
||||
import org.thoughtcrime.securesms.attachments.AttachmentId
|
||||
import org.thoughtcrime.securesms.attachments.DatabaseAttachment
|
||||
import org.thoughtcrime.securesms.backup.ArchiveUploadProgress
|
||||
import org.thoughtcrime.securesms.backup.LocalExportProgress
|
||||
import org.thoughtcrime.securesms.backup.v2.ArchiveValidator
|
||||
import org.thoughtcrime.securesms.backup.v2.BackupRepository
|
||||
import org.thoughtcrime.securesms.backup.v2.DebugBackupMetadata
|
||||
@@ -92,7 +93,7 @@ class InternalBackupPlaygroundViewModel : ViewModel() {
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
SignalStore.backup.newLocalPlaintextBackupProgressFlow.collect { progress ->
|
||||
LocalExportProgress.plaintextProgress.collect { progress ->
|
||||
_state.value = _state.value.copy(plaintextProgress = progress)
|
||||
}
|
||||
}
|
||||
|
||||
-1
@@ -68,7 +68,6 @@ class PhoneNumberPrivacySettingsViewModel : ViewModel() {
|
||||
private fun setDiscoverableByPhoneNumber(discoverable: Boolean) {
|
||||
SignalStore.phoneNumberPrivacy.phoneNumberDiscoverabilityMode = if (discoverable) PhoneNumberDiscoverabilityMode.DISCOVERABLE else PhoneNumberDiscoverabilityMode.NOT_DISCOVERABLE
|
||||
SignalDatabase.recipients.markNeedsSync(Recipient.self().id)
|
||||
SignalDatabase.recipients.clearSelfKeyTransparencyData()
|
||||
StorageSyncHelper.scheduleSyncForDataChange()
|
||||
AppDependencies.jobManager.startChain(RefreshAttributesJob()).then(RefreshOwnProfileJob()).enqueue()
|
||||
refresh()
|
||||
|
||||
+2
-2
@@ -26,7 +26,7 @@ import org.signal.core.ui.compose.Dialogs
|
||||
import org.signal.core.ui.compose.theme.SignalTheme
|
||||
import org.signal.qr.QrScannerView
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.mediasend.camerax.CameraXModelBlocklist
|
||||
import org.thoughtcrime.securesms.mediasend.camerax.CameraXRemoteConfig
|
||||
import org.thoughtcrime.securesms.qr.QrScanScreens
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import java.util.concurrent.TimeUnit
|
||||
@@ -98,7 +98,7 @@ fun UsernameQrScanScreen(
|
||||
view
|
||||
},
|
||||
update = { view ->
|
||||
view.start(lifecycleOwner = lifecycleOwner, forceLegacy = CameraXModelBlocklist.isBlocklisted())
|
||||
view.start(lifecycleOwner = lifecycleOwner, forceLegacy = CameraXRemoteConfig.isBlocklisted())
|
||||
},
|
||||
hasPermission = hasCameraPermission,
|
||||
onRequestPermissions = onOpenCameraClicked,
|
||||
|
||||
+5
-3
@@ -96,7 +96,7 @@ import org.thoughtcrime.securesms.main.MainNavigationDetailLocation
|
||||
import org.thoughtcrime.securesms.main.MainNavigationRouter
|
||||
import org.thoughtcrime.securesms.mediaoverview.MediaOverviewActivity
|
||||
import org.thoughtcrime.securesms.mediapreview.MediaIntentFactory
|
||||
import org.thoughtcrime.securesms.mediasend.camerax.CameraXUtil
|
||||
import org.thoughtcrime.securesms.mediasend.camerax.CameraXRemoteConfig
|
||||
import org.thoughtcrime.securesms.messagerequests.MessageRequestRepository
|
||||
import org.thoughtcrime.securesms.nicknames.NicknameActivity
|
||||
import org.thoughtcrime.securesms.profiles.edit.CreateProfileActivity
|
||||
@@ -486,7 +486,7 @@ class ConversationSettingsFragment :
|
||||
.setMessage(R.string.ConversationSettingsFragment__only_admins_of_this_group_can_add_to_its_story)
|
||||
.setPositiveButton(android.R.string.ok) { d, _ -> d.dismiss() }
|
||||
.show()
|
||||
} else if (CameraXUtil.isSupported()) {
|
||||
} else if (CameraXRemoteConfig.isSupported()) {
|
||||
addToGroupStoryDelegate.addToStory(state.recipient.id)
|
||||
} else {
|
||||
Permissions.with(this@ConversationSettingsFragment)
|
||||
@@ -718,7 +718,9 @@ class ConversationSettingsFragment :
|
||||
mediaRecords = state.sharedMedia,
|
||||
mediaIds = state.sharedMediaIds,
|
||||
onMediaRecordClick = { view, mediaRecord, isLtr ->
|
||||
if (mediaRecord.attachment?.transferState != AttachmentTable.TRANSFER_PROGRESS_DONE) {
|
||||
if (mediaRecord.attachment?.transferState != AttachmentTable.TRANSFER_PROGRESS_DONE &&
|
||||
mediaRecord.attachment?.transferState != AttachmentTable.TRANSFER_RESTORE_OFFLOADED
|
||||
) {
|
||||
Toast.makeText(context, R.string.ConversationSettingsFragment__this_media_is_not_sent_yet, Toast.LENGTH_LONG).show()
|
||||
return@Model
|
||||
}
|
||||
|
||||
+8
-4
@@ -23,6 +23,7 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalInspectionMode
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.lifecycle.toLiveData
|
||||
@@ -32,10 +33,12 @@ import org.signal.core.ui.compose.AllNightPreviews
|
||||
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.horizontalGutters
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.AvatarImageView
|
||||
import org.thoughtcrime.securesms.events.CallParticipant
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.util.SignalE164Util
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -159,6 +162,7 @@ private fun ParticipantHeader(recipient: Recipient) {
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.horizontalGutters()
|
||||
.padding(vertical = 16.dp)
|
||||
) {
|
||||
if (LocalInspectionMode.current) {
|
||||
@@ -176,14 +180,14 @@ private fun ParticipantHeader(recipient: Recipient) {
|
||||
|
||||
Text(
|
||||
text = recipient.getDisplayName(androidx.compose.ui.platform.LocalContext.current),
|
||||
style = MaterialTheme.typography.titleLarge
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
val e164 = recipient.e164
|
||||
if (e164.isPresent) {
|
||||
if (recipient.shouldShowE164) {
|
||||
Spacer(modifier = Modifier.size(2.dp))
|
||||
Text(
|
||||
text = e164.get(),
|
||||
text = SignalE164Util.prettyPrint(recipient.requireE164()),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
@@ -5,16 +5,21 @@
|
||||
|
||||
package org.thoughtcrime.securesms.components.webrtc.v2
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.CubicBezierEasing
|
||||
import androidx.compose.animation.core.FiniteAnimationSpec
|
||||
import androidx.compose.animation.core.VectorConverter
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.scaleIn
|
||||
import androidx.compose.animation.scaleOut
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.absoluteOffset
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
@@ -25,32 +30,27 @@ import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshots.SnapshotStateMap
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.scale
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.Layout
|
||||
import androidx.compose.ui.layout.layoutId
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.Constraints
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.window.core.layout.WindowSizeClass
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import org.signal.core.ui.compose.AllNightPreviews
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.thoughtcrime.securesms.components.webrtc.BroadcastVideoSink
|
||||
@@ -110,9 +110,6 @@ data class GridCell(
|
||||
val height: Float
|
||||
)
|
||||
|
||||
/**
|
||||
* Internal helper for grid layout parameters
|
||||
*/
|
||||
private data class GridLayoutParams(
|
||||
val rows: Int,
|
||||
val cols: Int,
|
||||
@@ -226,9 +223,6 @@ sealed class CallGridStrategy(val maxTiles: Int) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remembers the appropriate CallGridStrategy based on current window size
|
||||
*/
|
||||
private const val WIDTH_DP_LARGE_LOWER_BOUND = 1200
|
||||
|
||||
@Composable
|
||||
@@ -365,7 +359,6 @@ private fun calculateGridCells(
|
||||
val actualItemsInRow = min(itemsInThisRow, remainingItems)
|
||||
val isPartialRow = actualItemsInRow < config.columns
|
||||
|
||||
// Stretch items in partial rows to fill width (compact mode only)
|
||||
val cellWidthForRow = if (config.aspectRatio == null && isPartialRow) {
|
||||
(totalGridWidth - (spacing * (actualItemsInRow - 1))) / actualItemsInRow
|
||||
} else {
|
||||
@@ -422,7 +415,6 @@ private fun calculateGridCellsWithSpanningColumn(
|
||||
val gridStartX = padding + (availableWidth - totalGridWidth) / 2
|
||||
val gridStartY = padding + (availableHeight - totalGridHeight) / 2
|
||||
|
||||
// Place regular items in column-major order (fills columns top-to-bottom, left-to-right)
|
||||
var index = 0
|
||||
for (col in 0 until columnsForRegularItems) {
|
||||
for (row in 0 until config.rows) {
|
||||
@@ -444,7 +436,6 @@ private fun calculateGridCellsWithSpanningColumn(
|
||||
}
|
||||
}
|
||||
|
||||
// Spanning item takes full height
|
||||
val spanningX = gridStartX + columnsForRegularItems * (cellWidth + spacing)
|
||||
val spanningY = gridStartY
|
||||
val spanningHeight = totalGridHeight
|
||||
@@ -463,14 +454,9 @@ private fun calculateGridCellsWithSpanningColumn(
|
||||
}
|
||||
|
||||
/**
|
||||
* State for an item that is exiting the grid with animation
|
||||
* Holds an item being tracked by [CallGrid], along with whether it should animate in on entry.
|
||||
*/
|
||||
private data class ExitingItem<T>(
|
||||
val item: T,
|
||||
val key: Any,
|
||||
val lastPosition: IntOffset,
|
||||
val lastSize: IntSize
|
||||
)
|
||||
private data class ManagedItem<T>(val item: T, val animateEnter: Boolean)
|
||||
|
||||
/**
|
||||
* An animated grid layout for call participants.
|
||||
@@ -479,7 +465,6 @@ private data class ExitingItem<T>(
|
||||
* - Smooth position animations when items move
|
||||
* - Fade-in/scale-in animation for new items (0% to 100% opacity, 90% to 100% scale)
|
||||
* - Fade-out/scale-out animation for removed items (100% to 0% opacity, 100% to 90% scale)
|
||||
* - Crossfade for swapped items (same position, different participant)
|
||||
* - Device-aware grid configurations
|
||||
*
|
||||
* @param items List of items to display, each with a stable key
|
||||
@@ -499,20 +484,9 @@ fun <T> CallGrid(
|
||||
content: @Composable (item: T, modifier: Modifier) -> Unit
|
||||
) {
|
||||
val strategy = rememberCallGridStrategy()
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val positionAnimatables: SnapshotStateMap<Any, Animatable<IntOffset, *>> = remember { mutableStateMapOf() }
|
||||
val sizeAnimatables: SnapshotStateMap<Any, Animatable<IntSize, *>> = remember { mutableStateMapOf() }
|
||||
val alphaAnimatables: SnapshotStateMap<Any, Animatable<Float, *>> = remember { mutableStateMapOf() }
|
||||
val scaleAnimatables: SnapshotStateMap<Any, Animatable<Float, *>> = remember { mutableStateMapOf() }
|
||||
val knownKeys = remember { mutableSetOf<Any>() }
|
||||
var exitingItems: List<ExitingItem<T>> by remember { mutableStateOf(emptyList()) }
|
||||
val previousItems = remember { mutableStateMapOf<Any, T>() }
|
||||
|
||||
val displayCount = min(items.size, strategy.maxTiles)
|
||||
val displayItems = items.take(displayCount)
|
||||
val baseConfig = remember(strategy, displayCount) { strategy.getConfig(displayCount) }
|
||||
|
||||
val config = if (displayCount == 1 && singleParticipantAspectRatio != null && baseConfig.aspectRatio != null) {
|
||||
baseConfig.copy(aspectRatio = singleParticipantAspectRatio)
|
||||
} else {
|
||||
@@ -525,226 +499,95 @@ fun <T> CallGrid(
|
||||
label = "cornerRadius"
|
||||
)
|
||||
|
||||
var containerSize by remember { mutableStateOf(IntSize.Zero) }
|
||||
val density = LocalDensity.current
|
||||
|
||||
val cells = remember(config, containerSize, displayCount) {
|
||||
if (containerSize == IntSize.Zero) emptyList()
|
||||
else calculateGridCells(
|
||||
config = config,
|
||||
containerWidth = containerSize.width.toFloat(),
|
||||
containerHeight = containerSize.height.toFloat(),
|
||||
itemCount = displayCount
|
||||
)
|
||||
}
|
||||
|
||||
// Holds all items currently in the grid, including those still animating out.
|
||||
val managedItems: SnapshotStateMap<Any, ManagedItem<T>> = remember { mutableStateMapOf() }
|
||||
|
||||
// lastKnownCells freezes the last grid position for items that are animating out so they
|
||||
// stay in place (rather than jumping to zero) while their exit animation plays.
|
||||
val lastKnownCells = remember { mutableMapOf<Any, GridCell>() }
|
||||
|
||||
val currentKeys = displayItems.map { itemKey(it) }.toSet()
|
||||
val newKeys = currentKeys - knownKeys
|
||||
val hasExistingItems = knownKeys.isNotEmpty()
|
||||
val hasExistingItems = managedItems.isNotEmpty()
|
||||
|
||||
newKeys.forEach { key ->
|
||||
if (exitingItems.any { it.key == key }) {
|
||||
exitingItems = exitingItems.filterNot { it.key == key }
|
||||
}
|
||||
if (hasExistingItems) {
|
||||
alphaAnimatables[key] = Animatable(0f)
|
||||
scaleAnimatables[key] = Animatable(CallGridDefaults.ENTER_SCALE_START)
|
||||
}
|
||||
knownKeys.add(key)
|
||||
}
|
||||
|
||||
displayItems.forEach { item ->
|
||||
previousItems[itemKey(item)] = item
|
||||
}
|
||||
|
||||
fun removeAnimationState(key: Any) {
|
||||
positionAnimatables.remove(key)
|
||||
sizeAnimatables.remove(key)
|
||||
alphaAnimatables.remove(key)
|
||||
scaleAnimatables.remove(key)
|
||||
previousItems.remove(key)
|
||||
}
|
||||
|
||||
val removedKeys = knownKeys - currentKeys
|
||||
removedKeys.forEach { key ->
|
||||
val exitingItem = previousItems[key]
|
||||
val position = positionAnimatables[key]?.value
|
||||
val size = sizeAnimatables[key]?.value
|
||||
|
||||
if (exitingItem != null && position != null && size != null) {
|
||||
exitingItems = exitingItems + ExitingItem(
|
||||
item = exitingItem,
|
||||
key = key,
|
||||
lastPosition = position,
|
||||
lastSize = size
|
||||
)
|
||||
|
||||
scope.launch {
|
||||
coroutineScope {
|
||||
launch { alphaAnimatables[key]?.animateTo(0f, CallGridDefaults.alphaAnimationSpec) }
|
||||
launch { scaleAnimatables[key]?.animateTo(CallGridDefaults.EXIT_SCALE_END, CallGridDefaults.scaleAnimationSpec) }
|
||||
}
|
||||
exitingItems = exitingItems.filterNot { it.key == key }
|
||||
if (key !in knownKeys) {
|
||||
removeAnimationState(key)
|
||||
}
|
||||
SideEffect {
|
||||
displayItems.forEach { item ->
|
||||
val key = itemKey(item)
|
||||
if (key !in managedItems) {
|
||||
managedItems[key] = ManagedItem(item, animateEnter = hasExistingItems)
|
||||
} else {
|
||||
managedItems[key] = managedItems[key]!!.copy(item = item)
|
||||
}
|
||||
} else {
|
||||
removeAnimationState(key)
|
||||
}
|
||||
knownKeys.remove(key)
|
||||
}
|
||||
|
||||
BoxWithConstraints(
|
||||
modifier = modifier,
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
val containerWidthPx = constraints.maxWidth.toFloat()
|
||||
val containerHeightPx = constraints.maxHeight.toFloat()
|
||||
Box(modifier = modifier.onSizeChanged { containerSize = it }) {
|
||||
managedItems.entries.toList().forEach { (key, managed) ->
|
||||
val index = displayItems.indexOfFirst { itemKey(it) == key }
|
||||
val targetCell = cells.getOrNull(index)
|
||||
if (targetCell != null) lastKnownCells[key] = targetCell
|
||||
val effectiveCell = targetCell ?: lastKnownCells[key] ?: return@forEach
|
||||
|
||||
val cells = remember(config, containerWidthPx, containerHeightPx, displayCount) {
|
||||
calculateGridCells(
|
||||
config = config,
|
||||
containerWidth = containerWidthPx,
|
||||
containerHeight = containerHeightPx,
|
||||
itemCount = displayCount
|
||||
)
|
||||
}
|
||||
key(key) {
|
||||
var isVisible by remember { mutableStateOf(!managed.animateEnter) }
|
||||
LaunchedEffect(Unit) { isVisible = true }
|
||||
|
||||
val density = LocalDensity.current
|
||||
AnimatedVisibility(
|
||||
visible = isVisible && key in currentKeys,
|
||||
enter = scaleIn(
|
||||
initialScale = CallGridDefaults.ENTER_SCALE_START,
|
||||
animationSpec = CallGridDefaults.scaleAnimationSpec
|
||||
) + fadeIn(animationSpec = CallGridDefaults.alphaAnimationSpec),
|
||||
exit = scaleOut(
|
||||
targetScale = CallGridDefaults.EXIT_SCALE_END,
|
||||
animationSpec = CallGridDefaults.scaleAnimationSpec
|
||||
) + fadeOut(animationSpec = CallGridDefaults.alphaAnimationSpec)
|
||||
) {
|
||||
DisposableEffect(Unit) {
|
||||
onDispose { managedItems.remove(key) }
|
||||
}
|
||||
|
||||
val enteringKeys = newKeys.filter { key ->
|
||||
val alpha = alphaAnimatables[key]?.value ?: 1f
|
||||
alpha < 1f
|
||||
}.toSet()
|
||||
val targetPosition = IntOffset(effectiveCell.x.roundToInt(), effectiveCell.y.roundToInt())
|
||||
val targetSize = IntSize(effectiveCell.width.roundToInt(), effectiveCell.height.roundToInt())
|
||||
|
||||
// Internal to capture closure variables: alphaAnimatables, scaleAnimatables, density, animatedCornerRadius, content
|
||||
@Composable
|
||||
fun RenderItem(item: T, itemKeyValue: Any, widthPx: Int, heightPx: Int) {
|
||||
val alpha = alphaAnimatables[itemKeyValue]?.value ?: 1f
|
||||
val itemScale = scaleAnimatables[itemKeyValue]?.value ?: 1f
|
||||
val positionAnim = remember { Animatable(targetPosition, IntOffset.VectorConverter) }
|
||||
val sizeAnim = remember { Animatable(targetSize, IntSize.VectorConverter) }
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.layoutId(itemKeyValue)
|
||||
.alpha(alpha)
|
||||
.scale(itemScale)
|
||||
) {
|
||||
content(
|
||||
item,
|
||||
Modifier
|
||||
.size(
|
||||
width = with(density) { widthPx.toDp() },
|
||||
height = with(density) { heightPx.toDp() }
|
||||
// LaunchedEffect is tied to this composable's lifecycle and cancels automatically
|
||||
// when the item leaves composition, preventing any deactivated-node interaction.
|
||||
LaunchedEffect(targetPosition) {
|
||||
positionAnim.animateTo(targetPosition, CallGridDefaults.positionAnimationSpec)
|
||||
}
|
||||
LaunchedEffect(targetSize) {
|
||||
sizeAnim.animateTo(targetSize, CallGridDefaults.sizeAnimationSpec)
|
||||
}
|
||||
|
||||
Box(modifier = Modifier.absoluteOffset { positionAnim.value }) {
|
||||
content(
|
||||
managed.item,
|
||||
Modifier
|
||||
.size(
|
||||
width = with(density) { sizeAnim.value.width.toDp() },
|
||||
height = with(density) { sizeAnim.value.height.toDp() }
|
||||
)
|
||||
.clip(RoundedCornerShape(animatedCornerRadius))
|
||||
)
|
||||
.clip(RoundedCornerShape(animatedCornerRadius))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-filter items by entering status, preserving indices for cell lookup
|
||||
val (enteringIndexedItems, nonEnteringIndexedItems) = displayItems
|
||||
.withIndex()
|
||||
.partition { (_, item) -> itemKey(item) in enteringKeys }
|
||||
|
||||
@Composable
|
||||
fun RenderDisplayItems(indexedItems: List<IndexedValue<T>>) {
|
||||
indexedItems.forEach { (index, item) ->
|
||||
val itemKeyValue = itemKey(item)
|
||||
key(itemKeyValue) {
|
||||
val animatedSize = sizeAnimatables[itemKeyValue]?.value
|
||||
val cell = cells.getOrNull(index)
|
||||
if (cell != null) {
|
||||
val widthPx = animatedSize?.width ?: cell.width.roundToInt()
|
||||
val heightPx = animatedSize?.height ?: cell.height.roundToInt()
|
||||
RenderItem(item, itemKeyValue, widthPx, heightPx)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Layout(
|
||||
content = {
|
||||
exitingItems.forEach { exitingItem ->
|
||||
key(exitingItem.key) {
|
||||
RenderItem(exitingItem.item, exitingItem.key, exitingItem.lastSize.width, exitingItem.lastSize.height)
|
||||
}
|
||||
}
|
||||
|
||||
RenderDisplayItems(enteringIndexedItems)
|
||||
RenderDisplayItems(nonEnteringIndexedItems)
|
||||
}
|
||||
) { measurables, constraints ->
|
||||
displayItems.forEachIndexed { index, item ->
|
||||
val itemKeyValue = itemKey(item)
|
||||
val cell = cells.getOrNull(index) ?: return@forEachIndexed
|
||||
val targetPosition = IntOffset(cell.x.roundToInt(), cell.y.roundToInt())
|
||||
val targetSize = IntSize(cell.width.roundToInt(), cell.height.roundToInt())
|
||||
|
||||
val existingPosition = positionAnimatables[itemKeyValue]
|
||||
if (existingPosition == null) {
|
||||
positionAnimatables[itemKeyValue] = Animatable(targetPosition, IntOffset.VectorConverter)
|
||||
if (hasExistingItems && itemKeyValue in newKeys) {
|
||||
scope.launch {
|
||||
coroutineScope {
|
||||
launch { alphaAnimatables[itemKeyValue]?.animateTo(1f, CallGridDefaults.alphaAnimationSpec) }
|
||||
launch { scaleAnimatables[itemKeyValue]?.animateTo(CallGridDefaults.ENTER_SCALE_END, CallGridDefaults.scaleAnimationSpec) }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (alphaAnimatables[itemKeyValue] == null) {
|
||||
alphaAnimatables[itemKeyValue] = Animatable(1f)
|
||||
}
|
||||
if (scaleAnimatables[itemKeyValue] == null) {
|
||||
scaleAnimatables[itemKeyValue] = Animatable(1f)
|
||||
}
|
||||
}
|
||||
} else if (existingPosition.targetValue != targetPosition) {
|
||||
scope.launch {
|
||||
existingPosition.animateTo(targetPosition, CallGridDefaults.positionAnimationSpec)
|
||||
}
|
||||
}
|
||||
|
||||
val existingSize = sizeAnimatables[itemKeyValue]
|
||||
if (existingSize == null) {
|
||||
sizeAnimatables[itemKeyValue] = Animatable(targetSize, IntSize.VectorConverter)
|
||||
} else if (existingSize.targetValue != targetSize) {
|
||||
scope.launch {
|
||||
existingSize.animateTo(targetSize, CallGridDefaults.sizeAnimationSpec)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val placeables = measurables.map { measurable ->
|
||||
val itemKeyValue = measurable.layoutId
|
||||
val animatedSize = sizeAnimatables[itemKeyValue]?.value
|
||||
val exitingItem = exitingItems.find { it.key == itemKeyValue }
|
||||
|
||||
when {
|
||||
animatedSize != null -> {
|
||||
measurable.measure(Constraints.fixed(animatedSize.width, animatedSize.height))
|
||||
}
|
||||
exitingItem != null -> {
|
||||
measurable.measure(Constraints.fixed(exitingItem.lastSize.width, exitingItem.lastSize.height))
|
||||
}
|
||||
else -> {
|
||||
measurable.measure(Constraints())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val keyToPlaceable = measurables.zip(placeables).associate { (measurable, placeable) ->
|
||||
measurable.layoutId to placeable
|
||||
}
|
||||
|
||||
layout(constraints.maxWidth, constraints.maxHeight) {
|
||||
fun placeDisplayItems(indexedItems: List<IndexedValue<T>>) {
|
||||
indexedItems.forEach { (_, item) ->
|
||||
val itemKeyValue = itemKey(item)
|
||||
val placeable = keyToPlaceable[itemKeyValue]
|
||||
val position = positionAnimatables[itemKeyValue]?.value
|
||||
if (placeable != null && position != null) {
|
||||
placeable.place(position.x, position.y)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
exitingItems.forEach { exitingItem ->
|
||||
val placeable = keyToPlaceable[exitingItem.key]
|
||||
placeable?.place(exitingItem.lastPosition.x, exitingItem.lastPosition.y)
|
||||
}
|
||||
|
||||
placeDisplayItems(enteringIndexedItems)
|
||||
placeDisplayItems(nonEnteringIndexedItems)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -775,10 +618,7 @@ private fun CallGridPreview() {
|
||||
val windowSizeClass = currentWindowAdaptiveInfo(supportLargeAndXLargeWidth = true).windowSizeClass
|
||||
val strategy = rememberCallGridStrategy()
|
||||
|
||||
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
|
||||
val widthDp = maxWidth
|
||||
val heightDp = maxHeight
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
CallGrid(
|
||||
items = items,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
@@ -796,8 +636,7 @@ private fun CallGridPreview() {
|
||||
}
|
||||
|
||||
Text(
|
||||
text = "${widthDp.value.toInt()} x ${heightDp.value.toInt()} dp\n" +
|
||||
"WSC: ${windowSizeClass.minWidthDp}x${windowSizeClass.minHeightDp}\n" +
|
||||
text = "WSC: ${windowSizeClass.minWidthDp}x${windowSizeClass.minHeightDp}\n" +
|
||||
"Strategy: ${strategy::class.simpleName}",
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.align(Alignment.TopEnd)
|
||||
|
||||
+1
-1
@@ -91,7 +91,7 @@ fun RemoteParticipantContent(
|
||||
val isBlocked = recipient.isBlocked
|
||||
val isMissingMediaKeys = !participant.isMediaKeysReceived &&
|
||||
(System.currentTimeMillis() - participant.addedToCallTime) > 5000
|
||||
val infoMode = isBlocked || isMissingMediaKeys
|
||||
val infoMode = !participant.isSelf && (isBlocked || isMissingMediaKeys)
|
||||
|
||||
Box(modifier = modifier) {
|
||||
BlurredBackgroundAvatar(recipient = recipient)
|
||||
|
||||
+2
-1
@@ -194,7 +194,8 @@ private fun PreJoinHeader(
|
||||
text = callStatus,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = Color.White,
|
||||
modifier = Modifier.padding(top = 8.dp)
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(top = 8.dp, start = 16.dp, end = 16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -310,7 +310,7 @@ public class ConversationMessage {
|
||||
long collapsedExpirationInMs = 0;
|
||||
if (CollapsedState.isHead(messageRecord.getCollapsedState())) {
|
||||
collapsedSize = SignalDatabase.messages().getCollapsedCount(messageRecord.getId());
|
||||
if (CollapsibleEvents.getCollapsibleType(messageRecord.getType(), messageRecord.getMessageExtras()) == CollapsibleEvents.CollapsibleType.DISAPPEARING_TIMER) {
|
||||
if (CollapsibleEvents.getCollapsibleType(messageRecord.getType(), messageRecord.getMessageExtras()) == CollapsibleEvents.CollapsibleType.DISAPPEARING_TIMER && collapsedSize > 1) {
|
||||
collapsedExpirationInMs = SignalDatabase.messages().getDisappearingTimerStateForCollapsedSet(messageRecord.getId());
|
||||
}
|
||||
}
|
||||
|
||||
+32
-25
@@ -32,6 +32,7 @@ import com.bumptech.glide.RequestManager;
|
||||
import com.google.android.material.button.MaterialButton;
|
||||
import com.google.common.collect.Sets;
|
||||
|
||||
import org.signal.core.util.DimensionUnit;
|
||||
import org.signal.core.util.concurrent.ListenableFuture;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.BindableConversationItem;
|
||||
@@ -863,40 +864,46 @@ public final class ConversationUpdateItem extends FrameLayout
|
||||
}
|
||||
|
||||
private void presentCollapsedHead(CollapsedState collapsedState) {
|
||||
CollapsibleEvents.CollapsibleType collapsibleType = CollapsibleEvents.getCollapsibleType(messageRecord.getType(), messageRecord.getMessageExtras());
|
||||
if (CollapsedState.isHead(collapsedState) && conversationMessage.getCollapsedSize() > 1 && collapsibleType != null) {
|
||||
SpannableStringBuilder text = new SpannableStringBuilder()
|
||||
.append(SignalSymbols.getSpannedString(getContext(), SignalSymbols.Weight.BOLD, getCollapsibleSymbol(collapsibleType), org.signal.core.ui.R.color.signal_colorOnSurfaceVariant))
|
||||
.append(" ")
|
||||
.append(getCollapsibleString(collapsibleType))
|
||||
.append(" ")
|
||||
.append(SignalSymbols.getSpannedString(getContext(), SignalSymbols.Weight.BOLD, collapsedState == CollapsedState.HEAD_EXPANDED ? SignalSymbols.Glyph.CHEVRON_UP : SignalSymbols.Glyph.CHEVRON_DOWN, org.signal.core.ui.R.color.signal_colorOnSurfaceVariant));
|
||||
collapsedButton.setText(text);
|
||||
collapsedButton.setOnClickListener(v -> {
|
||||
if (eventListener != null) {
|
||||
if (CollapsedState.isCollapsed(collapsedState)) {
|
||||
eventListener.onExpandEvents(conversationMessage.getMessageRecord().getId());
|
||||
} else if (!anyCollapsibleChildrenSelected()) {
|
||||
eventListener.onCollapseEvents(conversationMessage.getMessageRecord().getId());
|
||||
}
|
||||
} else {
|
||||
passthroughClickListener.onClick(v);
|
||||
}
|
||||
});
|
||||
collapsedButton.setVisibility(VISIBLE);
|
||||
} else {
|
||||
if (!conversationMessage.isActiveCollapsibleHead()) {
|
||||
collapsedButton.setVisibility(GONE);
|
||||
} else {
|
||||
CollapsibleEvents.CollapsibleType collapsibleType = CollapsibleEvents.getCollapsibleType(messageRecord.getType(), messageRecord.getMessageExtras());
|
||||
if (collapsibleType != null) {
|
||||
SpannableStringBuilder text = new SpannableStringBuilder()
|
||||
.append(SignalSymbols.getSpannedString(getContext(), SignalSymbols.Weight.BOLD, getCollapsibleSymbol(collapsibleType), org.signal.core.ui.R.color.signal_colorOnSurfaceVariant))
|
||||
.append(" ")
|
||||
.append(getCollapsibleString(collapsibleType))
|
||||
.append(" ")
|
||||
.append(SignalSymbols.getSpannedString(getContext(), SignalSymbols.Weight.BOLD, collapsedState == CollapsedState.HEAD_EXPANDED ? SignalSymbols.Glyph.CHEVRON_UP : SignalSymbols.Glyph.CHEVRON_DOWN, org.signal.core.ui.R.color.signal_colorOnSurfaceVariant));
|
||||
collapsedButton.setText(text);
|
||||
collapsedButton.setOnClickListener(v -> {
|
||||
if (eventListener != null) {
|
||||
if (CollapsedState.isCollapsed(collapsedState)) {
|
||||
eventListener.onExpandEvents(conversationMessage.getMessageRecord().getId(), ConversationUpdateItem.this, conversationMessage.getCollapsedSize());
|
||||
} else if (!anyCollapsibleChildrenSelected()) {
|
||||
eventListener.onCollapseEvents(conversationMessage.getMessageRecord().getId(), ConversationUpdateItem.this, conversationMessage.getCollapsedSize());
|
||||
}
|
||||
} else {
|
||||
passthroughClickListener.onClick(v);
|
||||
}
|
||||
});
|
||||
ViewUtil.setBottomMargin(collapsedButton, (int) DimensionUnit.DP.toPixels(conversationMessage.isActiveCollapsedHead() ? 0 : 12));
|
||||
collapsedButton.setVisibility(VISIBLE);
|
||||
} else {
|
||||
Log.w(TAG, "Found a message that is a collapsible head but does not have a collapsible type.");
|
||||
collapsedButton.setVisibility(GONE);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private @NonNull String getCollapsibleString(CollapsibleEvents.CollapsibleType type) {
|
||||
return switch (type) {
|
||||
case CALL_EVENT -> getContext().getString(R.string.CollapsedEvent__call_event, conversationMessage.getCollapsedSize());
|
||||
case CALL_EVENT -> getContext().getResources().getQuantityString(R.plurals.CollapsedEvent__call_event, conversationMessage.getCollapsedSize(), conversationMessage.getCollapsedSize());
|
||||
case DISAPPEARING_TIMER -> {
|
||||
String time = ExpirationUtil.getExpirationAbbreviatedDisplayValue(getContext(), (int) (conversationMessage.getCollapsedExpirationInMs() / 1000));
|
||||
yield getContext().getString(R.string.CollapsedEvent__disappearing_timer, conversationMessage.getCollapsedSize(), time) ;
|
||||
yield getContext().getResources().getQuantityString(R.plurals.CollapsedEvent__disappearing_timer, conversationMessage.getCollapsedSize(), conversationMessage.getCollapsedSize(), time) ;
|
||||
}
|
||||
case CHAT_UPDATE -> getContext().getString(conversationRecipient.isGroup() ? R.string.CollapsedEvent__group_update : R.string.CollapsedEvent__chat_update, conversationMessage.getCollapsedSize());
|
||||
case CHAT_UPDATE -> getContext().getResources().getQuantityString(conversationRecipient.isGroup() ? R.plurals.CollapsedEvent__group_update : R.plurals.CollapsedEvent__chat_update, conversationMessage.getCollapsedSize(), conversationMessage.getCollapsedSize());
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -258,7 +258,7 @@ public final class MenuState {
|
||||
|
||||
return builder.shouldShowCopyAction(!actionMessage && !remoteDelete && hasText && !hasGift && !hasPayment && !hasPoll)
|
||||
.shouldShowDeleteAction(!hasInMemory && onlyContainsCompleteMessages(selectedParts))
|
||||
.shouldShowReactions(!conversationRecipient.isReleaseNotes())
|
||||
.shouldShowReactions(!conversationRecipient.isReleaseNotes() && !conversationRecipient.isInactiveGroup())
|
||||
.shouldShowPaymentDetails(hasPayment)
|
||||
.shouldShowPollTerminate(hasPollTerminate)
|
||||
.shouldShowPinMessage(canPinMessage)
|
||||
|
||||
+2
-2
@@ -94,6 +94,6 @@ object EmptyConversationAdapterListener : ConversationAdapter.ItemClickListener
|
||||
override fun onViewPollClicked(messageId: Long) = Unit
|
||||
override fun onToggleVote(poll: PollRecord, pollOption: PollOption, isChecked: Boolean?) = Unit
|
||||
override fun onViewPinnedMessage(messageId: Long) = Unit
|
||||
override fun onExpandEvents(messageId: Long) = Unit
|
||||
override fun onCollapseEvents(messageId: Long) = Unit
|
||||
override fun onExpandEvents(messageId: Long, itemView: View, collapsedSize: Int) = Unit
|
||||
override fun onCollapseEvents(messageId: Long, itemView: View, collapsedSize: Int) = Unit
|
||||
}
|
||||
|
||||
+2
-2
@@ -29,7 +29,7 @@ import org.thoughtcrime.securesms.conversation.v2.ConversationActivityResultCont
|
||||
import org.thoughtcrime.securesms.giph.ui.GiphyActivity
|
||||
import org.thoughtcrime.securesms.maps.PlacePickerActivity
|
||||
import org.thoughtcrime.securesms.mediasend.MediaSendActivityResult
|
||||
import org.thoughtcrime.securesms.mediasend.camerax.CameraXUtil
|
||||
import org.thoughtcrime.securesms.mediasend.camerax.CameraXRemoteConfig
|
||||
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionActivity
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.signal.core.ui.R as CoreUiR
|
||||
@@ -76,7 +76,7 @@ class ConversationActivityResultContracts(private val fragment: Fragment, privat
|
||||
}
|
||||
|
||||
fun launchCamera(recipientId: RecipientId, isReply: Boolean) {
|
||||
if (CameraXUtil.isSupported()) {
|
||||
if (CameraXRemoteConfig.isSupported()) {
|
||||
cameraLauncher.launch(MediaSelectionInput(emptyList(), recipientId, null, isReply))
|
||||
fragment.requireActivity().overridePendingTransition(R.anim.camera_slide_from_bottom, R.anim.stationary)
|
||||
} else {
|
||||
|
||||
@@ -387,6 +387,7 @@ class ConversationAdapterV2(
|
||||
return
|
||||
}
|
||||
|
||||
bindable.setParentScrolling(true)
|
||||
bindable.bind(
|
||||
lifecycleOwner,
|
||||
model.conversationMessage,
|
||||
@@ -404,6 +405,7 @@ class ConversationAdapterV2(
|
||||
colorizer,
|
||||
displayMode
|
||||
)
|
||||
bindable.setParentScrolling(isParentInScroll)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -415,6 +417,7 @@ class ConversationAdapterV2(
|
||||
return
|
||||
}
|
||||
|
||||
bindable.setParentScrolling(true)
|
||||
bindable.bind(
|
||||
lifecycleOwner,
|
||||
model.conversationMessage,
|
||||
@@ -432,6 +435,7 @@ class ConversationAdapterV2(
|
||||
colorizer,
|
||||
displayMode
|
||||
)
|
||||
bindable.setParentScrolling(isParentInScroll)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -107,7 +107,7 @@ class ConversationBannerView @JvmOverloads constructor(
|
||||
setBannerRecipients(requestReviewState.individualReviewState.target, requestReviewState.individualReviewState.firstDuplicate)
|
||||
setOnClickListener { listener?.onRequestReviewIndividual(requestReviewState.individualReviewState.target.id) }
|
||||
} else if (requestReviewState.groupReviewState != null) {
|
||||
setBannerMessage(context.getString(R.string.ConversationFragment__d_group_members_have_the_same_name, requestReviewState.groupReviewState.count))
|
||||
setBannerMessage(context.resources.getQuantityString(R.plurals.ConversationFragment__d_group_members_have_the_same_name, requestReviewState.groupReviewState.count, requestReviewState.groupReviewState.count))
|
||||
setBannerRecipients(requestReviewState.groupReviewState.target, requestReviewState.groupReviewState.firstDuplicate)
|
||||
setOnClickListener { listener?.onReviewGroupMembers(requestReviewState.groupReviewState.groupId) }
|
||||
}
|
||||
|
||||
+64
-29
@@ -48,12 +48,12 @@ import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.annotation.MainThread
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.appcompat.widget.SearchView
|
||||
import androidx.appcompat.widget.Toolbar
|
||||
import androidx.core.app.ActivityOptionsCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.pm.ShortcutManagerCompat
|
||||
import androidx.core.os.bundleOf
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.doOnPreDraw
|
||||
import androidx.core.view.isInvisible
|
||||
import androidx.core.view.isVisible
|
||||
@@ -587,6 +587,7 @@ class ConversationFragment :
|
||||
private var progressDialog: ProgressCardDialogFragment? = null
|
||||
private var firstPinRender: Boolean = true
|
||||
private var skipNextBackPressHandling: Boolean = false
|
||||
private var collapsibleEventScrollPosition: CollapsibleEventScrollPosition? = null
|
||||
|
||||
private val jumpAndPulseScrollStrategy = object : ScrollToPositionDelegate.ScrollStrategy {
|
||||
override fun performScroll(recyclerView: RecyclerView, layoutManager: LinearLayoutManager, position: Int, smooth: Boolean) {
|
||||
@@ -650,13 +651,16 @@ class ConversationFragment :
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
viewModel.resetBackPressedState()
|
||||
binding.toolbar.isBackInvokedCallbackEnabled = false
|
||||
binding.root.setUseWindowTypes(args.conversationScreenType == ConversationScreenType.NORMAL && !resources.getWindowSizeClass().isSplitPane())
|
||||
if (args.conversationScreenType == ConversationScreenType.BUBBLE) {
|
||||
binding.root.setNavigationBarInsetOverride(0)
|
||||
view.post {
|
||||
ViewCompat.requestApplyInsets(binding.root)
|
||||
binding.root.requestLayout()
|
||||
if (isAdded && this@ConversationFragment.view != null) {
|
||||
ViewCompat.requestApplyInsets(binding.root)
|
||||
binding.root.requestLayout()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -711,7 +715,6 @@ class ConversationFragment :
|
||||
startActivity(MemberLabelActivity.createIntent(requireContext(), groupId))
|
||||
}
|
||||
|
||||
ToolbarDependentMarginListener(binding.toolbar)
|
||||
initializeMediaKeyboard()
|
||||
|
||||
binding.conversationVideoContainer.setClipToOutline(true)
|
||||
@@ -724,8 +727,15 @@ class ConversationFragment :
|
||||
viewModel.onChatBoundsChanged(Rect(left, top, right, bottom))
|
||||
}
|
||||
|
||||
binding.toolbar.addOnLayoutChangeListener { _, _, _, _, bottom, _, _, _, _ ->
|
||||
binding.toolbar.addOnLayoutChangeListener { _, _, _, _, bottom, _, _, _, oldBottom ->
|
||||
binding.conversationItemRecycler.padding(top = bottom)
|
||||
if (bottom != oldBottom && ::threadHeaderMarginDecoration.isInitialized) {
|
||||
val newMargin = bottom + 16.dp
|
||||
if (threadHeaderMarginDecoration.toolbarMargin != newMargin) {
|
||||
threadHeaderMarginDecoration.toolbarMargin = newMargin
|
||||
binding.conversationItemRecycler.invalidateItemDecorations()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
binding.conversationItemRecycler.addItemDecoration(ChatColorsDrawable.ChatColorsItemDecoration)
|
||||
@@ -804,7 +814,6 @@ class ConversationFragment :
|
||||
|
||||
override fun onConfigurationChanged(newConfig: Configuration) {
|
||||
super.onConfigurationChanged(newConfig)
|
||||
ToolbarDependentMarginListener(binding.toolbar)
|
||||
inlineQueryController.onWindowSizeClassChanged(resources.getWindowSizeClass())
|
||||
}
|
||||
|
||||
@@ -1035,7 +1044,14 @@ class ConversationFragment :
|
||||
|
||||
state.isInActionMode -> finishActionMode()
|
||||
|
||||
state.isMediaKeyboardShowing -> container.hideInput()
|
||||
state.isMediaKeyboardShowing -> {
|
||||
if (container.isInputShowing) {
|
||||
container.hideInput()
|
||||
} else {
|
||||
Log.d(TAG, "handleBackPressed() - media keyboard state was stale, clearing")
|
||||
viewModel.setIsMediaKeyboardShowing(false)
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
// State has changed since the back handler was enabled. Let the back press proceed
|
||||
@@ -1132,6 +1148,13 @@ class ConversationFragment :
|
||||
doAfterFirstRender()
|
||||
}
|
||||
}
|
||||
|
||||
if (collapsibleEventScrollPosition != null) {
|
||||
val scrollState = collapsibleEventScrollPosition!!
|
||||
val offset = binding.conversationItemRecycler.height - scrollState.top - scrollState.height
|
||||
layoutManager.scrollToPositionWithOffset(scrollState.position, offset)
|
||||
collapsibleEventScrollPosition = null
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -2173,6 +2196,9 @@ class ConversationFragment :
|
||||
)
|
||||
|
||||
threadHeaderMarginDecoration = ThreadHeaderMarginDecoration()
|
||||
|
||||
val statusBarInset = ViewCompat.getRootWindowInsets(binding.root)?.getInsets(WindowInsetsCompat.Type.systemBars())?.top ?: 0
|
||||
threadHeaderMarginDecoration.toolbarMargin = statusBarInset + resources.getDimensionPixelSize(R.dimen.signal_m3_toolbar_height) + 16.dp
|
||||
binding.conversationItemRecycler.addItemDecoration(threadHeaderMarginDecoration)
|
||||
|
||||
conversationItemDecorations = ConversationItemDecorations(hasWallpaper = args.wallpaper != null)
|
||||
@@ -3003,6 +3029,14 @@ class ConversationFragment :
|
||||
return
|
||||
}
|
||||
|
||||
if (conversationGroupViewModel.groupRecordSnapshot?.isTerminated == true) {
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setMessage(R.string.conversation_activity__group_action_not_allowed_group_ended)
|
||||
.setPositiveButton(android.R.string.ok) { d, _ -> d.dismiss() }
|
||||
.show()
|
||||
return
|
||||
}
|
||||
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(getString(R.string.Poll__end_poll_title))
|
||||
.setMessage(getString(R.string.Poll__end_poll_body))
|
||||
@@ -3434,7 +3468,7 @@ class ConversationFragment :
|
||||
context ?: return
|
||||
val reactionsTag = "REACTIONS"
|
||||
if (parentFragmentManager.findFragmentByTag(reactionsTag) == null) {
|
||||
ReactionsBottomSheetDialogFragment.create(messageId, isMms).show(childFragmentManager, reactionsTag)
|
||||
ReactionsBottomSheetDialogFragment.create(messageId, isMms, conversationGroupViewModel.groupRecordSnapshot?.isTerminated == true).show(childFragmentManager, reactionsTag)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3590,6 +3624,13 @@ class ConversationFragment :
|
||||
}
|
||||
|
||||
override fun onToggleVote(poll: PollRecord, pollOption: PollOption, isChecked: Boolean) {
|
||||
if (conversationGroupViewModel.groupRecordSnapshot?.isTerminated == true) {
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setMessage(R.string.conversation_activity__group_action_not_allowed_group_ended)
|
||||
.setPositiveButton(android.R.string.ok) { d, _ -> d.dismiss() }
|
||||
.show()
|
||||
return
|
||||
}
|
||||
viewModel.toggleVote(poll, pollOption, isChecked)
|
||||
}
|
||||
|
||||
@@ -3779,11 +3820,19 @@ class ConversationFragment :
|
||||
}
|
||||
}
|
||||
|
||||
override fun onExpandEvents(messageId: Long) {
|
||||
override fun onExpandEvents(messageId: Long, itemView: View, collapsedSize: Int) {
|
||||
val position = binding.conversationItemRecycler.getChildAdapterPosition(itemView)
|
||||
if (position != RecyclerView.NO_POSITION && position != 0) {
|
||||
collapsibleEventScrollPosition = CollapsibleEventScrollPosition(position = position + (collapsedSize - 1), top = itemView.top, height = itemView.height)
|
||||
}
|
||||
viewModel.onExpandEvents(messageId)
|
||||
}
|
||||
|
||||
override fun onCollapseEvents(messageId: Long) {
|
||||
override fun onCollapseEvents(messageId: Long, itemView: View, collapsedSize: Int) {
|
||||
val position = binding.conversationItemRecycler.getChildAdapterPosition(itemView)
|
||||
if (position != RecyclerView.NO_POSITION) {
|
||||
collapsibleEventScrollPosition = CollapsibleEventScrollPosition(position = position - (collapsedSize - 1), top = itemView.top, height = itemView.height)
|
||||
}
|
||||
viewModel.onCollapseEvents(messageId)
|
||||
}
|
||||
|
||||
@@ -5136,25 +5185,6 @@ class ConversationFragment :
|
||||
}
|
||||
}
|
||||
|
||||
private inner class ToolbarDependentMarginListener(private val toolbar: Toolbar) : ViewTreeObserver.OnGlobalLayoutListener {
|
||||
|
||||
init {
|
||||
toolbar.viewTreeObserver.addOnGlobalLayoutListener(this)
|
||||
}
|
||||
|
||||
override fun onGlobalLayout() {
|
||||
if (!isAdded || view == null) {
|
||||
return
|
||||
}
|
||||
|
||||
val rect = Rect()
|
||||
toolbar.getGlobalVisibleRect(rect)
|
||||
threadHeaderMarginDecoration.toolbarMargin = rect.bottom + 16.dp
|
||||
binding.conversationItemRecycler.invalidateItemDecorations()
|
||||
toolbar.viewTreeObserver.removeOnGlobalLayoutListener(this)
|
||||
}
|
||||
}
|
||||
|
||||
private inner class ThreadHeaderMarginDecoration : RecyclerView.ItemDecoration() {
|
||||
var toolbarMargin: Int = 0
|
||||
|
||||
@@ -5227,4 +5257,9 @@ class ConversationFragment :
|
||||
override fun onDoubleTapEditEducationSheetNext(conversationMessage: ConversationMessage) {
|
||||
handleEditMessage(conversationMessage)
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks the scroll position so that after collapsing/expanding, we can restore it properly
|
||||
*/
|
||||
private data class CollapsibleEventScrollPosition(val position: Int, val top: Int, val height: Int)
|
||||
}
|
||||
|
||||
+8
-1
@@ -155,7 +155,7 @@ class ConversationRepository(
|
||||
metadata.threadSize
|
||||
)
|
||||
val config = PagingConfig.Builder().setPageSize(25)
|
||||
.setBufferPages(2)
|
||||
.setBufferPages(3)
|
||||
.setStartIndex(max(metadata.getStartPosition(), 0))
|
||||
.build()
|
||||
|
||||
@@ -221,6 +221,13 @@ class ConversationRepository(
|
||||
if (threadRecipient.isPushV2Group && threadRecipient.groupId.getOrNull()?.isV2 != true) {
|
||||
Log.w(TAG, "Missing group id")
|
||||
emitter.tryOnError(Exception("Poll terminate failed"))
|
||||
return@create
|
||||
}
|
||||
|
||||
if (threadRecipient.isPushV2Group && !SignalDatabase.groups.isActive(threadRecipient.requireGroupId())) {
|
||||
Log.w(TAG, "Cannot end poll in terminated or inactive group")
|
||||
emitter.tryOnError(Exception("Poll terminate failed"))
|
||||
return@create
|
||||
}
|
||||
|
||||
val message = OutgoingMessage.pollTerminateMessage(
|
||||
|
||||
@@ -733,6 +733,10 @@ class ConversationViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
fun resetBackPressedState() {
|
||||
internalBackPressedState.value = BackPressedState()
|
||||
}
|
||||
|
||||
fun toggleVote(poll: PollRecord, pollOption: PollOption, isChecked: Boolean) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val voteCount = if (isChecked) {
|
||||
|
||||
@@ -9,6 +9,7 @@ import com.google.android.material.datepicker.CalendarConstraints.DateValidator
|
||||
import kotlinx.parcelize.IgnoredOnParcel
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import org.signal.core.util.LRUCache
|
||||
import org.signal.core.util.ThreadUtil
|
||||
import org.signal.core.util.concurrent.SignalExecutors
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import java.time.Instant
|
||||
@@ -36,7 +37,7 @@ private typealias MessageDateLookup = (Collection<Long>) -> Map<Long, Boolean>
|
||||
class JumpToDateValidator private constructor(
|
||||
private val threadId: Long,
|
||||
@IgnoredOnParcel private val messageExistanceLookup: MessageDateLookup = createDefaultLookup(threadId),
|
||||
@IgnoredOnParcel private val executor: Executor = SignalExecutors.BOUNDED,
|
||||
@IgnoredOnParcel private val executor: Executor,
|
||||
private val zoneId: ZoneId = ZoneId.systemDefault()
|
||||
) : DateValidator {
|
||||
|
||||
@@ -51,7 +52,7 @@ class JumpToDateValidator private constructor(
|
||||
return JumpToDateValidator(
|
||||
threadId = threadId,
|
||||
messageExistanceLookup = createDefaultLookup(threadId),
|
||||
executor = SignalExecutors.BOUNDED,
|
||||
executor = SignalExecutors.newCachedSingleThreadExecutor("jump-to-date-validator", ThreadUtil.PRIORITY_BACKGROUND_THREAD),
|
||||
zoneId = ZoneId.systemDefault()
|
||||
).also {
|
||||
it.performInitialPrefetch()
|
||||
|
||||
+2
-1
@@ -52,6 +52,7 @@ import org.signal.glide.compose.GlideImage
|
||||
import org.signal.glide.decryptableuri.DecryptableUri
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiTextView
|
||||
import org.thoughtcrime.securesms.contactshare.ContactUtil
|
||||
import org.thoughtcrime.securesms.conversation.ConversationMessage
|
||||
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
|
||||
import org.thoughtcrime.securesms.fonts.SignalSymbols
|
||||
@@ -269,7 +270,7 @@ fun getMessageMetadata(conversationMessage: ConversationMessage): Triple<SignalS
|
||||
} else if (message.isPoll()) {
|
||||
Triple(SignalSymbols.Glyph.POLL, SpannableString(stringResource(R.string.Poll__poll_question, message.body)), false)
|
||||
} else if (message.hasSharedContact()) {
|
||||
Triple(SignalSymbols.Glyph.PERSON_CIRCLE, SpannableString(message.sharedContacts.first().name.givenName), false)
|
||||
Triple(SignalSymbols.Glyph.PERSON_CIRCLE, SpannableString(ContactUtil.getDisplayName(message.sharedContacts.first())), false)
|
||||
} else if (message.isPaymentNotification && message.payment != null) {
|
||||
Triple(SignalSymbols.Glyph.CREDIT_CARD, SpannableString(message.payment!!.amount.toString(FormatterOptions.defaults())), false)
|
||||
} else if (slide?.isVideoGif == true) {
|
||||
|
||||
+1
-1
@@ -73,7 +73,7 @@ class ConversationGroupViewModel(
|
||||
|
||||
fun canEditGroupInfo(): Boolean {
|
||||
val memberLevel = _memberLevel.value ?: return true
|
||||
return memberLevel.groupTableMemberLevel == GroupTable.MemberLevel.ADMINISTRATOR || memberLevel.allMembersCanEditGroupInfo
|
||||
return groupRecordSnapshot?.isActive == true && (memberLevel.groupTableMemberLevel == GroupTable.MemberLevel.ADMINISTRATOR || memberLevel.allMembersCanEditGroupInfo)
|
||||
}
|
||||
|
||||
fun blockJoinRequests(recipient: Recipient): Single<GroupBlockJoinRequestResult> {
|
||||
|
||||
@@ -7,6 +7,8 @@ import org.thoughtcrime.securesms.database.model.databaseprotos.MessageExtras
|
||||
*/
|
||||
object CollapsibleEvents {
|
||||
|
||||
const val MAX_SIZE = 50
|
||||
|
||||
@JvmStatic
|
||||
fun isCollapsibleType(type: Long, messageExtras: MessageExtras?): Boolean {
|
||||
return getCollapsibleType(type, messageExtras) != null
|
||||
@@ -41,6 +43,18 @@ object CollapsibleEvents {
|
||||
return CollapsibleType.CHAT_UPDATE
|
||||
}
|
||||
|
||||
if (MessageTypes.isPinnedMessageUpdate(type)) {
|
||||
return CollapsibleType.CHAT_UPDATE
|
||||
}
|
||||
|
||||
if (MessageTypes.isPollTerminate(type)) {
|
||||
return CollapsibleType.CHAT_UPDATE
|
||||
}
|
||||
|
||||
if (MessageTypes.isChangeNumber(type)) {
|
||||
return CollapsibleType.CHAT_UPDATE
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
@@ -236,36 +236,35 @@ class InAppPaymentTable(context: Context, databaseHelper: SignalDatabase) : Data
|
||||
* Retrieves all InAppPayment objects for donations that have been marked NOTIFIED = 0, and then marks them
|
||||
* all as notified.
|
||||
*/
|
||||
fun consumeDonationPaymentsToNotifyUser(): List<InAppPayment> {
|
||||
return writableDatabase.withinTransaction { db ->
|
||||
val payments = db.select()
|
||||
.from(TABLE_NAME)
|
||||
.where("$NOTIFIED = ? AND $TYPE != ?", 0, InAppPaymentType.serialize(InAppPaymentType.RECURRING_BACKUP))
|
||||
.run()
|
||||
.readToList(mapper = { InAppPayment.deserialize(it) })
|
||||
|
||||
db.update(TABLE_NAME).values(NOTIFIED to 1)
|
||||
.where("$TYPE != ?", InAppPaymentType.serialize(InAppPaymentType.RECURRING_BACKUP))
|
||||
.run()
|
||||
|
||||
payments
|
||||
}
|
||||
}
|
||||
fun consumeDonationPaymentsToNotifyUser(): List<InAppPayment> = consumePaymentsToNotifyUser(
|
||||
where = "$NOTIFIED = ? AND $TYPE != ?",
|
||||
args = arrayOf(0, InAppPaymentType.serialize(InAppPaymentType.RECURRING_BACKUP))
|
||||
)
|
||||
|
||||
/**
|
||||
* Retrieves all InAppPayment objects for backups that have been marked NOTIFIED = 0, and then marks them
|
||||
* all as notified.
|
||||
*/
|
||||
fun consumeBackupPaymentsToNotifyUser(): List<InAppPayment> {
|
||||
fun consumeBackupPaymentsToNotifyUser(): List<InAppPayment> = consumePaymentsToNotifyUser(
|
||||
where = "$NOTIFIED = ? AND $TYPE = ?",
|
||||
args = arrayOf(0, InAppPaymentType.serialize(InAppPaymentType.RECURRING_BACKUP))
|
||||
)
|
||||
|
||||
private fun consumePaymentsToNotifyUser(where: String, args: Array<Any>): List<InAppPayment> {
|
||||
val hasUnnotified = readableDatabase.exists(TABLE_NAME)
|
||||
.where(where, *args)
|
||||
.run()
|
||||
if (!hasUnnotified) return emptyList()
|
||||
|
||||
return writableDatabase.withinTransaction { db ->
|
||||
val payments = db.select()
|
||||
.from(TABLE_NAME)
|
||||
.where("$NOTIFIED = ? AND $TYPE = ?", 0, InAppPaymentType.serialize(InAppPaymentType.RECURRING_BACKUP))
|
||||
.where(where, *args)
|
||||
.run()
|
||||
.readToList(mapper = { InAppPayment.deserialize(it) })
|
||||
|
||||
db.update(TABLE_NAME).values(NOTIFIED to 1)
|
||||
.where("$TYPE = ?", InAppPaymentType.serialize(InAppPaymentType.RECURRING_BACKUP))
|
||||
.where(where, *args)
|
||||
.run()
|
||||
|
||||
payments
|
||||
|
||||
@@ -20,65 +20,67 @@ class MediaTable internal constructor(context: Context?, databaseHelper: SignalD
|
||||
private val TAG = Log.tag(MediaTable::class)
|
||||
const val ALL_THREADS = -1
|
||||
private const val THREAD_RECIPIENT_ID = "THREAD_RECIPIENT_ID"
|
||||
private const val MEDIA_MESSAGE_ID = "media_message_id"
|
||||
private val BASE_MEDIA_QUERY = """
|
||||
SELECT
|
||||
${AttachmentTable.TABLE_NAME}.${AttachmentTable.ID} AS ${AttachmentTable.ID},
|
||||
${AttachmentTable.TABLE_NAME}.${AttachmentTable.CONTENT_TYPE},
|
||||
${AttachmentTable.TABLE_NAME}.${AttachmentTable.MESSAGE_ID},
|
||||
${AttachmentTable.TABLE_NAME}.${AttachmentTable.TRANSFER_STATE},
|
||||
${AttachmentTable.TABLE_NAME}.${AttachmentTable.DATA_SIZE},
|
||||
${AttachmentTable.TABLE_NAME}.${AttachmentTable.FILE_NAME},
|
||||
${AttachmentTable.TABLE_NAME}.${AttachmentTable.DATA_FILE},
|
||||
${AttachmentTable.TABLE_NAME}.${AttachmentTable.THUMBNAIL_FILE},
|
||||
SELECT
|
||||
${AttachmentTable.TABLE_NAME}.${AttachmentTable.ID} AS ${AttachmentTable.ID},
|
||||
${AttachmentTable.TABLE_NAME}.${AttachmentTable.CONTENT_TYPE},
|
||||
${AttachmentTable.TABLE_NAME}.${AttachmentTable.MESSAGE_ID},
|
||||
${AttachmentTable.TABLE_NAME}.${AttachmentTable.TRANSFER_STATE},
|
||||
${AttachmentTable.TABLE_NAME}.${AttachmentTable.DATA_SIZE},
|
||||
${AttachmentTable.TABLE_NAME}.${AttachmentTable.FILE_NAME},
|
||||
${AttachmentTable.TABLE_NAME}.${AttachmentTable.DATA_FILE},
|
||||
${AttachmentTable.TABLE_NAME}.${AttachmentTable.THUMBNAIL_FILE},
|
||||
${AttachmentTable.TABLE_NAME}.${AttachmentTable.CDN_NUMBER},
|
||||
${AttachmentTable.TABLE_NAME}.${AttachmentTable.REMOTE_LOCATION},
|
||||
${AttachmentTable.TABLE_NAME}.${AttachmentTable.REMOTE_KEY},
|
||||
${AttachmentTable.TABLE_NAME}.${AttachmentTable.REMOTE_DIGEST},
|
||||
${AttachmentTable.TABLE_NAME}.${AttachmentTable.REMOTE_DIGEST},
|
||||
${AttachmentTable.TABLE_NAME}.${AttachmentTable.FAST_PREFLIGHT_ID},
|
||||
${AttachmentTable.TABLE_NAME}.${AttachmentTable.VOICE_NOTE},
|
||||
${AttachmentTable.TABLE_NAME}.${AttachmentTable.BORDERLESS},
|
||||
${AttachmentTable.TABLE_NAME}.${AttachmentTable.VIDEO_GIF},
|
||||
${AttachmentTable.TABLE_NAME}.${AttachmentTable.WIDTH},
|
||||
${AttachmentTable.TABLE_NAME}.${AttachmentTable.HEIGHT},
|
||||
${AttachmentTable.TABLE_NAME}.${AttachmentTable.QUOTE},
|
||||
${AttachmentTable.TABLE_NAME}.${AttachmentTable.VOICE_NOTE},
|
||||
${AttachmentTable.TABLE_NAME}.${AttachmentTable.BORDERLESS},
|
||||
${AttachmentTable.TABLE_NAME}.${AttachmentTable.VIDEO_GIF},
|
||||
${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},
|
||||
${AttachmentTable.TABLE_NAME}.${AttachmentTable.STICKER_EMOJI},
|
||||
${AttachmentTable.TABLE_NAME}.${AttachmentTable.BLUR_HASH},
|
||||
${AttachmentTable.TABLE_NAME}.${AttachmentTable.TRANSFORM_PROPERTIES},
|
||||
${AttachmentTable.TABLE_NAME}.${AttachmentTable.DISPLAY_ORDER},
|
||||
${AttachmentTable.TABLE_NAME}.${AttachmentTable.CAPTION},
|
||||
${AttachmentTable.TABLE_NAME}.${AttachmentTable.UPLOAD_TIMESTAMP},
|
||||
${AttachmentTable.TABLE_NAME}.${AttachmentTable.REMOTE_INCREMENTAL_DIGEST},
|
||||
${AttachmentTable.TABLE_NAME}.${AttachmentTable.STICKER_PACK_ID},
|
||||
${AttachmentTable.TABLE_NAME}.${AttachmentTable.STICKER_PACK_KEY},
|
||||
${AttachmentTable.TABLE_NAME}.${AttachmentTable.STICKER_ID},
|
||||
${AttachmentTable.TABLE_NAME}.${AttachmentTable.STICKER_EMOJI},
|
||||
${AttachmentTable.TABLE_NAME}.${AttachmentTable.BLUR_HASH},
|
||||
${AttachmentTable.TABLE_NAME}.${AttachmentTable.TRANSFORM_PROPERTIES},
|
||||
${AttachmentTable.TABLE_NAME}.${AttachmentTable.DISPLAY_ORDER},
|
||||
${AttachmentTable.TABLE_NAME}.${AttachmentTable.CAPTION},
|
||||
${AttachmentTable.TABLE_NAME}.${AttachmentTable.UPLOAD_TIMESTAMP},
|
||||
${AttachmentTable.TABLE_NAME}.${AttachmentTable.REMOTE_INCREMENTAL_DIGEST},
|
||||
${AttachmentTable.TABLE_NAME}.${AttachmentTable.REMOTE_INCREMENTAL_DIGEST_CHUNK_SIZE},
|
||||
${AttachmentTable.TABLE_NAME}.${AttachmentTable.DATA_HASH_END},
|
||||
${AttachmentTable.TABLE_NAME}.${AttachmentTable.ARCHIVE_CDN},
|
||||
${AttachmentTable.TABLE_NAME}.${AttachmentTable.THUMBNAIL_RESTORE_STATE},
|
||||
${AttachmentTable.TABLE_NAME}.${AttachmentTable.ARCHIVE_TRANSFER_STATE},
|
||||
${AttachmentTable.TABLE_NAME}.${AttachmentTable.ATTACHMENT_UUID},
|
||||
${MessageTable.TABLE_NAME}.${MessageTable.TYPE},
|
||||
${MessageTable.TABLE_NAME}.${MessageTable.DATE_SENT},
|
||||
${MessageTable.TABLE_NAME}.${MessageTable.DATE_RECEIVED},
|
||||
${MessageTable.TABLE_NAME}.${MessageTable.DATE_SERVER},
|
||||
${MessageTable.TABLE_NAME}.${MessageTable.THREAD_ID},
|
||||
${MessageTable.TABLE_NAME}.${MessageTable.TYPE},
|
||||
${MessageTable.TABLE_NAME}.${MessageTable.DATE_SENT},
|
||||
${MessageTable.TABLE_NAME}.${MessageTable.DATE_RECEIVED},
|
||||
${MessageTable.TABLE_NAME}.${MessageTable.DATE_SERVER},
|
||||
${MessageTable.TABLE_NAME}.${MessageTable.THREAD_ID},
|
||||
${MessageTable.TABLE_NAME}.${MessageTable.FROM_RECIPIENT_ID},
|
||||
${ThreadTable.TABLE_NAME}.${ThreadTable.RECIPIENT_ID} as $THREAD_RECIPIENT_ID,
|
||||
${MessageTable.TABLE_NAME}.${MessageTable.LINK_PREVIEWS}
|
||||
${MessageTable.TABLE_NAME}.${MessageTable.LINK_PREVIEWS},
|
||||
${AttachmentTable.TABLE_NAME}.${AttachmentTable.MESSAGE_ID} as $MEDIA_MESSAGE_ID
|
||||
FROM
|
||||
${AttachmentTable.TABLE_NAME} __INDEX_HINT__
|
||||
LEFT JOIN ${MessageTable.TABLE_NAME} ON ${AttachmentTable.TABLE_NAME}.${AttachmentTable.MESSAGE_ID} = ${MessageTable.TABLE_NAME}.${MessageTable.ID}
|
||||
LEFT JOIN ${ThreadTable.TABLE_NAME} ON ${ThreadTable.TABLE_NAME}.${ThreadTable.ID} = ${MessageTable.TABLE_NAME}.${MessageTable.THREAD_ID}
|
||||
WHERE
|
||||
__THREAD_FILTER__ AND
|
||||
(%s) AND
|
||||
${MessageTable.VIEW_ONCE} = 0 AND
|
||||
(%s) AND
|
||||
${MessageTable.VIEW_ONCE} = 0 AND
|
||||
${MessageTable.STORY_TYPE} = 0 AND
|
||||
${MessageTable.LATEST_REVISION_ID} IS NULL AND
|
||||
${AttachmentTable.QUOTE} = 0 AND
|
||||
${AttachmentTable.STICKER_PACK_ID} IS NULL AND
|
||||
${MessageTable.TABLE_NAME}.${MessageTable.FROM_RECIPIENT_ID} > 0 AND
|
||||
${MessageTable.LATEST_REVISION_ID} IS NULL AND
|
||||
${AttachmentTable.QUOTE} = 0 AND
|
||||
${AttachmentTable.STICKER_PACK_ID} IS NULL AND
|
||||
${MessageTable.TABLE_NAME}.${MessageTable.FROM_RECIPIENT_ID} > 0 AND
|
||||
$THREAD_RECIPIENT_ID > 0
|
||||
"""
|
||||
|
||||
@@ -107,8 +109,8 @@ class MediaTable internal constructor(context: Context?, databaseHelper: SignalD
|
||||
private val GALLERY_MEDIA_QUERY_INCLUDING_TEMP_VIDEOS = String.format(
|
||||
BASE_MEDIA_QUERY,
|
||||
"""
|
||||
(${AttachmentTable.DATA_FILE} IS NOT NULL OR (${AttachmentTable.CONTENT_TYPE} LIKE 'video/%' AND ${AttachmentTable.REMOTE_INCREMENTAL_DIGEST} IS NOT NULL) OR (${AttachmentTable.THUMBNAIL_FILE} IS NOT NULL)) AND
|
||||
${AttachmentTable.CONTENT_TYPE} NOT LIKE 'image/svg%' AND
|
||||
(${AttachmentTable.DATA_FILE} IS NOT NULL OR (${AttachmentTable.CONTENT_TYPE} LIKE 'video/%' AND ${AttachmentTable.REMOTE_INCREMENTAL_DIGEST} IS NOT NULL) OR ${AttachmentTable.THUMBNAIL_FILE} IS NOT NULL OR ${AttachmentTable.TRANSFER_STATE} = ${AttachmentTable.TRANSFER_RESTORE_OFFLOADED}) AND
|
||||
${AttachmentTable.CONTENT_TYPE} NOT LIKE 'image/svg%' AND
|
||||
(${AttachmentTable.CONTENT_TYPE} LIKE 'image/%' OR ${AttachmentTable.CONTENT_TYPE} LIKE 'video/%') AND
|
||||
${MessageTable.LINK_PREVIEWS} IS NULL AND
|
||||
${MessageTable.SCHEDULED_DATE} < 0
|
||||
@@ -118,7 +120,7 @@ class MediaTable internal constructor(context: Context?, databaseHelper: SignalD
|
||||
private val AUDIO_MEDIA_QUERY = String.format(
|
||||
BASE_MEDIA_QUERY,
|
||||
"""
|
||||
${AttachmentTable.DATA_FILE} IS NOT NULL AND
|
||||
(${AttachmentTable.DATA_FILE} IS NOT NULL OR ${AttachmentTable.TRANSFER_STATE} = ${AttachmentTable.TRANSFER_RESTORE_OFFLOADED}) AND
|
||||
${AttachmentTable.CONTENT_TYPE} LIKE 'audio/%' AND
|
||||
${MessageTable.SCHEDULED_DATE} < 0
|
||||
"""
|
||||
@@ -127,7 +129,7 @@ class MediaTable internal constructor(context: Context?, databaseHelper: SignalD
|
||||
private val ALL_MEDIA_QUERY = String.format(
|
||||
BASE_MEDIA_QUERY,
|
||||
"""
|
||||
${AttachmentTable.DATA_FILE} IS NOT NULL AND
|
||||
(${AttachmentTable.DATA_FILE} IS NOT NULL OR ${AttachmentTable.TRANSFER_STATE} = ${AttachmentTable.TRANSFER_RESTORE_OFFLOADED}) AND
|
||||
${AttachmentTable.CONTENT_TYPE} NOT LIKE 'text/x-signal-plain' AND
|
||||
${MessageTable.LINK_PREVIEWS} IS NULL AND
|
||||
${MessageTable.SCHEDULED_DATE} < 0
|
||||
@@ -179,7 +181,8 @@ class MediaTable internal constructor(context: Context?, databaseHelper: SignalD
|
||||
${MessageTable.TABLE_NAME}.${MessageTable.THREAD_ID},
|
||||
${MessageTable.TABLE_NAME}.${MessageTable.FROM_RECIPIENT_ID},
|
||||
${ThreadTable.TABLE_NAME}.${ThreadTable.RECIPIENT_ID} as $THREAD_RECIPIENT_ID,
|
||||
${MessageTable.TABLE_NAME}.${MessageTable.LINK_PREVIEWS}
|
||||
${MessageTable.TABLE_NAME}.${MessageTable.LINK_PREVIEWS},
|
||||
${MessageTable.TABLE_NAME}.${MessageTable.ID} as $MEDIA_MESSAGE_ID
|
||||
FROM
|
||||
${MessageTable.TABLE_NAME}
|
||||
LEFT JOIN ${AttachmentTable.TABLE_NAME} ON ${AttachmentTable.TABLE_NAME}.${AttachmentTable.MESSAGE_ID} = ${MessageTable.TABLE_NAME}.${MessageTable.ID}
|
||||
@@ -200,13 +203,13 @@ class MediaTable internal constructor(context: Context?, databaseHelper: SignalD
|
||||
private val DOCUMENT_MEDIA_QUERY = String.format(
|
||||
BASE_MEDIA_QUERY,
|
||||
"""
|
||||
${AttachmentTable.DATA_FILE} IS NOT NULL AND
|
||||
(${AttachmentTable.DATA_FILE} IS NOT NULL OR ${AttachmentTable.TRANSFER_STATE} = ${AttachmentTable.TRANSFER_RESTORE_OFFLOADED}) AND
|
||||
(
|
||||
${AttachmentTable.CONTENT_TYPE} LIKE 'image/svg%' OR
|
||||
${AttachmentTable.CONTENT_TYPE} LIKE 'image/svg%' OR
|
||||
(
|
||||
${AttachmentTable.CONTENT_TYPE} NOT LIKE 'image/%' AND
|
||||
${AttachmentTable.CONTENT_TYPE} NOT LIKE 'video/%' AND
|
||||
${AttachmentTable.CONTENT_TYPE} NOT LIKE 'audio/%' AND
|
||||
${AttachmentTable.CONTENT_TYPE} NOT LIKE 'image/%' AND
|
||||
${AttachmentTable.CONTENT_TYPE} NOT LIKE 'video/%' AND
|
||||
${AttachmentTable.CONTENT_TYPE} NOT LIKE 'audio/%' AND
|
||||
${AttachmentTable.CONTENT_TYPE} NOT LIKE 'text/x-signal-plain' AND
|
||||
${MessageTable.SCHEDULED_DATE} < 0
|
||||
)
|
||||
@@ -229,9 +232,6 @@ class MediaTable internal constructor(context: Context?, databaseHelper: SignalD
|
||||
}
|
||||
|
||||
private fun applyIndexHint(query: String, threadId: Long, sorting: Sorting): String {
|
||||
if (threadId == ALL_THREADS.toLong() && sorting == Sorting.Largest) {
|
||||
return query.replace("__INDEX_HINT__", "INDEXED BY attachment_media_overview_size")
|
||||
}
|
||||
return query.replace("__INDEX_HINT__", "")
|
||||
}
|
||||
}
|
||||
@@ -285,8 +285,17 @@ class MediaTable internal constructor(context: Context?, databaseHelper: SignalD
|
||||
|
||||
@JvmOverloads
|
||||
fun getAllMediaForThread(threadId: Long, sorting: Sorting, limit: Int = 0): Cursor {
|
||||
var query = sorting.applyToQuery(applyEqualityOperator(threadId, applyIndexHint(ALL_MEDIA_QUERY, threadId, sorting)))
|
||||
val args = arrayOf(threadId.toString() + "")
|
||||
val allMediaSubquery = applyEqualityOperator(threadId, applyIndexHint(ALL_MEDIA_QUERY, threadId, sorting))
|
||||
val linkSubquery = applyEqualityOperator(threadId, LINK_MEDIA_QUERY)
|
||||
|
||||
val orderBy = when (sorting) {
|
||||
Sorting.Newest -> " ORDER BY $MEDIA_MESSAGE_ID DESC"
|
||||
Sorting.Oldest -> " ORDER BY $MEDIA_MESSAGE_ID ASC"
|
||||
Sorting.Largest -> " ORDER BY ${AttachmentTable.DATA_SIZE} DESC"
|
||||
}
|
||||
|
||||
var query = "$allMediaSubquery UNION ALL $linkSubquery$orderBy"
|
||||
val args = arrayOf(threadId.toString(), threadId.toString())
|
||||
|
||||
if (limit > 0) {
|
||||
query = "$query LIMIT $limit"
|
||||
@@ -344,6 +353,7 @@ class MediaTable internal constructor(context: Context?, databaseHelper: SignalD
|
||||
val recipientId: RecipientId,
|
||||
val threadRecipientId: RecipientId,
|
||||
val threadId: Long,
|
||||
val messageId: Long,
|
||||
val date: Long,
|
||||
val isOutgoing: Boolean,
|
||||
val linkPreviewJson: String? = null
|
||||
@@ -363,6 +373,7 @@ class MediaTable internal constructor(context: Context?, databaseHelper: SignalD
|
||||
recipientId = RecipientId.from(cursor.requireLong(MessageTable.FROM_RECIPIENT_ID)),
|
||||
threadId = cursor.requireLong(MessageTable.THREAD_ID),
|
||||
threadRecipientId = RecipientId.from(cursor.requireLong(THREAD_RECIPIENT_ID)),
|
||||
messageId = cursor.requireLong(MEDIA_MESSAGE_ID),
|
||||
date = if (MessageTypes.isPushType(cursor.requireLong(MessageTable.TYPE))) {
|
||||
cursor.requireLong(MessageTable.DATE_SENT)
|
||||
} else {
|
||||
|
||||
@@ -3062,7 +3062,9 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
|
||||
|
||||
if (retrieved.attachments.isEmpty() && editedMessage?.id != null && attachments.getAttachmentsForMessage(editedMessage.id).isNotEmpty()) {
|
||||
val linkPreviewAttachmentIds = editedMessage.linkPreviews.mapNotNull { it.attachmentId?.id }.toSet()
|
||||
attachments.duplicateAttachmentsForMessage(messageId, editedMessage.id, linkPreviewAttachmentIds)
|
||||
val textAttachmentIds = editedMessage.slideDeck.asAttachments().filter { it.contentType == MediaUtil.LONG_TEXT }.mapNotNull { (it as? DatabaseAttachment)?.attachmentId?.id }.toSet()
|
||||
val excludeIds = linkPreviewAttachmentIds + textAttachmentIds
|
||||
attachments.duplicateAttachmentsForMessage(messageId, editedMessage.id, excludeIds)
|
||||
}
|
||||
|
||||
val isNotStoryGroupReply = retrieved.parentStoryId == null || !retrieved.parentStoryId.isGroupReply()
|
||||
@@ -3629,12 +3631,29 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
|
||||
.limit(1)
|
||||
.run()
|
||||
.readToSingleObject { cursor ->
|
||||
PotentialCollapsibleMessage(
|
||||
val message = PotentialCollapsibleMessage(
|
||||
type = cursor.requireLong(TYPE),
|
||||
dateReceived = cursor.requireLong(DATE_RECEIVED),
|
||||
collapsedHeadId = cursor.requireLong(COLLAPSED_HEAD_ID),
|
||||
messageExtras = cursor.requireBlob(MESSAGE_EXTRAS)?.let { MessageExtras.ADAPTER.decode(it) }
|
||||
)
|
||||
|
||||
val collapsedSize = if (message.collapsedHeadId != 0L) {
|
||||
readableDatabase
|
||||
.count()
|
||||
.from(TABLE_NAME)
|
||||
.where("$COLLAPSED_HEAD_ID = ?", message.collapsedHeadId)
|
||||
.run()
|
||||
.readToSingleInt()
|
||||
} else {
|
||||
-1
|
||||
}
|
||||
|
||||
if (collapsedSize in 1..<CollapsibleEvents.MAX_SIZE) {
|
||||
message
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}?.takeIf { DateUtils.isSameDay(it.dateReceived, dateReceived) }
|
||||
}
|
||||
|
||||
@@ -6212,7 +6231,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the number of updates that belong in a collapsed update set where [messageId] is the head (first update) in that set
|
||||
* Returns the number of updates that belong in a collapsed update set where [messageId] is the head (first update) in that set.
|
||||
* If an event is [PENDING_COLLAPSED], we do not want to consider it part of the count until it is seen.
|
||||
*/
|
||||
fun getCollapsedCount(messageId: Long): Int {
|
||||
|
||||
@@ -2322,7 +2322,6 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
|
||||
.values(NEEDS_PNI_SIGNATURE to 0)
|
||||
.run()
|
||||
|
||||
clearSelfKeyTransparencyData()
|
||||
SignalDatabase.pendingPniSignatureMessages.deleteAll()
|
||||
|
||||
db.setTransactionSuccessful()
|
||||
@@ -2351,10 +2350,6 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
|
||||
}
|
||||
}
|
||||
|
||||
if (id == Recipient.self().id) {
|
||||
clearSelfKeyTransparencyData()
|
||||
}
|
||||
|
||||
if (update(id, contentValuesOf(USERNAME to username))) {
|
||||
AppDependencies.databaseObserver.notifyRecipientChanged(id)
|
||||
rotateStorageId(id)
|
||||
|
||||
@@ -910,6 +910,7 @@ class ThreadTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa
|
||||
|
||||
where += " AND $ARCHIVED = 0"
|
||||
where += " AND ${RecipientTable.TABLE_NAME}.${RecipientTable.BLOCKED} = 0"
|
||||
where += " AND ${RecipientTable.TABLE_NAME}.${RecipientTable.HIDDEN} = 0"
|
||||
|
||||
if (SignalStore.releaseChannel.releaseChannelRecipientId != null) {
|
||||
where += " AND $TABLE_NAME.$RECIPIENT_ID != ${SignalStore.releaseChannel.releaseChannelRecipientId!!.toLong()}"
|
||||
|
||||
+1
@@ -185,6 +185,7 @@ public final class GroupedThreadMediaLoader extends AsyncTaskLoader<GroupedThrea
|
||||
|
||||
@Override
|
||||
public int groupForRecord(@NonNull MediaTable.MediaRecord mediaRecord) {
|
||||
if (mediaRecord.getAttachment() == null) return SMALL;
|
||||
long size = mediaRecord.getAttachment().size;
|
||||
|
||||
if (size < MB) return SMALL;
|
||||
|
||||
+5
@@ -118,6 +118,11 @@ object GroupsV2UpdateMessageConverter {
|
||||
}
|
||||
}
|
||||
|
||||
if (group != null && group.terminated) {
|
||||
updates.add(GroupChangeChatUpdate.Update(groupTerminateChangeUpdate = GroupTerminateChangeUpdate(updaterAci = null)))
|
||||
return GroupChangeChatUpdate(updates = updates)
|
||||
}
|
||||
|
||||
if (group != null && DecryptedGroupUtil.findMemberByAci(group.members, selfIds.aci).isPresent) {
|
||||
updates.add(GroupChangeChatUpdate.Update(groupMemberJoinedUpdate = GroupMemberJoinedUpdate(newMemberAci = selfIds.aci.toByteString())))
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package org.thoughtcrime.securesms.dependencies
|
||||
|
||||
import org.signal.libsignal.keytrans.KeyTransparencyException
|
||||
import org.signal.libsignal.net.KeyTransparency
|
||||
import org.signal.libsignal.net.KeyTransparency.CheckMode
|
||||
import org.signal.libsignal.net.RequestResult
|
||||
import org.signal.libsignal.net.getOrError
|
||||
import org.signal.libsignal.protocol.IdentityKey
|
||||
@@ -14,21 +14,9 @@ import org.whispersystems.signalservice.api.websocket.SignalWebSocket
|
||||
*/
|
||||
class KeyTransparencyApi(private val unauthWebSocket: SignalWebSocket.UnauthenticatedWebSocket) {
|
||||
|
||||
/**
|
||||
* Uses KT to verify recipient. This is an unauthenticated and should only be called the first time KT is being requested for this recipient.
|
||||
*/
|
||||
suspend fun search(aci: ServiceId.Aci, aciIdentityKey: IdentityKey, e164: String?, unidentifiedAccessKey: ByteArray?, usernameHash: ByteArray?, keyTransparencyStore: KeyTransparencyStore): RequestResult<Unit, KeyTransparencyException> {
|
||||
suspend fun check(checkMode: CheckMode, aci: ServiceId.Aci, aciIdentityKey: IdentityKey, e164: String?, unidentifiedAccessKey: ByteArray?, usernameHash: ByteArray?, keyTransparencyStore: KeyTransparencyStore): RequestResult<Unit, KeyTransparencyException> {
|
||||
return unauthWebSocket.runCatchingWithUnauthChatConnection { chatConnection ->
|
||||
chatConnection.keyTransparencyClient().search(aci, aciIdentityKey, e164, unidentifiedAccessKey, usernameHash, keyTransparencyStore)
|
||||
}.getOrError()
|
||||
}
|
||||
|
||||
/**
|
||||
* Monitors KT to verify recipient. This is an unauthenticated and should only be called following a successful [search].
|
||||
*/
|
||||
suspend fun monitor(monitorMode: KeyTransparency.MonitorMode, aci: ServiceId.Aci, aciIdentityKey: IdentityKey, e164: String?, unidentifiedAccessKey: ByteArray?, usernameHash: ByteArray?, keyTransparencyStore: KeyTransparencyStore): RequestResult<Unit, KeyTransparencyException> {
|
||||
return unauthWebSocket.runCatchingWithUnauthChatConnection { chatConnection ->
|
||||
chatConnection.keyTransparencyClient().monitor(monitorMode, aci, aciIdentityKey, e164, unidentifiedAccessKey, usernameHash, keyTransparencyStore)
|
||||
chatConnection.keyTransparencyClient().check(checkMode, aci, aciIdentityKey, e164, unidentifiedAccessKey, usernameHash, keyTransparencyStore)
|
||||
}.getOrError()
|
||||
}
|
||||
}
|
||||
|
||||
+4
@@ -8,6 +8,7 @@ import android.os.Bundle;
|
||||
import android.provider.Settings;
|
||||
import android.text.method.LinkMovementMethod;
|
||||
import android.view.View;
|
||||
import android.view.WindowManager;
|
||||
import android.widget.TextView;
|
||||
import android.widget.Toast;
|
||||
|
||||
@@ -70,6 +71,8 @@ public abstract class DeviceTransferSetupFragment extends LoggingFragment {
|
||||
|
||||
@Override
|
||||
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
|
||||
requireActivity().getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
|
||||
|
||||
Group progressGroup = view.findViewById(R.id.device_transfer_setup_fragment_progress_group);
|
||||
Group errorGroup = view.findViewById(R.id.device_transfer_setup_fragment_error_group);
|
||||
View verifyGroup = view.findViewById(R.id.device_transfer_setup_fragment_verify);
|
||||
@@ -274,6 +277,7 @@ public abstract class DeviceTransferSetupFragment extends LoggingFragment {
|
||||
|
||||
@Override
|
||||
public void onDestroyView() {
|
||||
requireActivity().getWindow().clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
|
||||
cancelTakingTooLong();
|
||||
EventBus.getDefault().unregister(this);
|
||||
super.onDestroyView();
|
||||
|
||||
+2
-2
@@ -67,9 +67,9 @@ class PushProcessMessageJobMigration : JobMigration(10) {
|
||||
|
||||
val envelope = Envelope.Builder()
|
||||
.sourceServiceId(sourceServiceId.toString())
|
||||
.sourceDevice(proto.metadata!!.senderDevice)
|
||||
.sourceDeviceId(proto.metadata!!.senderDevice)
|
||||
.destinationServiceId(destinationServiceId.toString())
|
||||
.timestamp(proto.metadata!!.timestamp)
|
||||
.clientTimestamp(proto.metadata!!.timestamp)
|
||||
.serverGuid(proto.metadata!!.serverGuid)
|
||||
.serverTimestamp(proto.metadata!!.serverReceivedTimestamp)
|
||||
|
||||
|
||||
@@ -108,6 +108,11 @@ class AdminDeleteSendJob private constructor(
|
||||
}
|
||||
|
||||
val groupRecord = SignalDatabase.groups.getGroup(conversationRecipient.requireGroupId())
|
||||
if (groupRecord.isPresent && groupRecord.get().isTerminated) {
|
||||
Log.w(TAG, "Cannot admin delete in a terminated group.")
|
||||
return Result.failure()
|
||||
}
|
||||
|
||||
if (groupRecord.isEmpty || !groupRecord.get().isAdmin(Recipient.self())) {
|
||||
Log.w(TAG, "Cannot delete because you are not an admin.")
|
||||
return Result.failure()
|
||||
|
||||
@@ -8,12 +8,12 @@ package org.thoughtcrime.securesms.jobs
|
||||
import org.signal.core.util.Util
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.glide.decryptableuri.DecryptableUri
|
||||
import org.signal.protos.resumableuploads.ResumableUpload
|
||||
import org.thoughtcrime.securesms.attachments.AttachmentId
|
||||
import org.thoughtcrime.securesms.attachments.AttachmentUploadUtil
|
||||
import org.thoughtcrime.securesms.attachments.DatabaseAttachment
|
||||
import org.thoughtcrime.securesms.attachments.PointerAttachment
|
||||
import org.thoughtcrime.securesms.backup.v2.ArchiveDatabaseExecutor
|
||||
import org.thoughtcrime.securesms.backup.v2.BackupRepository
|
||||
import org.thoughtcrime.securesms.backup.v2.UploadedThumbnailInfo
|
||||
import org.thoughtcrime.securesms.backup.v2.hadIntegrityCheckPerformed
|
||||
import org.thoughtcrime.securesms.backup.v2.requireThumbnailMediaName
|
||||
import org.thoughtcrime.securesms.database.AttachmentTable
|
||||
@@ -30,12 +30,12 @@ import org.thoughtcrime.securesms.util.ImageCompressionUtil
|
||||
import org.thoughtcrime.securesms.util.MediaUtil
|
||||
import org.thoughtcrime.securesms.util.RemoteConfig
|
||||
import org.whispersystems.signalservice.api.NetworkResult
|
||||
import org.whispersystems.signalservice.api.attachment.AttachmentUploadResult
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceAttachment
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentStream
|
||||
import org.whispersystems.signalservice.internal.push.http.ResumableUploadSpec
|
||||
import org.whispersystems.signalservice.internal.push.AttachmentUploadForm
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.IOException
|
||||
import java.util.Optional
|
||||
import kotlin.math.floor
|
||||
import kotlin.math.max
|
||||
import kotlin.time.Duration.Companion.days
|
||||
@@ -176,49 +176,24 @@ class ArchiveThumbnailUploadJob private constructor(
|
||||
return Result.failure()
|
||||
}
|
||||
|
||||
val mediaRootBackupKey = SignalStore.backup.mediaRootBackupKey
|
||||
|
||||
val specResult = BackupRepository
|
||||
.getAttachmentUploadForm()
|
||||
.then { form ->
|
||||
SignalNetwork.attachments.getResumableUploadSpec(
|
||||
key = mediaRootBackupKey.deriveThumbnailTransitKey(attachment.requireThumbnailMediaName()),
|
||||
iv = Util.getSecretBytes(16),
|
||||
uploadForm = form
|
||||
)
|
||||
}
|
||||
|
||||
if (isCanceled) {
|
||||
ArchiveDatabaseExecutor.runBlocking {
|
||||
SignalDatabase.attachments.setArchiveThumbnailTransferState(attachmentId, AttachmentTable.ArchiveTransferState.TEMPORARY_FAILURE)
|
||||
}
|
||||
return Result.failure()
|
||||
}
|
||||
|
||||
val resumableUpload = when (specResult) {
|
||||
is NetworkResult.Success -> {
|
||||
Log.d(TAG, "Got an upload spec!")
|
||||
specResult.result.toProto()
|
||||
}
|
||||
|
||||
val form: AttachmentUploadForm = when (val formResult = BackupRepository.getAttachmentUploadForm()) {
|
||||
is NetworkResult.Success -> formResult.result
|
||||
is NetworkResult.ApplicationError -> {
|
||||
Log.w(TAG, "Failed to get an upload spec due to an application error. Retrying.", specResult.throwable)
|
||||
Log.w(TAG, "Failed to get upload form due to an application error. Retrying.", formResult.throwable)
|
||||
return Result.retry(defaultBackoff())
|
||||
}
|
||||
|
||||
is NetworkResult.NetworkError -> {
|
||||
Log.w(TAG, "Encountered a transient network error when getting upload spec. Retrying.")
|
||||
Log.w(TAG, "Encountered a transient network error when getting upload form. Retrying.")
|
||||
return Result.retry(defaultBackoff())
|
||||
}
|
||||
|
||||
is NetworkResult.StatusCodeError -> {
|
||||
return when (specResult.code) {
|
||||
return when (formResult.code) {
|
||||
429 -> {
|
||||
Log.w(TAG, "Rate limited when getting upload spec.")
|
||||
Result.retry(specResult.retryAfter()?.inWholeMilliseconds ?: defaultBackoff())
|
||||
Log.w(TAG, "Rate limited when getting upload form.")
|
||||
Result.retry(formResult.retryAfter()?.inWholeMilliseconds ?: defaultBackoff())
|
||||
}
|
||||
else -> {
|
||||
Log.w(TAG, "Failed to get an upload spec with status code ${specResult.code}")
|
||||
Log.w(TAG, "Failed to get upload form with status code ${formResult.code}")
|
||||
Result.retry(defaultBackoff())
|
||||
}
|
||||
}
|
||||
@@ -232,13 +207,31 @@ class ArchiveThumbnailUploadJob private constructor(
|
||||
return Result.failure()
|
||||
}
|
||||
|
||||
val mediaRootBackupKey = SignalStore.backup.mediaRootBackupKey
|
||||
val key = mediaRootBackupKey.deriveThumbnailTransitKey(attachment.requireThumbnailMediaName())
|
||||
val iv = Util.getSecretBytes(16)
|
||||
|
||||
val checksumSha256 = ByteArrayInputStream(thumbnailResult.data).use { stream ->
|
||||
AttachmentUploadUtil.computeCiphertextChecksum(key, iv, stream, thumbnailResult.data.size.toLong())
|
||||
}
|
||||
|
||||
val attachmentPointer = try {
|
||||
buildSignalServiceAttachmentStream(thumbnailResult, resumableUpload).use { stream ->
|
||||
val pointer = AppDependencies.signalServiceMessageSender.uploadAttachment(stream)
|
||||
PointerAttachment.forPointer(Optional.of(pointer)).get()
|
||||
val uploadResult: AttachmentUploadResult = buildSignalServiceAttachmentStream(thumbnailResult).use { stream ->
|
||||
when (val result = SignalNetwork.attachments.uploadAttachmentV4(form, key, iv, checksumSha256, stream)) {
|
||||
is NetworkResult.Success -> result.result
|
||||
is NetworkResult.ApplicationError -> throw result.throwable
|
||||
is NetworkResult.NetworkError -> throw result.exception
|
||||
is NetworkResult.StatusCodeError -> throw IOException("Upload failed with status ${result.code}")
|
||||
}
|
||||
}
|
||||
|
||||
UploadedThumbnailInfo(
|
||||
cdnNumber = uploadResult.cdnNumber,
|
||||
remoteLocation = uploadResult.remoteId.toString(),
|
||||
size = uploadResult.dataSize
|
||||
)
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "Failed to upload attachment", e)
|
||||
Log.w(TAG, "Failed to upload thumbnail", e)
|
||||
return Result.retry(defaultBackoff())
|
||||
}
|
||||
|
||||
@@ -336,7 +329,7 @@ class ArchiveThumbnailUploadJob private constructor(
|
||||
return result
|
||||
}
|
||||
|
||||
private fun buildSignalServiceAttachmentStream(result: ImageCompressionUtil.Result, uploadSpec: ResumableUpload): SignalServiceAttachmentStream {
|
||||
private fun buildSignalServiceAttachmentStream(result: ImageCompressionUtil.Result): SignalServiceAttachmentStream {
|
||||
return SignalServiceAttachment.newStreamBuilder()
|
||||
.withStream(ByteArrayInputStream(result.data))
|
||||
.withContentType(result.mimeType)
|
||||
@@ -344,7 +337,6 @@ class ArchiveThumbnailUploadJob private constructor(
|
||||
.withWidth(result.width)
|
||||
.withHeight(result.height)
|
||||
.withUploadTimestamp(System.currentTimeMillis())
|
||||
.withResumableUploadSpec(ResumableUploadSpec.from(uploadSpec))
|
||||
.build()
|
||||
}
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ import org.thoughtcrime.securesms.transport.UndeliverableMessageException;
|
||||
import org.thoughtcrime.securesms.util.BitmapDecodingException;
|
||||
import org.thoughtcrime.securesms.util.ImageCompressionUtil;
|
||||
import org.thoughtcrime.securesms.util.MediaUtil;
|
||||
import org.thoughtcrime.securesms.util.MemoryFileDescriptor.MemoryFileException;
|
||||
import org.signal.core.util.MemoryFileDescriptor.MemoryFileException;
|
||||
import org.thoughtcrime.securesms.util.RemoteConfig;
|
||||
import org.thoughtcrime.securesms.video.StreamingTranscoder;
|
||||
import org.thoughtcrime.securesms.video.TranscoderOptions;
|
||||
|
||||
@@ -28,6 +28,7 @@ import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint
|
||||
import org.thoughtcrime.securesms.jobmanager.persistence.JobSpec
|
||||
import org.thoughtcrime.securesms.jobs.protos.AttachmentUploadJobData
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.mms.PartAuthority
|
||||
import org.thoughtcrime.securesms.net.NotPushRegisteredException
|
||||
import org.thoughtcrime.securesms.net.SignalNetwork
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
@@ -44,6 +45,7 @@ import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentStre
|
||||
import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResumableUploadResponseCodeException
|
||||
import org.whispersystems.signalservice.api.push.exceptions.ResumeLocationInvalidException
|
||||
import org.whispersystems.signalservice.internal.crypto.PaddingInputStream
|
||||
import org.whispersystems.signalservice.internal.push.http.ResumableUploadSpec
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.time.Duration.Companion.days
|
||||
@@ -146,7 +148,7 @@ class AttachmentUploadJob private constructor(
|
||||
|
||||
val timeSinceUpload = System.currentTimeMillis() - databaseAttachment.uploadTimestamp
|
||||
if (timeSinceUpload < UPLOAD_REUSE_THRESHOLD && !TextUtils.isEmpty(databaseAttachment.remoteLocation)) {
|
||||
Log.i(TAG, "We can re-use an already-uploaded file. It was uploaded $timeSinceUpload ms (${timeSinceUpload.milliseconds.inRoundedDays()} days) ago. Skipping.")
|
||||
Log.i(TAG, "[$attachmentId] We can re-use an already-uploaded file. It was uploaded $timeSinceUpload ms (${timeSinceUpload.milliseconds.inRoundedDays()} days) ago. Skipping.")
|
||||
SignalDatabase.attachments.setTransferState(databaseAttachment.mmsId, attachmentId, AttachmentTable.TRANSFER_PROGRESS_DONE)
|
||||
if (SignalStore.account.isPrimaryDevice && BackupRepository.shouldCopyAttachmentToArchive(databaseAttachment.attachmentId, databaseAttachment.mmsId)) {
|
||||
Log.i(TAG, "[$attachmentId] The re-used file was not copied to the archive. Copying now.")
|
||||
@@ -154,39 +156,50 @@ class AttachmentUploadJob private constructor(
|
||||
}
|
||||
return
|
||||
} else if (databaseAttachment.uploadTimestamp > 0) {
|
||||
Log.i(TAG, "This file was previously-uploaded, but too long ago to be re-used. Age: $timeSinceUpload ms (${timeSinceUpload.milliseconds.inRoundedDays()} days)")
|
||||
Log.i(TAG, "[$attachmentId] This file was previously-uploaded, but too long ago to be re-used. Age: $timeSinceUpload ms (${timeSinceUpload.milliseconds.inRoundedDays()} days)")
|
||||
if (databaseAttachment.archiveTransferState != AttachmentTable.ArchiveTransferState.NONE) {
|
||||
SignalDatabase.attachments.clearArchiveData(attachmentId)
|
||||
}
|
||||
}
|
||||
|
||||
if (uploadSpec != null && System.currentTimeMillis() > uploadSpec!!.timeout) {
|
||||
Log.w(TAG, "Upload spec expired! Clearing.")
|
||||
Log.w(TAG, "[$attachmentId] Upload spec expired! Clearing.")
|
||||
uploadSpec = null
|
||||
}
|
||||
|
||||
if (uploadSpec == null) {
|
||||
Log.d(TAG, "Need an upload spec. Fetching...")
|
||||
uploadSpec = SignalNetwork.attachments
|
||||
.getAttachmentV4UploadForm()
|
||||
.then { form ->
|
||||
SignalNetwork.attachments.getResumableUploadSpec(
|
||||
key = Base64.decode(databaseAttachment.remoteKey!!),
|
||||
iv = Util.getSecretBytes(16),
|
||||
uploadForm = form
|
||||
)
|
||||
}
|
||||
.successOrThrow()
|
||||
.toProto()
|
||||
} else {
|
||||
Log.d(TAG, "Re-using existing upload spec.")
|
||||
}
|
||||
|
||||
Log.i(TAG, "Uploading attachment for message " + databaseAttachment.mmsId + " with ID " + databaseAttachment.attachmentId)
|
||||
Log.i(TAG, "[$attachmentId] Uploading attachment for message ${databaseAttachment.mmsId}")
|
||||
try {
|
||||
val existingSpec = uploadSpec?.let { ResumableUploadSpec.from(it) }
|
||||
|
||||
val uploadForm = if (existingSpec == null) {
|
||||
SignalNetwork.attachments.getAttachmentV4UploadForm().successOrThrow()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
val key = existingSpec?.attachmentKey ?: Base64.decode(databaseAttachment.remoteKey!!)
|
||||
val iv = existingSpec?.attachmentIv ?: Util.getSecretBytes(16)
|
||||
|
||||
val checksumSha256 = if (existingSpec == null) {
|
||||
PartAuthority.getAttachmentStream(context, databaseAttachment.uri!!).use { stream ->
|
||||
AttachmentUploadUtil.computeCiphertextChecksum(key, iv, stream, databaseAttachment.size)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
getAttachmentNotificationIfNeeded(databaseAttachment).use { notification ->
|
||||
buildAttachmentStream(databaseAttachment, notification, uploadSpec!!).use { localAttachment ->
|
||||
val uploadResult: AttachmentUploadResult = SignalNetwork.attachments.uploadAttachmentV4(localAttachment).successOrThrow()
|
||||
buildAttachmentStream(databaseAttachment, notification).use { localAttachment ->
|
||||
val uploadResult: AttachmentUploadResult = SignalNetwork.attachments.uploadAttachmentV4(
|
||||
form = uploadForm,
|
||||
key = key,
|
||||
iv = iv,
|
||||
checksumSha256 = checksumSha256,
|
||||
attachmentStream = localAttachment,
|
||||
existingSpec = existingSpec,
|
||||
onSpecCreated = { spec -> uploadSpec = spec.toProto() }
|
||||
).successOrThrow()
|
||||
|
||||
SignalDatabase.attachments.finalizeAttachmentAfterUpload(databaseAttachment.attachmentId, uploadResult)
|
||||
if (SignalStore.backup.backsUpMedia) {
|
||||
val messageId = SignalDatabase.attachments.getMessageId(databaseAttachment.attachmentId)
|
||||
@@ -235,7 +248,7 @@ class AttachmentUploadJob private constructor(
|
||||
throw e
|
||||
} catch (e: NonSuccessfulResumableUploadResponseCodeException) {
|
||||
if (e.code == 400) {
|
||||
Log.w(TAG, "Failed to upload due to a 400 when getting resumable upload information. Clearing upload spec.", e)
|
||||
Log.w(TAG, "[$attachmentId] Failed to upload due to a 400 when getting resumable upload information. Clearing upload spec.", e)
|
||||
uploadSpec = null
|
||||
}
|
||||
|
||||
@@ -243,7 +256,7 @@ class AttachmentUploadJob private constructor(
|
||||
|
||||
throw e
|
||||
} catch (e: ResumeLocationInvalidException) {
|
||||
Log.w(TAG, "Resume location invalid. Clearing upload spec.", e)
|
||||
Log.w(TAG, "[$attachmentId] Resume location invalid. Clearing upload spec.", e)
|
||||
uploadSpec = null
|
||||
|
||||
resetProgressListeners(databaseAttachment)
|
||||
@@ -268,7 +281,7 @@ class AttachmentUploadJob private constructor(
|
||||
val database = SignalDatabase.attachments
|
||||
val databaseAttachment = database.getAttachment(attachmentId)
|
||||
if (databaseAttachment == null) {
|
||||
Log.i(TAG, "Could not find attachment in DB for upload job upon failure/cancellation.")
|
||||
Log.i(TAG, "[$attachmentId] Could not find attachment in DB for upload job upon failure/cancellation.")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -280,7 +293,7 @@ class AttachmentUploadJob private constructor(
|
||||
}
|
||||
|
||||
@Throws(InvalidAttachmentException::class)
|
||||
private fun buildAttachmentStream(attachment: Attachment, notification: AttachmentProgressService.Controller?, resumableUploadSpec: ResumableUpload): SignalServiceAttachmentStream {
|
||||
private fun buildAttachmentStream(attachment: Attachment, notification: AttachmentProgressService.Controller?): SignalServiceAttachmentStream {
|
||||
if (attachment.uri == null || attachment.size == 0L) {
|
||||
throw InvalidAttachmentException(IOException("Outgoing attachment has no data!"))
|
||||
}
|
||||
@@ -289,7 +302,6 @@ class AttachmentUploadJob private constructor(
|
||||
AttachmentUploadUtil.buildSignalServiceAttachmentStream(
|
||||
context = context,
|
||||
attachment = attachment,
|
||||
uploadSpec = resumableUploadSpec,
|
||||
cancellationSignal = { isCanceled },
|
||||
progressListener = object : SignalServiceAttachment.ProgressListener {
|
||||
override fun onAttachmentProgress(progress: AttachmentTransferProgress) {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package org.thoughtcrime.securesms.jobs
|
||||
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.core.util.readToList
|
||||
import org.signal.core.util.requireBlob
|
||||
import org.signal.core.util.requireBoolean
|
||||
import org.signal.core.util.requireLong
|
||||
@@ -16,6 +15,7 @@ import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.jobmanager.Job
|
||||
import org.thoughtcrime.securesms.jobs.BackfillCollapsedMessageJob.Companion.BATCH_SIZE
|
||||
import org.thoughtcrime.securesms.jobs.protos.BackfillCollapsedMessageJobData
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.util.DateUtils
|
||||
|
||||
/**
|
||||
@@ -51,61 +51,70 @@ class BackfillCollapsedMessageJob private constructor(
|
||||
override fun getFactoryKey(): String = KEY
|
||||
|
||||
override fun run(): Result {
|
||||
if (SignalStore.misc.completedCollapsedEventsMigration) {
|
||||
Log.i(TAG, "Already completed migration")
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
val db = SignalDatabase.rawDatabase
|
||||
|
||||
val messages = db
|
||||
.select(MessageTable.ID, MessageTable.THREAD_ID, MessageTable.DATE_RECEIVED, MessageTable.TYPE, MessageTable.READ, MessageTable.COLLAPSED_STATE, MessageTable.MESSAGE_EXTRAS)
|
||||
var messageCount = 0
|
||||
var lastProcessedDateReceived = lastDateReceived
|
||||
|
||||
// Tracks the last/previous message to compare against the current message when determining collapsed state
|
||||
val lastMessageByThread = mutableMapOf<Long, LastMessage?>()
|
||||
|
||||
db
|
||||
.select(MessageTable.ID, MessageTable.THREAD_ID, MessageTable.DATE_RECEIVED, MessageTable.TYPE, MessageTable.READ, MessageTable.MESSAGE_EXTRAS)
|
||||
.from(MessageTable.TABLE_NAME)
|
||||
.where("${MessageTable.DATE_RECEIVED} > ?", lastDateReceived)
|
||||
.orderBy("${MessageTable.DATE_RECEIVED}, ${MessageTable.ID}")
|
||||
.limit(BATCH_SIZE)
|
||||
.run()
|
||||
.readToList { cursor ->
|
||||
PotentialCollapsibleMessage(
|
||||
id = cursor.requireLong(MessageTable.ID),
|
||||
threadId = cursor.requireLong(MessageTable.THREAD_ID),
|
||||
type = cursor.requireLong(MessageTable.TYPE),
|
||||
dateReceived = cursor.requireLong(MessageTable.DATE_RECEIVED),
|
||||
collapsedState = cursor.requireLong(MessageTable.COLLAPSED_STATE),
|
||||
read = cursor.requireBoolean(MessageTable.READ),
|
||||
messageExtras = cursor.requireBlob(MessageTable.MESSAGE_EXTRAS)?.let { MessageExtras.ADAPTER.decode(it) }
|
||||
)
|
||||
}
|
||||
.use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
val id = cursor.requireLong(MessageTable.ID)
|
||||
val threadId = cursor.requireLong(MessageTable.THREAD_ID)
|
||||
val type = cursor.requireLong(MessageTable.TYPE)
|
||||
val dateReceived = cursor.requireLong(MessageTable.DATE_RECEIVED)
|
||||
val read = cursor.requireBoolean(MessageTable.READ)
|
||||
val messageExtras = cursor.requireBlob(MessageTable.MESSAGE_EXTRAS)?.let { MessageExtras.ADAPTER.decode(it) }
|
||||
|
||||
// Tracks the last/previous message to compare against the current message when determining collapsed state
|
||||
val lastMessageByThread = mutableMapOf<Long, LastMessage?>()
|
||||
for (message in messages) {
|
||||
val collapsibleType = CollapsibleEvents.getCollapsibleType(message.type, message.messageExtras)
|
||||
val collapsibleType = CollapsibleEvents.getCollapsibleType(type, messageExtras)
|
||||
|
||||
if (collapsibleType == null) {
|
||||
lastMessageByThread[message.threadId] = null
|
||||
} else {
|
||||
val previous = lastMessageByThread[message.threadId]
|
||||
if (collapsibleType == null) {
|
||||
lastMessageByThread[threadId] = null
|
||||
} else {
|
||||
val previous = lastMessageByThread[threadId]
|
||||
|
||||
val (collapsedState, headId) = if ((previous?.collapsibleType == collapsibleType) && DateUtils.isSameDay(previous.dateReceived, message.dateReceived)) {
|
||||
val state = if (message.read) CollapsedState.COLLAPSED.id else CollapsedState.PENDING_COLLAPSED.id
|
||||
Pair(state, previous.headId)
|
||||
} else {
|
||||
Pair(CollapsedState.HEAD_COLLAPSED.id, message.id)
|
||||
val (collapsedState, headId, size) = if ((previous?.collapsibleType == collapsibleType) && DateUtils.isSameDay(previous.dateReceived, dateReceived) && previous.collapsedSetSize < CollapsibleEvents.MAX_SIZE) {
|
||||
val state = if (read) CollapsedState.COLLAPSED.id else CollapsedState.PENDING_COLLAPSED.id
|
||||
Triple(state, previous.headId, previous.collapsedSetSize)
|
||||
} else {
|
||||
Triple(CollapsedState.HEAD_COLLAPSED.id, id, 0)
|
||||
}
|
||||
|
||||
db.update(MessageTable.TABLE_NAME)
|
||||
.values(
|
||||
MessageTable.COLLAPSED_STATE to collapsedState,
|
||||
MessageTable.COLLAPSED_HEAD_ID to headId
|
||||
)
|
||||
.where("${MessageTable.ID} = ?", id)
|
||||
.run()
|
||||
lastMessageByThread[threadId] = LastMessage(collapsibleType, headId, dateReceived, size + 1)
|
||||
}
|
||||
|
||||
messageCount++
|
||||
lastProcessedDateReceived = dateReceived
|
||||
}
|
||||
|
||||
db.update(MessageTable.TABLE_NAME)
|
||||
.values(
|
||||
MessageTable.COLLAPSED_STATE to collapsedState,
|
||||
MessageTable.COLLAPSED_HEAD_ID to headId
|
||||
)
|
||||
.where("${MessageTable.ID} = ?", message.id)
|
||||
.run()
|
||||
lastMessageByThread[message.threadId] = LastMessage(collapsibleType, headId, message.dateReceived)
|
||||
}
|
||||
}
|
||||
|
||||
if (messages.isEmpty() || messages.size != BATCH_SIZE) {
|
||||
if (messageCount == 0 || messageCount != BATCH_SIZE) {
|
||||
Log.i(TAG, "Finished processing all messages, backfill is completed")
|
||||
SignalStore.misc.completedCollapsedEventsMigration = true
|
||||
} else {
|
||||
val dateReceived = messages.last().dateReceived
|
||||
Log.i(TAG, "Processed ${messages.size} messages, up to time $dateReceived. Re-enqueuing job")
|
||||
AppDependencies.jobManager.add(BackfillCollapsedMessageJob(lastDateReceived = dateReceived))
|
||||
Log.i(TAG, "Processed $messageCount messages, up to time $lastProcessedDateReceived. Re-enqueuing job")
|
||||
AppDependencies.jobManager.add(BackfillCollapsedMessageJob(lastDateReceived = lastProcessedDateReceived))
|
||||
}
|
||||
|
||||
return Result.success()
|
||||
@@ -115,26 +124,14 @@ class BackfillCollapsedMessageJob private constructor(
|
||||
Log.w(TAG, "Failed to backfill collapsed messages. Time of last processed message: $lastDateReceived")
|
||||
}
|
||||
|
||||
/**
|
||||
* Data required from a message to know if it collapsible
|
||||
*/
|
||||
private data class PotentialCollapsibleMessage(
|
||||
val id: Long,
|
||||
val threadId: Long,
|
||||
val type: Long,
|
||||
val dateReceived: Long,
|
||||
val collapsedState: Long,
|
||||
val read: Boolean,
|
||||
val messageExtras: MessageExtras?
|
||||
)
|
||||
|
||||
/**
|
||||
* Information about the previous message, used when deciding the collapsible state of the next
|
||||
*/
|
||||
private data class LastMessage(
|
||||
val collapsibleType: CollapsibleEvents.CollapsibleType?,
|
||||
val headId: Long,
|
||||
val dateReceived: Long
|
||||
val dateReceived: Long,
|
||||
val collapsedSetSize: Int
|
||||
)
|
||||
|
||||
class Factory : Job.Factory<BackfillCollapsedMessageJob> {
|
||||
|
||||
@@ -25,6 +25,7 @@ import org.signal.libsignal.net.SvrBStoreResponse
|
||||
import org.signal.libsignal.zkgroup.VerificationFailedException
|
||||
import org.signal.protos.resumableuploads.ResumableUpload
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.attachments.AttachmentUploadUtil
|
||||
import org.thoughtcrime.securesms.backup.ArchiveUploadProgress
|
||||
import org.thoughtcrime.securesms.backup.v2.ArchiveRestoreProgress
|
||||
import org.thoughtcrime.securesms.backup.v2.ArchiveValidator
|
||||
@@ -108,15 +109,7 @@ class BackupMessagesJob private constructor(
|
||||
return
|
||||
}
|
||||
|
||||
val jobManager = AppDependencies.jobManager
|
||||
|
||||
val chain = jobManager.startChain(BackupMessagesJob())
|
||||
|
||||
if (SignalStore.backup.optimizeStorage && SignalStore.backup.backsUpMedia) {
|
||||
chain.then(OptimizeMediaJob())
|
||||
}
|
||||
|
||||
chain.enqueue()
|
||||
AppDependencies.jobManager.add(BackupMessagesJob())
|
||||
}
|
||||
|
||||
fun cancel() {
|
||||
@@ -158,6 +151,14 @@ class BackupMessagesJob private constructor(
|
||||
}
|
||||
|
||||
override fun run(): Result {
|
||||
val result = doWork()
|
||||
if (result.isSuccess && !isCanceled && SignalStore.backup.optimizeStorage && SignalStore.backup.backsUpMedia) {
|
||||
AppDependencies.jobManager.add(OptimizeMediaJob())
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private fun doWork(): Result {
|
||||
if (!isBackupAllowed()) {
|
||||
Log.d(TAG, "Skip running BackupMessagesJob.", true)
|
||||
return Result.success()
|
||||
@@ -294,49 +295,45 @@ class BackupMessagesJob private constructor(
|
||||
this.syncTime = currentTime
|
||||
this.dataFile = tempBackupFile.path
|
||||
|
||||
val backupSpec: ResumableMessagesBackupUploadSpec = resumableMessagesBackupUploadSpec ?: when (val result = BackupRepository.getResumableMessagesBackupUploadSpec(tempBackupFile.length())) {
|
||||
is NetworkResult.Success -> {
|
||||
Log.i(TAG, "Successfully generated a new upload spec.", true)
|
||||
val existingSpec = resumableMessagesBackupUploadSpec
|
||||
val form: AttachmentUploadForm = if (existingSpec == null) {
|
||||
when (val result = BackupRepository.getMessageBackupUploadForm(tempBackupFile.length())) {
|
||||
is NetworkResult.Success -> result.result
|
||||
is NetworkResult.NetworkError -> {
|
||||
Log.i(TAG, "Network failure", result.getCause(), true)
|
||||
return Result.retry(defaultBackoff())
|
||||
}
|
||||
is NetworkResult.StatusCodeError -> {
|
||||
when (result.code) {
|
||||
413 -> {
|
||||
Log.i(TAG, "Backup file is too large! Size: ${tempBackupFile.length()} bytes. Current threshold: ${SignalStore.backup.messageCuttoffDuration}", result.getCause(), true)
|
||||
tempBackupFile.delete()
|
||||
this.dataFile = ""
|
||||
BackupRepository.markBackupCreationFailed(BackupValues.BackupCreationError.BACKUP_FILE_TOO_LARGE)
|
||||
backupErrorHandled = true
|
||||
|
||||
val spec = result.result
|
||||
resumableMessagesBackupUploadSpec = spec
|
||||
spec
|
||||
}
|
||||
|
||||
is NetworkResult.NetworkError -> {
|
||||
Log.i(TAG, "Network failure", result.getCause(), true)
|
||||
return Result.retry(defaultBackoff())
|
||||
}
|
||||
|
||||
is NetworkResult.StatusCodeError -> {
|
||||
when (result.code) {
|
||||
413 -> {
|
||||
Log.i(TAG, "Backup file is too large! Size: ${tempBackupFile.length()} bytes. Current threshold: ${SignalStore.backup.messageCuttoffDuration}", result.getCause(), true)
|
||||
tempBackupFile.delete()
|
||||
this.dataFile = ""
|
||||
BackupRepository.markBackupCreationFailed(BackupValues.BackupCreationError.BACKUP_FILE_TOO_LARGE)
|
||||
backupErrorHandled = true
|
||||
|
||||
if (SignalStore.backup.messageCuttoffDuration == null) {
|
||||
Log.i(TAG, "Setting message cuttoff duration to $TOO_LARGE_MESSAGE_CUTTOFF_DURATION", true)
|
||||
SignalStore.backup.messageCuttoffDuration = TOO_LARGE_MESSAGE_CUTTOFF_DURATION
|
||||
if (SignalStore.backup.messageCuttoffDuration == null) {
|
||||
Log.i(TAG, "Setting message cuttoff duration to $TOO_LARGE_MESSAGE_CUTTOFF_DURATION", true)
|
||||
SignalStore.backup.messageCuttoffDuration = TOO_LARGE_MESSAGE_CUTTOFF_DURATION
|
||||
return Result.retry(defaultBackoff())
|
||||
} else {
|
||||
return Result.failure()
|
||||
}
|
||||
}
|
||||
429 -> {
|
||||
Log.i(TAG, "Rate limited when getting upload form.", result.getCause(), true)
|
||||
return Result.retry(result.retryAfter()?.inWholeMilliseconds ?: defaultBackoff())
|
||||
}
|
||||
else -> {
|
||||
Log.i(TAG, "Status code failure", result.getCause(), true)
|
||||
return Result.retry(defaultBackoff())
|
||||
} else {
|
||||
return Result.failure()
|
||||
}
|
||||
}
|
||||
429 -> {
|
||||
Log.i(TAG, "Rate limited when getting upload spec.", result.getCause(), true)
|
||||
return Result.retry(result.retryAfter()?.inWholeMilliseconds ?: defaultBackoff())
|
||||
}
|
||||
else -> {
|
||||
Log.i(TAG, "Status code failure", result.getCause(), true)
|
||||
return Result.retry(defaultBackoff())
|
||||
}
|
||||
}
|
||||
is NetworkResult.ApplicationError -> throw result.throwable
|
||||
}
|
||||
|
||||
is NetworkResult.ApplicationError -> throw result.throwable
|
||||
} else {
|
||||
existingSpec.attachmentUploadForm
|
||||
}
|
||||
|
||||
val progressListener = object : SignalServiceAttachment.ProgressListener {
|
||||
@@ -347,56 +344,58 @@ class BackupMessagesJob private constructor(
|
||||
override fun shouldCancel(): Boolean = isCanceled
|
||||
}
|
||||
|
||||
FileInputStream(tempBackupFile).use { fileStream ->
|
||||
val uploadResult = SignalNetwork.archive.uploadBackupFile(
|
||||
uploadForm = backupSpec.attachmentUploadForm,
|
||||
resumableUploadUrl = backupSpec.resumableUri,
|
||||
val checksumSha256 = if (existingSpec == null) {
|
||||
FileInputStream(tempBackupFile).use { AttachmentUploadUtil.computeRawChecksum(it) }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
val uploadResult = FileInputStream(tempBackupFile).use { fileStream ->
|
||||
SignalNetwork.archive.uploadBackupFile(
|
||||
uploadForm = form,
|
||||
data = fileStream,
|
||||
dataLength = tempBackupFile.length(),
|
||||
progressListener = progressListener
|
||||
checksumSha256 = checksumSha256,
|
||||
progressListener = progressListener,
|
||||
existingResumeUrl = existingSpec?.resumableUri,
|
||||
onResumeUrlCreated = { url ->
|
||||
resumableMessagesBackupUploadSpec = ResumableMessagesBackupUploadSpec(attachmentUploadForm = form, resumableUri = url)
|
||||
}
|
||||
)
|
||||
|
||||
when (uploadResult) {
|
||||
is NetworkResult.Success -> {
|
||||
Log.i(TAG, "Successfully uploaded backup file.", true)
|
||||
if (!SignalStore.backup.hasBackupBeenUploaded) {
|
||||
Log.i(TAG, "First time making a backup - scheduling a storage sync.", true)
|
||||
SignalDatabase.recipients.markNeedsSync(Recipient.self().id)
|
||||
StorageSyncHelper.scheduleSyncForDataChange()
|
||||
}
|
||||
SignalStore.backup.hasBackupBeenUploaded = true
|
||||
}
|
||||
|
||||
is NetworkResult.NetworkError -> {
|
||||
Log.i(TAG, "Network failure", uploadResult.getCause(), true)
|
||||
return if (isCanceled) {
|
||||
Result.failure()
|
||||
} else {
|
||||
Result.retry(defaultBackoff())
|
||||
}
|
||||
}
|
||||
|
||||
is NetworkResult.StatusCodeError -> {
|
||||
when (uploadResult.code) {
|
||||
400 -> {
|
||||
Log.w(TAG, "400 likely means bad resumable state. Resetting the upload spec before retrying.", true)
|
||||
resumableMessagesBackupUploadSpec = null
|
||||
return Result.retry(defaultBackoff())
|
||||
}
|
||||
429 -> {
|
||||
Log.w(TAG, "Rate limited when uploading backup file.", uploadResult.getCause(), true)
|
||||
return Result.retry(uploadResult.retryAfter()?.inWholeMilliseconds ?: defaultBackoff())
|
||||
}
|
||||
else -> {
|
||||
Log.i(TAG, "Status code failure (${uploadResult.code})", uploadResult.getCause(), true)
|
||||
return Result.retry(defaultBackoff())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is NetworkResult.ApplicationError -> throw uploadResult.throwable
|
||||
}
|
||||
}
|
||||
when (uploadResult) {
|
||||
is NetworkResult.Success -> Unit
|
||||
is NetworkResult.NetworkError -> {
|
||||
Log.i(TAG, "Network failure", uploadResult.getCause(), true)
|
||||
return if (isCanceled) Result.failure() else Result.retry(defaultBackoff())
|
||||
}
|
||||
is NetworkResult.StatusCodeError -> {
|
||||
when (uploadResult.code) {
|
||||
400 -> {
|
||||
Log.w(TAG, "400 likely means bad resumable state. Resetting the upload spec before retrying.", true)
|
||||
resumableMessagesBackupUploadSpec = null
|
||||
return Result.retry(defaultBackoff())
|
||||
}
|
||||
429 -> {
|
||||
Log.w(TAG, "Rate limited when uploading backup file.", uploadResult.getCause(), true)
|
||||
return Result.retry(uploadResult.retryAfter()?.inWholeMilliseconds ?: defaultBackoff())
|
||||
}
|
||||
else -> {
|
||||
Log.i(TAG, "Status code failure (${uploadResult.code})", uploadResult.getCause(), true)
|
||||
return Result.retry(defaultBackoff())
|
||||
}
|
||||
}
|
||||
}
|
||||
is NetworkResult.ApplicationError -> throw uploadResult.throwable
|
||||
}
|
||||
|
||||
Log.i(TAG, "Successfully uploaded backup file.", true)
|
||||
if (!SignalStore.backup.hasBackupBeenUploaded) {
|
||||
Log.i(TAG, "First time making a backup - scheduling a storage sync.", true)
|
||||
SignalDatabase.recipients.markNeedsSync(Recipient.self().id)
|
||||
StorageSyncHelper.scheduleSyncForDataChange()
|
||||
}
|
||||
SignalStore.backup.hasBackupBeenUploaded = true
|
||||
stopwatch.split("upload")
|
||||
|
||||
SignalStore.backup.nextBackupSecretData = svrBMetadata.nextBackupSecretData
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package org.thoughtcrime.securesms.jobs
|
||||
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.libsignal.net.KeyTransparency
|
||||
import org.signal.libsignal.net.KeyTransparency.CheckMode
|
||||
import org.signal.libsignal.net.RequestResult
|
||||
import org.signal.libsignal.usernames.Username
|
||||
import org.thoughtcrime.securesms.crypto.ProfileKeyUtil
|
||||
@@ -110,25 +110,16 @@ class CheckKeyTransparencyJob private constructor(
|
||||
SignalStore.misc.lastKeyTransparencyTime = System.currentTimeMillis()
|
||||
|
||||
val recipient = SignalDatabase.recipients.getRecord(Recipient.self().id)
|
||||
val aciIdentityKey = SignalStore.account.aciIdentityKey.publicKey
|
||||
val aci = recipient.aci!!.libSignalAci
|
||||
|
||||
val (e164, unidentifiedAccessKey) = if (SignalStore.phoneNumberPrivacy.phoneNumberDiscoverabilityMode == PhoneNumberDiscoverabilityMode.DISCOVERABLE) {
|
||||
Pair(recipient.e164!!, ProfileKeyUtil.profileKeyOrNull(recipient.profileKey).let { UnidentifiedAccess.deriveAccessKeyFrom(it) })
|
||||
} else {
|
||||
Pair(null, null)
|
||||
}
|
||||
|
||||
val usernameHash = SignalStore.account.username?.let { Username(it).hash }
|
||||
val firstSearch = recipient.keyTransparencyData == null
|
||||
|
||||
val result = if (firstSearch) {
|
||||
Log.i(TAG, "First search in key transparency")
|
||||
SignalNetwork.keyTransparency.search(aci, aciIdentityKey, e164, unidentifiedAccessKey, usernameHash, KeyTransparencyStore)
|
||||
} else {
|
||||
Log.i(TAG, "Monitoring search in key transparency")
|
||||
SignalNetwork.keyTransparency.monitor(KeyTransparency.MonitorMode.SELF, aci, aciIdentityKey, e164, unidentifiedAccessKey, usernameHash, KeyTransparencyStore)
|
||||
}
|
||||
val result = SignalNetwork.keyTransparency.check(
|
||||
checkMode = CheckMode.Self(isE164Discoverable = SignalStore.phoneNumberPrivacy.phoneNumberDiscoverabilityMode == PhoneNumberDiscoverabilityMode.DISCOVERABLE),
|
||||
aci = recipient.aci!!.libSignalAci,
|
||||
aciIdentityKey = SignalStore.account.aciIdentityKey.publicKey,
|
||||
e164 = recipient.e164!!,
|
||||
unidentifiedAccessKey = ProfileKeyUtil.profileKeyOrNull(recipient.profileKey).let { UnidentifiedAccess.deriveAccessKeyFrom(it) },
|
||||
usernameHash = SignalStore.account.username?.let { Username(it).hash },
|
||||
keyTransparencyStore = KeyTransparencyStore
|
||||
)
|
||||
|
||||
Log.i(TAG, "Key transparency complete, result: $result")
|
||||
return when (result) {
|
||||
|
||||
@@ -7,9 +7,10 @@ import androidx.annotation.WorkerThread;
|
||||
import com.annimon.stream.Stream;
|
||||
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase;
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies;
|
||||
import org.thoughtcrime.securesms.jobmanager.JsonJobData;
|
||||
import org.thoughtcrime.securesms.jobmanager.Job;
|
||||
import org.thoughtcrime.securesms.jobmanager.JsonJobData;
|
||||
import org.thoughtcrime.securesms.jobmanager.impl.SealedSenderConstraint;
|
||||
import org.thoughtcrime.securesms.messages.GroupSendUtil;
|
||||
import org.thoughtcrime.securesms.net.NotPushRegisteredException;
|
||||
@@ -121,6 +122,11 @@ public class GroupCallUpdateSendJob extends BaseJob {
|
||||
throw new AssertionError("We have a recipient, but it's not a V2 Group");
|
||||
}
|
||||
|
||||
if (!SignalDatabase.groups().isActive(conversationRecipient.requireGroupId())) {
|
||||
Log.w(TAG, "Not sending group call update to terminated or inactive group.");
|
||||
return;
|
||||
}
|
||||
|
||||
List<Recipient> destinations = Stream.of(recipients).map(Recipient::resolved).toList();
|
||||
List<Recipient> completions = deliver(conversationRecipient, destinations);
|
||||
|
||||
|
||||
+14
-1
@@ -547,7 +547,20 @@ class InAppPaymentRecurringContextJob private constructor(
|
||||
}
|
||||
|
||||
409 -> {
|
||||
warning("Already redeemed this token during new subscription. Failing.", applicationError)
|
||||
warning("Already redeemed this token during new subscription.", applicationError)
|
||||
|
||||
if (inAppPayment.type == InAppPaymentType.RECURRING_DONATION) {
|
||||
info("Token already redeemed for recurring donation. Treating as successful redemption.")
|
||||
SignalDatabase.inAppPayments.update(
|
||||
inAppPayment = inAppPayment.copy(
|
||||
state = InAppPaymentTable.State.END,
|
||||
data = inAppPayment.data.newBuilder().redemption(
|
||||
redemption = InAppPaymentData.RedemptionState(stage = InAppPaymentData.RedemptionState.Stage.REDEEMED)
|
||||
).build()
|
||||
)
|
||||
)
|
||||
throw Exception(applicationError)
|
||||
}
|
||||
|
||||
// During keep-alive processing, we don't alert the user about redemption failures.
|
||||
if (inAppPayment.type == InAppPaymentType.RECURRING_BACKUP && inAppPayment.data.redemption?.keepAlive != true) {
|
||||
|
||||
@@ -57,6 +57,7 @@ import org.thoughtcrime.securesms.migrations.AttributesMigrationJob;
|
||||
import org.thoughtcrime.securesms.migrations.AvatarColorStorageServiceMigrationJob;
|
||||
import org.thoughtcrime.securesms.migrations.AvatarIdRemovalMigrationJob;
|
||||
import org.thoughtcrime.securesms.migrations.AvatarMigrationJob;
|
||||
import org.thoughtcrime.securesms.migrations.BackfillCollapsedEventsMigrationJob;
|
||||
import org.thoughtcrime.securesms.migrations.BackfillDigestsForDuplicatesMigrationJob;
|
||||
import org.thoughtcrime.securesms.migrations.BackupJitterMigrationJob;
|
||||
import org.thoughtcrime.securesms.migrations.BackupNotificationMigrationJob;
|
||||
@@ -263,6 +264,7 @@ public final class JobManagerFactories {
|
||||
put(ResumableUploadSpecJob.KEY, new ResumableUploadSpecJob.Factory());
|
||||
put(RequestGroupV2InfoWorkerJob.KEY, new RequestGroupV2InfoWorkerJob.Factory());
|
||||
put(RequestGroupV2InfoJob.KEY, new RequestGroupV2InfoJob.Factory());
|
||||
put(LocalBackupRestoreMediaJob.KEY, new LocalBackupRestoreMediaJob.Factory());
|
||||
put(RestoreAttachmentJob.KEY, new RestoreAttachmentJob.Factory());
|
||||
put(RestoreAttachmentThumbnailJob.KEY, new RestoreAttachmentThumbnailJob.Factory());
|
||||
put(RestoreLocalAttachmentJob.KEY, new RestoreLocalAttachmentJob.Factory());
|
||||
@@ -312,6 +314,7 @@ public final class JobManagerFactories {
|
||||
put(AvatarColorStorageServiceMigrationJob.KEY, new AvatarColorStorageServiceMigrationJob.Factory());
|
||||
put(AvatarIdRemovalMigrationJob.KEY, new AvatarIdRemovalMigrationJob.Factory());
|
||||
put(AvatarMigrationJob.KEY, new AvatarMigrationJob.Factory());
|
||||
put(BackfillCollapsedEventsMigrationJob.KEY, new BackfillCollapsedEventsMigrationJob.Factory());
|
||||
put(BackfillDigestsForDuplicatesMigrationJob.KEY, new BackfillDigestsForDuplicatesMigrationJob.Factory());
|
||||
put(BackupJitterMigrationJob.KEY, new BackupJitterMigrationJob.Factory());
|
||||
put(BackupNotificationMigrationJob.KEY, new BackupNotificationMigrationJob.Factory());
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
package org.thoughtcrime.securesms.jobs
|
||||
|
||||
import android.net.Uri
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import org.signal.core.util.Stopwatch
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.backup.BackupFileIOError
|
||||
import org.thoughtcrime.securesms.backup.FullBackupExporter.BackupCanceledException
|
||||
import org.thoughtcrime.securesms.backup.LocalExportProgress
|
||||
import org.thoughtcrime.securesms.backup.v2.local.ArchiveFileSystem
|
||||
import org.thoughtcrime.securesms.backup.v2.local.LocalArchiver
|
||||
import org.thoughtcrime.securesms.backup.v2.local.SnapshotFileSystem
|
||||
@@ -85,6 +91,13 @@ class LocalArchiveJob internal constructor(parameters: Parameters) : Job(paramet
|
||||
try {
|
||||
SignalDatabase.attachmentMetadata.insertNewKeysForExistingAttachments()
|
||||
|
||||
val progressScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
progressScope.launch {
|
||||
LocalExportProgress.encryptedProgress.collect { progress ->
|
||||
updateNotification(progress, notification)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
val result = LocalArchiver.export(snapshotFileSystem, archiveFileSystem.filesFileSystem, stopwatch, cancellationSignal = { isCanceled })
|
||||
Log.i(TAG, "Archive finished with result: $result")
|
||||
@@ -94,6 +107,8 @@ class LocalArchiveJob internal constructor(parameters: Parameters) : Job(paramet
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Unable to create local archive", e)
|
||||
return Result.failure()
|
||||
} finally {
|
||||
progressScope.cancel()
|
||||
}
|
||||
|
||||
stopwatch.split("archive-create")
|
||||
@@ -139,11 +154,11 @@ class LocalArchiveJob internal constructor(parameters: Parameters) : Job(paramet
|
||||
}
|
||||
|
||||
override fun onFailure() {
|
||||
SignalStore.backup.newLocalBackupProgress = LocalBackupCreationProgress(idle = LocalBackupCreationProgress.Idle())
|
||||
LocalExportProgress.setEncryptedProgress(LocalBackupCreationProgress(idle = LocalBackupCreationProgress.Idle()))
|
||||
}
|
||||
|
||||
private fun setProgress(progress: LocalBackupCreationProgress, notification: NotificationController?) {
|
||||
SignalStore.backup.newLocalBackupProgress = progress
|
||||
LocalExportProgress.setEncryptedProgress(progress)
|
||||
updateNotification(progress, notification)
|
||||
}
|
||||
|
||||
@@ -158,8 +173,25 @@ class LocalArchiveJob internal constructor(parameters: Parameters) : Job(paramet
|
||||
when {
|
||||
exporting != null -> {
|
||||
val phase = NotificationPhase.Export(exporting.phase)
|
||||
if (previousPhase != phase) {
|
||||
notification.replaceTitle(exporting.phase.toString())
|
||||
val title = when (exporting.phase) {
|
||||
LocalBackupCreationProgress.ExportPhase.MESSAGE -> {
|
||||
if (exporting.frameTotalCount > 0) {
|
||||
context.getString(
|
||||
R.string.BackupCreationProgressRow__processing_messages_s_of_s_d,
|
||||
"%,d".format(exporting.frameExportCount),
|
||||
"%,d".format(exporting.frameTotalCount),
|
||||
(exporting.frameExportCount * 100 / exporting.frameTotalCount).toInt()
|
||||
)
|
||||
} else {
|
||||
context.getString(R.string.BackupCreationProgressRow__processing_messages)
|
||||
}
|
||||
}
|
||||
LocalBackupCreationProgress.ExportPhase.FINALIZING -> context.getString(R.string.BackupCreationProgressRow__finalizing)
|
||||
LocalBackupCreationProgress.ExportPhase.NONE -> context.getString(R.string.BackupCreationProgressRow__processing_backup)
|
||||
else -> context.getString(R.string.BackupCreationProgressRow__preparing_backup)
|
||||
}
|
||||
if (previousPhase != phase || exporting.phase == LocalBackupCreationProgress.ExportPhase.MESSAGE) {
|
||||
notification.replaceTitle(title)
|
||||
previousPhase = phase
|
||||
}
|
||||
if (exporting.frameTotalCount == 0L) {
|
||||
|
||||
@@ -45,6 +45,7 @@ public final class LocalBackupJob extends BaseJob {
|
||||
private static final String TAG = Log.tag(LocalBackupJob.class);
|
||||
|
||||
public static final String QUEUE = "__LOCAL_BACKUP__";
|
||||
public static final String PLAINTEXT_ARCHIVE_QUEUE = "__LOCAL_PLAINTEXT_ARCHIVE__";
|
||||
|
||||
public static final String TEMP_BACKUP_FILE_PREFIX = ".backup";
|
||||
public static final String TEMP_BACKUP_FILE_SUFFIX = ".tmp";
|
||||
@@ -80,7 +81,7 @@ public final class LocalBackupJob extends BaseJob {
|
||||
public static void enqueuePlaintextArchive(String destinationUri, boolean includeMedia) {
|
||||
JobManager jobManager = AppDependencies.getJobManager();
|
||||
Parameters.Builder parameters = new Parameters.Builder()
|
||||
.setQueue(QUEUE)
|
||||
.setQueue(PLAINTEXT_ARCHIVE_QUEUE)
|
||||
.setMaxInstancesForFactory(1)
|
||||
.setMaxAttempts(3);
|
||||
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
package org.thoughtcrime.securesms.jobs
|
||||
|
||||
import android.net.Uri
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.backup.v2.ArchiveRestoreProgress
|
||||
import org.thoughtcrime.securesms.backup.v2.local.ArchiveFileSystem
|
||||
import org.thoughtcrime.securesms.jobmanager.Job
|
||||
import org.thoughtcrime.securesms.jobs.protos.LocalBackupRestoreMediaJobData
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Scans the local backup files directory and enqueues individual [RestoreLocalAttachmentJob]s for each restorable attachment.
|
||||
*/
|
||||
class LocalBackupRestoreMediaJob private constructor(
|
||||
parameters: Parameters,
|
||||
private val backupDirectoryUri: Uri
|
||||
) : Job(parameters) {
|
||||
|
||||
companion object {
|
||||
const val KEY = "LocalBackupRestoreMediaJob"
|
||||
private val TAG = Log.tag(LocalBackupRestoreMediaJob::class)
|
||||
|
||||
fun create(backupDirectoryUri: Uri): LocalBackupRestoreMediaJob {
|
||||
return LocalBackupRestoreMediaJob(
|
||||
Parameters.Builder()
|
||||
.setLifespan(Parameters.IMMORTAL)
|
||||
.setMaxAttempts(1)
|
||||
.build(),
|
||||
backupDirectoryUri = backupDirectoryUri
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun serialize(): ByteArray {
|
||||
return LocalBackupRestoreMediaJobData(
|
||||
backupDirectoryUri = backupDirectoryUri.toString()
|
||||
).encode()
|
||||
}
|
||||
|
||||
override fun getFactoryKey(): String = KEY
|
||||
|
||||
override fun run(): Result {
|
||||
val archiveFileSystem = when (backupDirectoryUri.scheme) {
|
||||
"content" -> ArchiveFileSystem.openForRestore(context, backupDirectoryUri) ?: run {
|
||||
Log.w(TAG, "Unable to open backup directory: $backupDirectoryUri")
|
||||
SignalStore.backup.localRestoreDirectoryError = true
|
||||
return Result.failure()
|
||||
}
|
||||
else -> ArchiveFileSystem.fromFile(context, File(backupDirectoryUri.path!!))
|
||||
}
|
||||
|
||||
val mediaNameToFileInfo = archiveFileSystem.filesFileSystem.allFiles()
|
||||
RestoreLocalAttachmentJob.enqueueRestoreLocalAttachmentsJobs(mediaNameToFileInfo)
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
override fun onFailure() {
|
||||
ArchiveRestoreProgress.allMediaRestored()
|
||||
// forceUpdate in case restoreState was already NONE and allMediaRestored() skipped its update()
|
||||
ArchiveRestoreProgress.forceUpdate()
|
||||
}
|
||||
|
||||
class Factory : Job.Factory<LocalBackupRestoreMediaJob> {
|
||||
override fun create(parameters: Parameters, serializedData: ByteArray?): LocalBackupRestoreMediaJob {
|
||||
val data = LocalBackupRestoreMediaJobData.ADAPTER.decode(serializedData!!)
|
||||
return LocalBackupRestoreMediaJob(
|
||||
parameters = parameters,
|
||||
backupDirectoryUri = Uri.parse(data.backupDirectoryUri)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,25 @@
|
||||
package org.thoughtcrime.securesms.jobs
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import org.signal.core.util.PendingIntentFlags.immutable
|
||||
import org.signal.core.util.Stopwatch
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.backup.LocalExportProgress
|
||||
import org.thoughtcrime.securesms.backup.v2.local.LocalArchiver
|
||||
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.jobmanager.Job
|
||||
import org.thoughtcrime.securesms.jobmanager.JsonJobData
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.keyvalue.protos.LocalBackupCreationProgress
|
||||
import org.thoughtcrime.securesms.notifications.NotificationChannels
|
||||
import org.thoughtcrime.securesms.service.GenericForegroundService
|
||||
@@ -19,7 +28,6 @@ import java.io.IOException
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.zip.ZipOutputStream
|
||||
|
||||
class LocalPlaintextArchiveJob internal constructor(
|
||||
private val destinationUri: String,
|
||||
@@ -36,6 +44,8 @@ class LocalPlaintextArchiveJob internal constructor(
|
||||
private const val KEY_INCLUDE_MEDIA = "include_media"
|
||||
}
|
||||
|
||||
private var exportDir: DocumentFile? = null
|
||||
|
||||
override fun serialize(): ByteArray? {
|
||||
return JsonJobData.Builder()
|
||||
.putString(KEY_DESTINATION_URI, destinationUri)
|
||||
@@ -50,13 +60,21 @@ class LocalPlaintextArchiveJob internal constructor(
|
||||
override fun run(): Result {
|
||||
Log.i(TAG, "Executing plaintext archive job...")
|
||||
|
||||
val contentIntent = PendingIntent.getActivity(
|
||||
context,
|
||||
0,
|
||||
AppSettingsActivity.chats(context).setFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP),
|
||||
immutable()
|
||||
)
|
||||
|
||||
var notification: NotificationController? = null
|
||||
try {
|
||||
notification = GenericForegroundService.startForegroundTask(
|
||||
context,
|
||||
context.getString(R.string.LocalBackupJob_creating_signal_backup),
|
||||
NotificationChannels.getInstance().BACKUPS,
|
||||
R.drawable.ic_signal_backup
|
||||
R.drawable.ic_signal_backup,
|
||||
contentIntent
|
||||
)
|
||||
} catch (e: UnableToStartException) {
|
||||
Log.w(TAG, "Unable to start foreground service, continuing without service")
|
||||
@@ -77,39 +95,46 @@ class LocalPlaintextArchiveJob internal constructor(
|
||||
val timestamp = SimpleDateFormat("yyyy-MM-dd-HH-mm-ss", Locale.US).format(Date())
|
||||
val fileName = "signal-export-$timestamp"
|
||||
|
||||
val zipFile = root.createFile("application/zip", fileName)
|
||||
if (zipFile == null) {
|
||||
Log.w(TAG, "Unable to create zip file")
|
||||
exportDir = root.createDirectory(fileName)
|
||||
val exportDir = this.exportDir ?: run {
|
||||
Log.w(TAG, "Unable to create export directory")
|
||||
return Result.failure()
|
||||
}
|
||||
|
||||
stopwatch.split("create-file")
|
||||
stopwatch.split("create-dir")
|
||||
|
||||
try {
|
||||
SignalDatabase.attachmentMetadata.insertNewKeysForExistingAttachments()
|
||||
|
||||
val outputStream = context.contentResolver.openOutputStream(zipFile.uri)
|
||||
if (outputStream == null) {
|
||||
Log.w(TAG, "Unable to open output stream for zip file")
|
||||
zipFile.delete()
|
||||
return Result.failure()
|
||||
}
|
||||
|
||||
ZipOutputStream(outputStream).use { zipOutputStream ->
|
||||
val result = LocalArchiver.exportPlaintext(zipOutputStream, includeMedia, stopwatch, cancellationSignal = { isCanceled })
|
||||
Log.i(TAG, "Plaintext archive finished with result: $result")
|
||||
if (result !is org.signal.core.util.Result.Success) {
|
||||
zipFile.delete()
|
||||
return Result.failure()
|
||||
val progressScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
progressScope.launch {
|
||||
LocalExportProgress.plaintextProgress.collect { progress ->
|
||||
updateNotification(progress, notification)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
val result = LocalArchiver.exportPlaintext(exportDir, context.contentResolver, includeMedia, stopwatch, cancellationSignal = { isCanceled })
|
||||
Log.i(TAG, "Plaintext archive finished with result: $result")
|
||||
if (isCanceled) {
|
||||
exportDir.delete()
|
||||
setProgress(LocalBackupCreationProgress(canceled = LocalBackupCreationProgress.Canceled()), notification)
|
||||
return Result.failure()
|
||||
} else if (result !is org.signal.core.util.Result.Success) {
|
||||
exportDir.delete()
|
||||
setProgress(LocalBackupCreationProgress(failed = LocalBackupCreationProgress.Failed()), notification)
|
||||
return Result.failure()
|
||||
}
|
||||
} finally {
|
||||
progressScope.cancel()
|
||||
}
|
||||
|
||||
stopwatch.split("archive-create")
|
||||
setProgress(LocalBackupCreationProgress(idle = LocalBackupCreationProgress.Idle()), notification)
|
||||
setProgress(LocalBackupCreationProgress(succeeded = LocalBackupCreationProgress.Succeeded()), notification)
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "Error during plaintext archive!", e)
|
||||
setProgress(LocalBackupCreationProgress(idle = LocalBackupCreationProgress.Idle()), notification)
|
||||
zipFile.delete()
|
||||
setProgress(LocalBackupCreationProgress(failed = LocalBackupCreationProgress.Failed()), notification)
|
||||
exportDir.delete()
|
||||
throw e
|
||||
}
|
||||
|
||||
@@ -122,11 +147,15 @@ class LocalPlaintextArchiveJob internal constructor(
|
||||
}
|
||||
|
||||
override fun onFailure() {
|
||||
SignalStore.backup.newLocalPlaintextBackupProgress = LocalBackupCreationProgress(idle = LocalBackupCreationProgress.Idle())
|
||||
exportDir?.delete()
|
||||
val current = LocalExportProgress.plaintextProgress.value
|
||||
if (current.canceled == null && current.failed == null) {
|
||||
LocalExportProgress.setPlaintextProgress(LocalBackupCreationProgress(failed = LocalBackupCreationProgress.Failed()))
|
||||
}
|
||||
}
|
||||
|
||||
private fun setProgress(progress: LocalBackupCreationProgress, notification: NotificationController?) {
|
||||
SignalStore.backup.newLocalPlaintextBackupProgress = progress
|
||||
LocalExportProgress.setPlaintextProgress(progress)
|
||||
updateNotification(progress, notification)
|
||||
}
|
||||
|
||||
@@ -141,8 +170,25 @@ class LocalPlaintextArchiveJob internal constructor(
|
||||
when {
|
||||
exporting != null -> {
|
||||
val phase = NotificationPhase.Export(exporting.phase)
|
||||
if (previousPhase != phase) {
|
||||
notification.replaceTitle(exporting.phase.toString())
|
||||
val title = when (exporting.phase) {
|
||||
LocalBackupCreationProgress.ExportPhase.MESSAGE -> {
|
||||
if (exporting.frameTotalCount > 0) {
|
||||
context.getString(
|
||||
R.string.BackupCreationProgressRow__processing_messages_s_of_s_d,
|
||||
"%,d".format(exporting.frameExportCount),
|
||||
"%,d".format(exporting.frameTotalCount),
|
||||
(exporting.frameExportCount * 100 / exporting.frameTotalCount).toInt()
|
||||
)
|
||||
} else {
|
||||
context.getString(R.string.BackupCreationProgressRow__processing_messages)
|
||||
}
|
||||
}
|
||||
LocalBackupCreationProgress.ExportPhase.FINALIZING -> context.getString(R.string.BackupCreationProgressRow__finalizing)
|
||||
LocalBackupCreationProgress.ExportPhase.NONE -> context.getString(R.string.BackupCreationProgressRow__processing_backup)
|
||||
else -> context.getString(R.string.BackupCreationProgressRow__preparing_backup)
|
||||
}
|
||||
if (previousPhase != phase || exporting.phase == LocalBackupCreationProgress.ExportPhase.MESSAGE) {
|
||||
notification.replaceTitle(title)
|
||||
previousPhase = phase
|
||||
}
|
||||
if (exporting.frameTotalCount == 0L) {
|
||||
|
||||
@@ -56,7 +56,6 @@ class MultiDeviceKeysUpdateJob private constructor(parameters: Parameters) : Bas
|
||||
val syncMessage = SignalServiceSyncMessage.forKeys(
|
||||
KeysMessage(
|
||||
storageService = SignalStore.storageService.storageKey,
|
||||
master = SignalStore.svr.masterKey,
|
||||
accountEntropyPool = SignalStore.account.accountEntropyPool,
|
||||
mediaRootBackupKey = SignalStore.backup.mediaRootBackupKey
|
||||
)
|
||||
|
||||
@@ -101,6 +101,11 @@ class PollVoteJob(
|
||||
return Result.failure()
|
||||
}
|
||||
|
||||
if (conversationRecipient.isPushV2Group && !SignalDatabase.groups.isActive(conversationRecipient.requireGroupId())) {
|
||||
Log.w(TAG, "Cannot send poll vote to terminated or inactive group.")
|
||||
return Result.failure()
|
||||
}
|
||||
|
||||
val poll = SignalDatabase.polls.getPoll(messageId)
|
||||
if (poll == null) {
|
||||
Log.w(TAG, "Unable to find corresponding poll")
|
||||
|
||||
@@ -155,7 +155,7 @@ class PushProcessMessageJob private constructor(
|
||||
try {
|
||||
messageProcessor.process(result.envelope, result.content, result.metadata, result.serverDeliveredTimestamp, localMetric = localReceiveMetric, batchCache = batchCache)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to process message with timestamp ${result.envelope.timestamp}. Dropping.", e)
|
||||
Log.e(TAG, "Failed to process message with timestamp ${result.envelope.clientTimestamp}. Dropping.", e)
|
||||
}
|
||||
null
|
||||
}
|
||||
|
||||
@@ -169,6 +169,11 @@ public class ReactionSendJob extends BaseJob {
|
||||
return;
|
||||
}
|
||||
|
||||
if (conversationRecipient.isPushV2Group() && !SignalDatabase.groups().isActive(conversationRecipient.requireGroupId())) {
|
||||
Log.w(TAG, "Cannot send reactions to terminated or inactive groups.");
|
||||
return;
|
||||
}
|
||||
|
||||
List<Recipient> resolved = recipients.stream().map(Recipient::resolved).collect(Collectors.toList());
|
||||
List<RecipientId> unregistered = resolved.stream().filter(Recipient::isUnregistered).map(Recipient::getId).collect(Collectors.toList());
|
||||
List<Recipient> destinations = resolved.stream().filter(Recipient::isMaybeRegistered).collect(Collectors.toList());
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user