Compare commits

...

48 Commits

Author SHA1 Message Date
Greyson Parrelli
abdad4cde8 Bump version to 8.4.2 2026-03-30 12:51:59 -04:00
Greyson Parrelli
fecb30a86e Update website variant manifest. 2026-03-30 12:51:17 -04:00
Michelle Tang
c7ec3ab837 Bump version to 8.4.1 2026-03-19 17:09:55 -04:00
Michelle Tang
2a7b58bf46 Update baseline profile. 2026-03-19 17:02:55 -04:00
Michelle Tang
7d5b0b1565 Update translations and other static files. 2026-03-19 16:52:44 -04:00
Cody Henthorne
3620db3a92 Make max compressed video size remote configurable. 2026-03-19 16:47:35 -04:00
andrew-signal
69cad04875 Bump to libsignal v0.89.1. 2026-03-19 12:23:30 -04:00
Michelle Tang
d533cdc619 Bump version to 8.4.0 2026-03-18 15:16:15 -04:00
Michelle Tang
ae455d2615 Update baseline profile. 2026-03-18 15:06:42 -04:00
Michelle Tang
7f27e52e58 Update translations and other static files. 2026-03-18 14:57:29 -04:00
Greyson Parrelli
4b10c19569 Validate individual APNG frame dimensions. 2026-03-18 13:30:11 -04:00
Greyson Parrelli
3f7f43d506 Show author of message in search results. 2026-03-18 13:15:49 -04:00
Cody Henthorne
b4296c1e4b Fix name collision clean up bug and flakey test. 2026-03-18 13:15:49 -04:00
Greyson Parrelli
b62b5ea8ef Add ability to open a chat incognito. 2026-03-18 13:15:49 -04:00
jeffrey-signal
db5cced91b Show mark read action on admin-only group notifications for non-admin members. 2026-03-18 13:15:49 -04:00
Michelle Tang
b677827c86 Inline pinned message config. 2026-03-18 13:15:49 -04:00
Alex Hart
fc0e902cbf Parallelize all-files. 2026-03-18 13:15:49 -04:00
jeffrey-signal
6fbf4d4ae6 Fix chevron appearing below the recipient name. 2026-03-18 13:15:49 -04:00
Greyson Parrelli
95149764eb Add a new internal-only 'labs' setting screen. 2026-03-18 13:15:49 -04:00
Cody Henthorne
a37680685f Fix flakey getAndPossiblyMerge test. 2026-03-18 13:15:49 -04:00
Greyson Parrelli
2b163a9acd Add the ability to do an export of a single chat. 2026-03-18 13:15:49 -04:00
Alex Hart
2f41d15a41 Add progress phases for initialization and finalization for local backups. 2026-03-18 13:15:49 -04:00
Greyson Parrelli
d2c8b6e14c Improve the storage controller for regV5. 2026-03-18 13:15:49 -04:00
Alex Hart
6877b9163b Resolve ANR when deleting local backup. 2026-03-18 13:15:49 -04:00
jeffrey-signal
6ee14d5e7c Fix closed conversation reopening after changing the device orientation. 2026-03-18 13:15:49 -04:00
andrew-signal
824ff18ba5 Bump libsignal to 0.88.3 2026-03-18 13:15:49 -04:00
emir-signal
548adb831d Update to RingRTC v2.67.0 2026-03-18 13:15:49 -04:00
Cody Henthorne
501ef69f97 Fix session establishment in message processing benchmark tests. 2026-03-18 13:15:49 -04:00
Cody Henthorne
a62f07db11 Reintroduce preliminary telecom support for 1:1 calling. 2026-03-18 13:15:49 -04:00
Greyson Parrelli
1b6cfe9fc6 Re-order megaphones so backups are above PIN reminders. 2026-03-18 13:15:49 -04:00
Alex Hart
eaa1124e71 Fix voice message waveform only showing activity at the beginning.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-03-18 13:15:49 -04:00
Michelle Tang
380036195a Update deleted string. 2026-03-18 13:15:49 -04:00
andrew-signal
d2619a6abd Use app locale when formatting LocalTime, rather than system locale. 2026-03-18 13:15:49 -04:00
Cody Henthorne
4d2f23ec37 Use libsignal-net for multi-recipient send. 2026-03-18 13:15:49 -04:00
Greyson Parrelli
6c1897d8d5 Add infra for regV5 restore flows. 2026-03-18 13:15:49 -04:00
Greyson Parrelli
39de824bf0 Add quick restore flow and DebugLoggableModel to regV5.
Renames restore → quickrestore package, adds QuickRestoreQrViewModel,
introduces DebugLoggableModel for safe toString in release builds,
updates all State/Events classes to extend it, switches previews to
AllDevicePreviews, and enables BuildConfig for the registration module.
2026-03-18 13:15:49 -04:00
Alex Hart
889ebcadd4 Prevent remove from call button from displaying in group calls.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-03-18 13:15:49 -04:00
Alex Hart
db17d1fd24 Unify backup creation progress model for local backups. 2026-03-18 13:15:48 -04:00
Alex Hart
cc282276c8 Disable proximity sensor during outgoing video call ringing.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-03-18 13:15:48 -04:00
jeffrey-signal
a5e00c4319 Inline the send member labels feature flag. 2026-03-18 13:15:48 -04:00
Alex Hart
dba5252be6 Move fetch of first emission to the observeInAppPaymentRedemption call. 2026-03-18 13:15:48 -04:00
Michelle Tang
874bc1a1c9 Bump version to 8.3.4 2026-03-18 13:12:06 -04:00
Michelle Tang
4b95851ae5 Update translations and other static files. 2026-03-18 13:07:28 -04:00
Greyson Parrelli
fbe907f1e9 Temporarily revert nickname bug to fix potential database churn. 2026-03-18 12:02:43 -04:00
Michelle Tang
cf0157c59d Bump version to 8.3.3 2026-03-17 13:35:15 -04:00
Michelle Tang
8f4dff8d53 Update translations and other static files. 2026-03-17 13:21:27 -04:00
Michelle Tang
1b3fb60cb0 Add more pin message checks. 2026-03-17 11:54:38 -04:00
Michelle Tang
ecbf9d60cb Add back remote deleted column. 2026-03-17 11:53:46 -04:00
335 changed files with 13310 additions and 4185 deletions

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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() {

View File

@@ -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()

View File

@@ -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

View File

@@ -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)

View File

@@ -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)));

View File

@@ -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) {

View File

@@ -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()
}

View File

@@ -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) {

View File

@@ -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
}
}

View File

@@ -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)
}

View File

@@ -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
}
}
}

View File

@@ -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
)
}
}

View File

@@ -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

View File

@@ -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)
}

View File

@@ -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()
}

View File

@@ -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())
}
}

View File

@@ -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
)

View File

@@ -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
)

View File

@@ -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) }
}
}

View File

@@ -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
}

View File

@@ -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)) }
)
}
}
}
}

View File

@@ -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
)

View File

@@ -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
)
}
}

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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),

View File

@@ -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)
}
)
}
}
)
}
}

View File

@@ -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? {

View File

@@ -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) {

View File

@@ -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))
}

View File

@@ -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) {

View File

@@ -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)

View File

@@ -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) {

View File

@@ -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);

View File

@@ -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()
}
}

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -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
}
}

View File

@@ -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()
)
}
}

View File

@@ -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 {

View File

@@ -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,

View File

@@ -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 {

View File

@@ -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) {

View File

@@ -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());

View File

@@ -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()

View File

@@ -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()
}

View File

@@ -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) {

View File

@@ -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) {

View File

@@ -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")
}
}

View File

@@ -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")
}
}

View File

@@ -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())

View File

@@ -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) {

View File

@@ -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)

View File

@@ -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()

View File

@@ -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 + ")") );

View File

@@ -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) {

View File

@@ -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) {

View File

@@ -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 + ")") );

View File

@@ -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 &&

View File

@@ -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()
}

View File

@@ -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?)

View File

@@ -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 }
}
}

View File

@@ -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)

View File

@@ -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)

View File

@@ -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
) {

View File

@@ -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);
}};
}

View File

@@ -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}")

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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)
}
}

View File

@@ -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

View File

@@ -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")

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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");

View File

@@ -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());

View File

@@ -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());

View File

@@ -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)) {

View File

@@ -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
}
}

View File

@@ -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;
}

View File

@@ -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);
}

View File

@@ -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)

View File

@@ -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 {

View File

@@ -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)
}
/**

View File

@@ -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.
*/

View File

@@ -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);

View File

@@ -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)) }
}
}

View File

@@ -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()

View File

@@ -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

View File

@@ -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)
}
}
}

View File

@@ -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