mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-04 09:22:45 +01:00
Compare commits
48 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
abdad4cde8 | ||
|
|
fecb30a86e | ||
|
|
c7ec3ab837 | ||
|
|
2a7b58bf46 | ||
|
|
7d5b0b1565 | ||
|
|
3620db3a92 | ||
|
|
69cad04875 | ||
|
|
d533cdc619 | ||
|
|
ae455d2615 | ||
|
|
7f27e52e58 | ||
|
|
4b10c19569 | ||
|
|
3f7f43d506 | ||
|
|
b4296c1e4b | ||
|
|
b62b5ea8ef | ||
|
|
db5cced91b | ||
|
|
b677827c86 | ||
|
|
fc0e902cbf | ||
|
|
6fbf4d4ae6 | ||
|
|
95149764eb | ||
|
|
a37680685f | ||
|
|
2b163a9acd | ||
|
|
2f41d15a41 | ||
|
|
d2c8b6e14c | ||
|
|
6877b9163b | ||
|
|
6ee14d5e7c | ||
|
|
824ff18ba5 | ||
|
|
548adb831d | ||
|
|
501ef69f97 | ||
|
|
a62f07db11 | ||
|
|
1b6cfe9fc6 | ||
|
|
eaa1124e71 | ||
|
|
380036195a | ||
|
|
d2619a6abd | ||
|
|
4d2f23ec37 | ||
|
|
6c1897d8d5 | ||
|
|
39de824bf0 | ||
|
|
889ebcadd4 | ||
|
|
db17d1fd24 | ||
|
|
cc282276c8 | ||
|
|
a5e00c4319 | ||
|
|
dba5252be6 | ||
|
|
874bc1a1c9 | ||
|
|
4b95851ae5 | ||
|
|
fbe907f1e9 | ||
|
|
cf0157c59d | ||
|
|
8f4dff8d53 | ||
|
|
1b3fb60cb0 | ||
|
|
ecbf9d60cb |
@@ -24,9 +24,9 @@ plugins {
|
||||
|
||||
apply(from = "static-ips.gradle.kts")
|
||||
|
||||
val canonicalVersionCode = 1666
|
||||
val canonicalVersionName = "8.3.2"
|
||||
val currentHotfixVersion = 0
|
||||
val canonicalVersionCode = 1670
|
||||
val canonicalVersionName = "8.4.2"
|
||||
val currentHotfixVersion = 1
|
||||
val maxHotfixVersions = 100
|
||||
|
||||
// We don't want versions to ever end in 0 so that they don't conflict with nightly versions
|
||||
@@ -654,6 +654,7 @@ dependencies {
|
||||
implementation(libs.androidx.concurrent.futures)
|
||||
implementation(libs.androidx.autofill)
|
||||
implementation(libs.androidx.biometric)
|
||||
implementation(libs.androidx.core.telecom)
|
||||
implementation(libs.androidx.sharetarget)
|
||||
implementation(libs.androidx.profileinstaller)
|
||||
implementation(libs.androidx.asynclayoutinflater)
|
||||
|
||||
@@ -14,6 +14,7 @@ import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.signal.storageservice.storage.protos.groups.Member
|
||||
import org.signal.storageservice.storage.protos.groups.local.DecryptedMember
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.mms.IncomingMessage
|
||||
import org.thoughtcrime.securesms.profiles.ProfileName
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
@@ -53,6 +54,8 @@ class NameCollisionTablesTest {
|
||||
setProfileName(bob, ProfileName.fromParts("Bob", "Android"))
|
||||
setProfileName(alice, ProfileName.fromParts("Bob", "Android"))
|
||||
|
||||
AppDependencies.recipientCache.clear()
|
||||
|
||||
val actualAlice = SignalDatabase.nameCollisions.getCollisionsForThreadRecipientId(alice)
|
||||
val actualBob = SignalDatabase.nameCollisions.getCollisionsForThreadRecipientId(bob)
|
||||
|
||||
@@ -80,6 +83,8 @@ class NameCollisionTablesTest {
|
||||
setProfileName(charlie, ProfileName.fromParts("Bob", "Android"))
|
||||
setProfileName(alice, ProfileName.fromParts("Alice", "Android"))
|
||||
|
||||
AppDependencies.recipientCache.clear()
|
||||
|
||||
val actualAlice = SignalDatabase.nameCollisions.getCollisionsForThreadRecipientId(alice)
|
||||
val actualBob = SignalDatabase.nameCollisions.getCollisionsForThreadRecipientId(bob)
|
||||
val actualCharlie = SignalDatabase.nameCollisions.getCollisionsForThreadRecipientId(charlie)
|
||||
@@ -98,6 +103,8 @@ class NameCollisionTablesTest {
|
||||
setProfileName(alice, ProfileName.fromParts("Alice", "Android"))
|
||||
setProfileName(alice, ProfileName.fromParts("Bob", "Android"))
|
||||
|
||||
AppDependencies.recipientCache.clear()
|
||||
|
||||
val actualAlice = SignalDatabase.nameCollisions.getCollisionsForThreadRecipientId(alice)
|
||||
|
||||
assertThat(actualAlice).hasSize(2)
|
||||
@@ -136,6 +143,8 @@ class NameCollisionTablesTest {
|
||||
setProfileName(bob, ProfileName.fromParts("Bob", "Android"))
|
||||
SignalDatabase.nameCollisions.markCollisionsForThreadRecipientDismissed(alice)
|
||||
|
||||
AppDependencies.recipientCache.clear()
|
||||
|
||||
val actualCollisions = SignalDatabase.nameCollisions.getCollisionsForThreadRecipientId(bob)
|
||||
|
||||
assertThat(actualCollisions).hasSize(2)
|
||||
@@ -153,6 +162,8 @@ class NameCollisionTablesTest {
|
||||
SignalDatabase.threads.getOrCreateThreadIdFor(Recipient.resolved(info.recipientId))
|
||||
SignalDatabase.messages.insertProfileNameChangeMessages(alice, "Bob Android", "Alice Android")
|
||||
|
||||
AppDependencies.recipientCache.clear()
|
||||
|
||||
val collisions = SignalDatabase.nameCollisions.getCollisionsForThreadRecipientId(info.recipientId)
|
||||
|
||||
assertThat(collisions).hasSize(2)
|
||||
|
||||
@@ -1190,16 +1190,16 @@ class RecipientTableTest_getAndPossiblyMerge {
|
||||
}
|
||||
|
||||
fun expect(id: RecipientId, e164: String?, pni: PNI?, aci: ACI?) {
|
||||
val recipient = Recipient.resolved(id)
|
||||
val record = SignalDatabase.recipients.getRecord(id)
|
||||
val expected = RecipientTuple(
|
||||
e164 = e164,
|
||||
pni = pni,
|
||||
aci = aci
|
||||
)
|
||||
val actual = RecipientTuple(
|
||||
e164 = recipient.e164.orElse(null),
|
||||
pni = recipient.pni.orElse(null),
|
||||
aci = recipient.aci.orElse(null)
|
||||
e164 = record.e164,
|
||||
pni = record.pni,
|
||||
aci = record.aci
|
||||
)
|
||||
|
||||
assertEquals("Recipient $id did not match expected result!", expected, actual)
|
||||
|
||||
@@ -76,7 +76,7 @@ class BenchmarkCommandReceiver : BroadcastReceiver() {
|
||||
|
||||
runBlocking {
|
||||
launch(Dispatchers.IO) {
|
||||
Log.i(TAG, "Sending initial message form Bob to establish session.")
|
||||
Log.i(TAG, "Sending initial message from Bob to establish session.")
|
||||
BenchmarkWebSocketConnection.addPendingMessages(listOf(encryptedEnvelope.toWebSocketPayload()))
|
||||
BenchmarkWebSocketConnection.releaseMessages()
|
||||
|
||||
@@ -85,6 +85,10 @@ class BenchmarkCommandReceiver : BroadcastReceiver() {
|
||||
}
|
||||
}
|
||||
|
||||
// Complete the session handshake so both sides have a proper double-ratchet session
|
||||
Log.i(TAG, "Completing session handshake with reply.")
|
||||
client.completeSession()
|
||||
|
||||
// Have Bob generate N messages that will be received by Alice
|
||||
val messageCount = 500
|
||||
val envelopes = client.generateInboundEnvelopes(messageCount)
|
||||
@@ -103,7 +107,7 @@ class BenchmarkCommandReceiver : BroadcastReceiver() {
|
||||
|
||||
runBlocking {
|
||||
launch(Dispatchers.IO) {
|
||||
Log.i(TAG, "Sending initial group messages from client to establish sessions.")
|
||||
Log.i(TAG, "Sending initial group messages from clients to establish sessions.")
|
||||
BenchmarkWebSocketConnection.addPendingMessages(encryptedEnvelopes.map { it.toWebSocketPayload() })
|
||||
BenchmarkWebSocketConnection.releaseMessages()
|
||||
|
||||
@@ -112,6 +116,10 @@ class BenchmarkCommandReceiver : BroadcastReceiver() {
|
||||
}
|
||||
}
|
||||
|
||||
// Complete session handshakes so both sides have proper double-ratchet sessions
|
||||
Log.i(TAG, "Completing session handshakes with Alice replies.")
|
||||
clients.forEach { it.completeSession() }
|
||||
|
||||
// Have clients generate N group messages that will be received by Alice
|
||||
val allClientMessages = clients.map { client ->
|
||||
val messageCount = 100
|
||||
@@ -150,6 +158,9 @@ class BenchmarkCommandReceiver : BroadcastReceiver() {
|
||||
ThreadUtil.sleep(1000)
|
||||
}
|
||||
}
|
||||
|
||||
Log.i(TAG, "Completing session handshakes with Alice replies.")
|
||||
clients.forEach { it.completeSession() }
|
||||
}
|
||||
|
||||
private fun handleDeleteThread() {
|
||||
|
||||
@@ -24,8 +24,13 @@ import org.signal.libsignal.protocol.util.KeyHelper
|
||||
import org.signal.libsignal.zkgroup.groups.GroupMasterKey
|
||||
import org.signal.libsignal.zkgroup.profiles.ProfileKey
|
||||
import org.thoughtcrime.securesms.crypto.ProfileKeyUtil
|
||||
import org.thoughtcrime.securesms.crypto.ReentrantSessionLock
|
||||
import org.thoughtcrime.securesms.crypto.SealedSenderAccessUtil
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.whispersystems.signalservice.api.SignalServiceAccountDataStore
|
||||
import org.whispersystems.signalservice.api.SignalSessionLock
|
||||
import org.whispersystems.signalservice.api.crypto.EnvelopeContent
|
||||
@@ -77,6 +82,35 @@ class OtherClient(val serviceId: ServiceId, val e164: String, val identityKeyPai
|
||||
.toEnvelope(timestamp, getAliceServiceId())
|
||||
}
|
||||
|
||||
fun decrypt(envelope: Envelope, serverDeliveredTimestamp: Long) {
|
||||
val cipher = SignalServiceCipher(serviceAddress, 1, aciStore, sessionLock, SealedSenderAccessUtil.getCertificateValidator())
|
||||
cipher.decrypt(envelope, serverDeliveredTimestamp)
|
||||
}
|
||||
|
||||
/**
|
||||
* Completes the Signal session handshake by having Alice (the app) encrypt a reply
|
||||
* to this client, then decrypting it. This establishes a proper double-ratchet session
|
||||
* on both sides.
|
||||
*
|
||||
* Must be called after this client's initial PreKeyMessage has been processed by Alice.
|
||||
*/
|
||||
fun completeSession() {
|
||||
val aliceAddress = SignalServiceAddress(Harness.SELF_ACI, Harness.SELF_E164)
|
||||
val aliceCipher = SignalServiceCipher(aliceAddress, 1, AppDependencies.protocolStore.aci(), ReentrantSessionLock.INSTANCE, null)
|
||||
|
||||
val bobProtocolAddress = SignalProtocolAddress(serviceId.toString(), 1)
|
||||
val now = System.currentTimeMillis()
|
||||
val content = Generator.encryptedTextMessage(now)
|
||||
|
||||
val recipientId = RecipientId.from(SignalServiceAddress(serviceId, e164))
|
||||
val sealedSenderAccess = SealedSenderAccessUtil.getSealedSenderAccessFor(Recipient.resolved(recipientId))
|
||||
|
||||
val outgoing = aliceCipher.encrypt(bobProtocolAddress, sealedSenderAccess, content)
|
||||
val envelope = outgoing.toEnvelope(now, serviceId)
|
||||
|
||||
decrypt(envelope, now)
|
||||
}
|
||||
|
||||
fun generateInboundEnvelopes(count: Int): List<Envelope> {
|
||||
val envelopes = ArrayList<Envelope>(count)
|
||||
var now = System.currentTimeMillis()
|
||||
|
||||
@@ -1282,16 +1282,6 @@
|
||||
android:enabled="true"
|
||||
android:exported="false" />
|
||||
|
||||
<service
|
||||
android:name=".service.webrtc.AndroidCallConnectionService"
|
||||
android:exported="true"
|
||||
android:foregroundServiceType="phoneCall"
|
||||
android:permission="android.permission.BIND_TELECOM_CONNECTION_SERVICE"
|
||||
tools:targetApi="28">
|
||||
<intent-filter>
|
||||
<action android:name="android.telecom.ConnectionService" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<service
|
||||
android:name=".components.voice.VoiceNotePlaybackService"
|
||||
@@ -1401,7 +1391,7 @@
|
||||
<service
|
||||
android:name="org.thoughtcrime.securesms.service.webrtc.ActiveCallManager$ActiveCallForegroundService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="dataSync|microphone|camera" />
|
||||
android:foregroundServiceType="dataSync|microphone|camera|phoneCall" />
|
||||
|
||||
<service
|
||||
android:name="com.google.android.datatransport.runtime.scheduling.jobscheduling.JobInfoSchedulerService"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -214,6 +214,7 @@ 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)
|
||||
@@ -224,7 +225,7 @@ public class ApplicationContext extends Application implements AppForegroundObse
|
||||
.addPostRender(() -> SignalDatabase.messageLog().trimOldMessages(System.currentTimeMillis(), RemoteConfig.retryRespondMaxAge()))
|
||||
.addPostRender(() -> JumboEmoji.updateCurrentVersion(this))
|
||||
.addPostRender(RetrieveRemoteAnnouncementsJob::enqueue)
|
||||
.addPostRender(() -> AndroidTelecomUtil.registerPhoneAccount())
|
||||
.addPostRender(AndroidTelecomUtil::registerPhoneAccount)
|
||||
.addPostRender(() -> AppDependencies.getJobManager().add(new FontDownloaderJob()))
|
||||
.addPostRender(CheckServiceReachabilityJob::enqueueIfNecessary)
|
||||
.addPostRender(GroupV2UpdateSelfProfileKeyJob::enqueueForGroupsIfNecessary)
|
||||
|
||||
@@ -41,9 +41,14 @@ public class MainNavigator {
|
||||
}
|
||||
|
||||
public void goToConversation(@NonNull RecipientId recipientId, long threadId, int distributionType, int startingPosition) {
|
||||
goToConversation(recipientId, threadId, distributionType, startingPosition, false);
|
||||
}
|
||||
|
||||
public void goToConversation(@NonNull RecipientId recipientId, long threadId, int distributionType, int startingPosition, boolean incognito) {
|
||||
Disposable disposable = ConversationIntents.createBuilder(activity, recipientId, threadId)
|
||||
.map(builder -> builder.withDistributionType(distributionType)
|
||||
.withStartingPosition(startingPosition)
|
||||
.asIncognito(incognito)
|
||||
.toConversationArgs())
|
||||
.subscribe(args -> viewModel.goTo(new MainNavigationDetailLocation.Chats.Conversation(args)));
|
||||
|
||||
|
||||
@@ -22,8 +22,7 @@ public final class AudioWaveFormGenerator {
|
||||
|
||||
private static final String TAG = Log.tag(AudioWaveFormGenerator.class);
|
||||
|
||||
public static final int BAR_COUNT = 46;
|
||||
private static final int SAMPLES_PER_BAR = 4;
|
||||
public static final int BAR_COUNT = 46;
|
||||
|
||||
private AudioWaveFormGenerator() {}
|
||||
|
||||
@@ -68,44 +67,37 @@ public final class AudioWaveFormGenerator {
|
||||
|
||||
extractor.selectTrack(0);
|
||||
|
||||
long kTimeOutUs = 5000;
|
||||
MediaCodec.BufferInfo info = new MediaCodec.BufferInfo();
|
||||
boolean sawInputEOS = false;
|
||||
boolean sawOutputEOS = false;
|
||||
int noOutputCounter = 0;
|
||||
long kTimeOutUs = 5000;
|
||||
MediaCodec.BufferInfo info = new MediaCodec.BufferInfo();
|
||||
boolean extractorDone = false;
|
||||
boolean inputEOSQueued = false;
|
||||
boolean sawOutputEOS = false;
|
||||
int noOutputCounter = 0;
|
||||
int maxSamplesPerBar = Math.max(1, (int) (totalDurationUs / BAR_COUNT / 10_000));
|
||||
boolean[] barSufficientlySampled = new boolean[BAR_COUNT];
|
||||
|
||||
while (!sawOutputEOS && noOutputCounter < 50) {
|
||||
noOutputCounter++;
|
||||
if (!sawInputEOS) {
|
||||
if (!inputEOSQueued) {
|
||||
int inputBufIndex = codec.dequeueInputBuffer(kTimeOutUs);
|
||||
if (inputBufIndex >= 0) {
|
||||
ByteBuffer dstBuf = codec.getInputBuffer(inputBufIndex);
|
||||
int sampleSize = extractor.readSampleData(dstBuf, 0);
|
||||
long presentationTimeUs = 0;
|
||||
|
||||
if (sampleSize < 0) {
|
||||
sawInputEOS = true;
|
||||
sampleSize = 0;
|
||||
if (extractorDone) {
|
||||
codec.queueInputBuffer(inputBufIndex, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM);
|
||||
inputEOSQueued = true;
|
||||
} else {
|
||||
presentationTimeUs = extractor.getSampleTime();
|
||||
}
|
||||
ByteBuffer dstBuf = codec.getInputBuffer(inputBufIndex);
|
||||
int sampleSize = extractor.readSampleData(dstBuf, 0);
|
||||
long presentationTimeUs = 0;
|
||||
|
||||
codec.queueInputBuffer(
|
||||
inputBufIndex,
|
||||
0,
|
||||
sampleSize,
|
||||
presentationTimeUs,
|
||||
sawInputEOS ? MediaCodec.BUFFER_FLAG_END_OF_STREAM : 0);
|
||||
if (sampleSize < 0) {
|
||||
codec.queueInputBuffer(inputBufIndex, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM);
|
||||
inputEOSQueued = true;
|
||||
} else {
|
||||
presentationTimeUs = extractor.getSampleTime();
|
||||
|
||||
if (!sawInputEOS) {
|
||||
int barSampleIndex = (int) (SAMPLES_PER_BAR * (wave.length * extractor.getSampleTime()) / totalDurationUs);
|
||||
sawInputEOS = !extractor.advance();
|
||||
int nextBarSampleIndex = (int) (SAMPLES_PER_BAR * (wave.length * extractor.getSampleTime()) / totalDurationUs);
|
||||
while (!sawInputEOS && nextBarSampleIndex == barSampleIndex) {
|
||||
sawInputEOS = !extractor.advance();
|
||||
if (!sawInputEOS) {
|
||||
nextBarSampleIndex = (int) (SAMPLES_PER_BAR * (wave.length * extractor.getSampleTime()) / totalDurationUs);
|
||||
}
|
||||
codec.queueInputBuffer(inputBufIndex, 0, sampleSize, presentationTimeUs, 0);
|
||||
|
||||
extractorDone = !extractor.advance();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -119,16 +111,19 @@ public final class AudioWaveFormGenerator {
|
||||
noOutputCounter = 0;
|
||||
}
|
||||
|
||||
ByteBuffer buf = codec.getOutputBuffer(outputBufferIndex);
|
||||
int barIndex = (int) ((wave.length * info.presentationTimeUs) / totalDurationUs);
|
||||
long total = 0;
|
||||
for (int i = 0; i < info.size; i += 2 * 4) {
|
||||
short aShort = buf.getShort(i);
|
||||
total += Math.abs(aShort);
|
||||
}
|
||||
if (barIndex >= 0 && barIndex < wave.length) {
|
||||
if (barIndex >= 0 && barIndex < wave.length && !barSufficientlySampled[barIndex]) {
|
||||
ByteBuffer buf = codec.getOutputBuffer(outputBufferIndex);
|
||||
long total = 0;
|
||||
for (int i = 0; i < info.size; i += 2 * 4) {
|
||||
short aShort = buf.getShort(i);
|
||||
total += Math.abs(aShort);
|
||||
}
|
||||
wave[barIndex] += total;
|
||||
waveSamples[barIndex] += info.size / 2;
|
||||
if (waveSamples[barIndex] >= maxSamplesPerBar) {
|
||||
barSufficientlySampled[barIndex] = true;
|
||||
}
|
||||
}
|
||||
codec.releaseOutputBuffer(outputBufferIndex, false);
|
||||
if ((info.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0) {
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
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)
|
||||
|
||||
fun LocalBackupCreationProgress.exportProgress(): Float {
|
||||
val exporting = exporting ?: return 0f
|
||||
return if (exporting.frameTotalCount == 0L) 0f else exporting.frameExportCount / exporting.frameTotalCount.toFloat()
|
||||
}
|
||||
|
||||
fun LocalBackupCreationProgress.transferProgress(): Float {
|
||||
val transferring = transferring ?: return 0f
|
||||
return if (transferring.total == 0L) 0f else transferring.completed / transferring.total.toFloat()
|
||||
}
|
||||
@@ -63,6 +63,7 @@ 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
|
||||
@@ -113,6 +114,7 @@ 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
|
||||
@@ -129,6 +131,7 @@ 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
|
||||
@@ -588,6 +591,14 @@ 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) {
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.backup.v2
|
||||
|
||||
class LocalBackupV2Event(val type: Type, val count: Long = 0, val estimatedTotalCount: Long = 0) {
|
||||
enum class Type {
|
||||
PROGRESS_ACCOUNT,
|
||||
PROGRESS_RECIPIENT,
|
||||
PROGRESS_THREAD,
|
||||
PROGRESS_CALL,
|
||||
PROGRESS_STICKER,
|
||||
NOTIFICATION_PROFILE,
|
||||
CHAT_FOLDER,
|
||||
PROGRESS_MESSAGE,
|
||||
PROGRESS_ATTACHMENT,
|
||||
FINISHED
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,13 @@ package org.thoughtcrime.securesms.backup.v2.local
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.runBlocking
|
||||
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.delete
|
||||
import org.signal.core.util.androidx.DocumentFileUtil.hasFile
|
||||
@@ -27,6 +33,8 @@ import java.text.SimpleDateFormat
|
||||
import java.util.Calendar
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
/**
|
||||
* Provide a domain-specific interface to the root file system backing a local directory based archive.
|
||||
@@ -161,10 +169,10 @@ class ArchiveFileSystem private constructor(private val context: Context, root:
|
||||
* Clean up unused files in the shared files directory leveraged across all current snapshots. A file
|
||||
* is unused if it is not referenced directly by any current snapshots.
|
||||
*/
|
||||
fun deleteUnusedFiles() {
|
||||
fun deleteUnusedFiles(allFilesProgressListener: AllFilesProgressListener? = null) {
|
||||
Log.i(TAG, "Deleting unused files")
|
||||
|
||||
val allFiles: MutableMap<String, DocumentFileInfo> = filesFileSystem.allFiles().toMutableMap()
|
||||
val allFiles: MutableMap<String, DocumentFileInfo> = filesFileSystem.allFiles(allFilesProgressListener).toMutableMap()
|
||||
val snapshots: List<SnapshotInfo> = listSnapshots()
|
||||
|
||||
snapshots
|
||||
@@ -251,6 +259,10 @@ class SnapshotFileSystem(private val context: Context, private val snapshotDirec
|
||||
*/
|
||||
class FilesFileSystem(private val context: Context, private val root: DocumentFile) {
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(FilesFileSystem::class.java)
|
||||
}
|
||||
|
||||
private val subFolders: Map<String, DocumentFile>
|
||||
|
||||
init {
|
||||
@@ -268,14 +280,37 @@ class FilesFileSystem(private val context: Context, private val root: DocumentFi
|
||||
/**
|
||||
* Enumerate all files in the directory.
|
||||
*/
|
||||
fun allFiles(): Map<String, DocumentFileInfo> {
|
||||
val allFiles = HashMap<String, DocumentFileInfo>()
|
||||
fun allFiles(allFilesProgressListener: AllFilesProgressListener? = null): Map<String, DocumentFileInfo> {
|
||||
val stopwatch = Stopwatch("allFiles")
|
||||
|
||||
for (subfolder in subFolders.values) {
|
||||
val subFiles = subfolder.listFiles(context)
|
||||
for (file in subFiles) {
|
||||
allFiles[file.name] = file
|
||||
}
|
||||
val asyncResult = runBlocking { allFilesAsync(allFilesProgressListener) }
|
||||
stopwatch.split("async")
|
||||
stopwatch.stop(TAG)
|
||||
|
||||
return asyncResult
|
||||
}
|
||||
|
||||
private suspend fun allFilesAsync(allFilesProgressListener: AllFilesProgressListener? = null, batchCount: Int = Runtime.getRuntime().availableProcessors()): Map<String, DocumentFileInfo> {
|
||||
val allFiles = ConcurrentHashMap<String, DocumentFileInfo>()
|
||||
val total = subFolders.values.size
|
||||
val completed = AtomicInteger(0)
|
||||
val chunkSize = (total + batchCount - 1) / batchCount
|
||||
|
||||
Log.d(TAG, "allFilesAsync: $batchCount")
|
||||
|
||||
coroutineScope {
|
||||
subFolders.values.chunked(chunkSize).map { chunk ->
|
||||
async(Dispatchers.IO) {
|
||||
for (subfolder in chunk) {
|
||||
val subFiles = subfolder.listFiles(context)
|
||||
for (file in subFiles) {
|
||||
allFiles[file.name] = file
|
||||
}
|
||||
|
||||
allFilesProgressListener?.onProgress(completed.incrementAndGet(), total)
|
||||
}
|
||||
}
|
||||
}.awaitAll()
|
||||
}
|
||||
|
||||
return allFiles
|
||||
@@ -330,3 +365,7 @@ private fun String.toMilliseconds(): Long {
|
||||
|
||||
return -1
|
||||
}
|
||||
|
||||
fun interface AllFilesProgressListener {
|
||||
fun onProgress(completed: Int, total: Int)
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
package org.thoughtcrime.securesms.backup.v2.local
|
||||
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import org.greenrobot.eventbus.EventBus
|
||||
import org.signal.core.models.backup.BackupId
|
||||
import org.signal.core.models.backup.MediaName
|
||||
import org.signal.core.util.Stopwatch
|
||||
@@ -16,11 +15,11 @@ import org.signal.core.util.logging.Log
|
||||
import org.signal.core.util.readFully
|
||||
import org.thoughtcrime.securesms.attachments.AttachmentId
|
||||
import org.thoughtcrime.securesms.backup.v2.BackupRepository
|
||||
import org.thoughtcrime.securesms.backup.v2.LocalBackupV2Event
|
||||
import org.thoughtcrime.securesms.backup.v2.local.proto.FilesFrame
|
||||
import org.thoughtcrime.securesms.backup.v2.local.proto.Metadata
|
||||
import org.thoughtcrime.securesms.database.AttachmentTable
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.keyvalue.protos.LocalBackupCreationProgress
|
||||
import org.whispersystems.signalservice.api.crypto.AttachmentCipherOutputStream
|
||||
import org.whispersystems.signalservice.api.crypto.AttachmentCipherStreamUtil
|
||||
import org.whispersystems.signalservice.internal.crypto.PaddingInputStream
|
||||
@@ -66,8 +65,11 @@ object LocalArchiver {
|
||||
mainStream = snapshotFileSystem.mainOutputStream() ?: return ArchiveResult.failure(ArchiveFailure.MainStream)
|
||||
|
||||
Log.i(TAG, "Listing all current files")
|
||||
val allFiles = filesFileSystem.allFiles()
|
||||
val allFiles = filesFileSystem.allFiles { completed, total ->
|
||||
SignalStore.backup.newLocalBackupProgress = 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))
|
||||
|
||||
val mediaNames: MutableSet<MediaName> = Collections.synchronizedSet(HashSet())
|
||||
|
||||
@@ -233,45 +235,54 @@ object LocalArchiver {
|
||||
private var lastVerboseUpdate: Long = 0
|
||||
|
||||
override fun onAccount() {
|
||||
EventBus.getDefault().post(LocalBackupV2Event(LocalBackupV2Event.Type.PROGRESS_ACCOUNT))
|
||||
post(LocalBackupCreationProgress(exporting = LocalBackupCreationProgress.Exporting(phase = LocalBackupCreationProgress.ExportPhase.ACCOUNT)))
|
||||
}
|
||||
|
||||
override fun onRecipient() {
|
||||
EventBus.getDefault().post(LocalBackupV2Event(LocalBackupV2Event.Type.PROGRESS_RECIPIENT))
|
||||
post(LocalBackupCreationProgress(exporting = LocalBackupCreationProgress.Exporting(phase = LocalBackupCreationProgress.ExportPhase.RECIPIENT)))
|
||||
}
|
||||
|
||||
override fun onThread() {
|
||||
EventBus.getDefault().post(LocalBackupV2Event(LocalBackupV2Event.Type.PROGRESS_THREAD))
|
||||
post(LocalBackupCreationProgress(exporting = LocalBackupCreationProgress.Exporting(phase = LocalBackupCreationProgress.ExportPhase.THREAD)))
|
||||
}
|
||||
|
||||
override fun onCall() {
|
||||
EventBus.getDefault().post(LocalBackupV2Event(LocalBackupV2Event.Type.PROGRESS_CALL))
|
||||
post(LocalBackupCreationProgress(exporting = LocalBackupCreationProgress.Exporting(phase = LocalBackupCreationProgress.ExportPhase.CALL)))
|
||||
}
|
||||
|
||||
override fun onSticker() {
|
||||
EventBus.getDefault().post(LocalBackupV2Event(LocalBackupV2Event.Type.PROGRESS_STICKER))
|
||||
post(LocalBackupCreationProgress(exporting = LocalBackupCreationProgress.Exporting(phase = LocalBackupCreationProgress.ExportPhase.STICKER)))
|
||||
}
|
||||
|
||||
override fun onNotificationProfile() {
|
||||
EventBus.getDefault().post(LocalBackupV2Event(LocalBackupV2Event.Type.NOTIFICATION_PROFILE))
|
||||
post(LocalBackupCreationProgress(exporting = LocalBackupCreationProgress.Exporting(phase = LocalBackupCreationProgress.ExportPhase.NOTIFICATION_PROFILE)))
|
||||
}
|
||||
|
||||
override fun onChatFolder() {
|
||||
EventBus.getDefault().post(LocalBackupV2Event(LocalBackupV2Event.Type.CHAT_FOLDER))
|
||||
post(LocalBackupCreationProgress(exporting = LocalBackupCreationProgress.Exporting(phase = LocalBackupCreationProgress.ExportPhase.CHAT_FOLDER)))
|
||||
}
|
||||
|
||||
override fun onMessage(currentProgress: Long, approximateCount: Long) {
|
||||
if (lastVerboseUpdate > System.currentTimeMillis() || lastVerboseUpdate + 1000 < System.currentTimeMillis() || currentProgress >= approximateCount) {
|
||||
EventBus.getDefault().post(LocalBackupV2Event(LocalBackupV2Event.Type.PROGRESS_MESSAGE, currentProgress, approximateCount))
|
||||
lastVerboseUpdate = System.currentTimeMillis()
|
||||
}
|
||||
if (shouldThrottle(currentProgress >= approximateCount)) return
|
||||
post(LocalBackupCreationProgress(exporting = LocalBackupCreationProgress.Exporting(phase = LocalBackupCreationProgress.ExportPhase.MESSAGE, frameExportCount = currentProgress, frameTotalCount = approximateCount)))
|
||||
}
|
||||
|
||||
override fun onAttachment(currentProgress: Long, totalCount: Long) {
|
||||
if (lastVerboseUpdate > System.currentTimeMillis() || lastVerboseUpdate + 1000 < System.currentTimeMillis() || currentProgress >= totalCount) {
|
||||
EventBus.getDefault().post(LocalBackupV2Event(LocalBackupV2Event.Type.PROGRESS_ATTACHMENT, currentProgress, totalCount))
|
||||
lastVerboseUpdate = System.currentTimeMillis()
|
||||
if (shouldThrottle(currentProgress >= totalCount)) return
|
||||
post(LocalBackupCreationProgress(transferring = LocalBackupCreationProgress.Transferring(completed = currentProgress, total = totalCount, mediaPhase = true)))
|
||||
}
|
||||
|
||||
private fun shouldThrottle(forceUpdate: Boolean): Boolean {
|
||||
val now = System.currentTimeMillis()
|
||||
if (forceUpdate || lastVerboseUpdate > now || lastVerboseUpdate + 1000 < now) {
|
||||
lastVerboseUpdate = now
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun post(progress: LocalBackupCreationProgress) {
|
||||
SignalStore.backup.newLocalBackupProgress = progress
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.backup.v2.ui.status
|
||||
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
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.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.dimensionResource
|
||||
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.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.backup.exportProgress
|
||||
import org.thoughtcrime.securesms.backup.transferProgress
|
||||
import org.thoughtcrime.securesms.keyvalue.protos.LocalBackupCreationProgress
|
||||
import org.signal.core.ui.R as CoreUiR
|
||||
|
||||
@Composable
|
||||
fun BackupCreationProgressRow(
|
||||
progress: LocalBackupCreationProgress,
|
||||
isRemote: Boolean,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.padding(horizontal = dimensionResource(id = CoreUiR.dimen.gutter))
|
||||
.padding(top = 16.dp, bottom = 14.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
BackupCreationProgressIndicator(progress = progress)
|
||||
|
||||
Text(
|
||||
text = getProgressMessage(progress, isRemote),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BackupCreationProgressIndicator(
|
||||
progress: LocalBackupCreationProgress
|
||||
) {
|
||||
val exporting = progress.exporting
|
||||
val transferring = progress.transferring
|
||||
|
||||
val fraction = when {
|
||||
exporting != null -> progress.exportProgress()
|
||||
transferring != null -> progress.transferProgress()
|
||||
else -> 0f
|
||||
}
|
||||
|
||||
val hasDeterminateProgress = when {
|
||||
exporting != null -> exporting.frameTotalCount > 0 && (exporting.phase == LocalBackupCreationProgress.ExportPhase.MESSAGE || exporting.phase == LocalBackupCreationProgress.ExportPhase.INITIALIZING || exporting.phase == LocalBackupCreationProgress.ExportPhase.FINALIZING)
|
||||
transferring != null -> transferring.total > 0
|
||||
else -> false
|
||||
}
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
if (hasDeterminateProgress) {
|
||||
val animatedProgress by animateFloatAsState(targetValue = fraction, animationSpec = tween(durationMillis = 250))
|
||||
LinearProgressIndicator(
|
||||
trackColor = MaterialTheme.colorScheme.secondaryContainer,
|
||||
progress = { animatedProgress },
|
||||
drawStopIndicator = {},
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(vertical = 12.dp)
|
||||
)
|
||||
} else {
|
||||
LinearProgressIndicator(
|
||||
trackColor = MaterialTheme.colorScheme.secondaryContainer,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(vertical = 12.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun getProgressMessage(progress: LocalBackupCreationProgress, isRemote: Boolean): String {
|
||||
val exporting = progress.exporting
|
||||
val transferring = progress.transferring
|
||||
|
||||
return when {
|
||||
exporting != null -> getExportPhaseMessage(exporting, progress)
|
||||
transferring != null -> getTransferPhaseMessage(transferring, isRemote)
|
||||
else -> stringResource(R.string.BackupCreationProgressRow__processing_backup)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun getExportPhaseMessage(exporting: LocalBackupCreationProgress.Exporting, progress: LocalBackupCreationProgress): String {
|
||||
return when (exporting.phase) {
|
||||
LocalBackupCreationProgress.ExportPhase.MESSAGE -> {
|
||||
if (exporting.frameTotalCount > 0) {
|
||||
stringResource(
|
||||
R.string.BackupCreationProgressRow__processing_messages_s_of_s_d,
|
||||
"%,d".format(exporting.frameExportCount),
|
||||
"%,d".format(exporting.frameTotalCount),
|
||||
(progress.exportProgress() * 100).toInt()
|
||||
)
|
||||
} else {
|
||||
stringResource(R.string.BackupCreationProgressRow__processing_messages)
|
||||
}
|
||||
}
|
||||
LocalBackupCreationProgress.ExportPhase.NONE -> stringResource(R.string.BackupCreationProgressRow__processing_backup)
|
||||
LocalBackupCreationProgress.ExportPhase.FINALIZING -> stringResource(R.string.BackupCreationProgressRow__finalizing)
|
||||
else -> stringResource(R.string.BackupCreationProgressRow__preparing_backup)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun getTransferPhaseMessage(transferring: LocalBackupCreationProgress.Transferring, isRemote: Boolean): String {
|
||||
val percent = if (transferring.total == 0L) 0 else (transferring.completed * 100 / transferring.total).toInt()
|
||||
return if (isRemote) {
|
||||
stringResource(R.string.BackupCreationProgressRow__uploading_media_d, percent)
|
||||
} else {
|
||||
stringResource(R.string.BackupCreationProgressRow__exporting_media_d, percent)
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun ExportingIndeterminatePreview() {
|
||||
Previews.Preview {
|
||||
BackupCreationProgressRow(
|
||||
progress = LocalBackupCreationProgress(exporting = LocalBackupCreationProgress.Exporting(phase = LocalBackupCreationProgress.ExportPhase.NONE)),
|
||||
isRemote = false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun InitializingIndeterminatePreview() {
|
||||
Previews.Preview {
|
||||
BackupCreationProgressRow(
|
||||
progress = LocalBackupCreationProgress(exporting = LocalBackupCreationProgress.Exporting(phase = LocalBackupCreationProgress.ExportPhase.INITIALIZING)),
|
||||
isRemote = false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun InitializingDeterminatePreview() {
|
||||
Previews.Preview {
|
||||
BackupCreationProgressRow(
|
||||
progress = LocalBackupCreationProgress(
|
||||
exporting = LocalBackupCreationProgress.Exporting(
|
||||
phase = LocalBackupCreationProgress.ExportPhase.INITIALIZING,
|
||||
frameExportCount = 128,
|
||||
frameTotalCount = 256
|
||||
)
|
||||
),
|
||||
isRemote = false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun ExportingMessagesPreview() {
|
||||
Previews.Preview {
|
||||
BackupCreationProgressRow(
|
||||
progress = LocalBackupCreationProgress(
|
||||
exporting = LocalBackupCreationProgress.Exporting(
|
||||
phase = LocalBackupCreationProgress.ExportPhase.MESSAGE,
|
||||
frameExportCount = 1000,
|
||||
frameTotalCount = 100_000
|
||||
)
|
||||
),
|
||||
isRemote = false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun TransferringLocalPreview() {
|
||||
Previews.Preview {
|
||||
BackupCreationProgressRow(
|
||||
progress = LocalBackupCreationProgress(
|
||||
transferring = LocalBackupCreationProgress.Transferring(
|
||||
completed = 50,
|
||||
total = 200,
|
||||
mediaPhase = true
|
||||
)
|
||||
),
|
||||
isRemote = false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun TransferringRemotePreview() {
|
||||
Previews.Preview {
|
||||
BackupCreationProgressRow(
|
||||
progress = LocalBackupCreationProgress(
|
||||
transferring = LocalBackupCreationProgress.Transferring(
|
||||
completed = 50,
|
||||
total = 200,
|
||||
mediaPhase = true
|
||||
)
|
||||
),
|
||||
isRemote = true
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -61,13 +61,18 @@ class SignalProgressDialog private constructor(
|
||||
message: CharSequence? = null,
|
||||
indeterminate: Boolean = false,
|
||||
cancelable: Boolean = false,
|
||||
cancelListener: DialogInterface.OnCancelListener? = null
|
||||
cancelListener: DialogInterface.OnCancelListener? = null,
|
||||
negativeButtonText: CharSequence? = null,
|
||||
negativeButtonListener: DialogInterface.OnClickListener? = null
|
||||
): SignalProgressDialog {
|
||||
val builder = MaterialAlertDialogBuilder(context).apply {
|
||||
setTitle(null)
|
||||
setMessage(null)
|
||||
setCancelable(cancelable)
|
||||
setOnCancelListener(cancelListener)
|
||||
if (negativeButtonText != null) {
|
||||
setNegativeButton(negativeButtonText, negativeButtonListener)
|
||||
}
|
||||
}
|
||||
|
||||
val customView = LayoutInflater.from(context).inflate(R.layout.signal_progress_dialog, null) as ConstraintLayout
|
||||
|
||||
@@ -109,6 +109,7 @@ class AppSettingsFragment : ComposeFragment(), Callbacks {
|
||||
is AppSettingsRoute.Payments -> findNavController().safeNavigate(R.id.action_appSettingsFragment_to_paymentsActivity)
|
||||
is AppSettingsRoute.HelpRoute.Settings -> findNavController().safeNavigate(R.id.action_appSettingsFragment_to_helpSettingsFragment)
|
||||
is AppSettingsRoute.Invite -> findNavController().safeNavigate(R.id.action_appSettingsFragment_to_inviteFragment)
|
||||
is AppSettingsRoute.LabsRoute.Labs -> findNavController().safeNavigate(R.id.action_appSettingsFragment_to_labsSettingsFragment)
|
||||
is AppSettingsRoute.InternalRoute.Internal -> findNavController().safeNavigate(R.id.action_appSettingsFragment_to_internalSettingsFragment)
|
||||
is AppSettingsRoute.AccountRoute.ManageProfile -> findNavController().safeNavigate(R.id.action_appSettingsFragment_to_manageProfileActivity)
|
||||
is AppSettingsRoute.UsernameLinkRoute.UsernameLink -> findNavController().safeNavigate(R.id.action_appSettingsFragment_to_usernameLinkSettingsFragment)
|
||||
@@ -531,9 +532,20 @@ private fun AppSettingsContent(
|
||||
Dividers.Default()
|
||||
}
|
||||
|
||||
item {
|
||||
Rows.TextRow(
|
||||
text = "Labs",
|
||||
icon = painterResource(R.drawable.symbol_flash_24),
|
||||
onClick = {
|
||||
callbacks.navigate(AppSettingsRoute.LabsRoute.Labs)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Rows.TextRow(
|
||||
text = stringResource(R.string.preferences__internal_preferences),
|
||||
icon = painterResource(R.drawable.symbol_key_24),
|
||||
onClick = {
|
||||
callbacks.navigate(AppSettingsRoute.InternalRoute.Internal)
|
||||
}
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.settings.app.backups.local
|
||||
|
||||
/**
|
||||
* Progress indicator state for the on-device backups creation/verification workflow.
|
||||
*/
|
||||
sealed class BackupProgressState {
|
||||
data object Idle : BackupProgressState()
|
||||
|
||||
/**
|
||||
* Represents either backup creation or verification progress.
|
||||
*
|
||||
* @param summary High-level status label (e.g. "In progress…", "Verifying backup…")
|
||||
* @param percentLabel Secondary progress label (either a percent string or a count-based string)
|
||||
* @param progressFraction Optional progress fraction in \\([0, 1]\\). Null indicates indeterminate progress.
|
||||
*/
|
||||
data class InProgress(
|
||||
val summary: String,
|
||||
val percentLabel: String,
|
||||
val progressFraction: Float?
|
||||
) : BackupProgressState()
|
||||
}
|
||||
@@ -16,8 +16,6 @@ import com.google.android.material.timepicker.TimeFormat
|
||||
import org.signal.core.ui.permissions.Permissions
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.jobs.LocalBackupJob
|
||||
import org.thoughtcrime.securesms.jobs.LocalBackupJob.enqueueArchive
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.service.LocalBackupListener
|
||||
@@ -144,11 +142,6 @@ class DefaultLocalBackupsSettingsCallback(
|
||||
}
|
||||
|
||||
override fun onTurnOffAndDeleteConfirmed() {
|
||||
SignalStore.backup.newLocalBackupsEnabled = false
|
||||
|
||||
val path = SignalStore.backup.newLocalBackupsDirectory
|
||||
SignalStore.backup.newLocalBackupsDirectory = null
|
||||
AppDependencies.jobManager.cancelAllInQueue(LocalBackupJob.QUEUE)
|
||||
BackupUtil.deleteUnifiedBackups(fragment.requireContext(), path)
|
||||
viewModel.turnOffAndDelete(fragment.requireContext())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,10 +5,8 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.backups.local
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Text
|
||||
@@ -38,7 +36,10 @@ import org.signal.core.ui.compose.Rows
|
||||
import org.signal.core.ui.compose.Scaffolds
|
||||
import org.signal.core.ui.compose.Snackbars
|
||||
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.keyvalue.protos.LocalBackupCreationProgress
|
||||
import org.thoughtcrime.securesms.util.BackupUtil
|
||||
import org.signal.core.ui.R as CoreUiR
|
||||
import org.signal.core.ui.compose.DayNightPreviews as DayNightPreview
|
||||
@@ -120,51 +121,26 @@ internal fun LocalBackupsSettingsScreen(
|
||||
)
|
||||
}
|
||||
} else {
|
||||
val isCreating = state.progress is BackupProgressState.InProgress
|
||||
val isCreating = !state.progress.isIdle
|
||||
|
||||
item {
|
||||
Rows.TextRow(
|
||||
text = {
|
||||
Column {
|
||||
Text(
|
||||
text = stringResource(id = R.string.BackupsPreferenceFragment__create_backup),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
|
||||
if (state.progress is BackupProgressState.InProgress) {
|
||||
if (isCreating) {
|
||||
item {
|
||||
BackupCreationProgressRow(
|
||||
progress = state.progress,
|
||||
isRemote = false
|
||||
)
|
||||
}
|
||||
} else {
|
||||
item {
|
||||
Rows.TextRow(
|
||||
text = {
|
||||
Column {
|
||||
Text(
|
||||
text = state.progress.summary,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 4.dp)
|
||||
text = stringResource(id = R.string.BackupsPreferenceFragment__create_backup),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
|
||||
if (state.progress.progressFraction == null) {
|
||||
LinearProgressIndicator(
|
||||
trackColor = MaterialTheme.colorScheme.secondaryContainer,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 8.dp)
|
||||
)
|
||||
} else {
|
||||
LinearProgressIndicator(
|
||||
trackColor = MaterialTheme.colorScheme.secondaryContainer,
|
||||
progress = { state.progress.progressFraction },
|
||||
drawStopIndicator = {},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 8.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = state.progress.percentLabel,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 8.dp)
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = state.lastBackupLabel.orEmpty(),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
@@ -172,11 +148,10 @@ internal fun LocalBackupsSettingsScreen(
|
||||
modifier = Modifier.padding(top = 4.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
enabled = !isCreating,
|
||||
onClick = callback::onCreateBackupClick
|
||||
)
|
||||
},
|
||||
onClick = callback::onCreateBackupClick
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
@@ -224,7 +199,7 @@ internal fun LocalBackupsSettingsScreen(
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(
|
||||
horizontal = dimensionResource(id = org.signal.core.ui.R.dimen.gutter),
|
||||
horizontal = dimensionResource(id = CoreUiR.dimen.gutter),
|
||||
vertical = 16.dp
|
||||
)
|
||||
)
|
||||
@@ -254,6 +229,10 @@ internal fun LocalBackupsSettingsScreen(
|
||||
onDismiss = { showTurnOffAndDeleteDialog = false }
|
||||
)
|
||||
}
|
||||
|
||||
if (state.isDeleting) {
|
||||
Dialogs.IndeterminateProgressDialog(message = stringResource(id = R.string.BackupDialog_deleting_local_backup))
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreview
|
||||
@@ -294,7 +273,7 @@ private fun LocalBackupsSettingsEnabledIdlePreview() {
|
||||
lastBackupLabel = "Last backup: 1 hour ago",
|
||||
folderDisplayName = "/storage/emulated/0/Signal/Backups",
|
||||
scheduleTimeLabel = "1:00 AM",
|
||||
progress = BackupProgressState.Idle
|
||||
progress = LocalBackupCreationProgress(idle = LocalBackupCreationProgress.Idle())
|
||||
),
|
||||
callback = LocalBackupsSettingsCallback.Empty
|
||||
)
|
||||
@@ -303,7 +282,7 @@ private fun LocalBackupsSettingsEnabledIdlePreview() {
|
||||
|
||||
@DayNightPreview
|
||||
@Composable
|
||||
private fun LocalBackupsSettingsEnabledInProgressIndeterminatePreview() {
|
||||
private fun LocalBackupsSettingsEnabledExportingIndeterminatePreview() {
|
||||
Previews.Preview {
|
||||
LocalBackupsSettingsScreen(
|
||||
state = LocalBackupsSettingsState(
|
||||
@@ -311,10 +290,8 @@ private fun LocalBackupsSettingsEnabledInProgressIndeterminatePreview() {
|
||||
lastBackupLabel = "Last backup: 1 hour ago",
|
||||
folderDisplayName = "/storage/emulated/0/Signal/Backups",
|
||||
scheduleTimeLabel = "1:00 AM",
|
||||
progress = BackupProgressState.InProgress(
|
||||
summary = "In progress…",
|
||||
percentLabel = "123 so far…",
|
||||
progressFraction = null
|
||||
progress = LocalBackupCreationProgress(
|
||||
exporting = LocalBackupCreationProgress.Exporting(phase = LocalBackupCreationProgress.ExportPhase.ACCOUNT)
|
||||
)
|
||||
),
|
||||
callback = LocalBackupsSettingsCallback.Empty
|
||||
@@ -324,7 +301,7 @@ private fun LocalBackupsSettingsEnabledInProgressIndeterminatePreview() {
|
||||
|
||||
@DayNightPreview
|
||||
@Composable
|
||||
private fun LocalBackupsSettingsEnabledInProgressPercentPreview() {
|
||||
private fun LocalBackupsSettingsEnabledExportingMessagesPreview() {
|
||||
Previews.Preview {
|
||||
LocalBackupsSettingsScreen(
|
||||
state = LocalBackupsSettingsState(
|
||||
@@ -332,10 +309,35 @@ private fun LocalBackupsSettingsEnabledInProgressPercentPreview() {
|
||||
lastBackupLabel = "Last backup: 1 hour ago",
|
||||
folderDisplayName = "/storage/emulated/0/Signal/Backups",
|
||||
scheduleTimeLabel = "1:00 AM",
|
||||
progress = BackupProgressState.InProgress(
|
||||
summary = "In progress…",
|
||||
percentLabel = "42.0% so far…",
|
||||
progressFraction = 0.42f
|
||||
progress = LocalBackupCreationProgress(
|
||||
exporting = LocalBackupCreationProgress.Exporting(
|
||||
phase = LocalBackupCreationProgress.ExportPhase.MESSAGE,
|
||||
frameExportCount = 42000,
|
||||
frameTotalCount = 100000
|
||||
)
|
||||
)
|
||||
),
|
||||
callback = LocalBackupsSettingsCallback.Empty
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreview
|
||||
@Composable
|
||||
private fun LocalBackupsSettingsEnabledTransferringPreview() {
|
||||
Previews.Preview {
|
||||
LocalBackupsSettingsScreen(
|
||||
state = LocalBackupsSettingsState(
|
||||
backupsEnabled = true,
|
||||
lastBackupLabel = "Last backup: 1 hour ago",
|
||||
folderDisplayName = "/storage/emulated/0/Signal/Backups",
|
||||
scheduleTimeLabel = "1:00 AM",
|
||||
progress = LocalBackupCreationProgress(
|
||||
transferring = LocalBackupCreationProgress.Transferring(
|
||||
completed = 50,
|
||||
total = 200,
|
||||
mediaPhase = true
|
||||
)
|
||||
)
|
||||
),
|
||||
callback = LocalBackupsSettingsCallback.Empty
|
||||
@@ -353,7 +355,7 @@ private fun LocalBackupsSettingsEnabledNonLegacyPreview() {
|
||||
lastBackupLabel = "Last backup: 1 hour ago",
|
||||
folderDisplayName = "Signal Backups",
|
||||
scheduleTimeLabel = "1:00 AM",
|
||||
progress = BackupProgressState.Idle
|
||||
progress = LocalBackupCreationProgress(idle = LocalBackupCreationProgress.Idle())
|
||||
),
|
||||
callback = LocalBackupsSettingsCallback.Empty
|
||||
)
|
||||
|
||||
@@ -4,8 +4,10 @@
|
||||
*/
|
||||
package org.thoughtcrime.securesms.components.settings.app.backups.local
|
||||
|
||||
import org.thoughtcrime.securesms.keyvalue.protos.LocalBackupCreationProgress
|
||||
|
||||
/**
|
||||
* Immutable state for the on-device (legacy) backups settings screen.
|
||||
* Immutable state for the on-device backups settings screen.
|
||||
*
|
||||
* This is intended to be the single source of truth for UI rendering (i.e. a single `StateFlow`
|
||||
* emission fully describes what the screen should display).
|
||||
@@ -16,5 +18,6 @@ data class LocalBackupsSettingsState(
|
||||
val lastBackupLabel: String? = null,
|
||||
val folderDisplayName: String? = null,
|
||||
val scheduleTimeLabel: String? = null,
|
||||
val progress: BackupProgressState = BackupProgressState.Idle
|
||||
val progress: LocalBackupCreationProgress = LocalBackupCreationProgress(idle = LocalBackupCreationProgress.Idle()),
|
||||
val isDeleting: Boolean = false
|
||||
)
|
||||
|
||||
@@ -14,24 +14,20 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.greenrobot.eventbus.EventBus
|
||||
import org.greenrobot.eventbus.Subscribe
|
||||
import org.greenrobot.eventbus.ThreadMode
|
||||
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.v2.LocalBackupV2Event
|
||||
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
|
||||
import org.thoughtcrime.securesms.jobs.LocalBackupJob
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.keyvalue.protos.LocalBackupCreationProgress
|
||||
import org.thoughtcrime.securesms.util.BackupUtil
|
||||
import org.thoughtcrime.securesms.util.DateUtils
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences
|
||||
import org.thoughtcrime.securesms.util.formatHours
|
||||
import java.text.NumberFormat
|
||||
import java.time.LocalTime
|
||||
import java.util.Locale
|
||||
|
||||
@@ -44,11 +40,6 @@ class LocalBackupsViewModel : ViewModel(), BackupKeyCredentialManagerHandler {
|
||||
private val TAG = Log.tag(LocalBackupsViewModel::class)
|
||||
}
|
||||
|
||||
private val formatter: NumberFormat = NumberFormat.getInstance().apply {
|
||||
minimumFractionDigits = 1
|
||||
maximumFractionDigits = 1
|
||||
}
|
||||
|
||||
private val internalSettingsState = MutableStateFlow(
|
||||
LocalBackupsSettingsState(
|
||||
backupsEnabled = SignalStore.backup.newLocalBackupsEnabled,
|
||||
@@ -82,11 +73,11 @@ class LocalBackupsViewModel : ViewModel(), BackupKeyCredentialManagerHandler {
|
||||
}
|
||||
}
|
||||
|
||||
EventBus.getDefault().register(this)
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
EventBus.getDefault().unregister(this)
|
||||
viewModelScope.launch {
|
||||
SignalStore.backup.newLocalBackupProgressFlow.collect { progress ->
|
||||
internalSettingsState.update { it.copy(progress = progress) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun refreshSettingsState() {
|
||||
@@ -117,45 +108,22 @@ class LocalBackupsViewModel : ViewModel(), BackupKeyCredentialManagerHandler {
|
||||
}
|
||||
|
||||
fun onBackupStarted() {
|
||||
val context = AppDependencies.application
|
||||
internalSettingsState.update {
|
||||
it.copy(
|
||||
progress = BackupProgressState.InProgress(
|
||||
summary = context.getString(R.string.BackupsPreferenceFragment__in_progress),
|
||||
percentLabel = context.getString(R.string.BackupsPreferenceFragment__d_so_far, 0),
|
||||
progressFraction = null
|
||||
)
|
||||
)
|
||||
}
|
||||
SignalStore.backup.newLocalBackupProgress = LocalBackupCreationProgress(exporting = LocalBackupCreationProgress.Exporting(phase = LocalBackupCreationProgress.ExportPhase.NONE))
|
||||
}
|
||||
|
||||
@Subscribe(threadMode = ThreadMode.MAIN)
|
||||
fun onBackupEvent(event: LocalBackupV2Event) {
|
||||
val context = AppDependencies.application
|
||||
when (event.type) {
|
||||
LocalBackupV2Event.Type.FINISHED -> {
|
||||
internalSettingsState.update { it.copy(progress = BackupProgressState.Idle) }
|
||||
fun turnOffAndDelete(context: Context) {
|
||||
internalSettingsState.update { it.copy(isDeleting = true) }
|
||||
|
||||
viewModelScope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
SignalStore.backup.newLocalBackupsEnabled = false
|
||||
val path = SignalStore.backup.newLocalBackupsDirectory
|
||||
SignalStore.backup.newLocalBackupsDirectory = null
|
||||
AppDependencies.jobManager.cancelAllInQueue(LocalBackupJob.QUEUE)
|
||||
BackupUtil.deleteUnifiedBackups(context, path)
|
||||
}
|
||||
|
||||
else -> {
|
||||
val summary = context.getString(R.string.BackupsPreferenceFragment__in_progress)
|
||||
val progressState = if (event.estimatedTotalCount == 0L) {
|
||||
BackupProgressState.InProgress(
|
||||
summary = summary,
|
||||
percentLabel = context.getString(R.string.BackupsPreferenceFragment__d_so_far, event.count),
|
||||
progressFraction = null
|
||||
)
|
||||
} else {
|
||||
val fraction = ((event.count / event.estimatedTotalCount.toDouble()) / 100.0).toFloat().coerceIn(0f, 1f)
|
||||
BackupProgressState.InProgress(
|
||||
summary = summary,
|
||||
percentLabel = context.getString(R.string.BackupsPreferenceFragment__s_so_far, formatter.format((event.count / event.estimatedTotalCount.toDouble()))),
|
||||
progressFraction = fraction
|
||||
)
|
||||
}
|
||||
|
||||
internalSettingsState.update { it.copy(progress = progressState) }
|
||||
}
|
||||
internalSettingsState.update { it.copy(isDeleting = false) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.settings.app.labs
|
||||
|
||||
sealed interface LabsSettingsEvents {
|
||||
data class ToggleIndividualChatPlaintextExport(val enabled: Boolean) : LabsSettingsEvents
|
||||
data class ToggleStoryArchive(val enabled: Boolean) : LabsSettingsEvents
|
||||
data class ToggleIncognito(val enabled: Boolean) : LabsSettingsEvents
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.settings.app.labs
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import org.signal.core.ui.compose.ComposeFragment
|
||||
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.horizontalGutters
|
||||
import org.signal.core.ui.compose.theme.SignalTheme
|
||||
import org.thoughtcrime.securesms.R
|
||||
|
||||
class LabsSettingsFragment : ComposeFragment() {
|
||||
|
||||
private val viewModel: LabsSettingsViewModel by viewModels()
|
||||
|
||||
@Composable
|
||||
override fun FragmentContent() {
|
||||
val state by viewModel.state.collectAsState()
|
||||
|
||||
LabsSettingsContent(
|
||||
state = state,
|
||||
onEvent = viewModel::onEvent,
|
||||
onNavigationClick = { findNavController().popBackStack() }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LabsSettingsContent(
|
||||
state: LabsSettingsState,
|
||||
onEvent: (LabsSettingsEvents) -> Unit,
|
||||
onNavigationClick: () -> Unit
|
||||
) {
|
||||
Scaffolds.Settings(
|
||||
title = "Labs",
|
||||
navigationContentDescription = "Go back",
|
||||
navigationIcon = SignalIcons.ArrowStart.imageVector,
|
||||
onNavigationClick = onNavigationClick
|
||||
) { contentPadding ->
|
||||
LazyColumn(
|
||||
contentPadding = contentPadding
|
||||
) {
|
||||
item {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.horizontalGutters()
|
||||
.padding(vertical = 16.dp)
|
||||
.background(
|
||||
color = SignalTheme.colors.colorSurface2,
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
)
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.symbol_info_fill_24),
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
Text(
|
||||
text = "These are internal-only pre-release features. They are all in various unfinished states. They may need more polish, finalized designs, or cross-client compatibility before they can be released.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(start = 12.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Rows.ToggleRow(
|
||||
checked = state.individualChatPlaintextExport,
|
||||
text = "Individual Chat Plaintext Export",
|
||||
label = "Enable exporting individual chats as a collection of human-readable plaintext files. New entry in the three-dot menu in the chat screen.",
|
||||
onCheckChanged = { onEvent(LabsSettingsEvents.ToggleIndividualChatPlaintextExport(it)) }
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Rows.ToggleRow(
|
||||
checked = state.storyArchive,
|
||||
text = "Story Archive",
|
||||
label = "Keep your own stories for longer and view them later. A new button on the toolbar on the stories tab.",
|
||||
onCheckChanged = { onEvent(LabsSettingsEvents.ToggleStoryArchive(it)) }
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Rows.ToggleRow(
|
||||
checked = state.incognito,
|
||||
text = "Incognito Mode",
|
||||
label = "Adds an option to long-press a conversation to open it in incognito mode. Messages will not be marked as read and no read receipts will be sent.",
|
||||
onCheckChanged = { onEvent(LabsSettingsEvents.ToggleIncognito(it)) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.settings.app.labs
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
|
||||
@Immutable
|
||||
data class LabsSettingsState(
|
||||
val individualChatPlaintextExport: Boolean = false,
|
||||
val storyArchive: Boolean = false,
|
||||
val incognito: Boolean = false
|
||||
)
|
||||
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.settings.app.labs
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
|
||||
class LabsSettingsViewModel : ViewModel() {
|
||||
|
||||
private val _state = MutableStateFlow(loadState())
|
||||
val state: StateFlow<LabsSettingsState> = _state
|
||||
|
||||
fun onEvent(event: LabsSettingsEvents) {
|
||||
when (event) {
|
||||
is LabsSettingsEvents.ToggleIndividualChatPlaintextExport -> {
|
||||
SignalStore.labs.individualChatPlaintextExport = event.enabled
|
||||
_state.value = _state.value.copy(individualChatPlaintextExport = event.enabled)
|
||||
}
|
||||
is LabsSettingsEvents.ToggleStoryArchive -> {
|
||||
SignalStore.labs.storyArchive = event.enabled
|
||||
_state.value = _state.value.copy(storyArchive = event.enabled)
|
||||
}
|
||||
is LabsSettingsEvents.ToggleIncognito -> {
|
||||
SignalStore.labs.incognito = event.enabled
|
||||
_state.value = _state.value.copy(incognito = event.enabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadState(): LabsSettingsState {
|
||||
return LabsSettingsState(
|
||||
individualChatPlaintextExport = SignalStore.labs.individualChatPlaintextExport,
|
||||
storyArchive = SignalStore.labs.storyArchive,
|
||||
incognito = SignalStore.labs.incognito
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -114,6 +114,11 @@ sealed interface AppSettingsRoute : Parcelable {
|
||||
data object Featured : DonationsRoute
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
sealed interface LabsRoute : AppSettingsRoute {
|
||||
data object Labs : LabsRoute
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
sealed interface InternalRoute : AppSettingsRoute {
|
||||
data object Internal : InternalRoute
|
||||
|
||||
@@ -588,12 +588,14 @@ object InAppPaymentsRepository {
|
||||
val fromDatabase: Observable<DonationRedemptionJobStatus> = Observable.create { emitter ->
|
||||
val observer = InAppPaymentObserver {
|
||||
val latestInAppPayment = SignalDatabase.inAppPayments.getLatestInAppPaymentByType(type)
|
||||
|
||||
emitter.onNext(Optional.ofNullable(latestInAppPayment))
|
||||
}
|
||||
|
||||
AppDependencies.databaseObserver.registerInAppPaymentObserver(observer)
|
||||
emitter.setCancellable { AppDependencies.databaseObserver.unregisterObserver(observer) }
|
||||
|
||||
val latestInAppPayment = SignalDatabase.inAppPayments.getLatestInAppPaymentByType(type)
|
||||
emitter.onNext(Optional.ofNullable(latestInAppPayment))
|
||||
}.switchMap { inAppPaymentOptional ->
|
||||
val inAppPayment = inAppPaymentOptional.getOrNull() ?: return@switchMap Observable.just(DonationRedemptionJobStatus.None)
|
||||
|
||||
|
||||
@@ -66,8 +66,6 @@ class ManageDonationsViewModel : ViewModel() {
|
||||
}
|
||||
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
updateRecurringDonationState()
|
||||
|
||||
InAppPaymentsRepository.observeInAppPaymentRedemption(InAppPaymentType.RECURRING_DONATION)
|
||||
.asFlow()
|
||||
.collect { redemptionStatus ->
|
||||
@@ -79,6 +77,7 @@ class ManageDonationsViewModel : ViewModel() {
|
||||
|
||||
store.update { manageDonationsState ->
|
||||
manageDonationsState.copy(
|
||||
isLoaded = true,
|
||||
nonVerifiedMonthlyDonation = if (redemptionStatus is DonationRedemptionJobStatus.PendingExternalVerification) redemptionStatus.nonVerifiedMonthlyDonation else null,
|
||||
subscriptionRedemptionState = deriveRedemptionState(redemptionStatus, latestPayment),
|
||||
activeSubscription = activeSubscription
|
||||
@@ -160,21 +159,6 @@ class ManageDonationsViewModel : ViewModel() {
|
||||
)
|
||||
}
|
||||
|
||||
private fun updateRecurringDonationState() {
|
||||
val latestPayment = SignalDatabase.inAppPayments.getLatestInAppPaymentByType(InAppPaymentType.RECURRING_DONATION)
|
||||
|
||||
val activeSubscription: InAppPaymentTable.InAppPayment? = latestPayment?.let {
|
||||
if (it.data.cancellation == null) it else null
|
||||
}
|
||||
|
||||
store.update { manageDonationsState ->
|
||||
manageDonationsState.copy(
|
||||
isLoaded = true,
|
||||
activeSubscription = activeSubscription
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun deriveRedemptionState(status: DonationRedemptionJobStatus, latestPayment: InAppPaymentTable.InAppPayment?): ManageDonationsState.RedemptionState {
|
||||
return when (status) {
|
||||
DonationRedemptionJobStatus.None -> ManageDonationsState.RedemptionState.NONE
|
||||
|
||||
@@ -860,20 +860,18 @@ class ConversationSettingsFragment :
|
||||
}
|
||||
)
|
||||
|
||||
if (RemoteConfig.sendMemberLabels) {
|
||||
clickPref(
|
||||
title = DSLSettingsText.from(R.string.ConversationSettingsFragment__group_member_label),
|
||||
icon = DSLSettingsIcon.from(R.drawable.symbol_tag_24),
|
||||
isEnabled = groupState.canSetOwnMemberLabel && !state.isDeprecatedOrUnregistered,
|
||||
onClick = {
|
||||
val action = ConversationSettingsFragmentDirections.actionConversationSettingsFragmentToMemberLabelFragment(groupState.groupId)
|
||||
navController.safeNavigate(action)
|
||||
},
|
||||
onDisabledClicked = {
|
||||
Snackbar.make(requireView(), R.string.GroupMemberLabel__error_no_edit_permission, Snackbar.LENGTH_SHORT).show()
|
||||
}
|
||||
)
|
||||
}
|
||||
clickPref(
|
||||
title = DSLSettingsText.from(R.string.ConversationSettingsFragment__group_member_label),
|
||||
icon = DSLSettingsIcon.from(R.drawable.symbol_tag_24),
|
||||
isEnabled = groupState.canSetOwnMemberLabel && !state.isDeprecatedOrUnregistered,
|
||||
onClick = {
|
||||
val action = ConversationSettingsFragmentDirections.actionConversationSettingsFragmentToMemberLabelFragment(groupState.groupId)
|
||||
navController.safeNavigate(action)
|
||||
},
|
||||
onDisabledClicked = {
|
||||
Snackbar.make(requireView(), R.string.GroupMemberLabel__error_no_edit_permission, Snackbar.LENGTH_SHORT).show()
|
||||
}
|
||||
)
|
||||
|
||||
clickPref(
|
||||
title = DSLSettingsText.from(R.string.ConversationSettingsFragment__requests_and_invites),
|
||||
|
||||
@@ -11,7 +11,6 @@ import org.thoughtcrime.securesms.components.settings.DSLSettingsText
|
||||
import org.thoughtcrime.securesms.components.settings.configure
|
||||
import org.thoughtcrime.securesms.groups.GroupId
|
||||
import org.thoughtcrime.securesms.groups.ui.GroupErrors
|
||||
import org.thoughtcrime.securesms.util.RemoteConfig
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
|
||||
|
||||
class PermissionsSettingsFragment : DSLSettingsFragment(
|
||||
@@ -87,21 +86,19 @@ class PermissionsSettingsFragment : DSLSettingsFragment(
|
||||
}
|
||||
)
|
||||
|
||||
if (RemoteConfig.sendMemberLabels) {
|
||||
radioListPref(
|
||||
title = DSLSettingsText.from(R.string.PermissionsSettingsFragment__add_member_labels),
|
||||
isEnabled = state.selfCanEditSettings,
|
||||
listItems = permissionsOptions,
|
||||
dialogTitle = DSLSettingsText.from(R.string.PermissionsSettingsFragment__who_can_add_member_labels),
|
||||
selected = getSelected(state.nonAdminCanSetMemberLabel),
|
||||
confirmAction = true,
|
||||
onSelected = { selectedIndex ->
|
||||
if (selectedIndex >= 0) {
|
||||
viewModel.onMemberLabelPermissionChangeRequested(nonAdminCanSetMemberLabel = selectedIndex == 1)
|
||||
}
|
||||
radioListPref(
|
||||
title = DSLSettingsText.from(R.string.PermissionsSettingsFragment__add_member_labels),
|
||||
isEnabled = state.selfCanEditSettings,
|
||||
listItems = permissionsOptions,
|
||||
dialogTitle = DSLSettingsText.from(R.string.PermissionsSettingsFragment__who_can_add_member_labels),
|
||||
selected = getSelected(state.nonAdminCanSetMemberLabel),
|
||||
confirmAction = true,
|
||||
onSelected = { selectedIndex ->
|
||||
if (selectedIndex >= 0) {
|
||||
viewModel.onMemberLabelPermissionChangeRequested(nonAdminCanSetMemberLabel = selectedIndex == 1)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,23 +2,16 @@ package org.thoughtcrime.securesms.components.settings.conversation.preferences
|
||||
|
||||
import android.content.ClipData
|
||||
import android.content.Context
|
||||
import android.text.SpannableStringBuilder
|
||||
import android.view.View
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.core.content.ContextCompat
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.PreferenceModel
|
||||
import org.thoughtcrime.securesms.fonts.SignalSymbols
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.util.ContextUtil
|
||||
import org.thoughtcrime.securesms.util.ServiceUtil
|
||||
import org.thoughtcrime.securesms.util.SpanUtil
|
||||
import org.thoughtcrime.securesms.util.ViewUtil
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder
|
||||
import org.signal.core.ui.R as CoreUiR
|
||||
|
||||
/**
|
||||
* Renders name, description, about, etc. for a given group or recipient.
|
||||
@@ -44,54 +37,7 @@ object BioTextPreference {
|
||||
) : BioTextPreferenceModel<RecipientModel>() {
|
||||
|
||||
override fun getHeadlineText(context: Context): CharSequence {
|
||||
val name = if (recipient.isSelf) {
|
||||
context.getString(R.string.note_to_self)
|
||||
} else {
|
||||
recipient.getDisplayName(context)
|
||||
}
|
||||
|
||||
if (!recipient.showVerified && !recipient.isIndividual) {
|
||||
return name
|
||||
}
|
||||
|
||||
return SpannableStringBuilder(name).apply {
|
||||
if (recipient.showVerified) {
|
||||
SpanUtil.appendSpacer(this, 8)
|
||||
SpanUtil.appendCenteredImageSpanWithoutSpace(this, ContextUtil.requireDrawable(context, R.drawable.ic_official_28), 28, 28)
|
||||
} else if (recipient.isSystemContact) {
|
||||
val systemContactGlyph = SignalSymbols.getSpannedString(
|
||||
context,
|
||||
SignalSymbols.Weight.BOLD,
|
||||
SignalSymbols.Glyph.PERSON_CIRCLE
|
||||
).let {
|
||||
SpanUtil.ofSize(it, 20)
|
||||
}
|
||||
|
||||
append(" ")
|
||||
append(systemContactGlyph)
|
||||
}
|
||||
|
||||
if (recipient.isIndividual && !recipient.isSelf) {
|
||||
val isLtr = ViewUtil.isLtr(context)
|
||||
val chevronGlyph = SignalSymbols.getSpannedString(
|
||||
context,
|
||||
SignalSymbols.Weight.BOLD,
|
||||
if (isLtr) SignalSymbols.Glyph.CHEVRON_RIGHT else SignalSymbols.Glyph.CHEVRON_LEFT
|
||||
).let {
|
||||
SpanUtil.ofSize(it, 24)
|
||||
}.let {
|
||||
SpanUtil.color(ContextCompat.getColor(context, CoreUiR.color.signal_colorOutline), it)
|
||||
}
|
||||
|
||||
if (isLtr) {
|
||||
append(" ")
|
||||
append(chevronGlyph)
|
||||
} else {
|
||||
insert(0, " ")
|
||||
insert(0, chevronGlyph)
|
||||
}
|
||||
}
|
||||
}
|
||||
return recipient.getDisplayNameForHeadline(context)
|
||||
}
|
||||
|
||||
override fun getSubhead1Text(context: Context): String? {
|
||||
|
||||
@@ -16,6 +16,7 @@ import kotlinx.collections.immutable.toImmutableList
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.service.webrtc.AndroidTelecomUtil
|
||||
import org.thoughtcrime.securesms.webrtc.audio.AudioDeviceMapping
|
||||
import org.thoughtcrime.securesms.webrtc.audio.SignalAudioManager
|
||||
import org.signal.core.ui.R as CoreUiR
|
||||
@@ -38,17 +39,40 @@ class WebRtcAudioPicker31(private val audioOutputChangedListener: OnAudioOutputC
|
||||
WebRtcAudioOutput.WIRED_HEADSET -> SignalAudioManager.AudioDevice.WIRED_HEADSET
|
||||
}
|
||||
}
|
||||
|
||||
private fun getDeviceList(context: Context): Pair<List<AudioOutputOption>, Int>? {
|
||||
val telecomDevices = AndroidTelecomUtil.getAvailableAudioOutputOptions()
|
||||
if (telecomDevices != null) {
|
||||
return telecomDevices to AndroidTelecomUtil.getCurrentEndpointDeviceId()
|
||||
}
|
||||
|
||||
val am = AppDependencies.androidCallAudioManager
|
||||
val devices = am.availableCommunicationDevices
|
||||
.map { AudioOutputOption(it.toFriendlyName(context).toString(), AudioDeviceMapping.fromPlatformType(it.type), it.id) }
|
||||
.distinctBy { it.deviceType.name }
|
||||
.filterNot { it.deviceType == SignalAudioManager.AudioDevice.NONE }
|
||||
if (devices.isEmpty()) return null
|
||||
return devices to (am.communicationDevice?.id ?: -1)
|
||||
}
|
||||
|
||||
private fun AudioDeviceInfo.toFriendlyName(context: Context): CharSequence {
|
||||
return when (this.type) {
|
||||
AudioDeviceInfo.TYPE_BUILTIN_EARPIECE -> context.getString(R.string.WebRtcAudioOutputToggle__phone_earpiece)
|
||||
AudioDeviceInfo.TYPE_BUILTIN_SPEAKER -> context.getString(R.string.WebRtcAudioOutputToggle__speaker)
|
||||
AudioDeviceInfo.TYPE_WIRED_HEADPHONES -> context.getString(R.string.WebRtcAudioOutputToggle__wired_headphones)
|
||||
AudioDeviceInfo.TYPE_WIRED_HEADSET -> context.getString(R.string.WebRtcAudioOutputToggle__wired_headset)
|
||||
AudioDeviceInfo.TYPE_USB_HEADSET -> context.getString(R.string.WebRtcAudioOutputToggle__wired_headset_usb)
|
||||
else -> this.productName
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun showPicker(fragmentActivity: FragmentActivity, threshold: Int, onDismiss: (DialogInterface) -> Unit): DialogInterface? {
|
||||
val am = AppDependencies.androidCallAudioManager
|
||||
if (am.availableCommunicationDevices.isEmpty()) {
|
||||
val (devices, currentDeviceId) = getDeviceList(fragmentActivity) ?: run {
|
||||
Toast.makeText(fragmentActivity, R.string.WebRtcAudioOutputToggleButton_no_eligible_audio_i_o_detected, Toast.LENGTH_LONG).show()
|
||||
return null
|
||||
}
|
||||
|
||||
val devices: List<AudioOutputOption> = am.availableCommunicationDevices.map { AudioOutputOption(it.toFriendlyName(fragmentActivity).toString(), AudioDeviceMapping.fromPlatformType(it.type), it.id) }.distinctBy { it.deviceType.name }.filterNot { it.deviceType == SignalAudioManager.AudioDevice.NONE }
|
||||
val currentDeviceId = am.communicationDevice?.id ?: -1
|
||||
if (devices.size < threshold) {
|
||||
Log.d(TAG, "Only found $devices devices, not showing picker.")
|
||||
if (devices.isEmpty()) return null
|
||||
@@ -68,8 +92,8 @@ class WebRtcAudioPicker31(private val audioOutputChangedListener: OnAudioOutputC
|
||||
fun Picker(threshold: Int) {
|
||||
val context = LocalContext.current
|
||||
|
||||
val am = AppDependencies.androidCallAudioManager
|
||||
if (am.availableCommunicationDevices.isEmpty()) {
|
||||
val deviceList = getDeviceList(context)
|
||||
if (deviceList == null) {
|
||||
LaunchedEffect(Unit) {
|
||||
Toast.makeText(context, R.string.WebRtcAudioOutputToggleButton_no_eligible_audio_i_o_detected, Toast.LENGTH_LONG).show()
|
||||
stateUpdater.hidePicker()
|
||||
@@ -77,8 +101,7 @@ class WebRtcAudioPicker31(private val audioOutputChangedListener: OnAudioOutputC
|
||||
return
|
||||
}
|
||||
|
||||
val devices: List<AudioOutputOption> = am.availableCommunicationDevices.map { AudioOutputOption(it.toFriendlyName(context).toString(), AudioDeviceMapping.fromPlatformType(it.type), it.id) }.distinctBy { it.deviceType.name }.filterNot { it.deviceType == SignalAudioManager.AudioDevice.NONE }
|
||||
val currentDeviceId = am.communicationDevice?.id ?: -1
|
||||
val (devices, currentDeviceId) = deviceList
|
||||
if (devices.size < threshold) {
|
||||
LaunchedEffect(Unit) {
|
||||
Log.d(TAG, "Only found $devices devices, not showing picker.")
|
||||
@@ -101,7 +124,12 @@ class WebRtcAudioPicker31(private val audioOutputChangedListener: OnAudioOutputC
|
||||
@RequiresApi(31)
|
||||
val onAudioDeviceSelected: (AudioOutputOption) -> Unit = {
|
||||
Log.d(TAG, "User selected audio device of type ${it.deviceType}")
|
||||
audioOutputChangedListener.audioOutputChanged(WebRtcAudioDevice(it.toWebRtcAudioOutput(), it.deviceId))
|
||||
val webRtcDevice = if (AndroidTelecomUtil.hasActiveController()) {
|
||||
WebRtcAudioDevice(it.toWebRtcAudioOutput(), null)
|
||||
} else {
|
||||
WebRtcAudioDevice(it.toWebRtcAudioOutput(), it.deviceId)
|
||||
}
|
||||
audioOutputChangedListener.audioOutputChanged(webRtcDevice)
|
||||
|
||||
when (it.deviceType) {
|
||||
SignalAudioManager.AudioDevice.WIRED_HEADSET -> {
|
||||
@@ -123,35 +151,22 @@ class WebRtcAudioPicker31(private val audioOutputChangedListener: OnAudioOutputC
|
||||
}
|
||||
}
|
||||
|
||||
private fun AudioDeviceInfo.toFriendlyName(context: Context): CharSequence {
|
||||
return when (this.type) {
|
||||
AudioDeviceInfo.TYPE_BUILTIN_EARPIECE -> context.getString(R.string.WebRtcAudioOutputToggle__phone_earpiece)
|
||||
AudioDeviceInfo.TYPE_BUILTIN_SPEAKER -> context.getString(R.string.WebRtcAudioOutputToggle__speaker)
|
||||
AudioDeviceInfo.TYPE_WIRED_HEADPHONES -> context.getString(R.string.WebRtcAudioOutputToggle__wired_headphones)
|
||||
AudioDeviceInfo.TYPE_WIRED_HEADSET -> context.getString(R.string.WebRtcAudioOutputToggle__wired_headset)
|
||||
AudioDeviceInfo.TYPE_USB_HEADSET -> context.getString(R.string.WebRtcAudioOutputToggle__wired_headset_usb)
|
||||
else -> this.productName
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cycles to the next audio device without showing a picker.
|
||||
* Uses the system device list to resolve the actual device ID, falling back to
|
||||
* type-based lookup from app-tracked state when the current communication device is unknown.
|
||||
*/
|
||||
fun cycleToNextDevice() {
|
||||
val am = AppDependencies.androidCallAudioManager
|
||||
val devices: List<AudioOutputOption> = am.availableCommunicationDevices
|
||||
.map { AudioOutputOption("", AudioDeviceMapping.fromPlatformType(it.type), it.id) }
|
||||
.distinctBy { it.deviceType.name }
|
||||
.filterNot { it.deviceType == SignalAudioManager.AudioDevice.NONE }
|
||||
val (devices, currentDeviceId) = getDeviceList(AppDependencies.application) ?: run {
|
||||
Log.w(TAG, "cycleToNextDevice: no available communication devices")
|
||||
return
|
||||
}
|
||||
|
||||
if (devices.isEmpty()) {
|
||||
Log.w(TAG, "cycleToNextDevice: no available communication devices")
|
||||
return
|
||||
}
|
||||
|
||||
val currentDeviceId = am.communicationDevice?.id ?: -1
|
||||
val index = devices.indexOfFirst { it.deviceId == currentDeviceId }
|
||||
|
||||
if (index != -1) {
|
||||
|
||||
@@ -276,6 +276,7 @@ private fun CallInfo(
|
||||
CallParticipantRow(
|
||||
callParticipant = it,
|
||||
isSelfAdmin = controlAndInfoState.isSelfAdmin() && !participantsState.inCallLobby,
|
||||
isCallLink = controlAndInfoState.callLink != null,
|
||||
onBlockClicked = onBlock,
|
||||
onParticipantClicked = if (isInternalUser) {
|
||||
{ participant ->
|
||||
@@ -405,6 +406,7 @@ private fun HandRaisedRowPreview() {
|
||||
private fun CallParticipantRow(
|
||||
callParticipant: CallParticipant,
|
||||
isSelfAdmin: Boolean,
|
||||
isCallLink: Boolean = false,
|
||||
onBlockClicked: (CallParticipant) -> Unit,
|
||||
onParticipantClicked: ((CallParticipant) -> Unit)? = null
|
||||
) {
|
||||
@@ -417,6 +419,7 @@ private fun CallParticipantRow(
|
||||
showHandRaised = false,
|
||||
canLowerHand = false,
|
||||
isSelfAdmin = isSelfAdmin,
|
||||
isCallLink = isCallLink,
|
||||
onBlockClicked = { onBlockClicked(callParticipant) },
|
||||
onRowClicked = if (onParticipantClicked != null && !callParticipant.recipient.isSelf) {
|
||||
{ onParticipantClicked(callParticipant) }
|
||||
@@ -451,6 +454,7 @@ private fun CallParticipantRow(
|
||||
showHandRaised: Boolean,
|
||||
canLowerHand: Boolean,
|
||||
isSelfAdmin: Boolean = false,
|
||||
isCallLink: Boolean = false,
|
||||
onBlockClicked: () -> Unit = {},
|
||||
onRowClicked: (() -> Unit)? = null
|
||||
) {
|
||||
@@ -535,7 +539,7 @@ private fun CallParticipantRow(
|
||||
)
|
||||
}
|
||||
|
||||
if (showIcons && isSelfAdmin && !recipient.isSelf) {
|
||||
if (showIcons && isSelfAdmin && isCallLink && !recipient.isSelf) {
|
||||
if (!isMicrophoneEnabled) {
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
}
|
||||
|
||||
@@ -1285,7 +1285,16 @@ class WebRtcCallActivity : BaseActivity(), SafetyNumberChangeDialog.Callback, Re
|
||||
@RequiresApi(31)
|
||||
override fun onAudioOutputChanged31(audioOutput: WebRtcAudioDevice) {
|
||||
maybeDisplaySpeakerphonePopup(audioOutput.webRtcAudioOutput)
|
||||
AppDependencies.signalCallManager.selectAudioDevice(ChosenAudioDeviceIdentifier(audioOutput.deviceId!!))
|
||||
if (audioOutput.deviceId != null) {
|
||||
AppDependencies.signalCallManager.selectAudioDevice(ChosenAudioDeviceIdentifier(audioOutput.deviceId))
|
||||
} else {
|
||||
when (audioOutput.webRtcAudioOutput) {
|
||||
WebRtcAudioOutput.HANDSET -> handleSetAudioHandset()
|
||||
WebRtcAudioOutput.SPEAKER -> handleSetAudioSpeaker()
|
||||
WebRtcAudioOutput.BLUETOOTH_HEADSET -> handleSetAudioBluetooth()
|
||||
WebRtcAudioOutput.WIRED_HEADSET -> handleSetAudioWiredHeadset()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onVideoChanged(isVideoEnabled: Boolean) {
|
||||
|
||||
@@ -37,7 +37,8 @@ data class ConversationArgs(
|
||||
val isWithSearchOpen: Boolean,
|
||||
val giftBadge: Badge?,
|
||||
val shareDataTimestamp: Long,
|
||||
val conversationScreenType: ConversationScreenType
|
||||
val conversationScreenType: ConversationScreenType,
|
||||
val isIncognito: Boolean = false
|
||||
) : Parcelable {
|
||||
@IgnoredOnParcel
|
||||
val draftMediaType: SlideFactory.MediaType? = SlideFactory.MediaType.from(draftContentType)
|
||||
|
||||
@@ -16,7 +16,6 @@ import android.view.View;
|
||||
import androidx.annotation.DrawableRes;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.StringRes;
|
||||
import androidx.appcompat.content.res.AppCompatResources;
|
||||
import androidx.constraintlayout.widget.ConstraintLayout;
|
||||
import androidx.core.content.ContextCompat;
|
||||
@@ -33,11 +32,9 @@ import org.thoughtcrime.securesms.conversation.colors.AvatarGradientColors;
|
||||
import org.thoughtcrime.securesms.conversation.v2.data.AvatarDownloadStateCache;
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase;
|
||||
import org.thoughtcrime.securesms.databinding.ConversationHeaderViewBinding;
|
||||
import org.thoughtcrime.securesms.fonts.SignalSymbols;
|
||||
import org.thoughtcrime.securesms.jobs.AvatarGroupsV2DownloadJob;
|
||||
import org.thoughtcrime.securesms.jobs.RetrieveProfileAvatarJob;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.util.ContextUtil;
|
||||
import org.thoughtcrime.securesms.util.LongClickMovementMethod;
|
||||
import org.thoughtcrime.securesms.util.SpanUtil;
|
||||
import org.thoughtcrime.securesms.util.ViewUtil;
|
||||
@@ -130,23 +127,9 @@ public class ConversationHeaderView extends ConstraintLayout {
|
||||
}
|
||||
|
||||
public String setTitle(@NonNull Recipient recipient, @NonNull Runnable onTitleClicked) {
|
||||
SpannableStringBuilder title = new SpannableStringBuilder(recipient.isSelf() ? getContext().getString(R.string.note_to_self) : recipient.getDisplayName(getContext()));
|
||||
if (recipient.getShowVerified()) {
|
||||
SpanUtil.appendCenteredImageSpan(title, ContextUtil.requireDrawable(getContext(), R.drawable.ic_official_28), 28, 28);
|
||||
}
|
||||
CharSequence title = recipient.getDisplayNameForHeadline(getContext());
|
||||
|
||||
if (recipient.isIndividual() && !recipient.isSelf()) {
|
||||
boolean isLtr = ViewUtil.isLtr(this);
|
||||
CharSequence chevron = SignalSymbols.getSpannedString(getContext(), SignalSymbols.Weight.BOLD, isLtr ? SignalSymbols.Glyph.CHEVRON_RIGHT : SignalSymbols.Glyph.CHEVRON_LEFT, org.signal.core.ui.R.color.signal_colorOutline);
|
||||
|
||||
if (isLtr) {
|
||||
title.append(" ");
|
||||
title.append(SpanUtil.ofSize(chevron, 24));
|
||||
} else {
|
||||
title.insert(0, " ");
|
||||
title.insert(0, SpanUtil.ofSize(chevron, 24));
|
||||
}
|
||||
|
||||
binding.messageRequestTitle.setOnClickListener(v -> onTitleClicked.run());
|
||||
} else {
|
||||
binding.messageRequestTitle.setOnClickListener(null);
|
||||
@@ -225,8 +208,8 @@ public class ConversationHeaderView extends ConstraintLayout {
|
||||
binding.messageRequestProfileNameUnverified.setVisibility(View.VISIBLE);
|
||||
binding.messageRequestProfileNameUnverified.setOnClickListener(view -> onClick.run());
|
||||
|
||||
String substring = forGroup ? getContext().getString(R.string.ConversationFragment_group_names)
|
||||
: getContext().getString(R.string.ConversationFragment_profile_names);
|
||||
String substring = forGroup ? getContext().getString(R.string.ConversationFragment_group_names)
|
||||
: getContext().getString(R.string.ConversationFragment_profile_names);
|
||||
|
||||
String fullString = forGroup ? getContext().getString(R.string.ConversationFragment_group_names_not_verified, substring)
|
||||
: getContext().getString(R.string.ConversationFragment_profile_names_not_verified, substring);
|
||||
@@ -273,8 +256,8 @@ public class ConversationHeaderView extends ConstraintLayout {
|
||||
}
|
||||
|
||||
private void animateAvatarLoading(@NonNull Recipient recipient) {
|
||||
Drawable loadingProfile = AppCompatResources.getDrawable(getContext(), R.drawable.circle_profile_photo);
|
||||
ObjectAnimator animator = ObjectAnimator.ofFloat(binding.messageRequestAvatar, "alpha", 1f, 0f).setDuration(FADE_DURATION);
|
||||
Drawable loadingProfile = AppCompatResources.getDrawable(getContext(), R.drawable.circle_profile_photo);
|
||||
ObjectAnimator animator = ObjectAnimator.ofFloat(binding.messageRequestAvatar, "alpha", 1f, 0f).setDuration(FADE_DURATION);
|
||||
animator.addListener(new AnimatorListenerAdapter() {
|
||||
@Override
|
||||
public void onAnimationEnd(Animator animation) {
|
||||
|
||||
@@ -47,6 +47,7 @@ public class ConversationIntents {
|
||||
private static final String EXTRA_GIFT_BADGE = "gift_badge";
|
||||
private static final String EXTRA_SHARE_DATA_TIMESTAMP = "share_data_timestamp";
|
||||
private static final String EXTRA_CONVERSATION_TYPE = "conversation_type";
|
||||
private static final String EXTRA_INCOGNITO = "incognito";
|
||||
private static final String INTENT_DATA = "intent_data";
|
||||
private static final String INTENT_TYPE = "intent_type";
|
||||
|
||||
@@ -152,7 +153,8 @@ public class ConversationIntents {
|
||||
false,
|
||||
null,
|
||||
-1L,
|
||||
ConversationScreenType.BUBBLE);
|
||||
ConversationScreenType.BUBBLE,
|
||||
false);
|
||||
}
|
||||
|
||||
return new ConversationArgs(RecipientId.from(Objects.requireNonNull(arguments.getString(EXTRA_RECIPIENT))),
|
||||
@@ -169,7 +171,8 @@ public class ConversationIntents {
|
||||
arguments.getBoolean(EXTRA_WITH_SEARCH_OPEN, false),
|
||||
arguments.getParcelable(EXTRA_GIFT_BADGE),
|
||||
arguments.getLong(EXTRA_SHARE_DATA_TIMESTAMP, -1L),
|
||||
ConversationScreenType.from(arguments.getInt(EXTRA_CONVERSATION_TYPE, 0)));
|
||||
ConversationScreenType.from(arguments.getInt(EXTRA_CONVERSATION_TYPE, 0)),
|
||||
arguments.getBoolean(EXTRA_INCOGNITO, false));
|
||||
}
|
||||
|
||||
public final static class Builder {
|
||||
@@ -191,6 +194,7 @@ public class ConversationIntents {
|
||||
private boolean withSearchOpen;
|
||||
private Badge giftBadge;
|
||||
private long shareDataTimestamp = -1L;
|
||||
private boolean incognito;
|
||||
|
||||
private Builder(@NonNull Context context,
|
||||
@NonNull Class<? extends Activity> conversationActivityClass,
|
||||
@@ -218,6 +222,7 @@ public class ConversationIntents {
|
||||
withSearchOpen = args.isWithSearchOpen();
|
||||
giftBadge = args.getGiftBadge();
|
||||
shareDataTimestamp = args.getShareDataTimestamp();
|
||||
incognito = args.isIncognito();
|
||||
|
||||
return this;
|
||||
}
|
||||
@@ -282,6 +287,11 @@ public class ConversationIntents {
|
||||
return this;
|
||||
}
|
||||
|
||||
public @NonNull Builder asIncognito(boolean incognito) {
|
||||
this.incognito = incognito;
|
||||
return this;
|
||||
}
|
||||
|
||||
public @NonNull ConversationArgs toConversationArgs() {
|
||||
return new ConversationArgs(
|
||||
recipientId,
|
||||
@@ -298,7 +308,8 @@ public class ConversationIntents {
|
||||
withSearchOpen,
|
||||
giftBadge,
|
||||
shareDataTimestamp,
|
||||
conversationScreenType
|
||||
conversationScreenType,
|
||||
incognito
|
||||
);
|
||||
}
|
||||
|
||||
@@ -329,6 +340,7 @@ public class ConversationIntents {
|
||||
intent.putExtra(EXTRA_GIFT_BADGE, giftBadge);
|
||||
intent.putExtra(EXTRA_SHARE_DATA_TIMESTAMP, shareDataTimestamp);
|
||||
intent.putExtra(EXTRA_CONVERSATION_TYPE, conversationScreenType.code);
|
||||
intent.putExtra(EXTRA_INCOGNITO, incognito);
|
||||
|
||||
if (draftText != null) {
|
||||
intent.putExtra(EXTRA_TEXT, draftText);
|
||||
|
||||
@@ -16,6 +16,7 @@ import io.reactivex.rxjava3.kotlin.subscribeBy
|
||||
import org.signal.core.util.concurrent.LifecycleDisposable
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.messagerequests.MessageRequestState
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
|
||||
@@ -163,6 +164,12 @@ internal object ConversationOptionsMenu {
|
||||
hideMenuItem(menu, R.id.menu_add_shortcut)
|
||||
}
|
||||
|
||||
if (SignalStore.labs.individualChatPlaintextExport) {
|
||||
menu.findItem(R.id.menu_export)?.title = menu.findItem(R.id.menu_export)?.title.toString() + " (Labs)"
|
||||
} else {
|
||||
hideMenuItem(menu, R.id.menu_export)
|
||||
}
|
||||
|
||||
if (isActiveV2Group) {
|
||||
hideMenuItem(menu, R.id.menu_mute_notifications)
|
||||
hideMenuItem(menu, R.id.menu_conversation_settings)
|
||||
@@ -210,6 +217,7 @@ internal object ConversationOptionsMenu {
|
||||
R.id.menu_unmute_notifications -> callback.handleUnmuteNotifications()
|
||||
R.id.menu_conversation_settings -> callback.handleConversationSettings()
|
||||
R.id.menu_expiring_messages_off, R.id.menu_expiring_messages -> callback.handleSelectMessageExpiration()
|
||||
R.id.menu_export -> callback.handleExportChat()
|
||||
R.id.menu_create_bubble -> callback.handleCreateBubble()
|
||||
androidx.appcompat.R.id.home -> callback.handleGoHome()
|
||||
R.id.menu_block -> callback.handleBlock()
|
||||
@@ -289,5 +297,6 @@ internal object ConversationOptionsMenu {
|
||||
fun handleReportSpam()
|
||||
fun handleMessageRequestAccept()
|
||||
fun handleDeleteConversation()
|
||||
fun handleExportChat()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,18 +42,24 @@ public class MarkReadHelper {
|
||||
private final ConversationId conversationId;
|
||||
private final Context context;
|
||||
private final LifecycleOwner lifecycleOwner;
|
||||
private final boolean incognito;
|
||||
private final Debouncer debouncer = new Debouncer(DEBOUNCE_TIMEOUT);
|
||||
private long latestTimestamp;
|
||||
private boolean ignoreViewReveals = false;
|
||||
|
||||
public MarkReadHelper(@NonNull ConversationId conversationId, @NonNull Context context, @NonNull LifecycleOwner lifecycleOwner) {
|
||||
this(conversationId, context, lifecycleOwner, false);
|
||||
}
|
||||
|
||||
public MarkReadHelper(@NonNull ConversationId conversationId, @NonNull Context context, @NonNull LifecycleOwner lifecycleOwner, boolean incognito) {
|
||||
this.conversationId = conversationId;
|
||||
this.context = context.getApplicationContext();
|
||||
this.lifecycleOwner = lifecycleOwner;
|
||||
this.incognito = incognito;
|
||||
}
|
||||
|
||||
public void onViewsRevealed(long timestamp) {
|
||||
if (timestamp <= latestTimestamp || lifecycleOwner.getLifecycle().getCurrentState() != Lifecycle.State.RESUMED || ignoreViewReveals) {
|
||||
if (incognito || timestamp <= latestTimestamp || lifecycleOwner.getLifecycle().getCurrentState() != Lifecycle.State.RESUMED || ignoreViewReveals) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -171,11 +171,11 @@ public final class MenuState {
|
||||
hasPollTerminate = true;
|
||||
}
|
||||
|
||||
if (RemoteConfig.sendPinnedMessages() && !messageRecord.isPending() && messageRecord.getPinnedUntil() == 0 && !conversationRecipient.isReleaseNotes() && canEditGroupInfo && !hasGift) {
|
||||
if (!messageRecord.isPending() && messageRecord.getPinnedUntil() == 0 && !conversationRecipient.isReleaseNotes() && canEditGroupInfo && !hasGift) {
|
||||
canPinMessage = true;
|
||||
}
|
||||
|
||||
if (RemoteConfig.sendPinnedMessages() && messageRecord.getPinnedUntil() != 0 && !conversationRecipient.isReleaseNotes() && canEditGroupInfo && !hasGift) {
|
||||
if (messageRecord.getPinnedUntil() != 0 && !conversationRecipient.isReleaseNotes() && canEditGroupInfo && !hasGift) {
|
||||
canUnpinMessage = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,477 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.conversation.plaintext
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.webkit.MimeTypeMap
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import org.signal.core.util.EventTimer
|
||||
import org.signal.core.util.ParallelEventTimer
|
||||
import org.signal.core.util.androidx.DocumentFileUtil.outputStream
|
||||
import org.signal.core.util.concurrent.SignalExecutors
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.attachments.DatabaseAttachment
|
||||
import org.thoughtcrime.securesms.database.MentionUtil
|
||||
import org.thoughtcrime.securesms.database.MessageTable
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.model.Mention
|
||||
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
|
||||
import org.thoughtcrime.securesms.database.model.Quote
|
||||
import org.thoughtcrime.securesms.polls.PollRecord
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.util.MediaUtil
|
||||
import java.io.BufferedWriter
|
||||
import java.io.IOException
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.Callable
|
||||
import java.util.concurrent.ExecutorService
|
||||
import java.util.concurrent.Future
|
||||
|
||||
/**
|
||||
* Exports a conversation thread as user-friendly plaintext with attachments.
|
||||
*/
|
||||
object PlaintextExportRepository {
|
||||
|
||||
private val TAG = Log.tag(PlaintextExportRepository::class.java)
|
||||
private const val BATCH_SIZE = 500
|
||||
|
||||
fun export(
|
||||
context: Context,
|
||||
threadId: Long,
|
||||
directoryUri: Uri,
|
||||
chatName: String,
|
||||
progressListener: ProgressListener,
|
||||
cancellationSignal: CancellationSignal
|
||||
): Boolean {
|
||||
val eventTimer = EventTimer()
|
||||
val stats = getExportStats(threadId)
|
||||
eventTimer.emit("stats")
|
||||
|
||||
val root = DocumentFile.fromTreeUri(context, directoryUri) ?: run {
|
||||
Log.w(TAG, "Could not open directory")
|
||||
return false
|
||||
}
|
||||
|
||||
val sanitizedName = sanitizeFileName(chatName)
|
||||
if (root.findFile(sanitizedName) != null) {
|
||||
Log.w(TAG, "Export folder already exists: $sanitizedName")
|
||||
return false
|
||||
}
|
||||
|
||||
val chatDir = root.createDirectory(sanitizedName) ?: run {
|
||||
Log.w(TAG, "Could not create chat directory")
|
||||
return false
|
||||
}
|
||||
|
||||
val mediaDir = chatDir.createDirectory("media") ?: run {
|
||||
Log.w(TAG, "Could not create media directory")
|
||||
return false
|
||||
}
|
||||
|
||||
val chatFile = chatDir.createFile("text/plain", "chat.txt") ?: run {
|
||||
Log.w(TAG, "Could not create chat.txt")
|
||||
return false
|
||||
}
|
||||
|
||||
val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US)
|
||||
val attachmentDateFormat = SimpleDateFormat("yyyy-MM-dd", Locale.US)
|
||||
|
||||
val pendingAttachments = mutableListOf<PendingAttachment>()
|
||||
var messagesProcessed = 0
|
||||
|
||||
val outputStream = chatFile.outputStream(context) ?: run {
|
||||
Log.w(TAG, "Could not open chat.txt for writing")
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
outputStream.bufferedWriter().use { writer ->
|
||||
writer.write("Chat export: $chatName")
|
||||
writer.newLine()
|
||||
writer.write("Exported on: ${dateFormat.format(Date())}")
|
||||
writer.newLine()
|
||||
writer.write("=".repeat(60))
|
||||
writer.newLine()
|
||||
writer.newLine()
|
||||
|
||||
val extraDataTimer = ParallelEventTimer()
|
||||
|
||||
// Messages
|
||||
MessageTable.mmsReaderFor(SignalDatabase.messages.getConversation(threadId, dateReceiveOrderBy = "ASC")).use { reader ->
|
||||
while (true) {
|
||||
if (cancellationSignal.isCancelled()) return false
|
||||
|
||||
val batch = readBatch(reader)
|
||||
if (batch.isEmpty()) break
|
||||
|
||||
val extraData = fetchExtraData(batch, extraDataTimer)
|
||||
eventTimer.emit("extra-data")
|
||||
|
||||
for (message in batch) {
|
||||
if (cancellationSignal.isCancelled()) return false
|
||||
|
||||
writer.writeMessage(context, message, extraData, dateFormat, attachmentDateFormat, pendingAttachments)
|
||||
writer.newLine()
|
||||
|
||||
messagesProcessed++
|
||||
progressListener.onProgress(messagesProcessed, stats.messageCount, 0, stats.attachmentCount)
|
||||
}
|
||||
eventTimer.emit("messages")
|
||||
}
|
||||
}
|
||||
|
||||
Log.d(TAG, "[PlaintextExport] ${extraDataTimer.stop().summary}")
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "Error writing chat.txt", e)
|
||||
return false
|
||||
}
|
||||
|
||||
// Attachments — use createFile directly (like LocalArchiver's FilesFileSystem) to avoid
|
||||
// the extra content resolver queries that newFile/findFile perform.
|
||||
val totalAttachments = pendingAttachments.size
|
||||
var attachmentsProcessed = 0
|
||||
for (pending in pendingAttachments) {
|
||||
if (cancellationSignal.isCancelled()) return false
|
||||
|
||||
try {
|
||||
val outputStream = mediaDir.createFile("application/octet-stream", pending.exportedName)?.let { it.outputStream(context) }
|
||||
if (outputStream == null) {
|
||||
Log.w(TAG, "Could not create attachment file: ${pending.exportedName}")
|
||||
attachmentsProcessed++
|
||||
progressListener.onProgress(stats.messageCount, stats.messageCount, attachmentsProcessed, totalAttachments)
|
||||
continue
|
||||
}
|
||||
|
||||
outputStream.use { out ->
|
||||
SignalDatabase.attachments.getAttachmentStream(pending.attachment.attachmentId, 0).use { input ->
|
||||
input.copyTo(out)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Error exporting attachment: ${pending.exportedName}", e)
|
||||
}
|
||||
|
||||
attachmentsProcessed++
|
||||
progressListener.onProgress(stats.messageCount, stats.messageCount, attachmentsProcessed, totalAttachments)
|
||||
eventTimer.emit("media")
|
||||
}
|
||||
|
||||
Log.d(TAG, "[PlaintextExport] ${eventTimer.stop().summary}")
|
||||
return true
|
||||
}
|
||||
|
||||
private fun readBatch(reader: MessageTable.MmsReader): List<MmsMessageRecord> {
|
||||
val batch = ArrayList<MmsMessageRecord>(BATCH_SIZE)
|
||||
for (i in 0 until BATCH_SIZE) {
|
||||
val record = reader.getNext() ?: break
|
||||
if (record is MmsMessageRecord) {
|
||||
batch.add(record)
|
||||
}
|
||||
}
|
||||
return batch
|
||||
}
|
||||
|
||||
private fun fetchExtraData(batch: List<MmsMessageRecord>, extraDataTimer: ParallelEventTimer): ExtraMessageData {
|
||||
val messageIds = batch.map { it.id }
|
||||
val executor = SignalExecutors.BOUNDED
|
||||
|
||||
val attachmentsFuture = executor.submitTyped {
|
||||
extraDataTimer.timeEvent("attachments") {
|
||||
SignalDatabase.attachments.getAttachmentsForMessages(messageIds)
|
||||
}
|
||||
}
|
||||
|
||||
val mentionsFuture = executor.submitTyped {
|
||||
extraDataTimer.timeEvent("mentions") {
|
||||
SignalDatabase.mentions.getMentionsForMessages(messageIds)
|
||||
}
|
||||
}
|
||||
|
||||
val pollsFuture = executor.submitTyped {
|
||||
extraDataTimer.timeEvent("polls") {
|
||||
SignalDatabase.polls.getPollsForMessages(messageIds)
|
||||
}
|
||||
}
|
||||
|
||||
return ExtraMessageData(
|
||||
attachmentsById = attachmentsFuture.get(),
|
||||
mentionsById = mentionsFuture.get(),
|
||||
pollsById = pollsFuture.get()
|
||||
)
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
internal fun getExportStats(threadId: Long): ExportStats {
|
||||
val messageCount = SignalDatabase.messages.getMessageCountForThread(threadId)
|
||||
val attachmentCount = SignalDatabase.attachments.getPlaintextExportableAttachmentCountForThread(threadId)
|
||||
return ExportStats(messageCount, attachmentCount)
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
internal fun BufferedWriter.writeMessage(
|
||||
context: Context,
|
||||
message: MmsMessageRecord,
|
||||
extraData: ExtraMessageData,
|
||||
dateFormat: SimpleDateFormat,
|
||||
attachmentDateFormat: SimpleDateFormat,
|
||||
pendingAttachments: MutableList<PendingAttachment>
|
||||
) {
|
||||
val timestamp = dateFormat.format(Date(message.dateSent))
|
||||
|
||||
if (message.isUpdate) {
|
||||
this.writeUpdateMessage(context, message, timestamp)
|
||||
return
|
||||
}
|
||||
|
||||
val sender = getSenderName(context, message)
|
||||
val prefix = "[$timestamp] $sender: "
|
||||
|
||||
if (message.isRemoteDelete) {
|
||||
this.write("$prefix(This message was deleted)")
|
||||
this.newLine()
|
||||
return
|
||||
}
|
||||
|
||||
if (message.isViewOnce) {
|
||||
this.write("$prefix(View-once media)")
|
||||
this.newLine()
|
||||
return
|
||||
}
|
||||
|
||||
val poll = extraData.pollsById[message.id]
|
||||
if (poll != null) {
|
||||
this.writePoll(prefix, poll)
|
||||
return
|
||||
}
|
||||
|
||||
val attachments = extraData.attachmentsById[message.id] ?: emptyList()
|
||||
val mainAttachments = attachments.filter { it.hasData && !it.quote }
|
||||
val stickerAttachment = mainAttachments.find { it.stickerLocator != null }
|
||||
|
||||
val hasQuote = message.quote != null
|
||||
if (hasQuote) {
|
||||
this.writeQuote(context, message.quote!!, timestamp, sender)
|
||||
}
|
||||
|
||||
if (stickerAttachment != null) {
|
||||
this.writeSticker(stickerAttachment, prefix, hasQuote, attachmentDateFormat, pendingAttachments)
|
||||
return
|
||||
}
|
||||
|
||||
val mentions = extraData.mentionsById[message.id] ?: emptyList()
|
||||
val body = resolveBody(context, message.body, mentions)
|
||||
if (!body.isNullOrEmpty()) {
|
||||
if (!hasQuote) {
|
||||
this.write("$prefix$body")
|
||||
} else {
|
||||
this.write(body)
|
||||
}
|
||||
this.newLine()
|
||||
} else if (!hasQuote && mainAttachments.isEmpty()) {
|
||||
this.write(prefix)
|
||||
this.newLine()
|
||||
return
|
||||
}
|
||||
|
||||
val wrotePrefix = !body.isNullOrEmpty() || hasQuote
|
||||
this.writeAttachments(mainAttachments, prefix, wrotePrefix, attachmentDateFormat, pendingAttachments)
|
||||
}
|
||||
|
||||
private fun BufferedWriter.writeUpdateMessage(context: Context, message: MmsMessageRecord, timestamp: String) {
|
||||
this.write("--- ${formatUpdateMessage(context, message, timestamp)} ---")
|
||||
this.newLine()
|
||||
}
|
||||
|
||||
private fun BufferedWriter.writePoll(prefix: String, poll: PollRecord) {
|
||||
this.write("$prefix(Poll) ${poll.question}")
|
||||
this.newLine()
|
||||
for (option in poll.pollOptions) {
|
||||
val voteCount = option.voters.size
|
||||
val voteSuffix = if (voteCount == 1) "vote" else "votes"
|
||||
this.write(" - ${option.text} ($voteCount $voteSuffix)")
|
||||
this.newLine()
|
||||
}
|
||||
if (poll.hasEnded) {
|
||||
this.write(" (Poll ended)")
|
||||
this.newLine()
|
||||
}
|
||||
}
|
||||
|
||||
private fun BufferedWriter.writeQuote(context: Context, quote: Quote, timestamp: String, sender: String) {
|
||||
val quoteAuthor = Recipient.resolved(quote.author).getDisplayName(context)
|
||||
val quoteText = quote.displayText?.toString()?.ifEmpty { null } ?: "(media)"
|
||||
this.write("[$timestamp] $sender:")
|
||||
this.newLine()
|
||||
this.write("> Quoting $quoteAuthor:")
|
||||
this.newLine()
|
||||
for (line in quoteText.lines()) {
|
||||
this.write("> $line")
|
||||
this.newLine()
|
||||
}
|
||||
}
|
||||
|
||||
private fun BufferedWriter.writeSticker(
|
||||
stickerAttachment: DatabaseAttachment,
|
||||
prefix: String,
|
||||
hasQuote: Boolean,
|
||||
attachmentDateFormat: SimpleDateFormat,
|
||||
pendingAttachments: MutableList<PendingAttachment>
|
||||
) {
|
||||
val emoji = stickerAttachment.stickerLocator?.emoji ?: ""
|
||||
val exportedName = buildAttachmentFileName(stickerAttachment, attachmentDateFormat)
|
||||
pendingAttachments.add(PendingAttachment(stickerAttachment, exportedName))
|
||||
if (!hasQuote) {
|
||||
this.write(prefix)
|
||||
}
|
||||
this.write("(Sticker) $emoji [See: media/$exportedName]")
|
||||
this.newLine()
|
||||
}
|
||||
|
||||
private fun BufferedWriter.writeAttachments(
|
||||
attachments: List<DatabaseAttachment>,
|
||||
prefix: String,
|
||||
wrotePrefix: Boolean,
|
||||
attachmentDateFormat: SimpleDateFormat,
|
||||
pendingAttachments: MutableList<PendingAttachment>
|
||||
) {
|
||||
for ((index, attachment) in attachments.withIndex()) {
|
||||
val exportedName = buildAttachmentFileName(attachment, attachmentDateFormat)
|
||||
pendingAttachments.add(PendingAttachment(attachment, exportedName))
|
||||
|
||||
val label = getAttachmentLabel(attachment)
|
||||
|
||||
if (!wrotePrefix && index == 0) {
|
||||
this.write(prefix)
|
||||
}
|
||||
|
||||
val caption = attachment.caption
|
||||
if (caption != null) {
|
||||
this.write("[$label: media/$exportedName] $caption")
|
||||
} else {
|
||||
this.write("[$label: media/$exportedName]")
|
||||
}
|
||||
this.newLine()
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolveBody(context: Context, body: String?, mentions: List<Mention>): String? {
|
||||
if (mentions.isNotEmpty() && !body.isNullOrEmpty()) {
|
||||
return MentionUtil.updateBodyAndMentionsWithDisplayNames(context, body, mentions).body?.toString()
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
internal fun getAttachmentLabel(attachment: DatabaseAttachment): String {
|
||||
val contentType = attachment.contentType ?: return "Attachment"
|
||||
return when {
|
||||
MediaUtil.isAudioType(contentType) && attachment.voiceNote -> "Voice message"
|
||||
MediaUtil.isAudioType(contentType) -> "Audio"
|
||||
MediaUtil.isVideoType(contentType) && attachment.videoGif -> "GIF"
|
||||
MediaUtil.isVideoType(contentType) -> "Video"
|
||||
MediaUtil.isImageType(contentType) -> "Image"
|
||||
else -> "Document"
|
||||
}
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
internal fun getSenderName(context: Context, message: MmsMessageRecord): String {
|
||||
return if (message.isOutgoing) {
|
||||
"You"
|
||||
} else {
|
||||
message.fromRecipient.getDisplayName(context)
|
||||
}
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
internal fun formatUpdateMessage(context: Context, message: MmsMessageRecord, timestamp: String): String {
|
||||
return when {
|
||||
message.isGroupUpdate -> "$timestamp Group updated"
|
||||
message.isGroupQuit -> "$timestamp ${getSenderName(context, message)} left the group"
|
||||
message.isExpirationTimerUpdate -> "$timestamp Disappearing messages timer updated"
|
||||
message.isIdentityUpdate -> "$timestamp Safety number changed"
|
||||
message.isIdentityVerified -> "$timestamp Safety number verified"
|
||||
message.isIdentityDefault -> "$timestamp Safety number verification reset"
|
||||
message.isProfileChange -> "$timestamp Profile updated"
|
||||
message.isChangeNumber -> "$timestamp Phone number changed"
|
||||
message.isCallLog -> formatCallMessage(context, message, timestamp)
|
||||
message.isJoined -> "$timestamp ${getSenderName(context, message)} joined Signal"
|
||||
message.isGroupV1MigrationEvent -> "$timestamp Group upgraded to new group type"
|
||||
message.isPaymentNotification -> "$timestamp Payment sent/received"
|
||||
else -> "$timestamp System message"
|
||||
}
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
internal fun formatCallMessage(context: Context, message: MmsMessageRecord, timestamp: String): String {
|
||||
return when {
|
||||
message.isIncomingAudioCall -> "$timestamp Incoming voice call"
|
||||
message.isIncomingVideoCall -> "$timestamp Incoming video call"
|
||||
message.isOutgoingAudioCall -> "$timestamp Outgoing voice call"
|
||||
message.isOutgoingVideoCall -> "$timestamp Outgoing video call"
|
||||
message.isMissedAudioCall -> "$timestamp Missed voice call"
|
||||
message.isMissedVideoCall -> "$timestamp Missed video call"
|
||||
message.isGroupCall -> "$timestamp Group call"
|
||||
else -> "$timestamp Call"
|
||||
}
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
internal fun buildAttachmentFileName(attachment: DatabaseAttachment, dateFormat: SimpleDateFormat): String {
|
||||
val date = dateFormat.format(Date(attachment.uploadTimestamp.takeIf { it > 0 } ?: System.currentTimeMillis()))
|
||||
val id = attachment.attachmentId.id
|
||||
val extension = getExtension(attachment)
|
||||
return "$date-$id.$extension"
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
internal fun getExtension(attachment: DatabaseAttachment): String {
|
||||
val fromFileName = attachment.fileName?.substringAfterLast('.', "")?.takeIf { it.isNotEmpty() && it.length <= 10 }
|
||||
if (fromFileName != null) return fromFileName
|
||||
|
||||
val fromMime = attachment.contentType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) }
|
||||
if (fromMime != null) return fromMime
|
||||
|
||||
return "bin"
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
internal fun sanitizeFileName(name: String): String {
|
||||
return name.replace(Regex("[\\\\/:*?\"<>|]"), "_").trim().take(100)
|
||||
}
|
||||
|
||||
private fun <T> ExecutorService.submitTyped(callable: Callable<T>): Future<T> {
|
||||
return this.submit(callable)
|
||||
}
|
||||
|
||||
data class ExportStats(val messageCount: Int, val attachmentCount: Int)
|
||||
|
||||
@VisibleForTesting
|
||||
data class PendingAttachment(
|
||||
val attachment: DatabaseAttachment,
|
||||
val exportedName: String
|
||||
)
|
||||
|
||||
@VisibleForTesting
|
||||
internal data class ExtraMessageData(
|
||||
val attachmentsById: Map<Long, List<DatabaseAttachment>>,
|
||||
val mentionsById: Map<Long, List<Mention>>,
|
||||
val pollsById: Map<Long, PollRecord>
|
||||
)
|
||||
|
||||
fun interface ProgressListener {
|
||||
fun onProgress(messagesProcessed: Int, messageCount: Int, attachmentsProcessed: Int, attachmentCount: Int)
|
||||
}
|
||||
|
||||
fun interface CancellationSignal {
|
||||
fun isCancelled(): Boolean
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,7 @@ import android.view.View
|
||||
import android.view.View.OnFocusChangeListener
|
||||
import android.view.ViewGroup
|
||||
import android.view.ViewTreeObserver
|
||||
import android.view.WindowManager
|
||||
import android.view.animation.AnimationUtils
|
||||
import android.view.inputmethod.EditorInfo
|
||||
import android.widget.ImageButton
|
||||
@@ -104,6 +105,7 @@ import org.greenrobot.eventbus.ThreadMode
|
||||
import org.signal.core.models.media.Media
|
||||
import org.signal.core.models.media.TransformProperties
|
||||
import org.signal.core.ui.BottomSheetUtil
|
||||
import org.signal.core.ui.contracts.OpenDocumentTreeContract
|
||||
import org.signal.core.ui.getWindowSizeClass
|
||||
import org.signal.core.ui.isSplitPane
|
||||
import org.signal.core.ui.logging.LoggingFragment
|
||||
@@ -147,6 +149,7 @@ import org.thoughtcrime.securesms.components.InsetAwareConstraintLayout
|
||||
import org.thoughtcrime.securesms.components.ProgressCardDialogFragment
|
||||
import org.thoughtcrime.securesms.components.ScrollToPositionDelegate
|
||||
import org.thoughtcrime.securesms.components.SendButton
|
||||
import org.thoughtcrime.securesms.components.SignalProgressDialog
|
||||
import org.thoughtcrime.securesms.components.ViewBinderDelegate
|
||||
import org.thoughtcrime.securesms.components.compose.ActionModeTopBarView
|
||||
import org.thoughtcrime.securesms.components.compose.DeleteSyncEducationDialog
|
||||
@@ -548,6 +551,7 @@ class ConversationFragment :
|
||||
private lateinit var markReadHelper: MarkReadHelper
|
||||
private lateinit var giphyMp4ProjectionRecycler: GiphyMp4ProjectionRecycler
|
||||
private lateinit var addToContactsLauncher: ActivityResultLauncher<Intent>
|
||||
private lateinit var plaintextExportDirectoryLauncher: ActivityResultLauncher<Uri?>
|
||||
private lateinit var conversationActivityResultContracts: ConversationActivityResultContracts
|
||||
private lateinit var scrollToPositionDelegate: ScrollToPositionDelegate
|
||||
private lateinit var adapter: ConversationAdapterV2
|
||||
@@ -655,7 +659,7 @@ class ConversationFragment :
|
||||
FullscreenHelper(requireActivity()).showSystemUI()
|
||||
}
|
||||
|
||||
markReadHelper = MarkReadHelper(ConversationId.forConversation(args.threadId), requireContext(), viewLifecycleOwner)
|
||||
markReadHelper = MarkReadHelper(ConversationId.forConversation(args.threadId), requireContext(), viewLifecycleOwner, args.isIncognito)
|
||||
markReadHelper.ignoreViewReveals()
|
||||
|
||||
attachmentManager = AttachmentManager(requireContext(), requireView(), AttachmentManagerListener())
|
||||
@@ -666,7 +670,8 @@ class ConversationFragment :
|
||||
requireActivity(),
|
||||
binding.toolbarBackground,
|
||||
viewModel::wallpaperSnapshot,
|
||||
viewLifecycleOwner
|
||||
viewLifecycleOwner,
|
||||
incognito = args.isIncognito
|
||||
)
|
||||
conversationToolbarOnScrollHelper.attach(binding.conversationItemRecycler)
|
||||
presentWallpaper(args.wallpaper)
|
||||
@@ -677,6 +682,7 @@ class ConversationFragment :
|
||||
presentStoryRing()
|
||||
|
||||
observeConversationThread()
|
||||
observePlaintextExportState()
|
||||
|
||||
viewModel
|
||||
.inputReadyState
|
||||
@@ -1420,7 +1426,7 @@ class ConversationFragment :
|
||||
|
||||
private fun presentPinnedMessage(pinnedMessages: List<ConversationMessage>, hasWallpaper: Boolean) {
|
||||
if (pinnedMessages.isNotEmpty()) {
|
||||
binding.conversationBanner.showPinnedMessageStub(messages = pinnedMessages, canUnpin = conversationGroupViewModel.canEditGroupInfo() && RemoteConfig.sendPinnedMessages, hasWallpaper = hasWallpaper, shouldAnimate = !firstPinRender)
|
||||
binding.conversationBanner.showPinnedMessageStub(messages = pinnedMessages, canUnpin = conversationGroupViewModel.canEditGroupInfo(), hasWallpaper = hasWallpaper, shouldAnimate = !firstPinRender)
|
||||
} else {
|
||||
binding.conversationBanner.hidePinnedMessageStub()
|
||||
}
|
||||
@@ -1477,6 +1483,7 @@ class ConversationFragment :
|
||||
var inputDisabled = true
|
||||
when {
|
||||
inputReadyState.isClientExpired || inputReadyState.isUnauthorized -> disabledInputView.showAsExpiredOrUnauthorized(inputReadyState.isClientExpired, inputReadyState.isUnauthorized)
|
||||
args.isIncognito -> disabledInputView.showAsIncognito()
|
||||
!inputReadyState.messageRequestState.isAccepted -> disabledInputView.showAsMessageRequest(inputReadyState.conversationRecipient, inputReadyState.messageRequestState)
|
||||
inputReadyState.isActiveGroup == false -> disabledInputView.showAsNoLongerAMember()
|
||||
inputReadyState.isRequestingMember == true -> disabledInputView.showAsRequestingMember()
|
||||
@@ -1543,9 +1550,79 @@ class ConversationFragment :
|
||||
|
||||
private fun registerForResults() {
|
||||
addToContactsLauncher = registerForActivityResult(AddToContactsContract()) {}
|
||||
plaintextExportDirectoryLauncher = registerForActivityResult(OpenDocumentTreeContract()) { uri ->
|
||||
if (uri != null) {
|
||||
val takeFlags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
||||
requireContext().contentResolver.takePersistableUriPermission(uri, takeFlags)
|
||||
viewModel.startPlaintextExport(requireContext().applicationContext, uri)
|
||||
}
|
||||
}
|
||||
conversationActivityResultContracts = ConversationActivityResultContracts(this, ActivityResultCallbacks())
|
||||
}
|
||||
|
||||
private fun observePlaintextExportState() {
|
||||
var progressDialog: SignalProgressDialog? = null
|
||||
|
||||
lifecycleScope.launch {
|
||||
viewModel.plaintextExportState.collectLatest { state ->
|
||||
val exporting = state is ConversationViewModel.PlaintextExportState.Preparing || state is ConversationViewModel.PlaintextExportState.InProgress
|
||||
if (exporting) {
|
||||
requireActivity().window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
} else {
|
||||
requireActivity().window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
}
|
||||
|
||||
when (state) {
|
||||
is ConversationViewModel.PlaintextExportState.None -> {
|
||||
progressDialog?.dismiss()
|
||||
progressDialog = null
|
||||
}
|
||||
|
||||
is ConversationViewModel.PlaintextExportState.Preparing -> {
|
||||
progressDialog = SignalProgressDialog.show(
|
||||
context = requireContext(),
|
||||
title = getString(R.string.conversation_export__exporting),
|
||||
message = getString(R.string.conversation_export__preparing),
|
||||
indeterminate = true,
|
||||
cancelable = false,
|
||||
negativeButtonText = getString(android.R.string.cancel),
|
||||
negativeButtonListener = { _, _ -> viewModel.cancelExport() }
|
||||
)
|
||||
}
|
||||
|
||||
is ConversationViewModel.PlaintextExportState.InProgress -> {
|
||||
progressDialog?.let {
|
||||
it.isIndeterminate = false
|
||||
it.progress = state.percent
|
||||
it.setMessage(state.status)
|
||||
}
|
||||
}
|
||||
|
||||
is ConversationViewModel.PlaintextExportState.Complete -> {
|
||||
progressDialog?.dismiss()
|
||||
progressDialog = null
|
||||
toast(R.string.conversation_export__export_complete, toastDuration = Toast.LENGTH_LONG)
|
||||
viewModel.clearPlaintextExportState()
|
||||
}
|
||||
|
||||
is ConversationViewModel.PlaintextExportState.Failed -> {
|
||||
progressDialog?.dismiss()
|
||||
progressDialog = null
|
||||
toast(R.string.conversation_export__export_failed, toastDuration = Toast.LENGTH_LONG)
|
||||
viewModel.clearPlaintextExportState()
|
||||
}
|
||||
|
||||
is ConversationViewModel.PlaintextExportState.Cancelled -> {
|
||||
progressDialog?.dismiss()
|
||||
progressDialog = null
|
||||
toast(R.string.conversation_export__export_cancelled, toastDuration = Toast.LENGTH_SHORT)
|
||||
viewModel.clearPlaintextExportState()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onRecipientChanged(recipient: Recipient) {
|
||||
presentWallpaper(recipient.wallpaper)
|
||||
presentConversationTitle(recipient)
|
||||
@@ -4090,6 +4167,10 @@ class ConversationFragment :
|
||||
override fun handleDeleteConversation() {
|
||||
onDeleteConversation()
|
||||
}
|
||||
|
||||
override fun handleExportChat() {
|
||||
plaintextExportDirectoryLauncher.launch(null)
|
||||
}
|
||||
}
|
||||
|
||||
private inner class OnReactionsSelectedListener : ConversationReactionOverlay.OnReactionSelectedListener {
|
||||
@@ -4362,7 +4443,7 @@ class ConversationFragment :
|
||||
childFragmentManager,
|
||||
threadId = args.threadId,
|
||||
conversationRecipientId = viewModel.recipientSnapshot?.id!!,
|
||||
canUnpin = conversationGroupViewModel.canEditGroupInfo() && RemoteConfig.sendPinnedMessages
|
||||
canUnpin = conversationGroupViewModel.canEditGroupInfo()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,8 @@ class ConversationToolbarOnScrollHelper(
|
||||
activity: FragmentActivity,
|
||||
toolbarBackground: View,
|
||||
private val wallpaperProvider: () -> ChatWallpaper?,
|
||||
lifecycleOwner: LifecycleOwner
|
||||
lifecycleOwner: LifecycleOwner,
|
||||
private val incognito: Boolean = false
|
||||
) : Material3OnScrollHelper(
|
||||
activity = activity,
|
||||
views = listOf(toolbarBackground),
|
||||
@@ -24,10 +25,10 @@ class ConversationToolbarOnScrollHelper(
|
||||
setStatusBarColor = {}
|
||||
) {
|
||||
override val activeColorSet: ColorSet
|
||||
get() = ColorSet(getActiveToolbarColor(wallpaperProvider() != null))
|
||||
get() = if (incognito) ColorSet(R.color.conversation_toolbar_color_incognito) else ColorSet(getActiveToolbarColor(wallpaperProvider() != null))
|
||||
|
||||
override val inactiveColorSet: ColorSet
|
||||
get() = ColorSet(getInactiveToolbarColor(wallpaperProvider() != null))
|
||||
get() = if (incognito) ColorSet(R.color.conversation_toolbar_color_incognito) else ColorSet(getInactiveToolbarColor(wallpaperProvider() != null))
|
||||
|
||||
@ColorRes
|
||||
private fun getActiveToolbarColor(hasWallpaper: Boolean): Int {
|
||||
|
||||
@@ -46,6 +46,7 @@ import org.signal.core.models.ServiceId
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.core.util.orNull
|
||||
import org.signal.paging.ProxyPagingController
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.banner.Banner
|
||||
import org.thoughtcrime.securesms.banner.banners.BubbleOptOutBanner
|
||||
import org.thoughtcrime.securesms.banner.banners.GroupsV1MigrationSuggestionsBanner
|
||||
@@ -58,6 +59,7 @@ import org.thoughtcrime.securesms.conversation.ConversationMessage
|
||||
import org.thoughtcrime.securesms.conversation.ScheduledMessagesRepository
|
||||
import org.thoughtcrime.securesms.conversation.colors.ChatColors
|
||||
import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectPart
|
||||
import org.thoughtcrime.securesms.conversation.plaintext.PlaintextExportRepository
|
||||
import org.thoughtcrime.securesms.conversation.v2.data.ConversationElementKey
|
||||
import org.thoughtcrime.securesms.conversation.v2.items.ChatColorsDrawable
|
||||
import org.thoughtcrime.securesms.database.DatabaseObserver
|
||||
@@ -210,6 +212,11 @@ class ConversationViewModel(
|
||||
private val internalPinnedMessages = MutableStateFlow<List<ConversationMessage>>(emptyList())
|
||||
val pinnedMessages: StateFlow<List<ConversationMessage>> = internalPinnedMessages
|
||||
|
||||
private val _plaintextExportState = MutableStateFlow<PlaintextExportState>(PlaintextExportState.None)
|
||||
val plaintextExportState: StateFlow<PlaintextExportState> = _plaintextExportState
|
||||
|
||||
private val plaintextExportCancelled = java.util.concurrent.atomic.AtomicBoolean(false)
|
||||
|
||||
init {
|
||||
disposables += recipient
|
||||
.subscribeBy {
|
||||
@@ -723,6 +730,60 @@ class ConversationViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
fun startPlaintextExport(context: Context, directoryUri: Uri) {
|
||||
val recipient = recipientSnapshot ?: return
|
||||
val chatName = if (recipient.isSelf) context.getString(R.string.note_to_self) else recipient.getDisplayName(context)
|
||||
|
||||
plaintextExportCancelled.set(false)
|
||||
_plaintextExportState.value = PlaintextExportState.Preparing
|
||||
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val success = PlaintextExportRepository.export(
|
||||
context = context,
|
||||
threadId = threadId,
|
||||
directoryUri = directoryUri,
|
||||
chatName = chatName,
|
||||
progressListener = { messagesProcessed, messageCount, attachmentsProcessed, attachmentCount ->
|
||||
val messagePercent = if (messageCount > 0) (messagesProcessed * 25) / messageCount else 25
|
||||
val attachmentPercent = if (attachmentCount > 0) (attachmentsProcessed * 75) / attachmentCount else 75
|
||||
val percent = messagePercent + attachmentPercent
|
||||
|
||||
val status = if (attachmentsProcessed > 0 || messagesProcessed >= messageCount) {
|
||||
"Exporting media ($attachmentsProcessed/$attachmentCount)..."
|
||||
} else {
|
||||
"Exporting messages ($messagesProcessed/$messageCount)..."
|
||||
}
|
||||
|
||||
_plaintextExportState.value = PlaintextExportState.InProgress(percent = percent, status = status)
|
||||
},
|
||||
cancellationSignal = { plaintextExportCancelled.get() }
|
||||
)
|
||||
|
||||
_plaintextExportState.value = when {
|
||||
plaintextExportCancelled.get() -> PlaintextExportState.Cancelled
|
||||
success -> PlaintextExportState.Complete
|
||||
else -> PlaintextExportState.Failed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun cancelExport() {
|
||||
plaintextExportCancelled.set(true)
|
||||
}
|
||||
|
||||
fun clearPlaintextExportState() {
|
||||
_plaintextExportState.value = PlaintextExportState.None
|
||||
}
|
||||
|
||||
sealed interface PlaintextExportState {
|
||||
data object None : PlaintextExportState
|
||||
data object Preparing : PlaintextExportState
|
||||
data class InProgress(val percent: Int, val status: String) : PlaintextExportState
|
||||
data object Complete : PlaintextExportState
|
||||
data object Failed : PlaintextExportState
|
||||
data object Cancelled : PlaintextExportState
|
||||
}
|
||||
|
||||
data class BackPressedState(
|
||||
val isReactionDelegateShowing: Boolean = false,
|
||||
val isSearchRequested: Boolean = false,
|
||||
|
||||
@@ -48,6 +48,7 @@ class DisabledInputView @JvmOverloads constructor(
|
||||
private var announcementGroupOnly: TextView? = null
|
||||
private var inviteToSignal: View? = null
|
||||
private var releaseNoteChannel: View? = null
|
||||
private var incognitoView: View? = null
|
||||
|
||||
private var currentView: View? = null
|
||||
|
||||
@@ -76,6 +77,13 @@ class DisabledInputView @JvmOverloads constructor(
|
||||
)
|
||||
}
|
||||
|
||||
fun showAsIncognito() {
|
||||
incognitoView = show(
|
||||
existingView = incognitoView,
|
||||
create = { inflater.inflate(R.layout.conversation_incognito_mode, this, false) }
|
||||
)
|
||||
}
|
||||
|
||||
fun showAsMessageRequest(recipient: Recipient, messageRequestState: MessageRequestState) {
|
||||
messageRequestView = show(
|
||||
existingView = messageRequestView,
|
||||
@@ -210,6 +218,7 @@ class DisabledInputView @JvmOverloads constructor(
|
||||
noLongerAMember = null
|
||||
requestingGroup = null
|
||||
announcementGroupOnly = null
|
||||
incognitoView = null
|
||||
}
|
||||
|
||||
private fun <V : View> show(existingView: V?, create: () -> V, bind: V.() -> Unit = {}): V {
|
||||
|
||||
@@ -1234,6 +1234,22 @@ public class ConversationListFragment extends MainFragment implements Conversati
|
||||
});
|
||||
}
|
||||
|
||||
private void handleOpenIncognito(@NonNull Conversation conversation) {
|
||||
long threadId = conversation.getThreadRecord().getThreadId();
|
||||
Recipient recipient = conversation.getThreadRecord().getRecipient();
|
||||
int distributionType = conversation.getThreadRecord().getDistributionType();
|
||||
|
||||
SimpleTask.run(getLifecycle(), () -> {
|
||||
ChatWallpaper wallpaper = recipient.resolve().getWallpaper();
|
||||
if (wallpaper != null && !wallpaper.prefetch(requireContext(), 250)) {
|
||||
Log.w(TAG, "Failed to prefetch wallpaper.");
|
||||
}
|
||||
return null;
|
||||
}, (nothing) -> {
|
||||
getNavigator().goToConversation(recipient.getId(), threadId, distributionType, -1, true);
|
||||
});
|
||||
}
|
||||
|
||||
private void startActionModeIfNotActive() {
|
||||
if (!mainToolbarViewModel.isInActionMode()) {
|
||||
startActionMode();
|
||||
@@ -1327,6 +1343,10 @@ public class ConversationListFragment extends MainFragment implements Conversati
|
||||
} else {
|
||||
items.add(new ActionItem(R.drawable.symbol_bell_slash_24, getResources().getString(R.string.ConversationListFragment_mute), () -> handleMute(Collections.singleton(conversation))));
|
||||
}
|
||||
|
||||
if (SignalStore.labs().getIncognito()) {
|
||||
items.add(new ActionItem(R.drawable.symbol_view_once_24, "Open Incognito", () -> handleOpenIncognito(conversation)));
|
||||
}
|
||||
}
|
||||
|
||||
if (!isFromSearch) {
|
||||
|
||||
@@ -72,6 +72,7 @@ import org.thoughtcrime.securesms.database.model.MessageRecord;
|
||||
import org.thoughtcrime.securesms.database.model.ThreadRecord;
|
||||
import org.thoughtcrime.securesms.database.model.UpdateDescription;
|
||||
import org.thoughtcrime.securesms.fonts.SignalSymbols.Glyph;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.glide.targets.GlideLiveDataTarget;
|
||||
import org.signal.glide.decryptableuri.DecryptableUri;
|
||||
import org.thoughtcrime.securesms.recipients.LiveRecipient;
|
||||
@@ -318,7 +319,9 @@ public final class ConversationListItem extends ConstraintLayout implements Bind
|
||||
setSubjectViewText(null);
|
||||
|
||||
fromView.setText(recipient.get(), recipient.get().getDisplayName(getContext()), null, false);
|
||||
setSubjectViewText(SearchUtil.getHighlightedSpan(locale, searchStyleFactory, messageResult.getBodySnippet(), highlightSubstring, SearchUtil.MATCH_ALL));
|
||||
CharSequence snippet = SearchUtil.getHighlightedSpan(locale, searchStyleFactory, messageResult.getBodySnippet(), highlightSubstring, SearchUtil.MATCH_ALL);
|
||||
snippet = createGroupMessageUpdateString(getContext(), snippet, messageResult.getMessageRecipient());
|
||||
setSubjectViewText(snippet);
|
||||
|
||||
updateDateView = () -> {
|
||||
Pair<String, String> date = DateUtils.getBriefRelativeTimeSpanString(getContext(), locale, messageResult.getReceivedTimestampMs());
|
||||
|
||||
@@ -84,11 +84,6 @@ import org.thoughtcrime.securesms.crypto.AttachmentSecret
|
||||
import org.thoughtcrime.securesms.crypto.ClassicDecryptingPartInputStream
|
||||
import org.thoughtcrime.securesms.crypto.ModernDecryptingPartInputStream
|
||||
import org.thoughtcrime.securesms.crypto.ModernEncryptingPartOutputStream
|
||||
import org.thoughtcrime.securesms.database.AttachmentTable.ArchiveTransferState.COPY_PENDING
|
||||
import org.thoughtcrime.securesms.database.AttachmentTable.ArchiveTransferState.FINISHED
|
||||
import org.thoughtcrime.securesms.database.AttachmentTable.ArchiveTransferState.NONE
|
||||
import org.thoughtcrime.securesms.database.AttachmentTable.ArchiveTransferState.PERMANENT_FAILURE
|
||||
import org.thoughtcrime.securesms.database.AttachmentTable.ArchiveTransferState.UPLOAD_IN_PROGRESS
|
||||
import org.thoughtcrime.securesms.database.AttachmentTable.Companion.DATA_FILE
|
||||
import org.thoughtcrime.securesms.database.AttachmentTable.Companion.DATA_HASH_END
|
||||
import org.thoughtcrime.securesms.database.AttachmentTable.Companion.PREUPLOAD_MESSAGE_ID
|
||||
@@ -496,6 +491,32 @@ class AttachmentTable(
|
||||
.flatten()
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the number of attachments that will be exported for a plaintext export of a given thread.
|
||||
* Used for estimating progress.
|
||||
*/
|
||||
fun getPlaintextExportableAttachmentCountForThread(threadId: Long): Int {
|
||||
return readableDatabase.rawQuery(
|
||||
"""
|
||||
SELECT COUNT(*)
|
||||
FROM $TABLE_NAME
|
||||
INNER JOIN ${MessageTable.TABLE_NAME} ON $TABLE_NAME.$MESSAGE_ID = ${MessageTable.TABLE_NAME}.${MessageTable.ID}
|
||||
WHERE ${MessageTable.TABLE_NAME}.${MessageTable.THREAD_ID} = ?
|
||||
AND ${MessageTable.TABLE_NAME}.${MessageTable.STORY_TYPE} = 0
|
||||
AND ${MessageTable.TABLE_NAME}.${MessageTable.PARENT_STORY_ID} <= 0
|
||||
AND ${MessageTable.TABLE_NAME}.${MessageTable.SCHEDULED_DATE} = -1
|
||||
AND ${MessageTable.TABLE_NAME}.${MessageTable.LATEST_REVISION_ID} IS NULL
|
||||
AND ${MessageTable.TABLE_NAME}.${MessageTable.VIEW_ONCE} = 0
|
||||
AND ${MessageTable.TABLE_NAME}.${MessageTable.DELETED_BY} IS NULL
|
||||
AND $TABLE_NAME.$DATA_FILE IS NOT NULL
|
||||
AND $TABLE_NAME.$QUOTE = 0
|
||||
""".trimIndent(),
|
||||
arrayOf(threadId.toString())
|
||||
).use { cursor ->
|
||||
if (cursor.moveToFirst()) cursor.getInt(0) else 0
|
||||
}
|
||||
}
|
||||
|
||||
fun getAttachmentsForMessagesArchive(mmsIds: Collection<Long>): Map<Long, List<DatabaseAttachment>> {
|
||||
if (mmsIds.isEmpty()) {
|
||||
return emptyMap()
|
||||
|
||||
@@ -190,6 +190,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
|
||||
const val UNIDENTIFIED = "unidentified"
|
||||
const val REACTIONS_UNREAD = "reactions_unread"
|
||||
const val REACTIONS_LAST_SEEN = "reactions_last_seen"
|
||||
const val REMOTE_DELETED = "remote_deleted" // Note: Use [DELETED_BY] instead. All attempts to remove this have failed.
|
||||
const val SERVER_GUID = "server_guid"
|
||||
const val RECEIPT_TIMESTAMP = "receipt_timestamp"
|
||||
const val EXPORT_STATE = "export_state"
|
||||
@@ -277,6 +278,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
|
||||
$VIEW_ONCE INTEGER DEFAULT 0,
|
||||
$REACTIONS_UNREAD INTEGER DEFAULT 0,
|
||||
$REACTIONS_LAST_SEEN INTEGER DEFAULT -1,
|
||||
$REMOTE_DELETED INTEGER DEFAULT 0,
|
||||
$MENTIONS_SELF INTEGER DEFAULT 0,
|
||||
$NOTIFIED_TIMESTAMP INTEGER DEFAULT 0,
|
||||
$SERVER_GUID TEXT DEFAULT NULL,
|
||||
@@ -5370,14 +5372,14 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
|
||||
* A cursor containing all of the messages in a given thread, in the proper order, respecting offset/limit.
|
||||
* This does *not* have attachments in it.
|
||||
*/
|
||||
fun getConversation(threadId: Long, offset: Long, limit: Long): Cursor {
|
||||
fun getConversation(threadId: Long, offset: Long = 0, limit: Long = 0, dateReceiveOrderBy: String = "DESC"): Cursor {
|
||||
val limitStr: String = if (limit > 0 || offset > 0) "$offset, $limit" else ""
|
||||
|
||||
return readableDatabase
|
||||
.select(*MMS_PROJECTION)
|
||||
.from("$TABLE_NAME INDEXED BY $INDEX_THREAD_STORY_SCHEDULED_DATE_LATEST_REVISION_ID")
|
||||
.where("$THREAD_ID = ? AND $STORY_TYPE = ? AND $PARENT_STORY_ID <= ? AND $SCHEDULED_DATE = ? AND $LATEST_REVISION_ID IS NULL", threadId, 0, 0, -1)
|
||||
.orderBy("$DATE_RECEIVED DESC")
|
||||
.orderBy("$DATE_RECEIVED $dateReceiveOrderBy")
|
||||
.limit(limitStr)
|
||||
.run()
|
||||
}
|
||||
|
||||
@@ -366,7 +366,7 @@ class NameCollisionTables(
|
||||
|
||||
writableDatabase
|
||||
.delete(NameCollisionMembershipTable.TABLE_NAME)
|
||||
.where("${NameCollisionMembershipTable.COLLISION_ID} = ?")
|
||||
.where("${NameCollisionMembershipTable.COLLISION_ID} = ?", collision.id)
|
||||
.run()
|
||||
|
||||
if (collision.members.size < 2) {
|
||||
|
||||
@@ -160,7 +160,7 @@ import org.thoughtcrime.securesms.database.helpers.migration.V303_CaseInsensitiv
|
||||
import org.thoughtcrime.securesms.database.helpers.migration.V304_CallAndReplyNotificationSettings
|
||||
import org.thoughtcrime.securesms.database.helpers.migration.V305_AddStoryArchivedColumn
|
||||
import org.thoughtcrime.securesms.database.helpers.migration.V306_AddRemoteDeletedColumn
|
||||
import org.thoughtcrime.securesms.database.helpers.migration.V307_RemoveRemoteDeletedColumn
|
||||
import org.thoughtcrime.securesms.database.helpers.migration.V308_AddBackRemoteDeletedColumn
|
||||
import org.thoughtcrime.securesms.database.SQLiteDatabase as SignalSqliteDatabase
|
||||
|
||||
/**
|
||||
@@ -328,10 +328,11 @@ object SignalDatabaseMigrations {
|
||||
304 to V304_CallAndReplyNotificationSettings,
|
||||
305 to V305_AddStoryArchivedColumn,
|
||||
306 to V306_AddRemoteDeletedColumn,
|
||||
307 to V307_RemoveRemoteDeletedColumn
|
||||
// 307 to V307_RemoveRemoteDeletedColumn - Removed due to unsolvable OOM crashes. [TODO]: Attempt to fix in the future
|
||||
308 to V308_AddBackRemoteDeletedColumn
|
||||
)
|
||||
|
||||
const val DATABASE_VERSION = 307
|
||||
const val DATABASE_VERSION = 308
|
||||
|
||||
@JvmStatic
|
||||
fun migrate(context: Application, db: SignalSqliteDatabase, oldVersion: Int, newVersion: Int) {
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
package org.thoughtcrime.securesms.database.helpers.migration
|
||||
|
||||
import android.app.Application
|
||||
import org.signal.core.util.SqlUtil
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.database.SQLiteDatabase
|
||||
|
||||
/**
|
||||
* Attempts to remove the remote_deleted column again but in its own isolated change
|
||||
*/
|
||||
@Suppress("ClassName")
|
||||
object V307_RemoveRemoteDeletedColumn : SignalDatabaseMigration {
|
||||
|
||||
private val TAG = Log.tag(V307_RemoveRemoteDeletedColumn::class.java)
|
||||
|
||||
override fun migrate(context: Application, db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
|
||||
val start = System.currentTimeMillis()
|
||||
if (!SqlUtil.columnExists(db, "message", "remote_deleted")) {
|
||||
Log.i(TAG, "Does not have remote_deleted column!")
|
||||
return
|
||||
}
|
||||
|
||||
db.execSQL("ALTER TABLE message DROP COLUMN remote_deleted")
|
||||
Log.i(TAG, "Dropping remote_deleted column, took ${System.currentTimeMillis() - start}ms")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package org.thoughtcrime.securesms.database.helpers.migration
|
||||
|
||||
import android.app.Application
|
||||
import org.signal.core.util.SqlUtil
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.database.SQLiteDatabase
|
||||
|
||||
/**
|
||||
* Because of an OOM in [V302_AddDeletedByColumn] and V307, we could not drop the remote_deleted column for everyone.
|
||||
* This adds it back for the people who dropped it.
|
||||
*/
|
||||
@Suppress("ClassName")
|
||||
object V308_AddBackRemoteDeletedColumn : SignalDatabaseMigration {
|
||||
|
||||
private val TAG = Log.tag(V308_AddBackRemoteDeletedColumn::class.java)
|
||||
|
||||
override fun migrate(context: Application, db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
|
||||
if (SqlUtil.columnExists(db, "message", "remote_deleted")) {
|
||||
Log.i(TAG, "Already have remote deleted column!")
|
||||
return
|
||||
}
|
||||
|
||||
db.execSQL("ALTER TABLE message ADD COLUMN remote_deleted INTEGER DEFAULT 0")
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,6 @@ import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.keyvalue.UiHintValues
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.util.RemoteConfig
|
||||
import org.whispersystems.signalservice.api.NetworkResult
|
||||
|
||||
/**
|
||||
@@ -97,7 +96,6 @@ class MemberLabelRepository private constructor(
|
||||
* Checks whether [recipient] has permission to set their member label in the given group.
|
||||
*/
|
||||
suspend fun canSetLabel(groupId: GroupId.V2, recipient: Recipient): Boolean = withContext(Dispatchers.IO) {
|
||||
if (!RemoteConfig.sendMemberLabels) return@withContext false
|
||||
val groupRecord = groupsTable.getGroup(groupId).orNull() ?: return@withContext false
|
||||
|
||||
val memberLevel = groupRecord.memberLevel(recipient)
|
||||
@@ -147,10 +145,6 @@ class MemberLabelRepository private constructor(
|
||||
* Sets the group member label for the current user.
|
||||
*/
|
||||
suspend fun setLabel(groupId: GroupId.V2, label: MemberLabel): NetworkResult<Unit> = withContext(Dispatchers.IO) {
|
||||
if (!RemoteConfig.sendMemberLabels) {
|
||||
throw IllegalStateException("Set member label not allowed due to remote config.")
|
||||
}
|
||||
|
||||
val sanitizedLabel = label.sanitized()
|
||||
NetworkResult.fromFetch {
|
||||
GroupManager.updateMemberLabel(context, groupId, sanitizedLabel.text, sanitizedLabel.emoji.orEmpty())
|
||||
|
||||
@@ -147,6 +147,11 @@ public class GroupCallUpdateSendJob extends BaseJob {
|
||||
e instanceof RetryLaterException;
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getNextRunAttemptBackoff(int pastAttemptCount, @NonNull Exception exception) {
|
||||
return SendJobUtil.getBackoffMillisFromException(this, TAG, pastAttemptCount, exception, () -> super.getNextRunAttemptBackoff(pastAttemptCount, exception));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure() {
|
||||
if (recipients.size() < initialRecipientCount) {
|
||||
|
||||
@@ -1,21 +1,19 @@
|
||||
package org.thoughtcrime.securesms.jobs
|
||||
|
||||
import android.net.Uri
|
||||
import org.greenrobot.eventbus.EventBus
|
||||
import org.greenrobot.eventbus.Subscribe
|
||||
import org.greenrobot.eventbus.ThreadMode
|
||||
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.v2.LocalBackupV2Event
|
||||
import org.thoughtcrime.securesms.backup.v2.local.ArchiveFileSystem
|
||||
import org.thoughtcrime.securesms.backup.v2.local.LocalArchiver
|
||||
import org.thoughtcrime.securesms.backup.v2.local.SnapshotFileSystem
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.jobmanager.Job
|
||||
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
|
||||
import org.thoughtcrime.securesms.service.NotificationController
|
||||
@@ -47,8 +45,6 @@ class LocalArchiveJob internal constructor(parameters: Parameters) : Job(paramet
|
||||
|
||||
BackupFileIOError.clearNotification(context)
|
||||
|
||||
val updater = ProgressUpdater()
|
||||
|
||||
var notification: NotificationController? = null
|
||||
try {
|
||||
notification = GenericForegroundService.startForegroundTask(
|
||||
@@ -62,9 +58,8 @@ class LocalArchiveJob internal constructor(parameters: Parameters) : Job(paramet
|
||||
}
|
||||
|
||||
try {
|
||||
updater.notification = notification
|
||||
EventBus.getDefault().register(updater)
|
||||
notification?.setIndeterminateProgress()
|
||||
setProgress(LocalBackupCreationProgress(exporting = LocalBackupCreationProgress.Exporting(phase = LocalBackupCreationProgress.ExportPhase.INITIALIZING)), notification)
|
||||
|
||||
val stopwatch = Stopwatch("archive-export")
|
||||
|
||||
@@ -106,14 +101,14 @@ class LocalArchiveJob internal constructor(parameters: Parameters) : Job(paramet
|
||||
snapshotFileSystem.finalize()
|
||||
stopwatch.split("archive-finalize")
|
||||
|
||||
EventBus.getDefault().post(LocalBackupV2Event(LocalBackupV2Event.Type.FINISHED))
|
||||
setProgress(LocalBackupCreationProgress(exporting = LocalBackupCreationProgress.Exporting(phase = LocalBackupCreationProgress.ExportPhase.FINALIZING)), notification)
|
||||
} catch (e: BackupCanceledException) {
|
||||
EventBus.getDefault().post(LocalBackupV2Event(LocalBackupV2Event.Type.FINISHED))
|
||||
setProgress(LocalBackupCreationProgress(idle = LocalBackupCreationProgress.Idle()), notification)
|
||||
Log.w(TAG, "Archive cancelled")
|
||||
throw e
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "Error during archive!", e)
|
||||
EventBus.getDefault().post(LocalBackupV2Event(LocalBackupV2Event.Type.FINISHED))
|
||||
setProgress(LocalBackupCreationProgress(idle = LocalBackupCreationProgress.Idle()), notification)
|
||||
BackupFileIOError.postNotificationForException(context, e)
|
||||
throw e
|
||||
} finally {
|
||||
@@ -127,46 +122,76 @@ class LocalArchiveJob internal constructor(parameters: Parameters) : Job(paramet
|
||||
archiveFileSystem.deleteOldBackups()
|
||||
stopwatch.split("delete-old")
|
||||
|
||||
archiveFileSystem.deleteUnusedFiles()
|
||||
archiveFileSystem.deleteUnusedFiles { completed, total ->
|
||||
setProgress(LocalBackupCreationProgress(exporting = LocalBackupCreationProgress.Exporting(phase = LocalBackupCreationProgress.ExportPhase.FINALIZING, frameExportCount = completed.toLong(), frameTotalCount = total.toLong())), notification)
|
||||
}
|
||||
stopwatch.split("delete-unused")
|
||||
|
||||
stopwatch.stop(TAG)
|
||||
|
||||
setProgress(LocalBackupCreationProgress(idle = LocalBackupCreationProgress.Idle()), notification)
|
||||
SignalStore.backup.newLocalBackupsLastBackupTime = System.currentTimeMillis()
|
||||
} finally {
|
||||
notification?.close()
|
||||
EventBus.getDefault().unregister(updater)
|
||||
updater.notification = null
|
||||
}
|
||||
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
override fun onFailure() {
|
||||
SignalStore.backup.newLocalBackupProgress = LocalBackupCreationProgress(idle = LocalBackupCreationProgress.Idle())
|
||||
}
|
||||
|
||||
private class ProgressUpdater {
|
||||
var notification: NotificationController? = null
|
||||
private fun setProgress(progress: LocalBackupCreationProgress, notification: NotificationController?) {
|
||||
SignalStore.backup.newLocalBackupProgress = progress
|
||||
updateNotification(progress, notification)
|
||||
}
|
||||
|
||||
private var previousType: LocalBackupV2Event.Type? = null
|
||||
private var previousPhase: NotificationPhase? = null
|
||||
|
||||
@Subscribe(threadMode = ThreadMode.POSTING)
|
||||
fun onEvent(event: LocalBackupV2Event) {
|
||||
val notification = notification ?: return
|
||||
private fun updateNotification(progress: LocalBackupCreationProgress, notification: NotificationController?) {
|
||||
if (notification == null) return
|
||||
|
||||
if (previousType != event.type) {
|
||||
notification.replaceTitle(event.type.toString()) // todo [local-backup] use actual strings
|
||||
previousType = event.type
|
||||
val exporting = progress.exporting
|
||||
val transferring = progress.transferring
|
||||
|
||||
when {
|
||||
exporting != null -> {
|
||||
val phase = NotificationPhase.Export(exporting.phase)
|
||||
if (previousPhase != phase) {
|
||||
notification.replaceTitle(exporting.phase.toString())
|
||||
previousPhase = phase
|
||||
}
|
||||
if (exporting.frameTotalCount == 0L) {
|
||||
notification.setIndeterminateProgress()
|
||||
} else {
|
||||
notification.setProgress(exporting.frameTotalCount, exporting.frameExportCount)
|
||||
}
|
||||
}
|
||||
|
||||
if (event.estimatedTotalCount == 0L) {
|
||||
transferring != null -> {
|
||||
if (previousPhase !is NotificationPhase.Transfer) {
|
||||
notification.replaceTitle(AppDependencies.application.getString(R.string.LocalArchiveJob__exporting_media))
|
||||
previousPhase = NotificationPhase.Transfer
|
||||
}
|
||||
if (transferring.total == 0L) {
|
||||
notification.setIndeterminateProgress()
|
||||
} else {
|
||||
notification.setProgress(transferring.total, transferring.completed)
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
notification.setIndeterminateProgress()
|
||||
} else {
|
||||
notification.setProgress(event.estimatedTotalCount, event.count)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed interface NotificationPhase {
|
||||
data class Export(val phase: LocalBackupCreationProgress.ExportPhase) : NotificationPhase
|
||||
data object Transfer : NotificationPhase
|
||||
}
|
||||
|
||||
class Factory : Job.Factory<LocalArchiveJob?> {
|
||||
override fun create(parameters: Parameters, serializedData: ByteArray?): LocalArchiveJob {
|
||||
return LocalArchiveJob(parameters)
|
||||
|
||||
@@ -165,6 +165,11 @@ public class ProfileKeySendJob extends BaseJob {
|
||||
e instanceof RetryLaterException;
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getNextRunAttemptBackoff(int pastAttemptCount, @NonNull Exception exception) {
|
||||
return SendJobUtil.getBackoffMillisFromException(this, TAG, pastAttemptCount, exception, () -> super.getNextRunAttemptBackoff(pastAttemptCount, exception));
|
||||
}
|
||||
|
||||
@Override
|
||||
public @Nullable byte[] serialize() {
|
||||
return new JsonJobData.Builder()
|
||||
|
||||
@@ -9,14 +9,17 @@ import androidx.annotation.WorkerThread;
|
||||
import com.annimon.stream.Collectors;
|
||||
import com.annimon.stream.Stream;
|
||||
|
||||
import org.signal.core.models.ServiceId;
|
||||
import org.signal.core.models.ServiceId.ACI;
|
||||
import org.signal.core.util.Base64;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.signal.storageservice.storage.protos.groups.local.DecryptedGroup;
|
||||
import org.thoughtcrime.securesms.database.RecipientTable;
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase;
|
||||
import org.thoughtcrime.securesms.groups.GroupId;
|
||||
import org.thoughtcrime.securesms.jobmanager.Job;
|
||||
import org.thoughtcrime.securesms.jobmanager.impl.SealedSenderConstraint;
|
||||
import org.thoughtcrime.securesms.jobmanager.JsonJobData;
|
||||
import org.thoughtcrime.securesms.jobmanager.impl.SealedSenderConstraint;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.messages.GroupSendUtil;
|
||||
import org.thoughtcrime.securesms.mms.MessageGroupContext;
|
||||
@@ -25,7 +28,6 @@ import org.thoughtcrime.securesms.net.NotPushRegisteredException;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.transport.RetryLaterException;
|
||||
import org.signal.core.util.Base64;
|
||||
import org.thoughtcrime.securesms.util.GroupUtil;
|
||||
import org.whispersystems.signalservice.api.crypto.ContentHint;
|
||||
import org.whispersystems.signalservice.api.crypto.UntrustedIdentityException;
|
||||
@@ -33,8 +35,6 @@ import org.whispersystems.signalservice.api.groupsv2.DecryptedGroupUtil;
|
||||
import org.whispersystems.signalservice.api.messages.SendMessageResult;
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage;
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceGroupV2;
|
||||
import org.signal.core.models.ServiceId;
|
||||
import org.signal.core.models.ServiceId.ACI;
|
||||
import org.whispersystems.signalservice.api.push.exceptions.ServerRejectedException;
|
||||
import org.whispersystems.signalservice.internal.push.GroupContextV2;
|
||||
|
||||
@@ -168,6 +168,11 @@ public final class PushGroupSilentUpdateSendJob extends BaseJob {
|
||||
e instanceof RetryLaterException;
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getNextRunAttemptBackoff(int pastAttemptCount, @NonNull Exception exception) {
|
||||
return SendJobUtil.getBackoffMillisFromException(this, TAG, pastAttemptCount, exception, () -> super.getNextRunAttemptBackoff(pastAttemptCount, exception));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure() {
|
||||
Log.w(TAG, "Failed to send remote delete to all recipients! (" + (initialRecipientCount - recipients.size() + "/" + initialRecipientCount + ")") );
|
||||
|
||||
@@ -72,6 +72,7 @@ import org.signal.core.models.ServiceId.ACI;
|
||||
import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException;
|
||||
import org.whispersystems.signalservice.api.push.exceptions.ProofRequiredException;
|
||||
import org.whispersystems.signalservice.api.push.exceptions.RateLimitException;
|
||||
import org.whispersystems.signalservice.api.push.exceptions.RetryNetworkException;
|
||||
import org.whispersystems.signalservice.api.push.exceptions.ServerRejectedException;
|
||||
import org.whispersystems.signalservice.internal.push.BodyRange;
|
||||
|
||||
@@ -160,29 +161,7 @@ public abstract class PushSendJob extends SendJob {
|
||||
|
||||
@Override
|
||||
public long getNextRunAttemptBackoff(int pastAttemptCount, @NonNull Exception exception) {
|
||||
if (exception instanceof ProofRequiredException) {
|
||||
long backoff = ((ProofRequiredException) exception).getRetryAfterSeconds();
|
||||
warn(TAG, "[Proof Required] Retry-After is " + backoff + " seconds.");
|
||||
if (backoff >= 0) {
|
||||
return TimeUnit.SECONDS.toMillis(backoff);
|
||||
}
|
||||
} else if (exception instanceof RateLimitException) {
|
||||
long backoff = ((RateLimitException) exception).getRetryAfterMilliseconds().orElse(-1L);
|
||||
if (backoff >= 0) {
|
||||
return backoff;
|
||||
}
|
||||
} else if (exception instanceof NonSuccessfulResponseCodeException) {
|
||||
if (((NonSuccessfulResponseCodeException) exception).is5xx()) {
|
||||
return BackoffUtil.exponentialBackoff(pastAttemptCount, RemoteConfig.getServerErrorMaxBackoff());
|
||||
}
|
||||
} else if (exception instanceof RetryLaterException) {
|
||||
long backoff = ((RetryLaterException) exception).getBackoff();
|
||||
if (backoff >= 0) {
|
||||
return backoff;
|
||||
}
|
||||
}
|
||||
|
||||
return super.getNextRunAttemptBackoff(pastAttemptCount, exception);
|
||||
return SendJobUtil.getBackoffMillisFromException(this, TAG, pastAttemptCount, exception, () -> super.getNextRunAttemptBackoff(pastAttemptCount, exception));
|
||||
}
|
||||
|
||||
protected Optional<byte[]> getProfileKey(@NonNull Recipient recipient) {
|
||||
|
||||
@@ -15,8 +15,8 @@ import org.thoughtcrime.securesms.database.model.MessageRecord;
|
||||
import org.thoughtcrime.securesms.database.model.ReactionRecord;
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies;
|
||||
import org.thoughtcrime.securesms.groups.GroupId;
|
||||
import org.thoughtcrime.securesms.jobmanager.JsonJobData;
|
||||
import org.thoughtcrime.securesms.jobmanager.Job;
|
||||
import org.thoughtcrime.securesms.jobmanager.JsonJobData;
|
||||
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint;
|
||||
import org.thoughtcrime.securesms.jobmanager.impl.SealedSenderConstraint;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
@@ -193,6 +193,11 @@ public class ReactionSendJob extends BaseJob {
|
||||
e instanceof RetryLaterException;
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getNextRunAttemptBackoff(int pastAttemptCount, @NonNull Exception exception) {
|
||||
return SendJobUtil.getBackoffMillisFromException(this, TAG, pastAttemptCount, exception, () -> super.getNextRunAttemptBackoff(pastAttemptCount, exception));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure() {
|
||||
if (recipients.size() < initialRecipientCount) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import androidx.annotation.WorkerThread;
|
||||
import com.annimon.stream.Stream;
|
||||
|
||||
import org.signal.core.util.SetUtil;
|
||||
import org.signal.core.util.Util;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.database.MessageTable;
|
||||
import org.thoughtcrime.securesms.database.NoSuchMessageException;
|
||||
@@ -17,9 +18,9 @@ import org.thoughtcrime.securesms.database.model.MessageRecord;
|
||||
import org.thoughtcrime.securesms.database.model.MmsMessageRecord;
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies;
|
||||
import org.thoughtcrime.securesms.groups.GroupId;
|
||||
import org.thoughtcrime.securesms.jobmanager.JsonJobData;
|
||||
import org.thoughtcrime.securesms.jobmanager.Job;
|
||||
import org.thoughtcrime.securesms.jobmanager.JobManager;
|
||||
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;
|
||||
@@ -28,7 +29,6 @@ import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientUtil;
|
||||
import org.thoughtcrime.securesms.transport.RetryLaterException;
|
||||
import org.thoughtcrime.securesms.util.GroupUtil;
|
||||
import org.signal.core.util.Util;
|
||||
import org.whispersystems.signalservice.api.crypto.ContentHint;
|
||||
import org.whispersystems.signalservice.api.crypto.UntrustedIdentityException;
|
||||
import org.whispersystems.signalservice.api.messages.SendMessageResult;
|
||||
@@ -198,6 +198,11 @@ public class RemoteDeleteSendJob extends BaseJob {
|
||||
e instanceof RetryLaterException;
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getNextRunAttemptBackoff(int pastAttemptCount, @NonNull Exception exception) {
|
||||
return SendJobUtil.getBackoffMillisFromException(this, TAG, pastAttemptCount, exception, () -> super.getNextRunAttemptBackoff(pastAttemptCount, exception));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure() {
|
||||
Log.w(TAG, "Failed to send remote delete to all recipients! (" + (initialRecipientCount - recipients.size() + "/" + initialRecipientCount + ")") );
|
||||
|
||||
@@ -492,7 +492,7 @@ class RetrieveProfileJob private constructor(parameters: Parameters, private val
|
||||
|
||||
if (recipient.isIndividual &&
|
||||
!recipient.isSystemContact &&
|
||||
recipient.nickname.isEmpty &&
|
||||
!recipient.nickname.isEmpty &&
|
||||
!recipient.isProfileSharing &&
|
||||
!recipient.isBlocked &&
|
||||
!recipient.isSelf &&
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
@file:JvmName("SendJobUtil")
|
||||
|
||||
package org.thoughtcrime.securesms.jobs
|
||||
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.jobmanager.Job
|
||||
import org.thoughtcrime.securesms.jobmanager.JobLogger
|
||||
import org.thoughtcrime.securesms.jobmanager.impl.BackoffUtil
|
||||
import org.thoughtcrime.securesms.transport.RetryLaterException
|
||||
import org.thoughtcrime.securesms.util.RemoteConfig.serverErrorMaxBackoff
|
||||
import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException
|
||||
import org.whispersystems.signalservice.api.push.exceptions.ProofRequiredException
|
||||
import org.whispersystems.signalservice.api.push.exceptions.RateLimitException
|
||||
import org.whispersystems.signalservice.api.push.exceptions.RetryNetworkException
|
||||
import java.util.concurrent.TimeUnit
|
||||
import org.signal.libsignal.net.RetryLaterException as LibSignalRetryLaterException
|
||||
|
||||
fun Job.getBackoffMillisFromException(tag: String, pastAttemptCount: Int, exception: Exception, default: () -> Long): Long {
|
||||
when (exception) {
|
||||
is ProofRequiredException -> {
|
||||
val backoff = exception.retryAfterSeconds
|
||||
Log.w(tag, JobLogger.format(this, "[Proof Required] Retry-After is $backoff seconds."))
|
||||
if (backoff >= 0) {
|
||||
return TimeUnit.SECONDS.toMillis(backoff)
|
||||
}
|
||||
}
|
||||
|
||||
is RateLimitException -> {
|
||||
val backoff = exception.retryAfterMilliseconds.orElse(-1L)
|
||||
if (backoff >= 0) {
|
||||
return backoff
|
||||
}
|
||||
}
|
||||
|
||||
is NonSuccessfulResponseCodeException -> {
|
||||
if (exception.is5xx()) {
|
||||
return BackoffUtil.exponentialBackoff(pastAttemptCount, serverErrorMaxBackoff)
|
||||
}
|
||||
}
|
||||
|
||||
is LibSignalRetryLaterException -> {
|
||||
return exception.duration.toMillis()
|
||||
}
|
||||
|
||||
is RetryNetworkException -> {
|
||||
return exception.retryAfterMs
|
||||
}
|
||||
|
||||
is RetryLaterException -> {
|
||||
val backoff = exception.backoff
|
||||
if (backoff >= 0) {
|
||||
return backoff
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return default()
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import org.thoughtcrime.securesms.jobmanager.impl.NoRemoteArchiveGarbageCollecti
|
||||
import org.thoughtcrime.securesms.jobmanager.impl.RestoreAttachmentConstraintObserver
|
||||
import org.thoughtcrime.securesms.keyvalue.protos.ArchiveUploadProgressState
|
||||
import org.thoughtcrime.securesms.keyvalue.protos.BackupDownloadNotifierState
|
||||
import org.thoughtcrime.securesms.keyvalue.protos.LocalBackupCreationProgress
|
||||
import org.thoughtcrime.securesms.util.Environment
|
||||
import org.whispersystems.signalservice.api.archive.ArchiveServiceCredential
|
||||
import org.whispersystems.signalservice.api.archive.GetArchiveCdnCredentialsResponse
|
||||
@@ -104,6 +105,7 @@ class BackupValues(store: KeyValueStore) : SignalStoreValues(store) {
|
||||
private const val KEY_NEW_LOCAL_BACKUPS_DIRECTORY = "backup.new_local_backups_directory"
|
||||
private const val KEY_NEW_LOCAL_BACKUPS_LAST_BACKUP_TIME = "backup.new_local_backups_last_backup_time"
|
||||
private const val KEY_NEW_LOCAL_BACKUPS_SELECTED_SNAPSHOT_TIMESTAMP = "backup.new_local_backups_selected_snapshot_timestamp"
|
||||
private const val KEY_NEW_LOCAL_BACKUPS_CREATION_PROGRESS = "backup.new_local_backups_creation_progress"
|
||||
|
||||
private const val KEY_UPLOAD_BANNER_VISIBLE = "backup.upload_banner_visible"
|
||||
|
||||
@@ -474,6 +476,13 @@ class BackupValues(store: KeyValueStore) : SignalStoreValues(store) {
|
||||
val newLocalBackupsEnabledFlow: Flow<Boolean> by lazy { newLocalBackupsEnabledValue.toFlow() }
|
||||
|
||||
/**
|
||||
* Progress values for local backup progress.
|
||||
*/
|
||||
private val newLocalBackupProgressValue = protoValue(KEY_NEW_LOCAL_BACKUPS_CREATION_PROGRESS, LocalBackupCreationProgress(), LocalBackupCreationProgress.ADAPTER)
|
||||
var newLocalBackupProgress: LocalBackupCreationProgress by newLocalBackupProgressValue
|
||||
val newLocalBackupProgressFlow: Flow<LocalBackupCreationProgress> by lazy { newLocalBackupProgressValue.toFlow() }
|
||||
|
||||
/**IT
|
||||
* The directory URI path selected for new local backups.
|
||||
*/
|
||||
private val newLocalBackupsDirectoryValue = stringValue(KEY_NEW_LOCAL_BACKUPS_DIRECTORY, null as String?)
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package org.thoughtcrime.securesms.keyvalue
|
||||
|
||||
import org.thoughtcrime.securesms.util.RemoteConfig
|
||||
|
||||
class LabsValues internal constructor(store: KeyValueStore) : SignalStoreValues(store) {
|
||||
companion object {
|
||||
const val INDIVIDUAL_CHAT_PLAINTEXT_EXPORT: String = "labs.individual_chat_plaintext_export"
|
||||
const val STORY_ARCHIVE: String = "labs.story_archive"
|
||||
const val INCOGNITO: String = "labs.incognito"
|
||||
}
|
||||
|
||||
public override fun onFirstEverAppLaunch() = Unit
|
||||
|
||||
public override fun getKeysToIncludeInBackup(): List<String> = emptyList()
|
||||
|
||||
var individualChatPlaintextExport by booleanValue(INDIVIDUAL_CHAT_PLAINTEXT_EXPORT, true).falseForExternalUsers()
|
||||
|
||||
var storyArchive by booleanValue(STORY_ARCHIVE, true).falseForExternalUsers()
|
||||
|
||||
var incognito by booleanValue(INCOGNITO, true).falseForExternalUsers()
|
||||
|
||||
private fun SignalStoreValueDelegate<Boolean>.falseForExternalUsers(): SignalStoreValueDelegate<Boolean> {
|
||||
return this.map { actualValue -> RemoteConfig.internalUser && actualValue }
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,7 @@ class SignalStore(context: Application, private val store: KeyValueStore) {
|
||||
val apkUpdateValues = ApkUpdateValues(store)
|
||||
val backupValues = BackupValues(store)
|
||||
val callQualityValues = CallQualityValues(store)
|
||||
val labsValues = LabsValues(store)
|
||||
|
||||
val plainTextValues = PlainTextSharedPrefsDataStore(context)
|
||||
|
||||
@@ -86,6 +87,7 @@ class SignalStore(context: Application, private val store: KeyValueStore) {
|
||||
apkUpdate.onFirstEverAppLaunch()
|
||||
backup.onFirstEverAppLaunch()
|
||||
callQuality.onFirstEverAppLaunch()
|
||||
labs.onFirstEverAppLaunch()
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
@@ -118,7 +120,8 @@ class SignalStore(context: Application, private val store: KeyValueStore) {
|
||||
story.keysToIncludeInBackup +
|
||||
apkUpdate.keysToIncludeInBackup +
|
||||
backup.keysToIncludeInBackup +
|
||||
callQuality.keysToIncludeInBackup
|
||||
callQuality.keysToIncludeInBackup +
|
||||
labs.keysToIncludeInBackup
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -274,6 +277,11 @@ class SignalStore(context: Application, private val store: KeyValueStore) {
|
||||
val callQuality: CallQualityValues
|
||||
get() = instance!!.callQualityValues
|
||||
|
||||
@JvmStatic
|
||||
@get:JvmName("labs")
|
||||
val labs: LabsValues
|
||||
get() = instance!!.labsValues
|
||||
|
||||
val groupsV2AciAuthorizationCache: GroupsV2AuthorizationSignalStoreCache
|
||||
get() = GroupsV2AuthorizationSignalStoreCache.createAciCache(instance!!.store)
|
||||
|
||||
|
||||
@@ -319,6 +319,14 @@ class MainNavigationViewModel(
|
||||
* piece of content via [goTo].
|
||||
*/
|
||||
private inner class Nav<T>(delegate: ThreePaneScaffoldNavigator<T>) : AppScaffoldNavigator<T>(delegate) {
|
||||
override suspend fun navigateBack(backNavigationBehavior: BackNavigationBehavior): Boolean {
|
||||
val result = super.navigateBack(backNavigationBehavior)
|
||||
if (result) {
|
||||
lockPaneToSecondary = true
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
override suspend fun seekBack(backNavigationBehavior: BackNavigationBehavior, fraction: Float) {
|
||||
super.seekBack(backNavigationBehavior, fraction)
|
||||
|
||||
|
||||
@@ -81,9 +81,9 @@ import org.thoughtcrime.securesms.calls.log.CallLogFilter
|
||||
import org.thoughtcrime.securesms.components.compose.ActionModeTopBar
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.BadgeImageSmall
|
||||
import org.thoughtcrime.securesms.conversationlist.model.ConversationFilter
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.rememberRecipientField
|
||||
import org.thoughtcrime.securesms.util.RemoteConfig
|
||||
|
||||
interface MainToolbarCallback {
|
||||
fun onNewGroupClick()
|
||||
@@ -408,7 +408,7 @@ private fun PrimaryToolbar(
|
||||
NotificationProfileAction(state, callback)
|
||||
ProxyAction(state, callback)
|
||||
|
||||
if (state.destination == MainNavigationListLocation.STORIES && RemoteConfig.internalUser) {
|
||||
if (state.destination == MainNavigationListLocation.STORIES && SignalStore.labs.storyArchive) {
|
||||
IconButtons.IconButton(
|
||||
onClick = callback::onStoryArchiveClick
|
||||
) {
|
||||
|
||||
@@ -127,18 +127,22 @@ public final class Megaphones {
|
||||
put(Event.TURN_OFF_CENSORSHIP_CIRCUMVENTION, shouldShowTurnOffCircumventionMegaphone() ? RecurringSchedule.every(TimeUnit.DAYS.toMillis(7)) : NEVER);
|
||||
put(Event.REMOTE_MEGAPHONE, shouldShowRemoteMegaphone(records) ? RecurringSchedule.every(TimeUnit.DAYS.toMillis(1)) : NEVER);
|
||||
put(Event.LINKED_DEVICE_INACTIVE, shouldShowLinkedDeviceInactiveMegaphone() ? RecurringSchedule.every(TimeUnit.DAYS.toMillis(3)): NEVER);
|
||||
put(Event.PIN_REMINDER, new SignalPinReminderSchedule());
|
||||
put(Event.SET_UP_YOUR_USERNAME, shouldShowSetUpYourUsernameMegaphone(records) ? ALWAYS : NEVER);
|
||||
|
||||
// Feature-introduction megaphones should *probably* be added below this divider
|
||||
put(Event.ADD_A_PROFILE_PHOTO, shouldShowAddAProfilePhotoMegaphone(context) ? ALWAYS : NEVER);
|
||||
put(Event.PNP_LAUNCH, shouldShowPnpLaunchMegaphone() ? ALWAYS : NEVER);
|
||||
// Specifically putting backup reminders here, above PIN reminders
|
||||
put(Event.BACKUP_LOW_STORAGE_UPSELL, shouldShowBackupLowStorageUpsell(context) ? new BackupUpsellSchedule(records, TimeUnit.DAYS.toMillis(60), TimeUnit.DAYS.toMillis(120)) : NEVER);
|
||||
put(Event.BACKUP_MEDIA_SIZE_UPSELL, shouldShowBackupMediaSizeUpsell() ? new BackupUpsellSchedule(records, TimeUnit.DAYS.toMillis(60), TimeUnit.DAYS.toMillis(120)) : NEVER);
|
||||
put(Event.BACKUP_MESSAGE_COUNT_UPSELL, shouldShowBackupMessageCountUpsell(context) ? new BackupUpsellSchedule(records, TimeUnit.DAYS.toMillis(60)) : NEVER);
|
||||
put(Event.BACKUPS_GENERIC_UPSELL, shouldShowGenericBackupsMegaphone(context) ? new BackupUpsellSchedule(records, TimeUnit.DAYS.toMillis(60)) : NEVER);
|
||||
put(Event.VERIFY_BACKUP_KEY, new VerifyBackupKeyReminderSchedule());
|
||||
put(Event.USE_NEW_ON_DEVICE_BACKUPS, shouldShowUseNewOnDeviceBackupsMegaphone() ? RecurringSchedule.every(TimeUnit.DAYS.toMillis(14)) : NEVER);
|
||||
|
||||
// The Great Wall of PIN Reminder -- megaphones below this may not be seen by users who never do reminders
|
||||
put(Event.PIN_REMINDER, new SignalPinReminderSchedule());
|
||||
|
||||
// Feature-introduction megaphones should *probably* be added below this divider
|
||||
put(Event.SET_UP_YOUR_USERNAME, shouldShowSetUpYourUsernameMegaphone(records) ? ALWAYS : NEVER);
|
||||
put(Event.ADD_A_PROFILE_PHOTO, shouldShowAddAProfilePhotoMegaphone(context) ? ALWAYS : NEVER);
|
||||
put(Event.PNP_LAUNCH, shouldShowPnpLaunchMegaphone() ? ALWAYS : NEVER);
|
||||
}};
|
||||
}
|
||||
|
||||
|
||||
@@ -1272,11 +1272,6 @@ object DataMessageProcessor {
|
||||
receivedTime: Long,
|
||||
earlyMessageCacheEntry: EarlyMessageCacheEntry? = null
|
||||
): InsertResult? {
|
||||
if (!RemoteConfig.receivePinnedMessages) {
|
||||
log(envelope.timestamp!!, "Pinned message not allowed due to remote config.")
|
||||
return null
|
||||
}
|
||||
|
||||
val pinMessage = message.pinMessage!!
|
||||
log(envelope.timestamp!!, "[handlePinMessage] Pin message for " + pinMessage.targetSentTimestamp)
|
||||
|
||||
@@ -1316,6 +1311,11 @@ object DataMessageProcessor {
|
||||
return null
|
||||
}
|
||||
|
||||
if (targetThread.recipient.id != threadRecipient.id) {
|
||||
warn(envelope.timestamp!!, "[handlePinMessage] Target message is in a different thread than the thread recipient! timestamp: ${pinMessage.targetSentTimestamp}")
|
||||
return null
|
||||
}
|
||||
|
||||
val groupRecord = SignalDatabase.groups.getGroup(threadRecipient.id).orNull()
|
||||
if (groupRecord != null && !groupRecord.members.contains(senderRecipient.id)) {
|
||||
warn(envelope.timestamp!!, "[handlePinMessage] Sender is not in the group! timestamp: ${pinMessage.targetSentTimestamp}")
|
||||
@@ -1366,11 +1366,6 @@ object DataMessageProcessor {
|
||||
threadRecipient: Recipient,
|
||||
earlyMessageCacheEntry: EarlyMessageCacheEntry? = null
|
||||
): MessageId? {
|
||||
if (!RemoteConfig.receivePinnedMessages) {
|
||||
log(envelope.timestamp!!, "Unpinning message is not allowed due to remote config.")
|
||||
return null
|
||||
}
|
||||
|
||||
val unpinMessage = message.unpinMessage!!
|
||||
log(envelope.timestamp!!, "[handleUnpinMessage] Unpin message for ${unpinMessage.targetSentTimestamp}")
|
||||
|
||||
@@ -1408,6 +1403,11 @@ object DataMessageProcessor {
|
||||
return null
|
||||
}
|
||||
|
||||
if (targetThread.recipient.id != threadRecipient.id) {
|
||||
warn(envelope.timestamp!!, "[handleUnpinMessage] Target message is in a different thread than the thread recipient! timestamp: ${unpinMessage.targetSentTimestamp}")
|
||||
return null
|
||||
}
|
||||
|
||||
val groupRecord = SignalDatabase.groups.getGroup(threadRecipient.id).orNull()
|
||||
if (groupRecord != null && !groupRecord.members.contains(senderRecipient.id)) {
|
||||
warn(envelope.timestamp!!, "[handleUnpinMessage] Sender is not in the group! timestamp: ${unpinMessage.targetSentTimestamp}")
|
||||
|
||||
@@ -110,7 +110,6 @@ import org.thoughtcrime.securesms.util.EarlyMessageCacheEntry
|
||||
import org.thoughtcrime.securesms.util.IdentityUtil
|
||||
import org.thoughtcrime.securesms.util.MediaUtil
|
||||
import org.thoughtcrime.securesms.util.MessageConstraintsUtil
|
||||
import org.thoughtcrime.securesms.util.RemoteConfig
|
||||
import org.thoughtcrime.securesms.util.SignalE164Util
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences
|
||||
import org.thoughtcrime.securesms.util.hasGiftBadge
|
||||
@@ -1873,10 +1872,6 @@ object SyncMessageProcessor {
|
||||
senderRecipient: Recipient,
|
||||
earlyMessageCacheEntry: EarlyMessageCacheEntry?
|
||||
): Long {
|
||||
if (!RemoteConfig.receivePinnedMessages) {
|
||||
log(envelope.timestamp!!, "Sync pinned messages not allowed due to remote config.")
|
||||
}
|
||||
|
||||
log(envelope.timestamp!!, "Synchronize pinned message")
|
||||
|
||||
val recipient = getSyncMessageDestination(sent)
|
||||
|
||||
@@ -6,13 +6,15 @@ import androidx.annotation.IntRange;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.signal.core.util.Util;
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.util.RemoteConfig;
|
||||
import org.thoughtcrime.securesms.util.LocaleRemoteConfig;
|
||||
import org.signal.core.util.Util;
|
||||
import org.thoughtcrime.securesms.util.RemoteConfig;
|
||||
import org.thoughtcrime.securesms.video.TranscodingPreset;
|
||||
import org.thoughtcrime.securesms.video.videoconverter.utils.DeviceCapabilities;
|
||||
import org.whispersystems.signalservice.api.crypto.AttachmentCipherStreamUtil;
|
||||
import org.whispersystems.signalservice.internal.crypto.PaddingInputStream;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
@@ -65,7 +67,10 @@ public class PushMediaConstraints extends MediaConstraints {
|
||||
|
||||
@Override
|
||||
public long getCompressedVideoMaxSize(Context context) {
|
||||
return getMaxAttachmentSize();
|
||||
long maxCipherTextSize = RemoteConfig.videoTranscodeTargetSizeBytes();
|
||||
long maxPaddedSize = AttachmentCipherStreamUtil.getPlaintextLength(maxCipherTextSize);
|
||||
|
||||
return Math.min(PaddingInputStream.getMaxUnpaddedSize(maxPaddedSize), getMaxAttachmentSize());
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -39,7 +39,7 @@ private const val BIG_PICTURE_DIMEN = 500
|
||||
|
||||
/**
|
||||
* Wraps the compat and OS versions of the Notification builders so we can more easily access native
|
||||
* features in newer versions. Also provides some domain specific helpers.
|
||||
* features in newer versions. Also provides some domain-specific helpers.
|
||||
*
|
||||
* Note: All business logic should exist in the base builder or the models that drive the notifications
|
||||
* like NotificationConversation and NotificationItemV2.
|
||||
@@ -66,7 +66,6 @@ sealed class NotificationBuilder(protected val context: Context) {
|
||||
abstract fun setOnlyAlertOnce(onlyAlertOnce: Boolean)
|
||||
abstract fun setGroupSummary(isGroupSummary: Boolean)
|
||||
abstract fun setSubText(subText: String)
|
||||
abstract fun addMarkAsReadActionActual(state: NotificationState)
|
||||
abstract fun setPriority(priority: Int)
|
||||
abstract fun setAlarms(recipient: Recipient?)
|
||||
abstract fun setTicker(ticker: CharSequence?)
|
||||
@@ -79,6 +78,8 @@ sealed class NotificationBuilder(protected val context: Context) {
|
||||
protected abstract fun setShortcutIdActual(shortcutId: String)
|
||||
protected abstract fun setWhen(timestamp: Long)
|
||||
protected abstract fun addActions(replyMethod: ReplyMethod, conversation: NotificationConversation)
|
||||
protected abstract fun addMarkAsReadActionActual(conversation: NotificationConversation)
|
||||
protected abstract fun addMarkAsReadActionActual(state: NotificationState)
|
||||
protected abstract fun addMessagesActual(conversation: NotificationConversation, includeShortcut: Boolean)
|
||||
protected abstract fun addMessagesActual(state: NotificationState)
|
||||
protected abstract fun setBubbleMetadataActual(conversation: NotificationConversation, bubbleState: BubbleUtil.BubbleState)
|
||||
@@ -119,6 +120,7 @@ sealed class NotificationBuilder(protected val context: Context) {
|
||||
if (conversation.recipient.isPushV2Group) {
|
||||
val group: Optional<GroupRecord> = SignalDatabase.groups.getGroup(conversation.recipient.requireGroupId())
|
||||
if (group.isPresent && group.get().isAnnouncementGroup && !group.get().isAdmin(Recipient.self())) {
|
||||
addMarkAsReadAction(conversation)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -127,6 +129,12 @@ sealed class NotificationBuilder(protected val context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
fun addMarkAsReadAction(conversation: NotificationConversation) {
|
||||
if (privacy.isDisplayMessage && isNotLocked) {
|
||||
addMarkAsReadActionActual(conversation)
|
||||
}
|
||||
}
|
||||
|
||||
fun addMarkAsReadAction(state: NotificationState) {
|
||||
if (privacy.isDisplayMessage && isNotLocked) {
|
||||
addMarkAsReadActionActual(state)
|
||||
@@ -191,17 +199,7 @@ sealed class NotificationBuilder(protected val context: Context) {
|
||||
override fun addActions(replyMethod: ReplyMethod, conversation: NotificationConversation) {
|
||||
val extender: NotificationCompat.WearableExtender = NotificationCompat.WearableExtender()
|
||||
|
||||
val markAsRead: PendingIntent? = conversation.getMarkAsReadIntent(context)
|
||||
if (markAsRead != null) {
|
||||
val markAsReadAction: NotificationCompat.Action =
|
||||
NotificationCompat.Action.Builder(R.drawable.symbol_check_24, context.getString(R.string.MessageNotifier_mark_read), markAsRead)
|
||||
.setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_MARK_AS_READ)
|
||||
.setShowsUserInterface(false)
|
||||
.build()
|
||||
|
||||
builder.addAction(markAsReadAction)
|
||||
extender.addAction(markAsReadAction)
|
||||
}
|
||||
addMarkAsReadActionActual(conversation)
|
||||
|
||||
if (conversation.mostRecentNotification.canReply(context)) {
|
||||
val quickReply: PendingIntent? = conversation.getQuickReplyIntent(context)
|
||||
@@ -235,6 +233,20 @@ sealed class NotificationBuilder(protected val context: Context) {
|
||||
builder.extend(extender)
|
||||
}
|
||||
|
||||
override fun addMarkAsReadActionActual(conversation: NotificationConversation) {
|
||||
val markAsRead: PendingIntent? = conversation.getMarkAsReadIntent(context)
|
||||
if (markAsRead != null) {
|
||||
val markAsReadAction: NotificationCompat.Action =
|
||||
NotificationCompat.Action.Builder(R.drawable.symbol_check_24, context.getString(R.string.MessageNotifier_mark_read), markAsRead)
|
||||
.setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_MARK_AS_READ)
|
||||
.setShowsUserInterface(false)
|
||||
.build()
|
||||
|
||||
builder.addAction(markAsReadAction)
|
||||
builder.extend(NotificationCompat.WearableExtender().addAction(markAsReadAction))
|
||||
}
|
||||
}
|
||||
|
||||
override fun addMarkAsReadActionActual(state: NotificationState) {
|
||||
val markAsRead: PendingIntent? = state.getMarkAsReadIntent(context)
|
||||
|
||||
@@ -369,6 +381,7 @@ sealed class NotificationBuilder(protected val context: Context) {
|
||||
builder.bubbleMetadata = bubbleMetadata
|
||||
}
|
||||
}
|
||||
|
||||
override fun setLights(@ColorInt color: Int, onTime: Int, offTime: Int) {
|
||||
if (NotificationChannels.supported()) {
|
||||
return
|
||||
|
||||
@@ -4,6 +4,7 @@ import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.annotation.AnyThread
|
||||
import androidx.annotation.WorkerThread
|
||||
import androidx.core.text.buildSpannedString
|
||||
import io.reactivex.rxjava3.core.Observable
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
@@ -41,6 +42,7 @@ import org.thoughtcrime.securesms.database.model.ProfileAvatarFileDetails
|
||||
import org.thoughtcrime.securesms.database.model.RecipientRecord
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.RecipientExtras
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.fonts.SignalSymbols
|
||||
import org.thoughtcrime.securesms.groups.GroupId
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.notifications.NotificationChannels
|
||||
@@ -48,18 +50,22 @@ import org.thoughtcrime.securesms.phonenumbers.NumberUtil
|
||||
import org.thoughtcrime.securesms.profiles.ProfileName
|
||||
import org.thoughtcrime.securesms.recipients.Recipient.Companion.external
|
||||
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId
|
||||
import org.thoughtcrime.securesms.util.ContextUtil
|
||||
import org.thoughtcrime.securesms.util.RemoteConfig
|
||||
import org.thoughtcrime.securesms.util.SignalE164Util
|
||||
import org.thoughtcrime.securesms.util.SpanUtil
|
||||
import org.thoughtcrime.securesms.util.UsernameUtil.isValidUsernameForSearch
|
||||
import org.thoughtcrime.securesms.util.ViewUtil
|
||||
import org.thoughtcrime.securesms.wallpaper.ChatWallpaper
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress
|
||||
import org.whispersystems.signalservice.api.util.OptionalUtil
|
||||
import java.util.LinkedList
|
||||
import java.util.Objects
|
||||
import java.util.Optional
|
||||
import org.signal.core.ui.R as CoreUiR
|
||||
|
||||
/**
|
||||
* A recipient represents something you can send messages to, or receive messages from. They could be individuals, groups, or even distribution lists.
|
||||
* A recipient represents something you can send messages to or receive messages from. They could be individuals, groups, or even distribution lists.
|
||||
* This class is a snapshot of common state that is used to present recipients through the UI.
|
||||
*
|
||||
* It's important to note that this is only a snapshot, and the actual state of a recipient can change over time.
|
||||
@@ -387,13 +393,14 @@ class Recipient(
|
||||
* The badge to feature on a recipient's avatar, if any.
|
||||
* This value respects the local user's [SignalStore.inAppPayments.getDisplayBadgesOnProfile()] preference.
|
||||
*/
|
||||
val featuredBadge: Badge? get() {
|
||||
return if (isSelf && !SignalStore.inAppPayments.getDisplayBadgesOnProfile()) {
|
||||
null
|
||||
} else {
|
||||
badges.firstOrNull()
|
||||
val featuredBadge: Badge?
|
||||
get() {
|
||||
return if (isSelf && !SignalStore.inAppPayments.getDisplayBadgesOnProfile()) {
|
||||
null
|
||||
} else {
|
||||
badges.firstOrNull()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A string combining the about emoji + text for displaying various places. */
|
||||
val combinedAboutAndEmoji: String? by lazy { listOf(aboutEmoji, about).filter { it.isNotNullOrBlank() }.joinToString(separator = " ").nullIfBlank() }
|
||||
@@ -659,6 +666,47 @@ class Recipient(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the recipient's display name with any applicable decorations:
|
||||
* - A badge icon for verified recipients
|
||||
* - A person-circle glyph for system contacts
|
||||
* - A directional chevron for tappable individual profiles
|
||||
*/
|
||||
fun getDisplayNameForHeadline(context: Context): CharSequence {
|
||||
val name = if (isSelf) context.getString(R.string.note_to_self) else getDisplayName(context)
|
||||
|
||||
return buildSpannedString {
|
||||
append(name)
|
||||
|
||||
if (showVerified) {
|
||||
val verifiedBadge = ContextUtil.requireDrawable(context, R.drawable.ic_official_28)
|
||||
SpanUtil.appendSpacer(this, 8)
|
||||
SpanUtil.appendCenteredImageSpanWithoutSpace(this, verifiedBadge, 28, 28)
|
||||
} else if (isSystemContact) {
|
||||
val systemContactGlyph = SignalSymbols
|
||||
.getSpannedString(context, SignalSymbols.Weight.BOLD, SignalSymbols.Glyph.PERSON_CIRCLE)
|
||||
.let { SpanUtil.ofSize(it, 20) }
|
||||
|
||||
append("\u00A0")
|
||||
append(systemContactGlyph)
|
||||
}
|
||||
|
||||
if (isIndividual && !isSelf) {
|
||||
val isLtr = ViewUtil.isLtr(context)
|
||||
val chevronGlyph = SignalSymbols.getSpannedString(context, SignalSymbols.Weight.BOLD, if (isLtr) SignalSymbols.Glyph.CHEVRON_RIGHT else SignalSymbols.Glyph.CHEVRON_LEFT, CoreUiR.color.signal_colorOutline)
|
||||
.let { SpanUtil.ofSize(it, 24) }
|
||||
|
||||
if (isLtr) {
|
||||
append("\u00A0")
|
||||
append(chevronGlyph)
|
||||
} else {
|
||||
insert(0, "\u00A0")
|
||||
insert(0, chevronGlyph)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getFallbackAvatar(): FallbackAvatar {
|
||||
return if (isSelf) {
|
||||
FallbackAvatar.Resource.Local(avatarColor)
|
||||
|
||||
@@ -19,7 +19,6 @@ import org.thoughtcrime.securesms.groups.GroupId
|
||||
import org.thoughtcrime.securesms.groups.memberlabel.MemberLabel
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.util.RemoteConfig
|
||||
|
||||
class AboutSheetViewModel(
|
||||
recipientId: RecipientId,
|
||||
@@ -56,7 +55,7 @@ class AboutSheetViewModel(
|
||||
init {
|
||||
disposables.addAll(recipientDisposable, groupsInCommonDisposable, verifiedDisposable)
|
||||
|
||||
if (groupId != null && RemoteConfig.sendMemberLabels) {
|
||||
if (groupId != null) {
|
||||
observeMemberLabel(groupId)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import android.content.ActivityNotFoundException
|
||||
import android.content.DialogInterface
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.text.SpannableStringBuilder
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
@@ -34,7 +33,6 @@ import org.thoughtcrime.securesms.calls.YouAreAlreadyInACallSnackbar
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsIcon
|
||||
import org.thoughtcrime.securesms.components.settings.conversation.preferences.ButtonStripPreference
|
||||
import org.thoughtcrime.securesms.conversation.v2.data.AvatarDownloadStateCache
|
||||
import org.thoughtcrime.securesms.fonts.SignalSymbols
|
||||
import org.thoughtcrime.securesms.groups.GroupId
|
||||
import org.thoughtcrime.securesms.groups.memberlabel.MemberLabelEducationSheet
|
||||
import org.thoughtcrime.securesms.groups.memberlabel.MemberLabelPillView
|
||||
@@ -45,11 +43,8 @@ import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.recipients.RecipientUtil
|
||||
import org.thoughtcrime.securesms.recipients.ui.about.AboutSheet
|
||||
import org.thoughtcrime.securesms.util.ContextUtil
|
||||
import org.thoughtcrime.securesms.util.SpanUtil
|
||||
import org.thoughtcrime.securesms.util.ViewUtil
|
||||
import org.thoughtcrime.securesms.util.WindowUtil
|
||||
import org.thoughtcrime.securesms.util.visible
|
||||
import org.signal.core.ui.R as CoreUiR
|
||||
|
||||
/**
|
||||
* A bottom sheet that shows some simple recipient details, as well as some actions (like calling,
|
||||
@@ -216,42 +211,13 @@ class RecipientBottomSheetDialogFragment : FixedRoundedCornerBottomSheetDialogFr
|
||||
tapToView.setOnClickListener(null)
|
||||
}
|
||||
|
||||
val name = if (recipient.isSelf) requireContext().getString(R.string.note_to_self) else recipient.getDisplayName(requireContext())
|
||||
|
||||
fullName.visible = name.isNotEmpty()
|
||||
val nameBuilder = SpannableStringBuilder(name)
|
||||
if (recipient.showVerified) {
|
||||
SpanUtil.appendSpacer(nameBuilder, 8)
|
||||
SpanUtil.appendCenteredImageSpanWithoutSpace(nameBuilder, ContextUtil.requireDrawable(requireContext(), R.drawable.ic_official_28), 28, 28)
|
||||
} else if (recipient.isSystemContact) {
|
||||
val systemContactGlyph = SignalSymbols.getSpannedString(
|
||||
requireContext(),
|
||||
SignalSymbols.Weight.BOLD,
|
||||
SignalSymbols.Glyph.PERSON_CIRCLE
|
||||
)
|
||||
|
||||
nameBuilder.append(" ")
|
||||
nameBuilder.append(SpanUtil.ofSize(systemContactGlyph, 20))
|
||||
val name = recipient.getDisplayNameForHeadline(requireContext())
|
||||
fullName.apply {
|
||||
text = name
|
||||
visible = name.isNotEmpty()
|
||||
}
|
||||
|
||||
if (!recipient.isSelf && recipient.isIndividual) {
|
||||
val isLtr = ViewUtil.isLtr(view)
|
||||
val chevronGlyph = SignalSymbols.getSpannedString(
|
||||
requireContext(),
|
||||
SignalSymbols.Weight.BOLD,
|
||||
if (isLtr) SignalSymbols.Glyph.CHEVRON_RIGHT else SignalSymbols.Glyph.CHEVRON_LEFT,
|
||||
CoreUiR.color.signal_colorOutline
|
||||
)
|
||||
|
||||
if (isLtr) {
|
||||
nameBuilder.append(" ")
|
||||
nameBuilder.append(SpanUtil.ofSize(chevronGlyph, 24))
|
||||
} else {
|
||||
nameBuilder.insert(0, " ")
|
||||
nameBuilder.insert(0, SpanUtil.ofSize(chevronGlyph, 24))
|
||||
}
|
||||
|
||||
fullName.text = nameBuilder
|
||||
fullName.setOnClickListener {
|
||||
dismiss()
|
||||
AboutSheet.create(recipient).show(getParentFragmentManager(), null)
|
||||
@@ -261,8 +227,8 @@ class RecipientBottomSheetDialogFragment : FixedRoundedCornerBottomSheetDialogFr
|
||||
nickname.setOnClickListener {
|
||||
nicknameLauncher.launch(NicknameActivity.Args(recipientId, false))
|
||||
}
|
||||
} else if (recipient.isReleaseNotes) {
|
||||
fullName.text = name
|
||||
} else {
|
||||
fullName.setOnClickListener(null)
|
||||
}
|
||||
|
||||
noteToSelfDescription.visible = recipient.isSelf
|
||||
|
||||
@@ -212,7 +212,14 @@ class ActiveCallManager(
|
||||
|
||||
fun sendAudioCommand(audioCommand: AudioManagerCommand) {
|
||||
if (signalAudioManager == null) {
|
||||
signalAudioManager = create(application, this)
|
||||
val canUseTelecom = if (audioCommand is AudioManagerCommand.Initialize) {
|
||||
!audioCommand.isGroupCall
|
||||
} else {
|
||||
Log.w(TAG, "First AudioCommand received was not Initialize, skipping Telecom usage, command: ${audioCommand.javaClass.simpleName}")
|
||||
false
|
||||
}
|
||||
|
||||
signalAudioManager = create(context = application, eventListener = this, canUseTelecom = canUseTelecom)
|
||||
}
|
||||
|
||||
Log.i(TAG, "Sending audio command [" + audioCommand.javaClass.simpleName + "] to " + signalAudioManager?.javaClass?.simpleName)
|
||||
@@ -310,17 +317,23 @@ class ActiveCallManager(
|
||||
@get:RequiresApi(30)
|
||||
override val serviceType: Int
|
||||
get() {
|
||||
var type = ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
|
||||
val telecom = Build.VERSION.SDK_INT >= 34 && AndroidTelecomUtil.hasActiveController()
|
||||
|
||||
if (Permissions.hasAll(this, Manifest.permission.RECORD_AUDIO)) {
|
||||
type = type or ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE
|
||||
return if (telecom) {
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_PHONE_CALL
|
||||
} else {
|
||||
var type = ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
|
||||
|
||||
if (Permissions.hasAll(this, Manifest.permission.RECORD_AUDIO)) {
|
||||
type = type or ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE
|
||||
}
|
||||
|
||||
if (Permissions.hasAll(this, Manifest.permission.CAMERA)) {
|
||||
type = type or ServiceInfo.FOREGROUND_SERVICE_TYPE_CAMERA
|
||||
}
|
||||
|
||||
type
|
||||
}
|
||||
|
||||
if (Permissions.hasAll(this, Manifest.permission.CAMERA)) {
|
||||
type = type or ServiceInfo.FOREGROUND_SERVICE_TYPE_CAMERA
|
||||
}
|
||||
|
||||
return type
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
package org.thoughtcrime.securesms.service.webrtc
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.telecom.CallAudioState
|
||||
import android.telecom.Connection
|
||||
import androidx.annotation.RequiresApi
|
||||
import org.signal.core.ui.permissions.Permissions
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.components.webrtc.v2.CallIntent
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.webrtc.CallNotificationBuilder
|
||||
import org.thoughtcrime.securesms.webrtc.audio.AudioManagerCommand
|
||||
import org.thoughtcrime.securesms.webrtc.audio.SignalAudioManager
|
||||
|
||||
/**
|
||||
* Signal implementation for the telecom system connection. Provides an interaction point for the system to
|
||||
* inform us about changes in the telecom system. Created and returned by [AndroidCallConnectionService].
|
||||
*/
|
||||
@RequiresApi(26)
|
||||
class AndroidCallConnection(
|
||||
private val context: Context,
|
||||
private val recipientId: RecipientId,
|
||||
isOutgoing: Boolean,
|
||||
private val isVideoCall: Boolean
|
||||
) : Connection() {
|
||||
|
||||
private var needToResetAudioRoute = isOutgoing && !isVideoCall
|
||||
private var initialAudioRoute: SignalAudioManager.AudioDevice? = null
|
||||
|
||||
init {
|
||||
connectionProperties = PROPERTY_SELF_MANAGED
|
||||
connectionCapabilities = CAPABILITY_SUPPORTS_VT_LOCAL_BIDIRECTIONAL or
|
||||
CAPABILITY_SUPPORTS_VT_REMOTE_BIDIRECTIONAL or
|
||||
CAPABILITY_MUTE
|
||||
}
|
||||
|
||||
override fun onShowIncomingCallUi() {
|
||||
Log.i(TAG, "onShowIncomingCallUi()")
|
||||
ActiveCallManager.update(context, CallNotificationBuilder.TYPE_INCOMING_CONNECTING, recipientId, isVideoCall)
|
||||
setRinging()
|
||||
}
|
||||
|
||||
override fun onCallAudioStateChanged(state: CallAudioState) {
|
||||
Log.i(TAG, "onCallAudioStateChanged($state)")
|
||||
|
||||
val activeDevice = state.route.toDevices().firstOrNull() ?: SignalAudioManager.AudioDevice.EARPIECE
|
||||
val availableDevices = state.supportedRouteMask.toDevices()
|
||||
|
||||
AppDependencies.signalCallManager.onAudioDeviceChanged(activeDevice, availableDevices)
|
||||
|
||||
if (needToResetAudioRoute) {
|
||||
if (initialAudioRoute == null) {
|
||||
initialAudioRoute = activeDevice
|
||||
} else if (activeDevice == SignalAudioManager.AudioDevice.SPEAKER_PHONE) {
|
||||
Log.i(TAG, "Resetting audio route from SPEAKER_PHONE to $initialAudioRoute")
|
||||
AndroidTelecomUtil.selectAudioDevice(recipientId, initialAudioRoute!!)
|
||||
needToResetAudioRoute = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAnswer(videoState: Int) {
|
||||
Log.i(TAG, "onAnswer($videoState)")
|
||||
if (Permissions.hasAll(context, android.Manifest.permission.RECORD_AUDIO)) {
|
||||
AppDependencies.signalCallManager.acceptCall(false)
|
||||
} else {
|
||||
val intent = CallIntent.Builder(context)
|
||||
.withAddedIntentFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
.withAction(if (isVideoCall) CallIntent.Action.ANSWER_VIDEO else CallIntent.Action.ANSWER_AUDIO)
|
||||
.build()
|
||||
context.startActivity(intent)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSilence() {
|
||||
ActiveCallManager.sendAudioManagerCommand(context, AudioManagerCommand.SilenceIncomingRinger())
|
||||
}
|
||||
|
||||
override fun onReject() {
|
||||
Log.i(TAG, "onReject()")
|
||||
ActiveCallManager.denyCall()
|
||||
}
|
||||
|
||||
override fun onDisconnect() {
|
||||
Log.i(TAG, "onDisconnect()")
|
||||
ActiveCallManager.hangup()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG: String = Log.tag(AndroidCallConnection::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
private fun Int.toDevices(): Set<SignalAudioManager.AudioDevice> {
|
||||
val devices = mutableSetOf<SignalAudioManager.AudioDevice>()
|
||||
|
||||
if (this and CallAudioState.ROUTE_BLUETOOTH != 0) {
|
||||
devices += SignalAudioManager.AudioDevice.BLUETOOTH
|
||||
}
|
||||
|
||||
if (this and CallAudioState.ROUTE_EARPIECE != 0) {
|
||||
devices += SignalAudioManager.AudioDevice.EARPIECE
|
||||
}
|
||||
|
||||
if (this and CallAudioState.ROUTE_WIRED_HEADSET != 0) {
|
||||
devices += SignalAudioManager.AudioDevice.WIRED_HEADSET
|
||||
}
|
||||
|
||||
if (this and CallAudioState.ROUTE_SPEAKER != 0) {
|
||||
devices += SignalAudioManager.AudioDevice.SPEAKER_PHONE
|
||||
}
|
||||
|
||||
return devices
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
package org.thoughtcrime.securesms.service.webrtc
|
||||
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.telecom.Connection
|
||||
import android.telecom.ConnectionRequest
|
||||
import android.telecom.ConnectionService
|
||||
import android.telecom.PhoneAccountHandle
|
||||
import android.telecom.TelecomManager
|
||||
import androidx.annotation.RequiresApi
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
|
||||
/**
|
||||
* Signal implementation of the Android telecom [ConnectionService]. The system binds to this service
|
||||
* when we inform the [TelecomManager] of a new incoming or outgoing call. It'll then call the appropriate
|
||||
* create/failure method to let us know how to proceed.
|
||||
*/
|
||||
@RequiresApi(26)
|
||||
class AndroidCallConnectionService : ConnectionService() {
|
||||
|
||||
override fun onCreateIncomingConnection(
|
||||
connectionManagerPhoneAccount: PhoneAccountHandle?,
|
||||
request: ConnectionRequest
|
||||
): Connection {
|
||||
val (recipientId: RecipientId, callId: Long, isVideoCall: Boolean) = request.getOurExtras()
|
||||
|
||||
Log.i(TAG, "onCreateIncomingConnection($recipientId)")
|
||||
val recipient = Recipient.resolved(recipientId)
|
||||
val displayName = recipient.getDisplayName(this)
|
||||
val connection = AndroidCallConnection(
|
||||
context = applicationContext,
|
||||
recipientId = recipientId,
|
||||
isOutgoing = false,
|
||||
isVideoCall = isVideoCall
|
||||
).apply {
|
||||
setInitializing()
|
||||
if (SignalStore.settings.messageNotificationsPrivacy.isDisplayContact && recipient.e164.isPresent) {
|
||||
setAddress(Uri.fromParts("tel", recipient.e164.get(), null), TelecomManager.PRESENTATION_ALLOWED)
|
||||
setCallerDisplayName(displayName, TelecomManager.PRESENTATION_ALLOWED)
|
||||
}
|
||||
videoState = request.videoState
|
||||
extras = request.extras
|
||||
setRinging()
|
||||
}
|
||||
AndroidTelecomUtil.connections[recipientId] = connection
|
||||
AppDependencies.signalCallManager.setTelecomApproved(callId, recipientId)
|
||||
|
||||
return connection
|
||||
}
|
||||
|
||||
override fun onCreateIncomingConnectionFailed(
|
||||
connectionManagerPhoneAccount: PhoneAccountHandle?,
|
||||
request: ConnectionRequest
|
||||
) {
|
||||
val (recipientId: RecipientId, callId: Long) = request.getOurExtras()
|
||||
|
||||
Log.i(TAG, "onCreateIncomingConnectionFailed($recipientId)")
|
||||
AppDependencies.signalCallManager.dropCall(callId)
|
||||
}
|
||||
|
||||
override fun onCreateOutgoingConnection(
|
||||
connectionManagerPhoneAccount: PhoneAccountHandle?,
|
||||
request: ConnectionRequest
|
||||
): Connection {
|
||||
val (recipientId: RecipientId, callId: Long, isVideoCall: Boolean) = request.getOurExtras()
|
||||
|
||||
Log.i(TAG, "onCreateOutgoingConnection($recipientId)")
|
||||
val connection = AndroidCallConnection(
|
||||
context = applicationContext,
|
||||
recipientId = recipientId,
|
||||
isOutgoing = true,
|
||||
isVideoCall = isVideoCall
|
||||
).apply {
|
||||
videoState = request.videoState
|
||||
extras = request.extras
|
||||
setDialing()
|
||||
}
|
||||
AndroidTelecomUtil.connections[recipientId] = connection
|
||||
AppDependencies.signalCallManager.setTelecomApproved(callId, recipientId)
|
||||
|
||||
return connection
|
||||
}
|
||||
|
||||
override fun onCreateOutgoingConnectionFailed(
|
||||
connectionManagerPhoneAccount: PhoneAccountHandle?,
|
||||
request: ConnectionRequest
|
||||
) {
|
||||
val (recipientId: RecipientId, callId: Long) = request.getOurExtras()
|
||||
|
||||
Log.i(TAG, "onCreateOutgoingConnectionFailed($recipientId)")
|
||||
AppDependencies.signalCallManager.dropCall(callId)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG: String = Log.tag(AndroidCallConnectionService::class.java)
|
||||
const val KEY_RECIPIENT_ID = "org.thoughtcrime.securesms.RECIPIENT_ID"
|
||||
const val KEY_CALL_ID = "org.thoughtcrime.securesms.CALL_ID"
|
||||
const val KEY_VIDEO_CALL = "org.thoughtcrime.securesms.VIDEO_CALL"
|
||||
}
|
||||
|
||||
private fun ConnectionRequest.getOurExtras(): ServiceExtras {
|
||||
val ourExtras: Bundle = extras.getBundle(TelecomManager.EXTRA_INCOMING_CALL_EXTRAS) ?: extras
|
||||
|
||||
val recipientId: RecipientId = RecipientId.from(ourExtras.getString(KEY_RECIPIENT_ID)!!)
|
||||
val callId: Long = ourExtras.getLong(KEY_CALL_ID)
|
||||
val isVideoCall: Boolean = ourExtras.getBoolean(KEY_VIDEO_CALL, false)
|
||||
|
||||
return ServiceExtras(recipientId, callId, isVideoCall)
|
||||
}
|
||||
|
||||
private data class ServiceExtras(val recipientId: RecipientId, val callId: Long, val isVideoCall: Boolean)
|
||||
}
|
||||
@@ -1,23 +1,15 @@
|
||||
package org.thoughtcrime.securesms.service.webrtc
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.ComponentName
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Process
|
||||
import android.telecom.CallAudioState
|
||||
import android.telecom.Connection
|
||||
import android.telecom.DisconnectCause
|
||||
import android.telecom.DisconnectCause.REJECTED
|
||||
import android.telecom.DisconnectCause.UNKNOWN
|
||||
import android.telecom.PhoneAccount
|
||||
import android.telecom.PhoneAccountHandle
|
||||
import android.telecom.TelecomManager
|
||||
import android.telecom.VideoProfile
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.os.bundleOf
|
||||
import androidx.core.telecom.CallsManager
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.components.webrtc.AudioOutputOption
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
@@ -25,172 +17,194 @@ import org.thoughtcrime.securesms.util.RemoteConfig
|
||||
import org.thoughtcrime.securesms.webrtc.audio.SignalAudioManager
|
||||
|
||||
/**
|
||||
* Wrapper around various [TelecomManager] methods to make dealing with SDK versions easier. Also
|
||||
* maintains a global list of all Signal [AndroidCallConnection]s associated with their [RecipientId].
|
||||
* There should really only be one ever, but there may be times when dealing with glare or a busy that two
|
||||
* may kick off.
|
||||
* Wrapper around Jetpack [CallsManager] to manage telecom integration. Maintains a global map of
|
||||
* [TelecomCallController] instances associated with their [RecipientId].
|
||||
*/
|
||||
@SuppressLint("NewApi", "InlinedApi")
|
||||
@SuppressLint("NewApi")
|
||||
object AndroidTelecomUtil {
|
||||
|
||||
private val TAG = Log.tag(AndroidTelecomUtil::class.java)
|
||||
private val context = AppDependencies.application
|
||||
private var systemRejected = false
|
||||
private var accountRegistered = false
|
||||
private var registered = false
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
|
||||
private val callsManager: CallsManager by lazy { CallsManager(context) }
|
||||
|
||||
@JvmStatic
|
||||
val telecomSupported: Boolean
|
||||
get() {
|
||||
if (Build.VERSION.SDK_INT >= 26 && !systemRejected && isTelecomAllowedForDevice()) {
|
||||
if (!accountRegistered) {
|
||||
if (Build.VERSION.SDK_INT >= 34 && !systemRejected && isTelecomAllowedForDevice()) {
|
||||
if (!registered) {
|
||||
registerPhoneAccount()
|
||||
}
|
||||
|
||||
if (accountRegistered) {
|
||||
val phoneAccount = ContextCompat.getSystemService(context, TelecomManager::class.java)!!.getPhoneAccount(getPhoneAccountHandle())
|
||||
if (phoneAccount != null && phoneAccount.isEnabled) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return registered
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
val connections: MutableMap<RecipientId, AndroidCallConnection> = mutableMapOf()
|
||||
private val controllers: MutableMap<RecipientId, TelecomCallController> = mutableMapOf()
|
||||
|
||||
@JvmStatic
|
||||
fun registerPhoneAccount() {
|
||||
if (Build.VERSION.SDK_INT >= 26 && !systemRejected) {
|
||||
Log.i(TAG, "Registering phone account")
|
||||
val phoneAccount = PhoneAccount.Builder(getPhoneAccountHandle(), "Signal")
|
||||
.setCapabilities(PhoneAccount.CAPABILITY_SELF_MANAGED or PhoneAccount.CAPABILITY_VIDEO_CALLING)
|
||||
.build()
|
||||
|
||||
if (Build.VERSION.SDK_INT >= 34 && !systemRejected) {
|
||||
Log.i(TAG, "Registering with CallsManager")
|
||||
try {
|
||||
ContextCompat.getSystemService(context, TelecomManager::class.java)!!.registerPhoneAccount(phoneAccount)
|
||||
Log.i(TAG, "Phone account registered successfully")
|
||||
accountRegistered = true
|
||||
callsManager.registerAppWithTelecom(
|
||||
CallsManager.CAPABILITY_BASELINE or CallsManager.CAPABILITY_SUPPORTS_VIDEO_CALLING
|
||||
)
|
||||
Log.i(TAG, "CallsManager registration successful")
|
||||
registered = true
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Unable to register telecom account", e)
|
||||
Log.w(TAG, "Unable to register with CallsManager", e)
|
||||
systemRejected = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
@RequiresApi(26)
|
||||
fun getPhoneAccountHandle(): PhoneAccountHandle {
|
||||
return PhoneAccountHandle(ComponentName(context, AndroidCallConnectionService::class.java), context.packageName, Process.myUserHandle())
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun addIncomingCall(recipientId: RecipientId, callId: Long, remoteVideoOffer: Boolean): Boolean {
|
||||
if (telecomSupported) {
|
||||
val telecomBundle = bundleOf(
|
||||
TelecomManager.EXTRA_INCOMING_CALL_EXTRAS to bundleOf(
|
||||
AndroidCallConnectionService.KEY_RECIPIENT_ID to recipientId.serialize(),
|
||||
AndroidCallConnectionService.KEY_CALL_ID to callId,
|
||||
AndroidCallConnectionService.KEY_VIDEO_CALL to remoteVideoOffer,
|
||||
TelecomManager.EXTRA_INCOMING_VIDEO_STATE to if (remoteVideoOffer) VideoProfile.STATE_BIDIRECTIONAL else VideoProfile.STATE_AUDIO_ONLY
|
||||
),
|
||||
TelecomManager.EXTRA_INCOMING_VIDEO_STATE to if (remoteVideoOffer) VideoProfile.STATE_BIDIRECTIONAL else VideoProfile.STATE_AUDIO_ONLY
|
||||
Log.i(TAG, "addIncomingCall(recipientId=$recipientId, callId=$callId, videoOffer=$remoteVideoOffer)")
|
||||
val controller = TelecomCallController(
|
||||
context = context,
|
||||
recipientId = recipientId,
|
||||
callId = callId,
|
||||
isVideoCall = remoteVideoOffer,
|
||||
isOutgoing = false,
|
||||
callsManager = callsManager
|
||||
)
|
||||
try {
|
||||
Log.i(TAG, "Adding incoming call $telecomBundle")
|
||||
ContextCompat.getSystemService(context, TelecomManager::class.java)!!.addNewIncomingCall(getPhoneAccountHandle(), telecomBundle)
|
||||
} catch (e: SecurityException) {
|
||||
Log.w(TAG, "Unable to add incoming call", e)
|
||||
systemRejected = true
|
||||
return false
|
||||
synchronized(controllers) {
|
||||
controllers[recipientId] = controller
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun reject(recipientId: RecipientId) {
|
||||
if (telecomSupported) {
|
||||
connections[recipientId]?.setDisconnected(DisconnectCause(REJECTED))
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun activateCall(recipientId: RecipientId) {
|
||||
if (telecomSupported) {
|
||||
connections[recipientId]?.setActive()
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun terminateCall(recipientId: RecipientId) {
|
||||
if (telecomSupported) {
|
||||
connections[recipientId]?.let { connection ->
|
||||
if (connection.disconnectCause == null) {
|
||||
connection.setDisconnected(DisconnectCause(UNKNOWN))
|
||||
scope.launch {
|
||||
try {
|
||||
Log.i(TAG, "Incoming call controller starting for recipientId=$recipientId callId=$callId")
|
||||
controller.start()
|
||||
Log.i(TAG, "Incoming call controller scope ended normally for recipientId=$recipientId callId=$callId")
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "addIncomingCall failed for recipientId=$recipientId callId=$callId", e)
|
||||
systemRejected = true
|
||||
AppDependencies.signalCallManager.dropCall(callId)
|
||||
} finally {
|
||||
Log.i(TAG, "Removing incoming controller for recipientId=$recipientId")
|
||||
synchronized(controllers) {
|
||||
controllers.remove(recipientId)
|
||||
}
|
||||
}
|
||||
connection.destroy()
|
||||
connections.remove(recipientId)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun addOutgoingCall(recipientId: RecipientId, callId: Long, isVideoCall: Boolean): Boolean {
|
||||
if (telecomSupported) {
|
||||
val telecomBundle = bundleOf(
|
||||
TelecomManager.EXTRA_PHONE_ACCOUNT_HANDLE to getPhoneAccountHandle(),
|
||||
TelecomManager.EXTRA_START_CALL_WITH_VIDEO_STATE to if (isVideoCall) VideoProfile.STATE_BIDIRECTIONAL else VideoProfile.STATE_AUDIO_ONLY,
|
||||
TelecomManager.EXTRA_OUTGOING_CALL_EXTRAS to bundleOf(
|
||||
AndroidCallConnectionService.KEY_RECIPIENT_ID to recipientId.serialize(),
|
||||
AndroidCallConnectionService.KEY_CALL_ID to callId,
|
||||
AndroidCallConnectionService.KEY_VIDEO_CALL to isVideoCall
|
||||
)
|
||||
Log.i(TAG, "addOutgoingCall(recipientId=$recipientId, callId=$callId, isVideoCall=$isVideoCall)")
|
||||
val controller = TelecomCallController(
|
||||
context = context,
|
||||
recipientId = recipientId,
|
||||
callId = callId,
|
||||
isVideoCall = isVideoCall,
|
||||
isOutgoing = true,
|
||||
callsManager = callsManager
|
||||
)
|
||||
|
||||
try {
|
||||
Log.i(TAG, "Adding outgoing call $telecomBundle")
|
||||
ContextCompat.getSystemService(context, TelecomManager::class.java)!!.placeCall(recipientId.generateTelecomE164(), telecomBundle)
|
||||
} catch (e: SecurityException) {
|
||||
Log.w(TAG, "Unable to add outgoing call", e)
|
||||
systemRejected = true
|
||||
return false
|
||||
synchronized(controllers) {
|
||||
controllers[recipientId] = controller
|
||||
}
|
||||
scope.launch {
|
||||
try {
|
||||
Log.i(TAG, "Outgoing call controller starting for recipientId=$recipientId callId=$callId")
|
||||
controller.start()
|
||||
Log.i(TAG, "Outgoing call controller scope ended normally for recipientId=$recipientId callId=$callId")
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "addOutgoingCall failed for recipientId=$recipientId callId=$callId", e)
|
||||
systemRejected = true
|
||||
AppDependencies.signalCallManager.dropCall(callId)
|
||||
} finally {
|
||||
Log.i(TAG, "Removing outgoing controller for recipientId=$recipientId")
|
||||
synchronized(controllers) {
|
||||
controllers.remove(recipientId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
fun selectAudioDevice(recipientId: RecipientId, device: SignalAudioManager.AudioDevice) {
|
||||
@JvmStatic
|
||||
fun activateCall(recipientId: RecipientId) {
|
||||
if (telecomSupported) {
|
||||
val connection: AndroidCallConnection? = connections[recipientId]
|
||||
Log.i(TAG, "Selecting audio route: $device connection: ${connection != null}")
|
||||
if (connection?.callAudioState != null) {
|
||||
when (device) {
|
||||
SignalAudioManager.AudioDevice.SPEAKER_PHONE -> connection.setAudioRouteIfDifferent(CallAudioState.ROUTE_SPEAKER)
|
||||
SignalAudioManager.AudioDevice.BLUETOOTH -> connection.setAudioRouteIfDifferent(CallAudioState.ROUTE_BLUETOOTH)
|
||||
SignalAudioManager.AudioDevice.WIRED_HEADSET -> connection.setAudioRouteIfDifferent(CallAudioState.ROUTE_WIRED_HEADSET)
|
||||
else -> connection.setAudioRouteIfDifferent(CallAudioState.ROUTE_EARPIECE)
|
||||
}
|
||||
Log.i(TAG, "activateCall(recipientId=$recipientId) hasController=${synchronized(controllers) { controllers.containsKey(recipientId) }}")
|
||||
synchronized(controllers) {
|
||||
controllers[recipientId]?.activate()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
fun getSelectedAudioDevice(recipientId: RecipientId): SignalAudioManager.AudioDevice {
|
||||
@JvmStatic
|
||||
@JvmOverloads
|
||||
fun terminateCall(recipientId: RecipientId, disconnectCause: Int = DisconnectCause.REMOTE) {
|
||||
if (telecomSupported) {
|
||||
val connection: AndroidCallConnection? = connections[recipientId]
|
||||
if (connection?.callAudioState != null) {
|
||||
return when (connection.callAudioState.route) {
|
||||
CallAudioState.ROUTE_SPEAKER -> SignalAudioManager.AudioDevice.SPEAKER_PHONE
|
||||
CallAudioState.ROUTE_BLUETOOTH -> SignalAudioManager.AudioDevice.BLUETOOTH
|
||||
CallAudioState.ROUTE_WIRED_HEADSET -> SignalAudioManager.AudioDevice.WIRED_HEADSET
|
||||
else -> SignalAudioManager.AudioDevice.EARPIECE
|
||||
}
|
||||
Log.i(TAG, "terminateCall(recipientId=$recipientId, cause=$disconnectCause) hasController=${synchronized(controllers) { controllers.containsKey(recipientId) }}")
|
||||
synchronized(controllers) {
|
||||
controllers[recipientId]?.disconnect(disconnectCause)
|
||||
}
|
||||
}
|
||||
return SignalAudioManager.AudioDevice.NONE
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun reject(recipientId: RecipientId) {
|
||||
if (telecomSupported) {
|
||||
Log.i(TAG, "reject(recipientId=$recipientId) hasController=${synchronized(controllers) { controllers.containsKey(recipientId) }}")
|
||||
synchronized(controllers) {
|
||||
controllers[recipientId]?.disconnect(DisconnectCause.REJECTED)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getActiveAudioDevice(recipientId: RecipientId): SignalAudioManager.AudioDevice {
|
||||
return synchronized(controllers) {
|
||||
controllers[recipientId]?.currentAudioDevice ?: SignalAudioManager.AudioDevice.NONE
|
||||
}
|
||||
}
|
||||
|
||||
fun selectAudioDevice(recipientId: RecipientId, device: SignalAudioManager.AudioDevice) {
|
||||
if (telecomSupported) {
|
||||
synchronized(controllers) {
|
||||
val controller = controllers[recipientId]
|
||||
Log.i(TAG, "Selecting audio route: $device controller: ${controller != null}")
|
||||
controller?.requestEndpointChange(device)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun getAvailableAudioOutputOptions(): List<AudioOutputOption>? {
|
||||
if (!telecomSupported) return null
|
||||
return synchronized(controllers) {
|
||||
controllers.values.firstOrNull()?.getAvailableAudioOutputOptions()
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun getCurrentEndpointDeviceId(): Int {
|
||||
return synchronized(controllers) {
|
||||
controllers.values.firstOrNull()?.getCurrentEndpointDeviceId() ?: -1
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun getCurrentActiveAudioDevice(): SignalAudioManager.AudioDevice {
|
||||
return synchronized(controllers) {
|
||||
controllers.values.firstOrNull()?.currentAudioDevice ?: SignalAudioManager.AudioDevice.NONE
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun hasActiveController(): Boolean {
|
||||
return synchronized(controllers) { controllers.isNotEmpty() }
|
||||
}
|
||||
|
||||
private fun isTelecomAllowedForDevice(): Boolean {
|
||||
@@ -200,16 +214,3 @@ object AndroidTelecomUtil {
|
||||
return RingRtcDynamicConfiguration.isTelecomAllowedForDevice()
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
@RequiresApi(26)
|
||||
private fun Connection.setAudioRouteIfDifferent(newRoute: Int) {
|
||||
if (callAudioState.route != newRoute) {
|
||||
setAudioRoute(newRoute)
|
||||
}
|
||||
}
|
||||
|
||||
private fun RecipientId.generateTelecomE164(): Uri {
|
||||
val pseudoNumber = toLong().toString().padEnd(10, '0').replaceRange(3..5, "555")
|
||||
return Uri.fromParts("tel", "+1$pseudoNumber", null)
|
||||
}
|
||||
|
||||
@@ -124,7 +124,7 @@ public class BeginCallActionProcessorDelegate extends WebRtcActionProcessor {
|
||||
webRtcInteractor.setCallInProgressNotification(TYPE_INCOMING_CONNECTING, remotePeer, isRemoteVideoOffer);
|
||||
}
|
||||
webRtcInteractor.retrieveTurnServers(remotePeer);
|
||||
webRtcInteractor.initializeAudioForCall();
|
||||
webRtcInteractor.initializeAudioForCall(false);
|
||||
|
||||
if (!webRtcInteractor.addNewIncomingCall(remotePeer.getId(), remotePeer.getCallId().longValue(), offerType == OfferMessage.Type.VIDEO_CALL)) {
|
||||
Log.i(tag, "Unable to add new incoming call");
|
||||
|
||||
@@ -178,7 +178,7 @@ public class GroupPreJoinActionProcessor extends GroupActionProcessor {
|
||||
|
||||
webRtcInteractor.setCallInProgressNotification(TYPE_OUTGOING_RINGING, currentState.getCallInfoState().getCallRecipient(), true);
|
||||
webRtcInteractor.updatePhoneState(WebRtcUtil.getInCallPhoneState(context));
|
||||
webRtcInteractor.initializeAudioForCall();
|
||||
webRtcInteractor.initializeAudioForCall(true);
|
||||
|
||||
try {
|
||||
groupCall.setOutgoingVideoSource(currentState.getVideoState().requireLocalSink(), currentState.getVideoState().requireCamera());
|
||||
|
||||
@@ -122,7 +122,7 @@ public final class IncomingGroupCallActionProcessor extends DeviceAwareActionPro
|
||||
|
||||
currentState = WebRtcVideoUtil.initializeVideo(context, webRtcInteractor.getCameraEventListener(), currentState, RemotePeer.GROUP_CALL_ID.longValue());
|
||||
|
||||
webRtcInteractor.initializeAudioForCall();
|
||||
webRtcInteractor.initializeAudioForCall(true);
|
||||
|
||||
boolean shouldDisturbUserWithCall = DoNotDisturbUtil.shouldDisturbUserWithCall(context.getApplicationContext(), recipient.resolve());
|
||||
|
||||
@@ -256,7 +256,7 @@ public final class IncomingGroupCallActionProcessor extends DeviceAwareActionPro
|
||||
|
||||
webRtcInteractor.setCallInProgressNotification(TYPE_ESTABLISHED, currentState.getCallInfoState().getCallRecipient(), true);
|
||||
webRtcInteractor.updatePhoneState(WebRtcUtil.getInCallPhoneState(context));
|
||||
webRtcInteractor.initializeAudioForCall();
|
||||
webRtcInteractor.initializeAudioForCall(true);
|
||||
|
||||
try {
|
||||
groupCall.setOutgoingVideoSource(currentState.getVideoState().requireLocalSink(), currentState.getVideoState().requireCamera());
|
||||
|
||||
@@ -72,11 +72,11 @@ public class OutgoingCallActionProcessor extends DeviceAwareActionProcessor {
|
||||
boolean isVideoCall = offerType == OfferMessage.Type.VIDEO_CALL;
|
||||
|
||||
webRtcInteractor.setCallInProgressNotification(TYPE_OUTGOING_RINGING, remotePeer, isVideoCall);
|
||||
webRtcInteractor.updatePhoneState(WebRtcUtil.getInCallPhoneState(context, isVideoCall, false));
|
||||
webRtcInteractor.initializeAudioForCall(false);
|
||||
webRtcInteractor.setDefaultAudioDevice(remotePeer.getId(),
|
||||
isVideoCall ? SignalAudioManager.AudioDevice.SPEAKER_PHONE : SignalAudioManager.AudioDevice.EARPIECE,
|
||||
false);
|
||||
webRtcInteractor.updatePhoneState(WebRtcUtil.getInCallPhoneState(context));
|
||||
webRtcInteractor.initializeAudioForCall();
|
||||
webRtcInteractor.startOutgoingRinger();
|
||||
|
||||
if (!webRtcInteractor.addNewOutgoingCall(remotePeer.getId(), remotePeer.getCallId().longValue(), isVideoCall)) {
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
package org.thoughtcrime.securesms.service.webrtc
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.telecom.DisconnectCause
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.telecom.CallAttributesCompat
|
||||
import androidx.core.telecom.CallEndpointCompat
|
||||
import androidx.core.telecom.CallsManager
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.launch
|
||||
import org.signal.core.ui.permissions.Permissions
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.webrtc.AudioOutputOption
|
||||
import org.thoughtcrime.securesms.components.webrtc.v2.CallIntent
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.webrtc.CallNotificationBuilder
|
||||
import org.thoughtcrime.securesms.webrtc.audio.SignalAudioManager
|
||||
|
||||
sealed class TelecomCommand {
|
||||
object Activate : TelecomCommand()
|
||||
data class Disconnect(val cause: Int) : TelecomCommand()
|
||||
data class ChangeEndpoint(val device: SignalAudioManager.AudioDevice) : TelecomCommand()
|
||||
}
|
||||
|
||||
@RequiresApi(26)
|
||||
class TelecomCallController(
|
||||
private val context: Context,
|
||||
private val recipientId: RecipientId,
|
||||
private val callId: Long,
|
||||
private val isVideoCall: Boolean,
|
||||
private val isOutgoing: Boolean,
|
||||
private val callsManager: CallsManager
|
||||
) {
|
||||
|
||||
companion object {
|
||||
private val TAG: String = Log.tag(TelecomCallController::class.java)
|
||||
}
|
||||
|
||||
private val commandChannel = Channel<TelecomCommand>(Channel.BUFFERED)
|
||||
|
||||
@Volatile
|
||||
var currentAudioDevice: SignalAudioManager.AudioDevice = SignalAudioManager.AudioDevice.NONE
|
||||
private set
|
||||
|
||||
@Volatile
|
||||
private var cachedEndpoints: List<CallEndpointCompat> = emptyList()
|
||||
|
||||
@Volatile
|
||||
private var disconnected: Boolean = false
|
||||
|
||||
fun getAvailableAudioOutputOptions(): List<AudioOutputOption> {
|
||||
return cachedEndpoints
|
||||
.map { AudioOutputOption(it.name.toString(), it.type.toAudioDevice(), it.type) }
|
||||
.distinctBy { it.deviceType }
|
||||
.filterNot { it.deviceType == SignalAudioManager.AudioDevice.NONE }
|
||||
}
|
||||
|
||||
fun getCurrentEndpointDeviceId(): Int {
|
||||
return cachedEndpoints.firstOrNull { it.type.toAudioDevice() == currentAudioDevice }?.type ?: -1
|
||||
}
|
||||
|
||||
fun activate() {
|
||||
Log.i(TAG, "activate() recipientId=$recipientId callId=$callId")
|
||||
commandChannel.trySend(TelecomCommand.Activate)
|
||||
}
|
||||
|
||||
fun disconnect(cause: Int) {
|
||||
if (disconnected) {
|
||||
Log.i(TAG, "disconnect(cause=$cause) already disconnected, ignoring")
|
||||
return
|
||||
}
|
||||
disconnected = true
|
||||
|
||||
Log.i(TAG, "disconnect(cause=$cause) recipientId=$recipientId callId=$callId")
|
||||
commandChannel.trySend(TelecomCommand.Disconnect(cause))
|
||||
}
|
||||
|
||||
fun requestEndpointChange(device: SignalAudioManager.AudioDevice) {
|
||||
Log.i(TAG, "requestEndpointChange($device) recipientId=$recipientId")
|
||||
commandChannel.trySend(TelecomCommand.ChangeEndpoint(device))
|
||||
}
|
||||
|
||||
suspend fun start() {
|
||||
val recipient = Recipient.resolved(recipientId)
|
||||
val displayName = if (SignalStore.settings.messageNotificationsPrivacy.isDisplayContact) recipient.getDisplayName(context) else context.getString(R.string.Recipient_signal_call)
|
||||
val address = Uri.fromParts("sip", recipientId.serialize(), null)
|
||||
|
||||
val direction = if (isOutgoing) CallAttributesCompat.DIRECTION_OUTGOING else CallAttributesCompat.DIRECTION_INCOMING
|
||||
val callType = if (isVideoCall) CallAttributesCompat.CALL_TYPE_VIDEO_CALL else CallAttributesCompat.CALL_TYPE_AUDIO_CALL
|
||||
|
||||
val attributes = CallAttributesCompat(
|
||||
displayName = displayName,
|
||||
address = address,
|
||||
direction = direction,
|
||||
callType = callType
|
||||
)
|
||||
|
||||
Log.i(TAG, "start() recipientId=$recipientId callId=$callId isOutgoing=$isOutgoing isVideo=$isVideoCall")
|
||||
|
||||
callsManager.addCall(
|
||||
callAttributes = attributes,
|
||||
onAnswer = { callType -> onAnswer(callType) },
|
||||
onDisconnect = { cause -> onDisconnect(cause) },
|
||||
onSetActive = { onSetActive() },
|
||||
onSetInactive = { onSetInactive() }
|
||||
) {
|
||||
Log.i(TAG, "addCall block entered, callControlScope active for callId=$callId")
|
||||
|
||||
if (isOutgoing) {
|
||||
Log.i(TAG, "Posting outgoing call notification immediately for callId=$callId")
|
||||
try {
|
||||
val notification = CallNotificationBuilder.getCallInProgressNotification(
|
||||
context,
|
||||
CallNotificationBuilder.TYPE_OUTGOING_RINGING,
|
||||
recipient,
|
||||
isVideoCall,
|
||||
true
|
||||
)
|
||||
NotificationManagerCompat.from(context).notify(CallNotificationBuilder.WEBRTC_NOTIFICATION, notification)
|
||||
} catch (e: SecurityException) {
|
||||
Log.w(TAG, "Failed to post outgoing call notification", e)
|
||||
}
|
||||
}
|
||||
|
||||
AppDependencies.signalCallManager.setTelecomApproved(callId, recipientId)
|
||||
Log.i(TAG, "setTelecomApproved fired for callId=$callId recipientId=$recipientId")
|
||||
|
||||
var needToResetAudioRoute = isOutgoing && !isVideoCall
|
||||
var initialEndpoint: SignalAudioManager.AudioDevice? = null
|
||||
|
||||
launch {
|
||||
currentCallEndpoint.collect { endpoint ->
|
||||
val activeDevice = endpoint.type.toAudioDevice()
|
||||
Log.i(TAG, "currentCallEndpoint changed: ${endpoint.name} (type=${endpoint.type}) -> $activeDevice")
|
||||
currentAudioDevice = activeDevice
|
||||
|
||||
val available = cachedEndpoints.map { it.type.toAudioDevice() }.toSet()
|
||||
AppDependencies.signalCallManager.onAudioDeviceChanged(activeDevice, available)
|
||||
|
||||
if (needToResetAudioRoute) {
|
||||
if (initialEndpoint == null) {
|
||||
initialEndpoint = activeDevice
|
||||
} else if (activeDevice == SignalAudioManager.AudioDevice.SPEAKER_PHONE) {
|
||||
Log.i(TAG, "Resetting audio route from SPEAKER_PHONE to $initialEndpoint")
|
||||
val resetTarget = cachedEndpoints.firstOrNull { it.type.toAudioDevice() == initialEndpoint }
|
||||
if (resetTarget != null) {
|
||||
requestEndpointChange(resetTarget)
|
||||
}
|
||||
needToResetAudioRoute = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
launch {
|
||||
isMuted.collect { muted ->
|
||||
Log.i(TAG, "isMuted changed: $muted for callId=$callId")
|
||||
AppDependencies.signalCallManager.setMuteAudio(muted)
|
||||
}
|
||||
}
|
||||
|
||||
launch {
|
||||
availableEndpoints.collect { endpoints ->
|
||||
cachedEndpoints = endpoints
|
||||
val available = endpoints.map { it.type.toAudioDevice() }.toSet()
|
||||
Log.i(TAG, "availableEndpoints changed: $available (${endpoints.size} endpoints)")
|
||||
AppDependencies.signalCallManager.onAudioDeviceChanged(currentAudioDevice, available)
|
||||
}
|
||||
}
|
||||
|
||||
launch {
|
||||
for (command in commandChannel) {
|
||||
when (command) {
|
||||
is TelecomCommand.Activate -> {
|
||||
val result = setActive()
|
||||
Log.i(TAG, "setActive result: $result")
|
||||
needToResetAudioRoute = false
|
||||
}
|
||||
is TelecomCommand.Disconnect -> {
|
||||
val result = disconnect(DisconnectCause(command.cause))
|
||||
Log.i(TAG, "disconnect result: $result")
|
||||
break
|
||||
}
|
||||
is TelecomCommand.ChangeEndpoint -> {
|
||||
val targetDevice = command.device
|
||||
val target = cachedEndpoints.firstOrNull { it.type.toAudioDevice() == targetDevice }
|
||||
if (target != null) {
|
||||
val result = requestEndpointChange(target)
|
||||
Log.i(TAG, "requestEndpointChange($targetDevice) result: $result")
|
||||
} else {
|
||||
Log.w(TAG, "No endpoint found for device: $targetDevice, available: ${cachedEndpoints.map { it.type.toAudioDevice() }}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onAnswer(callType: Int) {
|
||||
val hasRecordAudio = Permissions.hasAll(context, android.Manifest.permission.RECORD_AUDIO)
|
||||
Log.i(TAG, "onAnswer(callType=$callType) recipientId=$recipientId hasRecordAudio=$hasRecordAudio")
|
||||
if (hasRecordAudio) {
|
||||
AppDependencies.signalCallManager.acceptCall(false)
|
||||
} else {
|
||||
Log.i(TAG, "Missing RECORD_AUDIO permission, launching CallIntent activity")
|
||||
val intent = CallIntent.Builder(context)
|
||||
.withAddedIntentFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
.withAction(if (isVideoCall) CallIntent.Action.ANSWER_VIDEO else CallIntent.Action.ANSWER_AUDIO)
|
||||
.build()
|
||||
context.startActivity(intent)
|
||||
}
|
||||
}
|
||||
|
||||
private fun onDisconnect(cause: DisconnectCause) {
|
||||
Log.i(TAG, "onDisconnect(code=${cause.code}, reason=${cause.reason})")
|
||||
when (cause.code) {
|
||||
DisconnectCause.REJECTED -> {
|
||||
Log.i(TAG, "Call rejected via system UI")
|
||||
ActiveCallManager.denyCall()
|
||||
}
|
||||
DisconnectCause.LOCAL -> {
|
||||
Log.i(TAG, "Local hangup via system UI")
|
||||
ActiveCallManager.hangup()
|
||||
}
|
||||
DisconnectCause.REMOTE,
|
||||
DisconnectCause.MISSED,
|
||||
DisconnectCause.CANCELED -> {
|
||||
Log.i(TAG, "Remote/missed/canceled disconnect, no action needed (handled by Signal processors)")
|
||||
}
|
||||
DisconnectCause.ERROR -> {
|
||||
Log.w(TAG, "Disconnect due to error, performing local hangup as fallback")
|
||||
ActiveCallManager.hangup()
|
||||
}
|
||||
else -> {
|
||||
Log.w(TAG, "Unknown disconnect cause: ${cause.code}, performing local hangup")
|
||||
ActiveCallManager.hangup()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onSetActive() {
|
||||
Log.i(TAG, "onSetActive()")
|
||||
}
|
||||
|
||||
private fun onSetInactive() {
|
||||
Log.i(TAG, "onSetInactive()")
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(26)
|
||||
private fun Int.toAudioDevice(): SignalAudioManager.AudioDevice {
|
||||
return when (this) {
|
||||
CallEndpointCompat.TYPE_EARPIECE -> SignalAudioManager.AudioDevice.EARPIECE
|
||||
CallEndpointCompat.TYPE_SPEAKER -> SignalAudioManager.AudioDevice.SPEAKER_PHONE
|
||||
CallEndpointCompat.TYPE_BLUETOOTH -> SignalAudioManager.AudioDevice.BLUETOOTH
|
||||
CallEndpointCompat.TYPE_WIRED_HEADSET -> SignalAudioManager.AudioDevice.WIRED_HEADSET
|
||||
CallEndpointCompat.TYPE_STREAMING -> SignalAudioManager.AudioDevice.SPEAKER_PHONE
|
||||
else -> SignalAudioManager.AudioDevice.EARPIECE
|
||||
}
|
||||
}
|
||||
@@ -290,7 +290,7 @@ public abstract class WebRtcActionProcessor {
|
||||
RemotePeer peer = currentState.getCallInfoState().getPeerByCallId(new CallId(callId));
|
||||
if (peer == null || !peer.callIdEquals(currentState.getCallInfoState().getActivePeer())) {
|
||||
Log.w(tag, "Received telecom approval after call terminated. callId: " + callId + " recipient: " + recipientId);
|
||||
webRtcInteractor.terminateCall(recipientId);
|
||||
webRtcInteractor.terminateCall(recipientId, android.telecom.DisconnectCause.LOCAL);
|
||||
return currentState;
|
||||
}
|
||||
|
||||
|
||||
@@ -138,8 +138,8 @@ public class WebRtcInteractor {
|
||||
ActiveCallManager.sendAudioManagerCommand(context, new AudioManagerCommand.SilenceIncomingRinger());
|
||||
}
|
||||
|
||||
void initializeAudioForCall() {
|
||||
ActiveCallManager.sendAudioManagerCommand(context, new AudioManagerCommand.Initialize());
|
||||
void initializeAudioForCall(boolean isGroupCall) {
|
||||
ActiveCallManager.sendAudioManagerCommand(context, new AudioManagerCommand.Initialize(isGroupCall));
|
||||
}
|
||||
|
||||
void startIncomingRinger(@Nullable Uri ringtoneUri, boolean vibrate) {
|
||||
@@ -186,6 +186,10 @@ public class WebRtcInteractor {
|
||||
AndroidTelecomUtil.terminateCall(recipientId);
|
||||
}
|
||||
|
||||
public void terminateCall(RecipientId recipientId, int disconnectCause) {
|
||||
AndroidTelecomUtil.terminateCall(recipientId, disconnectCause);
|
||||
}
|
||||
|
||||
public boolean addNewIncomingCall(RecipientId recipientId, long callId, boolean remoteVideoOffer) {
|
||||
return AndroidTelecomUtil.addIncomingCall(recipientId, callId, remoteVideoOffer);
|
||||
}
|
||||
|
||||
@@ -27,7 +27,6 @@ import org.thoughtcrime.securesms.stories.archive.StoryArchiveDuration
|
||||
import org.thoughtcrime.securesms.stories.dialogs.StoryDialogs
|
||||
import org.thoughtcrime.securesms.stories.settings.create.CreateStoryFlowDialogFragment
|
||||
import org.thoughtcrime.securesms.stories.settings.create.CreateStoryWithViewersFragment
|
||||
import org.thoughtcrime.securesms.util.RemoteConfig
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.PagingMappingAdapter
|
||||
import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
||||
@@ -170,7 +169,7 @@ class StoriesPrivacySettingsFragment :
|
||||
}
|
||||
)
|
||||
|
||||
if (RemoteConfig.internalUser) {
|
||||
if (SignalStore.labs.storyArchive) {
|
||||
dividerPref()
|
||||
|
||||
sectionHeaderPref(R.string.StoryArchive__archive)
|
||||
|
||||
@@ -82,7 +82,7 @@ object DeleteDialog {
|
||||
} else {
|
||||
MaterialAlertDialogBuilder(context)
|
||||
.setTitle("${context.getString(R.string.ConversationFragment_delete_for_everyone_title)} - INTERNAL ONLY")
|
||||
.setMessage(R.string.ConversationFragment_delete_for_everyone_body)
|
||||
.setMessage(context.resources.getQuantityString(R.plurals.ConversationFragment_delete_for_everyone_body, messageRecords.size, messageRecords.size))
|
||||
.setPositiveButton(R.string.ConversationFragment_delete_for_everyone) { _, _ ->
|
||||
SignalStore.uiHints.setHasSeenAdminDeleteEducationDialog()
|
||||
SignalExecutors.BOUNDED.execute {
|
||||
|
||||
@@ -95,7 +95,7 @@ fun LocalTime.formatHours(context: Context): String {
|
||||
// We have to create our own pattern here, since the formatter instance returned by DateTimeFormatter.ofLocalizedTime() is looked up lazily, is immutable,
|
||||
// and is not updated when the system's 24-hour time setting changes.
|
||||
val pattern = if (DateFormat.is24HourFormat(context)) "HH:mm" else "h:mm a"
|
||||
return DateTimeFormatter.ofPattern(pattern, Locale.getDefault()).format(this)
|
||||
return DateTimeFormatter.ofPattern(pattern, LocaleUtil.getFirstLocale()).format(this)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -11,7 +11,6 @@ import org.signal.core.util.gibiBytes
|
||||
import org.signal.core.util.kibiBytes
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.core.util.mebiBytes
|
||||
import org.thoughtcrime.securesms.BuildConfig
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.groups.SelectionLimits
|
||||
import org.thoughtcrime.securesms.jobs.RemoteConfigRefreshJob
|
||||
@@ -522,24 +521,6 @@ object RemoteConfig {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A config that evaluates to true when the app's version is >= the semantic version string (e.g. "8.2.0") stored in the remote config value.
|
||||
*
|
||||
* Returns false if the remote value is absent, empty, or unparseable.
|
||||
*/
|
||||
@Suppress("SameParameterValue")
|
||||
private fun remoteMinVersion(
|
||||
key: String
|
||||
): Config<Boolean> = remoteValue(
|
||||
key = key,
|
||||
hotSwappable = true,
|
||||
transformer = { value ->
|
||||
val minVersion = SemanticVersion.parse(value.asString(null))
|
||||
val appVersion = SemanticVersion.parse(BuildConfig.VERSION_NAME.substringBefore("-"))
|
||||
minVersion != null && appVersion != null && appVersion >= minVersion
|
||||
}
|
||||
)
|
||||
|
||||
private fun <T> remoteValue(
|
||||
key: String,
|
||||
hotSwappable: Boolean,
|
||||
@@ -948,6 +929,15 @@ object RemoteConfig {
|
||||
hotSwappable = true
|
||||
)
|
||||
|
||||
/** Maximum size a video transcode should target in bytes */
|
||||
@JvmStatic
|
||||
@get:JvmName("videoTranscodeTargetSizeBytes")
|
||||
val videoTranscodeTargetSizeBytes: Long by remoteLong(
|
||||
key = "global.videoAttachments.transcodeTargetBytes",
|
||||
defaultValue = 100.mebiBytes.inWholeBytes,
|
||||
hotSwappable = true
|
||||
)
|
||||
|
||||
/** Maximum input size when opening a video to send in bytes */
|
||||
@JvmStatic
|
||||
@get:JvmName("maxSourceTranscodeVideoSizeBytes")
|
||||
@@ -1235,22 +1225,6 @@ object RemoteConfig {
|
||||
hotSwappable = true
|
||||
)
|
||||
|
||||
@JvmStatic
|
||||
@get:JvmName("receivePinnedMessages")
|
||||
val receivePinnedMessages: Boolean by remoteBoolean(
|
||||
key = "android.receivePinnedMessages.2",
|
||||
defaultValue = false,
|
||||
hotSwappable = true
|
||||
)
|
||||
|
||||
@JvmStatic
|
||||
@get:JvmName("sendPinnedMessages")
|
||||
val sendPinnedMessages: Boolean by remoteBoolean(
|
||||
key = "android.sendPinnedMessages.2",
|
||||
defaultValue = false,
|
||||
hotSwappable = true
|
||||
)
|
||||
|
||||
@JvmStatic
|
||||
@get:JvmName("callQualitySurvey")
|
||||
val callQualitySurvey: Boolean by remoteBoolean(
|
||||
@@ -1278,15 +1252,6 @@ object RemoteConfig {
|
||||
hotSwappable = true
|
||||
)
|
||||
|
||||
/**
|
||||
* Whether to enable modifying group member labels.
|
||||
*/
|
||||
@JvmStatic
|
||||
@get:JvmName("sendMemberLabels")
|
||||
val sendMemberLabels: Boolean by remoteMinVersion(
|
||||
key = "android.sendMemberLabels.4"
|
||||
)
|
||||
|
||||
/**
|
||||
* Whether or not to receive admin delete messages.
|
||||
*/
|
||||
|
||||
@@ -16,6 +16,7 @@ import org.signal.core.util.PendingIntentFlags;
|
||||
import org.thoughtcrime.securesms.MainActivity;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.components.webrtc.v2.CallIntent;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.notifications.NotificationChannels;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.service.webrtc.ActiveCallManager;
|
||||
@@ -93,7 +94,7 @@ public class CallNotificationBuilder {
|
||||
.setSmallIcon(R.drawable.ic_call_secure_white_24dp)
|
||||
.setContentIntent(pendingIntent)
|
||||
.setOngoing(true)
|
||||
.setContentTitle(recipient.getDisplayName(context));
|
||||
.setContentTitle(SignalStore.settings().getMessageNotificationsPrivacy().isDisplayContact() ? recipient.getDisplayName(context) : context.getString(R.string.Recipient_signal_call));
|
||||
|
||||
if (type == TYPE_INCOMING_CONNECTING) {
|
||||
builder.setContentText(context.getString(R.string.CallNotificationBuilder_connecting));
|
||||
@@ -106,8 +107,15 @@ public class CallNotificationBuilder {
|
||||
builder.setCategory(NotificationCompat.CATEGORY_CALL);
|
||||
builder.setFullScreenIntent(pendingIntent, true);
|
||||
|
||||
Person person = skipPersonIcon ? ConversationUtil.buildPersonWithoutIcon(context, recipient)
|
||||
: ConversationUtil.buildPerson(context.getApplicationContext(), recipient);
|
||||
Person person;
|
||||
|
||||
if (SignalStore.settings().getMessageNotificationsPrivacy().isDisplayContact()) {
|
||||
person = skipPersonIcon ? ConversationUtil.buildPersonWithoutIcon(context, recipient)
|
||||
: ConversationUtil.buildPerson(context.getApplicationContext(), recipient);
|
||||
} else {
|
||||
person = new Person.Builder().setName(context.getString(R.string.Recipient_signal_call))
|
||||
.build();
|
||||
}
|
||||
|
||||
builder.addPerson(person);
|
||||
|
||||
@@ -130,8 +138,15 @@ public class CallNotificationBuilder {
|
||||
builder.setPriority(NotificationCompat.PRIORITY_DEFAULT);
|
||||
builder.setCategory(NotificationCompat.CATEGORY_CALL);
|
||||
|
||||
Person person = skipPersonIcon ? ConversationUtil.buildPersonWithoutIcon(context, recipient)
|
||||
: ConversationUtil.buildPerson(context.getApplicationContext(), recipient);
|
||||
Person person;
|
||||
|
||||
if (SignalStore.settings().getMessageNotificationsPrivacy().isDisplayContact()) {
|
||||
person = skipPersonIcon ? ConversationUtil.buildPersonWithoutIcon(context, recipient)
|
||||
: ConversationUtil.buildPerson(context.getApplicationContext(), recipient);
|
||||
} else {
|
||||
person = new Person.Builder().setName(context.getString(R.string.Recipient_signal_call))
|
||||
.build();
|
||||
}
|
||||
|
||||
builder.addPerson(person);
|
||||
|
||||
|
||||
@@ -20,10 +20,14 @@ sealed class AudioManagerCommand : Parcelable {
|
||||
override fun writeToParcel(parcel: Parcel, flags: Int) = Unit
|
||||
override fun describeContents(): Int = 0
|
||||
|
||||
class Initialize : AudioManagerCommand() {
|
||||
class Initialize(val isGroupCall: Boolean = false) : AudioManagerCommand() {
|
||||
override fun writeToParcel(parcel: Parcel, flags: Int) {
|
||||
ParcelUtil.writeBoolean(parcel, isGroupCall)
|
||||
}
|
||||
|
||||
companion object {
|
||||
@JvmField
|
||||
val CREATOR: Parcelable.Creator<Initialize> = ParcelCheat { Initialize() }
|
||||
val CREATOR: Parcelable.Creator<Initialize> = ParcelCheat { Initialize(ParcelUtil.readBoolean(it)) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import android.media.AudioDeviceInfo
|
||||
import android.media.AudioManager
|
||||
import android.media.AudioRecordingConfiguration
|
||||
import android.media.MediaRecorder
|
||||
import android.net.Uri
|
||||
import androidx.annotation.RequiresApi
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
@@ -22,9 +21,7 @@ class FullSignalAudioManagerApi31(context: Context, eventListener: EventListener
|
||||
|
||||
private var defaultAudioDevice: AudioDevice = AudioDevice.EARPIECE
|
||||
private var userSelectedAudioDevice: AudioDeviceInfo? = null
|
||||
private var savedAudioMode = AudioManager.MODE_INVALID
|
||||
private var savedIsSpeakerPhoneOn = false
|
||||
private var savedIsMicrophoneMute = false
|
||||
private var hasWiredHeadset = false
|
||||
|
||||
private val deviceCallback = object : AudioDeviceCallback() {
|
||||
@@ -207,32 +204,12 @@ class FullSignalAudioManagerApi31(context: Context, eventListener: EventListener
|
||||
updateAudioDeviceState()
|
||||
}
|
||||
|
||||
override fun startIncomingRinger(ringtoneUri: Uri?, vibrate: Boolean) {
|
||||
Log.i(TAG, "startIncomingRinger: uri: ${if (ringtoneUri != null) "present" else "null"} vibrate: $vibrate currentMode: ${getModeName(androidAudioManager.mode)}")
|
||||
androidAudioManager.mode = AudioManager.MODE_RINGTONE
|
||||
setMicrophoneMute(false)
|
||||
incomingRinger.start(ringtoneUri, vibrate)
|
||||
}
|
||||
|
||||
override fun startOutgoingRinger() {
|
||||
Log.i(TAG, "startOutgoingRinger: currentDevice: $selectedAudioDevice currentMode: ${getModeName(androidAudioManager.mode)}")
|
||||
androidAudioManager.mode = AudioManager.MODE_IN_COMMUNICATION
|
||||
setMicrophoneMute(false)
|
||||
outgoingRinger.start(OutgoingRinger.Type.RINGING)
|
||||
}
|
||||
|
||||
private fun setSpeakerphoneOn(on: Boolean) {
|
||||
if (androidAudioManager.isSpeakerphoneOn != on) {
|
||||
androidAudioManager.isSpeakerphoneOn = on
|
||||
}
|
||||
}
|
||||
|
||||
private fun setMicrophoneMute(on: Boolean) {
|
||||
if (androidAudioManager.isMicrophoneMute != on) {
|
||||
androidAudioManager.isMicrophoneMute = on
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateAudioDeviceState() {
|
||||
handler.assertHandlerThread()
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
package org.thoughtcrime.securesms.webrtc.audio
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
@@ -21,6 +22,7 @@ import org.thoughtcrime.securesms.audio.AudioDeviceUpdatedListener
|
||||
import org.thoughtcrime.securesms.audio.SignalBluetoothManager
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.service.webrtc.AndroidTelecomUtil
|
||||
import org.thoughtcrime.securesms.util.safeUnregisterReceiver
|
||||
import org.whispersystems.signalservice.api.util.Preconditions
|
||||
|
||||
@@ -46,10 +48,16 @@ sealed class SignalAudioManager(protected val context: Context, protected val ev
|
||||
|
||||
private val stateChangeUpSoundId = soundPool.load(context, R.raw.notification_simple_01, 1)
|
||||
|
||||
protected var savedAudioMode = AudioManager.MODE_INVALID
|
||||
protected var savedIsMicrophoneMute = false
|
||||
|
||||
companion object {
|
||||
@SuppressLint("NewApi")
|
||||
@JvmStatic
|
||||
fun create(context: Context, eventListener: EventListener?): SignalAudioManager {
|
||||
return if (Build.VERSION.SDK_INT >= 31) {
|
||||
fun create(context: Context, eventListener: EventListener?, canUseTelecom: Boolean): SignalAudioManager {
|
||||
return if (canUseTelecom && AndroidTelecomUtil.telecomSupported) {
|
||||
TelecomAudioManager(context, eventListener)
|
||||
} else if (Build.VERSION.SDK_INT >= 31) {
|
||||
FullSignalAudioManagerApi31(context, eventListener)
|
||||
} else {
|
||||
FullSignalAudioManager(context, eventListener)
|
||||
@@ -94,14 +102,32 @@ sealed class SignalAudioManager(protected val context: Context, protected val ev
|
||||
protected abstract fun stop(playDisconnect: Boolean)
|
||||
protected abstract fun setDefaultAudioDevice(recipientId: RecipientId?, newDefaultDevice: AudioDevice, clearUserEarpieceSelection: Boolean)
|
||||
protected abstract fun selectAudioDevice(recipientId: RecipientId?, device: Int, isId: Boolean)
|
||||
protected abstract fun startIncomingRinger(ringtoneUri: Uri?, vibrate: Boolean)
|
||||
protected abstract fun startOutgoingRinger()
|
||||
|
||||
protected open fun startIncomingRinger(ringtoneUri: Uri?, vibrate: Boolean) {
|
||||
Log.i(TAG, "startIncomingRinger(): uri: ${if (ringtoneUri != null) "present" else "null"} vibrate: $vibrate")
|
||||
androidAudioManager.mode = AudioManager.MODE_RINGTONE
|
||||
setMicrophoneMute(false)
|
||||
incomingRinger.start(ringtoneUri, vibrate)
|
||||
}
|
||||
|
||||
protected open fun startOutgoingRinger() {
|
||||
Log.i(TAG, "startOutgoingRinger(): currentDevice: $selectedAudioDevice")
|
||||
androidAudioManager.mode = AudioManager.MODE_IN_COMMUNICATION
|
||||
setMicrophoneMute(false)
|
||||
outgoingRinger.start(OutgoingRinger.Type.RINGING)
|
||||
}
|
||||
|
||||
protected open fun silenceIncomingRinger() {
|
||||
Log.i(TAG, "silenceIncomingRinger():")
|
||||
incomingRinger.stop()
|
||||
}
|
||||
|
||||
protected fun setMicrophoneMute(on: Boolean) {
|
||||
if (androidAudioManager.isMicrophoneMute != on) {
|
||||
androidAudioManager.isMicrophoneMute = on
|
||||
}
|
||||
}
|
||||
|
||||
enum class AudioDevice {
|
||||
SPEAKER_PHONE,
|
||||
WIRED_HEADSET,
|
||||
@@ -168,9 +194,7 @@ class FullSignalAudioManager(context: Context, eventListener: EventListener?) :
|
||||
private var userSelectedAudioDevice: AudioDevice = AudioDevice.NONE
|
||||
private var previousBluetoothState: SignalBluetoothManager.State? = null
|
||||
|
||||
private var savedAudioMode = AudioManager.MODE_INVALID
|
||||
private var savedIsSpeakerPhoneOn = false
|
||||
private var savedIsMicrophoneMute = false
|
||||
private var hasWiredHeadset = false
|
||||
private var autoSwitchToWiredHeadset = true
|
||||
private var autoSwitchToBluetooth = true
|
||||
@@ -419,29 +443,6 @@ class FullSignalAudioManager(context: Context, eventListener: EventListener?) :
|
||||
}
|
||||
}
|
||||
|
||||
private fun setMicrophoneMute(on: Boolean) {
|
||||
if (androidAudioManager.isMicrophoneMute != on) {
|
||||
androidAudioManager.isMicrophoneMute = on
|
||||
}
|
||||
}
|
||||
|
||||
override fun startIncomingRinger(ringtoneUri: Uri?, vibrate: Boolean) {
|
||||
Log.i(TAG, "startIncomingRinger(): uri: ${if (ringtoneUri != null) "present" else "null"} vibrate: $vibrate")
|
||||
androidAudioManager.mode = AudioManager.MODE_RINGTONE
|
||||
setMicrophoneMute(false)
|
||||
|
||||
incomingRinger.start(ringtoneUri, vibrate)
|
||||
}
|
||||
|
||||
override fun startOutgoingRinger() {
|
||||
Log.i(TAG, "startOutgoingRinger(): currentDevice: $selectedAudioDevice")
|
||||
|
||||
androidAudioManager.mode = AudioManager.MODE_IN_COMMUNICATION
|
||||
setMicrophoneMute(false)
|
||||
|
||||
outgoingRinger.start(OutgoingRinger.Type.RINGING)
|
||||
}
|
||||
|
||||
private fun onWiredHeadsetChange(pluggedIn: Boolean, hasMic: Boolean) {
|
||||
Log.i(TAG, "onWiredHeadsetChange state: $state plug: $pluggedIn mic: $hasMic")
|
||||
hasWiredHeadset = pluggedIn
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.webrtc.audio
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.RequiresApi
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.service.webrtc.AndroidTelecomUtil
|
||||
|
||||
/**
|
||||
* Lightweight [SignalAudioManager] used when Jetpack Core Telecom is managing the call.
|
||||
*
|
||||
* Core Telecom owns device routing (earpiece, speaker, bluetooth, wired headset) and audio focus
|
||||
* via the platform telecom framework. This manager only handles:
|
||||
* - Audio mode transitions (MODE_RINGTONE / MODE_IN_COMMUNICATION)
|
||||
* - Ringtone and sound effect playback
|
||||
* - Mic mute state
|
||||
* - Forwarding user device selection to Core Telecom via [AndroidTelecomUtil]
|
||||
*
|
||||
* Device availability and active device updates flow from [org.thoughtcrime.securesms.service.webrtc.TelecomCallController] directly
|
||||
* to [org.thoughtcrime.securesms.service.webrtc.SignalCallManager.onAudioDeviceChanged], bypassing this class entirely.
|
||||
*/
|
||||
@RequiresApi(34)
|
||||
class TelecomAudioManager(context: Context, eventListener: EventListener?) : SignalAudioManager(context, eventListener) {
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(TelecomAudioManager::class)
|
||||
}
|
||||
|
||||
override fun initialize() {
|
||||
Log.i(TAG, "initialize(): state=$state")
|
||||
if (state == State.UNINITIALIZED) {
|
||||
savedAudioMode = androidAudioManager.mode
|
||||
savedIsMicrophoneMute = androidAudioManager.isMicrophoneMute
|
||||
setMicrophoneMute(false)
|
||||
state = State.PREINITIALIZED
|
||||
}
|
||||
}
|
||||
|
||||
override fun start() {
|
||||
Log.i(TAG, "start(): state=$state")
|
||||
if (state == State.RUNNING) {
|
||||
Log.w(TAG, "Skipping, already active")
|
||||
return
|
||||
}
|
||||
|
||||
incomingRinger.stop()
|
||||
outgoingRinger.stop()
|
||||
|
||||
state = State.RUNNING
|
||||
|
||||
Log.i(TAG, "start(): platform audio mode is ${androidAudioManager.mode}, not overriding — letting telecom framework manage")
|
||||
|
||||
val volume: Float = androidAudioManager.ringVolumeWithMinimum()
|
||||
soundPool.play(connectedSoundId, volume, volume, 0, 0, 1.0f)
|
||||
}
|
||||
|
||||
override fun stop(playDisconnect: Boolean) {
|
||||
Log.i(TAG, "stop(): playDisconnect=$playDisconnect state=$state")
|
||||
|
||||
incomingRinger.stop()
|
||||
outgoingRinger.stop()
|
||||
|
||||
if (playDisconnect && state != State.UNINITIALIZED) {
|
||||
val volume: Float = androidAudioManager.ringVolumeWithMinimum()
|
||||
soundPool.play(disconnectedSoundId, volume, volume, 0, 0, 1.0f)
|
||||
}
|
||||
|
||||
if (state != State.UNINITIALIZED) {
|
||||
setMicrophoneMute(savedIsMicrophoneMute)
|
||||
}
|
||||
|
||||
state = State.UNINITIALIZED
|
||||
}
|
||||
|
||||
override fun setDefaultAudioDevice(recipientId: RecipientId?, newDefaultDevice: AudioDevice, clearUserEarpieceSelection: Boolean) {
|
||||
if (recipientId != null) {
|
||||
val currentDevice = AndroidTelecomUtil.getActiveAudioDevice(recipientId)
|
||||
if (currentDevice == AudioDevice.BLUETOOTH || currentDevice == AudioDevice.WIRED_HEADSET) {
|
||||
Log.i(TAG, "setDefaultAudioDevice(): device=$newDefaultDevice, but current device is $currentDevice — keeping external device")
|
||||
return
|
||||
}
|
||||
|
||||
if (newDefaultDevice == AudioDevice.EARPIECE) {
|
||||
Log.i(TAG, "setDefaultAudioDevice(): device=EARPIECE — no-op, letting telecom framework decide default routing")
|
||||
return
|
||||
}
|
||||
|
||||
Log.i(TAG, "setDefaultAudioDevice(): device=$newDefaultDevice (delegating to telecom)")
|
||||
AndroidTelecomUtil.selectAudioDevice(recipientId, newDefaultDevice)
|
||||
}
|
||||
}
|
||||
|
||||
override fun selectAudioDevice(recipientId: RecipientId?, device: Int, isId: Boolean) {
|
||||
val audioDevice: AudioDevice = if (isId) {
|
||||
Log.w(TAG, "selectAudioDevice(): unexpected isId=true for telecom call, ignoring")
|
||||
return
|
||||
} else {
|
||||
AudioDevice.entries[device]
|
||||
}
|
||||
|
||||
Log.i(TAG, "selectAudioDevice(): device=$audioDevice (delegating to telecom)")
|
||||
if (recipientId != null) {
|
||||
AndroidTelecomUtil.selectAudioDevice(recipientId, audioDevice)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -51,6 +51,44 @@ message ArchiveUploadProgressState {
|
||||
uint64 mediaTotalBytes = 8;
|
||||
}
|
||||
|
||||
message LocalBackupCreationProgress {
|
||||
message Idle {}
|
||||
message Canceled {}
|
||||
|
||||
message Exporting {
|
||||
ExportPhase phase = 1;
|
||||
uint64 frameExportCount = 2;
|
||||
uint64 frameTotalCount = 3;
|
||||
}
|
||||
|
||||
message Transferring {
|
||||
uint64 completed = 1;
|
||||
uint64 total = 2;
|
||||
bool mediaPhase = 3;
|
||||
}
|
||||
|
||||
enum ExportPhase {
|
||||
NONE = 0;
|
||||
INITIALIZING = 1;
|
||||
ACCOUNT = 2;
|
||||
RECIPIENT = 3;
|
||||
THREAD = 4;
|
||||
CALL = 5;
|
||||
STICKER = 6;
|
||||
NOTIFICATION_PROFILE = 7;
|
||||
CHAT_FOLDER = 8;
|
||||
MESSAGE = 9;
|
||||
FINALIZING = 10;
|
||||
}
|
||||
|
||||
oneof state {
|
||||
Idle idle = 1;
|
||||
Canceled canceled = 2;
|
||||
Exporting exporting = 3;
|
||||
Transferring transferring = 4;
|
||||
}
|
||||
}
|
||||
|
||||
message BackupDownloadNotifierState {
|
||||
enum Type {
|
||||
SHEET = 0;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user